fix(fields): one home for the date display convention in the readonly widgets - #8215
Merged
os-justin merged 2 commits intoSep 7, 2026
Merged
Conversation
…ly widgets
Four readonly `date` faces in `@object-ui/fields` called
`toLocaleDateString(locale)` with no options bag at all — Intl's numeric
default — so they never implemented the year-dropping decision the shared
`formatDate` documents and every `date` cell already follows:
- the readonly `DateField` (also what `FieldEditWidget` renders in the
grid and detail inline editors),
- `GridField`'s readonly sub-grid `date` column,
- a `FormulaField` declaring `return_type: 'date'`,
- `lookupColumnDisplay`'s plain-text `$date` fallback, which sits in the
same function as the descriptor path that already used `formatDate`.
All four now call `formatDate(value, undefined, { locale })`. Rendered and
compared in five locales (en/de/zh/ja/ar) before and after: every one moves
onto the face the `date` cell beside it already showed, and nothing else in
the package moves.
`GridField` keeps building its local `Date` from the verbatim `YYYY-MM-DD`
parts and hands that instance to `formatDate`, so the UTC-midnight day shift
that construction exists to avoid stays avoided; its unparseable-value guard
still answers the raw stored string.
Unlike the sibling change in `@object-ui/components`, the former face here
carried no short month either, so the whole face changes in both years, not
only the year token.
The two readonly `datetime` faces found by the same enumeration are left
alone on purpose: their home is `formatDateTime`, whose named faces are a
separate display-convention question. Recorded separately and fenced by a pin.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Contributor
✅ Console Performance Budget
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
Size Limits
|
Contributor
✅ Console Performance Budget
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
Size Limits
|
Contributor
Hold released — #8194 ruled option A (director seat, decision batch #72, 2026-09-07)The maintainer confirmed that #7620's ruling A extends to the four readonly Ruling comment: #8194 (batch #72). Generated by Claude Code |
os-justin
marked this pull request as ready for review
September 7, 2026 09:40
os-justin
deleted the
claude/issue-8194-fields-date-widget-format-convergence
branch
September 7, 2026 09:56
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #8194
Follows the maintainer's ruling A on objectui#7620 — "one home for the
datedisplay convention; the year-dropping rule applies here too" — one surface over, in@object-ui/fields. The sibling execution of that ruling is PR #8196 (@object-ui/components, still in CI at the time of writing); this PR touches none of the same files.1. The enumeration, redone (the card asked for this first)
The card says its own population is defined by omission — sites that render a date without an options bag — and that a grep for
toLocaleDateStringcannot measure such a population. Correct, so it was re-enumerated by mechanism instead, over the 78 non-test source files ofpackages/fields/src, before anything was edited.Every way a
Datecan become user-visible text, counted with fixed-string matches (no ERE alternation), matched lines printed and read:toLocaleDateStringtoLocaleTimeStringtoLocaleStringGridField)Intl.DateTimeFormatIntl.RelativeTimeFormattoDateString/toUTCString/toTimeStringdate-fns/dayjs/luxon/moment(formatDate/formatDateTime/formatRelativeDatefamilysrc/index.tsx(the cell renderers)Controls for the zeros, stated alongside them:
useDisplayLocaleover the same file set returns 29 (a spelling known to be present, so the harness fires), andtoLocaleDateStringXXreturns 0 (a spelling known to be absent). Date construction was swept independently —new Date(has 15 occurrences and every one was read; the ones that are not display arenativeDateValue.ts(control values forinput type=date,YYYY-MM-DD),coerceToSafeValue's normalisation, andDateCellRenderer's deliberately-raw ISOtitle=tooltip.Result: six bare no-bag sites, not four. The card named four; it missed two.
widgets/DateField.tsxreadonly branchdatewidgets/GridField.tsxtemporalTextdatebranchdatewidgets/FormulaField.tsxreturn_type: 'date'datewidgets/lookupColumnDisplay.tsx$datefallbackdatewidgets/DateTimeField.tsxreadonly branchdatetimewidgets/GridField.tsxtemporalTextdatetimebranchdatetimeSites 5 and 6 are the same omission one type over, but their one home is
formatDateTime, and choosing between its named faces ('compact'versus the verbose default) is a second display-convention decision that the #7620 ruling does not reach. Filed as objectui#8209 (finding, ungraded) with its own five-locale measurement, and fenced by a pin here so this PR's boundary is measured rather than asserted in prose.There is a third class worth naming and not fixing: a date that reaches a cell with no type information at all (
lookupColumnDisplay's finalString(val),coerceToSafeValue'sString(obj.$date)) renders raw. Those are not "a date formatted the wrong way" — nothing on those paths knows the value is a date — so they are out of reach of any convention, including this one.2. Render-measured, not source-confirmed
The card flags its four sites as source-confirmed, not render-measured. All six were rendered through the real widgets (
I18nProvider+LocalizationProvider, happy-dom,TZ=UTC) in five locales before touching anything, and the card's prediction held exactly: every one of the four renderedIntl's numeric default, and the referencedatecell renderedJul 4.2026-07-04(current year) and2024-07-04(past year),en/de/zh/ja/ar:dateCELL (reference)7/4/2026Jul 4Jul 44.7.20264. Juli4. Juli2026/7/47月4日7月4日2026/7/47月4日7月4日4/7/20264 يوليو4 يوليو7/4/2024Jul 4, 2024Jul 4, 20244.7.20244. Juli 20244. Juli 20242024/7/42024年7月4日2024年7月4日2024/7/42024年7月4日2024年7月4日4/7/20244 يوليو 20244 يوليو 2024(The
arrows carry U+200F marks around the numeric separators; omitted above so the table stays readable, present in the measurement.)All four sites produce the identical string at every cell of that table, and the diff of the whole before/after measurement contains only these rows: sites 5 and 6 and both reference rows are byte-identical across the change.
DateCellRenderer's own default style is'relative', so it reachesformatDate's default face only outside the ±7-day window (July 4 is, from today). Inside that window the cell saysToday/3 days agoand the widgets do not — that asymmetry is unchanged by this PR and is the same asymmetry PR #8196 leaves indata-table. "Converging onformatDate" here means its default face, exactly as the ruling spells it out, not the cell's relative style.3.⚠️ The one thing that differs from #7620 — please price this
#7620's former face already asked for
{ year: 'numeric', month: 'short', day: 'numeric' }, so converging it moved only the year token, and its past-year row was byte-identical. That is what the ruling was priced against: 「每张表每个当年日期Jul 4, 2026→Jul 4」.These four passed no bag at all. So the whole face changes, in every locale, in both years —
7/4/2024→Jul 4, 2024as much as7/4/2026→Jul 4. There is no must-not-change row here.Reasoning for implementing rather than escalating, stated either way so it can be overruled cheaply:
formatstyle honoured by both paths, not a second option bag". That is precisely the alternative the [fields] the readonlydateWIDGET faces are a third convention — a baretoLocaleDateString(locale), notformatDate#8194 card raises ("declare the second convention rather than delete it") — considered as option B ondatehas the same two-convention splitdatetimejust had:data-table's date-only cell keeps a year thatformatDatedrops #7620 and not taken.formatDate; one without landed on the bare bag), site 2 is a sub-grid cell, and site 1's readonly face is whatFieldEditWidgetrenders in the grid and detail inline editors — besideDateCellRenderer's.4. What changed
Four call sites, each
formatDate(value, undefined, { locale }).undefinedin the positional slot is how the published signature asks for the default face; the positional argument outranksoptions.style(objectui#7745).GridFieldis the one that needed care: itsdatebranch splits the storedYYYY-MM-DDinto local calendar parts first, becausenew Date('2026-06-17')is UTC midnight and reading local components back out of it moves the day west of Greenwich (objectui#3569). That localDateis handed toformatDateas aDateinstance, which the shared function uses verbatim — passing the raw string would have re-introduced exactly the bug that construction exists to avoid. Its!ymdguard still runs first, so an unreadable stored value keeps showing what is actually stored.The other three inherit
formatDate's—for a value it cannot parse, where they used to render the literalInvalid Date. That is a consequence of using the one home, not a second convention; it is pinned.Nothing gains a
formatstyle vocabulary: the widgets still read no style, exactly as before.5. Tests
New:
packages/fields/src/__tests__/fields-date-widget-convention-8194.test.tsx, in the #7621 / #8196 shape.FORMER_FACE— the removed baretoLocaleDateString(locale)copied verbatim — and against the shared function, never against a literal typed by hand.GridFieldgets its ownFORMER_GRID_FACEbecause its removed spelling was the local-parts construction, not the string parse.FIXTURE VALIDITYasserts the premise: the two formatters disagree on the current-year value and on the past-year one (the line that differs fromdatehas the same two-convention splitdatetimejust had:data-table's date-only cell keeps a year thatformatDatedrops #7620), the current-year fixture really is the current year, and the year-dropping decision is what separates the rows. Green on both sides by construction — it measures the formatters, never the widgets — so a fixture that silently stopped exercising the fork fails loudly instead of passing for free.enliterals (Jul 4,Jul 4, 2024) so a silent redesign offormatDate's default face cannot slide through with the shared-function comparisons still agreeing.SCOPE FENCE: sites 5 and 6 asserted unchanged, in all five locales.Updated (their expectations were the old face — this is the change being visible, not collateral):
__tests__/date-locale-channel.test.tsx— thedatecases now build expectations through a localdefaultDateFace()helper, the idiom this file already used for its ±7-day fallback case, because the face is year-sensitive and a literal would turn a locale file red on a January 1st. Its subject (which tag reachesIntl) is unchanged and still non-vacuous: each case asserts the other locale's form is absent. Itsdatetimecases are untouched.widgets/RecordPickerDialog.dateLocale.test.tsx— same treatment; itsencase is no longer byte-identical across this change and the header says so.datetime-widgets.test.tsx— the readonlyDateFieldcase, now built from the same bag.Runs
Heavy runs went through the container's shared verify lock; verdict lines are in the dev report.
Lint narrowing, declared.
pnpm lint(turbo run lint,eslint .per package) is the repo-scoped run and belongs to CI; locally eslint was run over the 8 files this diff touches (count read from--format jsonoutput, not estimated): 0 errors, 57 warnings, all of them pre-existingno-explicit-any/react-refresh/no-unused-varspatterns thatorigin/mainalready carries on these files. The narrowing is sound because type-aware linting is not enabled in this repo —eslint.config.jsextendstseslint.configs.recommended(notrecommendedTypeChecked) and contains zeroproject/projectService/tsconfigRootDirsettings — so this diff cannot move the verdict on any file it does not touch.Ablation — measured, from the committed state
1130b9aa2All four sites reverted to their removed bare spelling in one run.
The mutation reached disk — anchored fixed-string counts, before and after, per file (
grep -coF, no ERE alternation):formatDate(before → aftertoLocaleDateString(before → afterDateField.tsxGridField.tsxFormulaField.tsxlookupColumnDisplay.tsx(The residual counts are prose in the surrounding comments — which is why the count is read per file and printed, not asserted as "zero somewhere".)
git diff HEAD --statthen showed 4 files changed, 4 insertions(+), 4 deletions(-), so the edit is on disk and not merely a tool exit code.The pins reddened:
91 failed | 27 passed (118). The 27 survivors are the cases that by construction measure something other than the convergence —FIXTURE VALIDITY(it compares the two formatters, never a widget), theSCOPE FENCEblock (the twodatetimesites, untouched by the mutation),GridField's preserved raw-value guard, and the "no time token" property both faces share.The restore was proved BY STATE, not by an exit code —
git hash-objectagainstgit rev-parse HEAD:PATHfor each of the four files (four MATCHes), then an emptygit diff HEADand an emptygit status --porcelain. The script carries atrap … EXIT INT TERMwhose restore paths are absolute (git rev-parse --show-toplevel), and every checkout namesHEADexplicitly rather than the bare form, which would have restored the mutation out of the index.⭐ Worth knowing for anyone repeating it here:
vitest.config.mtsaliases every@object-ui/*specifier to that package'ssrc, so an ablation in this repo is read from source and the "unbuilt mutation stays green" hazard does not apply — thedistpreflight step other repos need has no counterpart here.6. Scope
packages/fields/src/widgets/file-size-guard.tsuntouched (card finding(app-shell,fields):TranslateFnis declared three times, character-for-character identical — the cheapest available shrink of the one-authority KNOWN_COLLISIONS baseline #8165 runs concurrently on it).@object-ui/componentsordata-table—datehas the same two-convention splitdatetimejust had:data-table's date-only cell keeps a year thatformatDatedrops #7620's surface, PR fix(components): one home for the date display convention in data-table #8196's.@object-ui/fieldsminor, spelling the visible consequence out plainly in all five measured locales.🤖 Generated with Claude Code
https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
Generated by Claude Code