Skip to content

fix(core): formatDate reads options.style, with the positional argument winning - #7813

Merged
os-sam merged 1 commit into
mainfrom
claude/issue-7745-formatdate-options-style
Sep 5, 2026
Merged

fix(core): formatDate reads options.style, with the positional argument winning#7813
os-sam merged 1 commit into
mainfrom
claude/issue-7745-formatdate-options-style

Conversation

@os-sam

@os-sam os-sam commented Sep 5, 2026

Copy link
Copy Markdown
Collaborator

Fixes #7745

The additive half of maintainer ruling B on objectui#7443 (comment 5539935824): both
functions accepting options.style. Direction not re-litigated.

Base for every reading below: a617bb8d3 (recorded at worktree creation — not a
two-dot origin/main..HEAD diff, which over-reports under concurrent landings).
All readings taken at head 0ef85f821, which is also the head of this branch.

Before / after

v = '2024-07-04T07:00:00.000Z', container TZ UTC, Intl locale en-US.

call before after
formatDate(v, undefined, { style: 'short', locale: 'en-US' }) "Jul 4, 2024" "Jul 4, '24"

That is the only line that moved. The same 16-reading probe was run against the
unmodified tree and the fixed tree and diffed; the diff is one line. Everything else —
formatDate(v, 'short', …) = "Jul 4, '24", formatDate(v, undefined, …) =
"Jul 4, 2024", formatDate(v, 'relative', …) = "Jul 4, 2024", and every
formatDateTime / formatRelativeDate reading — is byte-identical across the change.

The precedence, and why this direction

The positional argument wins. options.style is consulted only when the positional
slot is undefinedstyle ?? options?.style.

  1. It is the only direction that is purely additive. It fires exactly on the input
    that is a silent no-op today (positional absent, key present). Every call that renders
    a face today renders the same face after — measured above, not asserted.
  2. The bag is shared across three functions, so it legitimately carries keys meant for
    a sibling.
    That is this module's own convention: dueLike and t are read by
    formatRelativeDate alone. A caller that built { style: 'compact', locale } for
    formatDateTime and reused the bag in formatDate(v, 'short', bag) must keep its
    short face. A key aimed at a sibling must not outrank an argument written for THIS
    call — objectui#7694's shape (an alias overwriting a canonical key), and the
    silent-override half of objectui#4272.
  3. ??, not ||. formatDate(v, '', bag) renders the default face today, so an
    empty string counts as GIVEN and does not fall through to the key. || would have
    moved it.

Pinned in three places so it cannot drift back to "whatever the implementation order
happens to be": the formatDate JSDoc, the DateDisplayOptions.style JSDoc, and the
test file.

The pin

packages/core/src/utils/__tests__/date-display.optionsStyle-7745.test.ts — 14 cases,
including both spellings supplied together:

  • formatDate(v, 'short', { style: 'long', locale }) is "Jul 4, '24"
  • formatDate(v, 'long', { style: 'short', locale }) is "Jul 4, 2024"
  • on an in-window date, formatDate(d, 'short', { style: 'relative', locale }) equals
    formatDate(d, 'short', { locale }) — the pair where the loser is visibly different
  • formatDate(v, '', { style: 'short', locale }) is "Jul 4, 2024"
  • arity pin: formatDate.length is 3, so dropping the positional slot turns red

One thing the card did not name: the relative path

formatRelativeDate delegates to formatDate for dates outside its ±7-day window. Make
formatDate read options.style and that delegation leaks the key into
formatRelativeDate
— for out-of-window dates only — and, for
{ style: 'relative' }, loops until the stack runs out. Both are measured below.

So the out-of-window call strips the key (absoluteFallbackOptions, a module-local
non-exported helper). It is a behaviour-PRESERVING guard, not a feature: it is what keeps
formatRelativeDate reading exactly as it read before this PR.

Lit control (three legs, each hash-proved)

Each leg: hash the file, confirm it equals the HEAD blob, mutate, prove the mutation
landed by hash and by removed/injected occurrence counts
(an empty hash reads as
FAILURE), run the pin file, restore with git checkout HEAD -- ABSOLUTE_PATH under
trap … EXIT INT TERM, then prove restoration by state: blob hash equals the HEAD
blob AND git diff HEAD is empty. HEAD blob 6cbabb21ccb0eb105722aaaf87b5185b3bfebd2c
for all three; each restored to that exact hash with an empty diff.

