Skip to content

fix(fields,i18n): translate AddressField sub-labels, drop US placeholders, localize read-only part order - #4740

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4028-addressfield-locale-remainder
Aug 15, 2026
Merged

fix(fields,i18n): translate AddressField sub-labels, drop US placeholders, localize read-only part order#4740
yinlianghui merged 1 commit into
mainfrom
claude/issue-4028-addressfield-locale-remainder

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #4028

Premise re-check first — the dispatch's "already fixed" half was not fixed

The dispatch re-scoped this card on the finding that the five sub-labels "no longer exist as literals in AddressField.tsx — they were keyed by the composite-group-label work". They still existed. Measured on origin/main at baa89a1ea (the tip this branch forks from, and the same tree whose placeholder line numbers the dispatch quotes correctly at L118/133/146/161/174):

packages/fields/src/widgets/AddressField.tsx
111:  < Label htmlFor={subId('street')} className="text-xs">Street Address< /Label>
127:    < Label htmlFor={subId('city')} className="text-xs">City< /Label>
140:    < Label htmlFor={subId('state')} className="text-xs">State / Province< /Label>
155:    < Label htmlFor={subId('postalCode')} className="text-xs">ZIP / Postal Code< /Label>
168:    < Label htmlFor={subId('country')} className="text-xs">Country< /Label>