leg mutation predicted observed
A style ?? options?.style to style (revert the read) the three "options spelling is read" cases RED, precedence cases GREEN exactly that — 3 failed / 11 passed
B style ?? options?.style to options?.style ?? style (invert precedence) the three precedence cases RED, additive cases GREEN exactly that — 3 failed / 11 passed
C drop the absoluteFallbackOptions strip the two formatRelativeDate cases RED 3 failed / 11 passed — one more than predicted (see below)

A and B turn disjoint sets red, so neither half of the deliverable can be green by
accident: no single mutation can hide behind the other.

Leg C's third red is honest and worth recording — my written prediction named two cases
and three went red. formatDate(v, undefined, { style: 'relative' }) on an out-of-window
date recurses, so the equivalence loop in "the options spelling IS the positional
spelling, for every face" throws rather than mismatches. The two measured failures the
strip prevents:

AssertionError: expected 'Jul 4, \'24' to be 'Jul 4, 2024'   (formatRelativeDate leaking the key)
RangeError: Maximum call stack size exceeded                 (formatRelativeDate(v, { style: 'relative' }))

Regression surface: the two existing inert keys

dueLike and t behave exactly as before — read by formatRelativeDate alone, silently
inert everywhere else. Measured on both sides of the change, identical:

call before and after
formatDateTime(v, { dueLike: true, t: () = 'X', locale }) "Jul 4, 2024, 07:00 AM"
formatDate(v, undefined, { dueLike: true, t: () = 'X', locale }) "Jul 4, 2024"
formatDateTime(v, { style: 'compact', locale }) "7/4/2024 7:00 am"
formatDateTime(v, { locale }) "Jul 4, 2024, 07:00 AM"
formatRelativeDate(v, { style: 'compact', locale }) "Jul 4, 2024"

All five are pinned in the test file, so they cannot drift silently later.

Judgement: should formatRelativeDate read it too? — NOT in this PR

The ruling names formatDate only, so nothing here was added for it. Reported for the
next card rather than assumed:

It is not genuinely style-less, but it is not a one-liner either. Two candidate
meanings, and only one is reachable today:

  • Its own relative phrase. There is no relative-style vocabulary — no second phrase
    face exists to select. Adding the read would accept a key with nothing to name.
    Recommend no.
  • Its out-of-window ABSOLUTE fallback. Well-defined today with zero new vocabulary:
    formatRelativeDate(v, { style: 'short' }) on a two-year-old date could render
    "Jul 4, '24". Recommend yes — but as a deliberate card, because it makes the
    ±7-day boundary a visible face boundary ("In 2 days" inside, "Jul 4, '24"
    outside) and changes live grid/gantt output for any caller already threading a shared
    bag.

The strip added here is precisely the line that card would flip, and leg C is the
measurement of what flipping it costs — so that card has its before/after already taken.

Changeset gate

node scripts/check-changeset-presence.mjs, verbatim, before and after:

Compared the working tree with a617bb8d3 (merge-base with origin/main): 1 file(s) changed, 1 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 0 changeset(s) added.

❌  1 source file(s) of 1 released package(s) changed, and this change adds no changeset:

      @object-ui/core
          packages/core/src/utils/date-display.ts
Compared the working tree with a617bb8d3 (merge-base with origin/main): 1 file(s) changed, 1 of them published source of a package the release covers, 0 of them a manifest whose published contract moved, 0 under a package changesets ignores, 1 changeset(s) added.
✅  1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7745-formatdate-options-style.md.

Acted on: .changeset/7745-formatdate-options-style.md, scored patch (a fix that
makes an already-declared key do what it says; the only output that moves is a call that
was silently rendering the wrong face). node scripts/check-changeset-no-major.mjs:
✅ No changeset declares a 'major' bump.

Verification, all at 0ef85f821

command result
pnpm --filter @object-ui/core test 118 files / 2509 tests passed, exit 0
pnpm turbo run type-check --filter @object-ui/core --concurrency=2 3 tasks successful, exit 0
pnpm turbo run lint --filter @object-ui/core --concurrency=2 exit 0 — 0 errors, 516 pre-existing warnings
pnpm exec vitest run packages/core/src/utils/__tests__/date-display.optionsStyle-7745.test.ts 14/14 passed, exit 0
pnpm exec vitest run packages/fields/src/__tests__/date-display.reexport-identity.test.ts date-formatter-residue-4272 datetime-compact-style-7443 3 files / 36 tests passed, exit 0

Exit codes captured before any pipe (redirect first, then read $?); the wrapper's own
VERDICT line was read rather than a bare $?.

Not measured by "type-check is green" unless stated: tsc -p tsconfig.test.json --listFiles was checked to confirm the new test file is in that program (1
occurrence) — so the green does cover it.