(The space after each angle bracket is a GitHub-sanitizer workaround, not part of the source.) Inline JSX children, no t() call, no key — exactly what the issue reports. What the composite-group-label work (objectui#3961 / #3990) did touch is the group container's naming (toHostGroupProps, the host id / aria-labelledby pair); it never went near the five sub-labels. The literals in composite-group-label-e2e.test.tsx that read like "keyed already" are that suite asserting getByLabelText('Street Address') provider-free — the English default, not a pack lookup.

So this PR implements the whole card, not the remainder: the sub-labels are keyed here for the first time, and that is a deviation from the dispatch's scope, taken because the premise excluding them was false and the issue's acceptance criteria include them.

1. Sub-labels are keyed (fields.address.*)

New keys in all ten packs and in FIELD_DEFAULTS:

key en
fields.address.street Street Address
fields.address.city City
fields.address.state State / Province
fields.address.postalCode ZIP / Postal Code
fields.address.country Country

en values are byte-identical to the literals they replace, and FIELD_DEFAULTS carries the same five, so English rendering and provider-less rendering are unchanged (pinned positively in AddressField.no-provider.test.tsx, not merely assumed). A fields.address.* namespace slots into the existing fields.tags.* / fields.textarea.* / fields.options.* shape with no new mechanism.

2. Placeholders: DROPPED, not keyed

123 Main St / San Francisco / CA / 94102 / United States are gone. The measurement behind choosing removal over ten-pack keys:

  1. Precedent, read from the packs themselves. Every example-data placeholder the packs carry teaches a FORMAT its label cannot: name@example.com (local@domain), +1 555 000 0000 (dial prefix), Icon name (e.g. Users) (which identifier). Each address box already has a visible label naming exactly what it wants, so its example added nothing but a country — and a US postal code's shape actively misinforms anyone whose own is a different length.
  2. The channel is wrong in principle. The right example follows the address's COUNTRY, not the reader's language. Nothing in the stored value can supply one (this widget writes no countryCode — see the follow-up below), and a UI-language-keyed example is wrong for every cross-border address, which is the CRM case this was reported from. A Japanese example address shown to a zh user entering a US customer's address is the same defect with a different flag on it.
  3. Cost if keyed. 50 pack entries of invented fictional address fragments, bound from then on by check:i18n-drift; each Latin-script one inside zh/ja/ko/ru/ar would additionally need an untranslated-identity-4376 allowlist entry — an explicit written admission that the "translation" is not one.

Consistent with the dispatch's own instruction that US example text shown to a zh/ja/ar user is worse than no placeholder. Absence is asserted per locale, because a re-added placeholder= attribute is invisible to every i18n gate (they only ever look at keys).

3. Read-only part order is locale-aware

formatAddress(addr, locale) now has two orders, the second being exactly the first reversed:

  • small-to-large (default; en and every Latin/Cyrillic/Arabic locale shipped) — Street, City, State ZIP, Country. Unchanged.
  • large-to-small (zh, ja, ko) — Country, ZIP State, City, Street. The reported case: 中国, 310000 浙江, 杭州, 中策路 1 号.

Only the ORDER varies. The separator stays ', ' in both — not what the issue reports, and these lines carry mixed-script data, where fullwidth punctuation would read stranger than the comma already there. The state/postal pair keeps its single comma group in both directions, and missing parts are still dropped rather than spaced over.

AddressCellRenderer: touched, and here is the measurement that says it had to be

The dispatch permits touching it only if it provably shares the formatter and unification is measured. It does, and it is:

  • formatAddress has exactly two callers on this branch — AddressField's readonly branch and AddressCellRenderer (grep -rn formatAddress packages/ apps/). Their sharing one definition is not incidental; it is the entire point of objectui#4037, which exists because that rule previously had two spellings.
  • Passing the locale on only one caller re-opens that exact drift, one build after it was closed: a zh console would show 中国, 310000 浙江, 杭州, 中策路 1 号 in a record's readonly form and the US-ordered line in the grid cell beside it — one stored address, two spellings, on two surfaces of one app.

The change there is two lines (useDisplayLocale() plus passing it), using the same resolver every other locale-aware cell renderer in that file already uses. Nothing else about the renderer moves: string / {} / unrecognized-shape branches are pinned unchanged under a zh provider.

Tests

New: AddressField.i18n.test.tsx (labels under en/zh/ja/ar with an English-literal negative; placeholder absence per locale; readonly order zh vs the es Latin control vs en), AddressField.no-provider.test.tsx (the FIELD_DEFAULTS half — English, no raw key, unchanged readonly line), AddressCellRenderer.locale-order.test.tsx (the display side, resolved through getCellRenderer so a registry regression fails it too).

Changed: InlineFieldInput.composite.test.tsx located two sub-inputs by placeholder text; re-spelled to getByLabelText. The suite is provider-free, so the labels resolve through FIELD_DEFAULTS — a more durable handle than the example text, and the one that names each empty box for a real user.

Reverse verification (fix committed first, then git checkout origin/main -- the three source files, re-run, restore): 16 failed / 9 passed. The nine that stay green are the intended no-op pins — en labels, the es / de / en order controls, and the non-address branches — which is the direction to expect here: this change is designed to leave English and Latin locales byte-identical, so a reverse run that went fully red would have meant the controls were not controlling anything.

Gates run locally (at c22a9a87e, the final commit)

gate result
vitest packages/fields/ 94 files, 1441 tests passed
vitest packages/i18n/ 45 files, 804 tests passed
vitest plugin-detail InlineFieldInput composite + delegation 2 files, 51 tests passed
type-check fields / i18n / plugin-detail Done (after building the dependency closure)
lint fields / i18n / plugin-detail 0 errors (warnings pre-existing)
check-changeset-presence.mjs 1 changeset declared for 15 changed source files
check:i18n-keys 2932 en keys, every call-site key resolves
check:i18n-drift 0 en values changed (5 keys added — parity's business, not drift's)
check:i18n-dead-keys the 5 new keys are not among the candidates
check:control-bytes OK, 4248 files
check:spec-symbols, check:phantom-deps, changeset:check pass

@object-ui/fields patch + @object-ui/i18n patch changeset added. (objectui has no skip-changeset mechanism; none was applied.)

Follow-up filed


Generated by Claude Code

…ders, localize part order

The five sub-labels ("Street Address", "City", "State / Province", "ZIP /
Postal Code", "Country") were English string literals with no i18n key, so a
non-English console rendered five English words inside an otherwise fully
translated address form and an app had nothing to key a bundle on — the parts
are not fields on the object. They now resolve through `fields.address.*`,
added to all ten locale packs; the `en` values and `FIELD_DEFAULTS` are
byte-identical to the literals, so English and provider-less rendering are
unchanged.

The five input placeholders (`123 Main St`, `San Francisco`, `CA`, `94102`,
`United States`) are removed rather than keyed: the correct example follows the
address's country, not the reader's language, and no channel in the stored
value can supply one today.

The readonly line's part order now follows `useDisplayLocale()` — zh/ja/ko read
largest-first, every other locale keeps the unchanged small-to-large order. The
display cell renderer passes the same locale through the same shared
`formatAddress`, so the two surfaces objectui#4037 unified cannot drift apart.
@vercel

vercel Bot commented Aug 15, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectui Ignored Ignored Aug 15, 2026 9:44pm

Request Review

Copy link
Copy Markdown
Collaborator Author

PM review — ACCEPT (reviewer of record, session session_01RnQd8iMMUwXQEV1crFmQiQ) — and a dispatch-premise correction this seat owes the record.

My re-scope was wrong, and the dev was right to override it. The dispatch claimed the five sub-labels were already keyed. They were not — they were still inline JSX literals on origin/main (AddressField.tsx L111–168). The error was mine and methodological: my pre-dispatch grep listed matching files through | head -5, and packages/fields/src/widgets/AddressField.tsx is the sixth entry of seven — the truncation silently converted a present hit into an absent one, and I concluded "already fixed" from a cut-off listing. Reproduced today with the full listing to confirm. Standing lesson for this seat, now applied: never conclude absence from a truncated listing — an existence check must run untruncated or count-based. The dev re-measured at the exact tree instead of trusting the dispatch, which is precisely the discipline we ask for; the issue's own acceptance criteria therefore governed, and implementing all three halves was correct, not scope creep.

Independent verification on the branch:

  1. Five labels keyed (fields.address.street/city/state/postalCode/country), placeholders gone (the only remaining match is a comment recording what used to be there). The DROP-not-key disposition is backed by measurement I find sound: placeholder examples teach a format the label already names, the right example follows the address's country while the widget writes no countryCode (so any UI-locale example is wrong cross-border), and keying would cost 50 invented fictional fragments plus untranslated-identity allowlist entries.
  2. AddressCellRenderer was touched under the dispatch's own stated condition and the measurement is on record: formatAddress has exactly two callers, and objectui#4037 exists to keep them identical — passing locale to one side only would re-fork that rule. Two lines, using the same useDisplayLocale resolver its sibling renderers already use.
  3. en/provider-less byte-identity pinned positively (the en pack values equal the replaced literals; no-provider tests assert it), and the reverse verification's 9 green survivors are the intended no-op pins — controls that stayed controlling.
  4. Concurrency discipline held: pack edits confined to the fields.address.* namespace, no overlap with PR feat(fields,components,i18n): author the spec's $icontains from FilterConditionField #4737's filterBuilder.operators.*; the second to merge rebases if the queue flags text conflicts.
  5. The reader-locale-vs-address-country limit is honestly recorded as finding Address part order follows the reader's locale because nothing carries the address's own country #4738 with the exact test named as the pin that must move if it is ever taken — graded separately.

Body: Fixes #4028 sole closing keyword (checked); fixture re-spell in plugin-detail is locator-only. Changeset present; no skip-changeset applied (the label exists in the registry but no gate consumes it). Gates at c22a9a87e: awaiting CI convergence — will flip ready + auto-merge once Lint + Type Check + Test shards 1-4 conclude success at this head.


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 24.7 KB 350 KB
Entry file index-Di_dgTym.js
Status PASS

📦 Bundle Size Report

Package Size Gzipped
app-shell (index.js) 9.56KB 3.59KB
app-shell (runtime-config.js) 7.42KB 2.32KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 8.92KB 3.41KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 1.17KB 0.53KB
auth (AuthProvider.js) 25.13KB 5.40KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.13KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.64KB 2.21KB
auth (SocialSignInButtons.js) 9.60KB 3.89KB
auth (UserMenu.js) 3.40KB 1.22KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 38.46KB 10.17KB
auth (createAuthenticatedFetch.js) 6.34KB 2.43KB
auth (index.js) 2.35KB 1.07KB
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.02KB 0.88KB
auth (useIsWorkspaceAdmin.js) 1.61KB 0.85KB
collaboration (CommentThread.js) 26.07KB 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.65KB 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) 490.20KB 108.71KB
core (index.js) 3.79KB 1.52KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 157.05KB 43.28KB
fields (index.js) 230.75KB 57.26KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (currency.js) 1.22KB 0.64KB
i18n (i18n.js) 4.32KB 1.77KB
i18n (index.js) 3.35KB 1.38KB
i18n (pickLocalized.js) 3.69KB 1.73KB
i18n (provider.js) 23.12KB 7.62KB
i18n (useDisplayLocale.js) 2.84KB 1.45KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 7.77KB 3.13KB
layout (index.js) 38.98KB 10.85KB
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.74KB
mobile (index.js) 1.50KB 0.62KB
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.71KB 0.42KB
mobile (useResponsiveConfig.js) 1.36KB 0.63KB
mobile (useSpecGesture.js) 4.32KB 1.64KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 9.35KB 3.31KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.89KB 1.22KB
permissions (evaluator.js) 4.41KB 1.44KB
permissions (index.js) 0.91KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.52KB
permissions (usePermissions.js) 1.81KB 0.83KB
plugin-ai (index.js) 15.75KB 3.80KB
plugin-calendar (index.js) 46.86KB 12.91KB
plugin-charts (index.js) 64.44KB 18.28KB
plugin-chatbot (index.js) 181.21KB 43.14KB
plugin-dashboard (index.js) 121.90KB 31.76KB
plugin-designer (index.js) 212.39KB 42.83KB
plugin-detail (index.js) 239.81KB 59.97KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.72KB 27.70KB
plugin-gantt (index.js) 164.30KB 40.02KB
plugin-grid (index.js) 192.10KB 51.24KB
plugin-kanban (index.js) 52.72KB 14.54KB
plugin-list (index.js) 111.14KB 26.92KB
plugin-map (index.js) 18.16KB 5.81KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 41.38KB 11.09KB
plugin-timeline (index.js) 26.68KB 7.66KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 83.81KB 20.49KB
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.71KB 3.53KB
providers (index.js) 0.44KB 0.22KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.67KB 2.37KB
react (LazyPluginLoader.js) 3.77KB 1.33KB
react (SchemaRenderer.js) 27.64KB 9.44KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.28KB 0.68KB
react (schema-input.js) 1.45KB 0.83KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 4.09KB 1.74KB
sdui-parser (index.js) 4.47KB 2.03KB
sdui-parser (parse.js) 10.04KB 2.82KB
sdui-parser (types.js) 0.29KB 0.24KB
sdui-parser (validate.js) 4.69KB 1.48KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 0.99KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 0.20KB 0.18KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 0.20KB 0.18KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.87KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-retry.js) 4.32KB 2.02KB
types (index.js) 3.05KB 1.52KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 2.59KB 1.31KB
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 (spec-report.js) 5.05KB 1.93KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 0.20KB 0.18KB
types (ui-action.js) 3.40KB 1.71KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 15, 2026 21:56
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 15, 2026
Merged via the queue into main with commit 69251bf Aug 15, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4028-addressfield-locale-remainder branch August 15, 2026 21:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Console: AddressField sub-labels and placeholders are hardcoded English literals — an address field is untranslatable on any non-English console

2 participants