Lint scope, declared: the run is turbo run lint --filter @object-ui/core, which is
the per-package eslint . CI runs for this package, i.e. the whole population for the
only package this PR touches. --format json reports 215 files in eslint's own
population; both changed files are in it. eslint.config.js declares no
parserOptions.project / projectService, so linting is not type-aware and this diff
cannot move a verdict on any untouched file. My two files: 0 errors; date-display.ts
carries 4 no-explicit-any warnings, unchanged (as any count 4 before, 4 after);
the new test file 0/0.

Clause-② remains no

No exported type or symbol is added or rewritten. DateDisplayOptions keeps exactly its
four keys with exactly the same types — style?: string was already public (PR #7621).
formatDate's signature is unchanged (arity 3, pinned). The one new function,
absoluteFallbackOptions, is module-local and not exported. JSDoc prose on
DateDisplayOptions.style and on the module header was rewritten because it stated the
opposite of the new behaviour; that changes documentation, not the declared type. Flagged
for the reviewer rather than self-graded.

Instrument note for other seats

pnpm --filter PKG exec vitest run PATH is refused by this repo's guard
(objectui#3378): running vitest from a package directory silently runs the wrong test set
(it reported Test Files 22 passed from apps/console while running none of the target
package's). The guard caught it loudly rather than going falsely green. Correct form is
from the repo root: pnpm exec vitest run packages/core/src/….

🤖 Generated with Claude Code

https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3


Generated by Claude Code

…nt winning

`DateDisplayOptions` is the one bag `formatDate` / `formatRelativeDate` /
`formatDateTime` share. PR #7621 added `style?: string` to it for
`formatDateTime`'s `'compact'` grid face and only `formatDateTime` read it, so
on `formatDate` the key was inert — and inert beside a POSITIONAL parameter of
the same name. One function, two spellings for one concept, one of them silently
doing nothing, and the silent one is the spelling `formatDateTime` requires:

    formatDate(v, undefined, { style: 'short', locale: 'en-US' })
      before  "Jul 4, 2024"   (the default face, no diagnostic)
      after   "Jul 4, '24"

This is the additive half of the maintainer's long-run ruling B on objectui#7443
(comment 5539935824): both functions accepting `options.style`.

The precedence between the two spellings is PINNED rather than left to
implementation order: the positional argument wins, and `options.style` is
consulted only when the positional slot is `undefined` (`??`, not `||`, so an
empty string still counts as given and still renders the default face). It is
the only direction that is purely additive — it fires exactly on the input that
is a silent no-op today — and the reverse would let a key aimed at a SIBLING
function outrank an argument written for this call, which is objectui#7694's
shape and the silent-override half of objectui#4272.

`formatRelativeDate` still does not read the key; the ruling names `formatDate`
only. Its out-of-window delegation to `formatDate` therefore strips `style`
(`absoluteFallbackOptions`), which both preserves that function's behaviour
exactly and keeps `formatRelativeDate(v, { style: 'relative' })` from recursing
until the stack runs out.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KbJQ1y1J12nZxYzFWhP8Q3
@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3187.4 KB 3191.4 KB
Main entry chunk (gzip) 143.2 KB 350 KB
Entry file index-DRjAR2je.js
Status PASS

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 5.13KB 2.35KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 510.60KB 116.20KB
core (index.js) 6.96KB 2.79KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 182.08KB 50.62KB
fields (index.js) 242.44KB 61.25KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 4.28KB 1.75KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.98KB 10.98KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 11.71KB 4.29KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 5.12KB 1.74KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 47.87KB 13.31KB
plugin-charts (index.js) 70.92KB 19.75KB
plugin-chatbot (index.js) 196.19KB 46.37KB
plugin-dashboard (index.js) 132.88KB 34.69KB
plugin-designer (index.js) 212.86KB 43.19KB
plugin-detail (index.js) 250.55KB 64.06KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 132.87KB 32.66KB
plugin-gantt (index.js) 167.26KB 41.00KB
plugin-grid (index.js) 209.29KB 56.78KB
plugin-kanban (index.js) 52.71KB 14.55KB
plugin-list (index.js) 113.28KB 27.59KB
plugin-map (index.js) 20.44KB 6.78KB
plugin-markdown (index.js) 13.93KB 4.81KB
plugin-report (index.js) 43.59KB 11.97KB
plugin-timeline (index.js) 30.84KB 8.85KB
plugin-tree (index.js) 9.20KB 3.19KB
plugin-view (index.js) 85.24KB 20.94KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 5.41KB 2.34KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 4.93KB 2.24KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 10.35KB 3.60KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.74KB 1.41KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants