Skip to content

fix(i18n): pickLocalized reads own properties only and takes only string values on every limb (#3907) - #4359

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-3907-picklocalized-own-props
Aug 11, 2026
Merged

fix(i18n): pickLocalized reads own properties only and takes only string values on every limb (#3907)#4359
yinlianghui merged 1 commit into
mainfrom
claude/issue-3907-picklocalized-own-props

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes #3907

packages/i18n/src/pickLocalized.ts read four of its six limbs — the exact tag, the base language, default and en — with a bare bracket access, and skipped the typeof === 'string' filter that the regional and last-resort limbs already applied. Both guards now apply uniformly on every limb.

What was wrong

Bare access walks the prototype chain, so a locale that happened to name an Object.prototype member resolved to that member and the function stringified it into the label. Measured on origin/main @ 78b6c4a88 before the change, all seven members the card lists reproduce:

pickLocalized({ en: 'Pricing' }, 'constructor')          => "function Object() { [native code] }"
pickLocalized({ en: 'Pricing' }, 'toString')             => "function toString() { [native code] }"
pickLocalized({ en: 'Pricing' }, 'valueOf')              => "function valueOf() { [native code] }"
pickLocalized({ en: 'Pricing' }, 'hasOwnProperty')       => "function hasOwnProperty() { [native code] }"
pickLocalized({ en: 'Pricing' }, 'isPrototypeOf')        => "function isPrototypeOf() { [native code] }"
pickLocalized({ en: 'Pricing' }, 'propertyIsEnumerable') => "function propertyIsEnumerable() { [native code] }"
pickLocalized({ en: 'Pricing' }, 'toLocaleString')       => "function toLocaleString() { [native code] }"

The same four limbs also let a non-string value short-circuit the chain and render as the stringified object:

pickLocalized({ 'zh-CN': { n: 1 }, en: 'P' }, 'zh-CN')   => "[object Object]"   (limb 1)
pickLocalized({ zh: { n: 1 }, en: 'P' }, 'zh-CN')        => "[object Object]"   (limb 2)
pickLocalized({ default: { n: 1 }, ja: 'J' }, 'fr')      => "[object Object]"   (limb 4)
pickLocalized({ en: { n: 1 }, ja: 'J' }, 'fr')           => "[object Object]"   (limb 5)

The shape of the fix

A guard makes its limb miss; it does not abort the resolution. An unusable entry now falls through to the next limb exactly as an absent one does.

That distinction is the one correction this PR makes to the card's stated acceptance criterion. The dispatch note expected pickLocalized({ en: 'Pricing' }, 'constructor') to become ''; the correct converged answer is 'Pricing', because the prototype limb misses and the chain continues to limb 5 (en). '' is right only when no limb hits at all, e.g. pickLocalized({}, 'constructor'). Both are pinned. The already-hardened backend twin answers the same way, which is what the expectation was checked against:

resolveI18nLabel({ en: 'Pricing' }, 'constructor')  => 'Pricing'
resolveI18nLabel({}, 'constructor')                 => undefined  (i.e. '' after the `?? ''` bridge)

A test demanding '' for the first case would have pinned a fallthrough bug rather than this fix.

Why this is behaviour-neutral

No BCP-47 tag is an Object.prototype member, and the inline locale map is declared as a record whose values are z.string(), so no in-contract input can reach either guard. 20 control vectors covering every limb — exact tag, base language, regional upgrade, default, en, last resort, trimming, case asymmetry, empty map, empty-string values — resolve byte-identically to before, and were green on both sides of the change.

Cross-repo convergence

This collapses to zero the two departures that resolveI18nLabel (objectstack#6765, packages/spec/src/ui/i18n-label-resolver.ts) shipped with as documented, deliberate narrowings of this function — it reads own properties only and filters string on every limb, because on a server the locale can arrive in an Accept-Language header. The only remaining difference is how each side spells a miss: '' here for a text node, undefined there for a producer's ?? name chain.

packages/plugin-list/src/__tests__/i18nLabel-resolver-parity.test.ts (the six-limb parity pin from the #4208 train) is updated to say exactly that. Its header no longer claims one rule difference; a new CONVERGED table carries the 19 vectors that used to tell the two implementations apart — the only inputs that ever could — and asserts both sides now answer them identically, plus a one-pass check that the union of both tables has no disagreement left.

Upstream note (no objectstack change in this PR — that repo was read-only here). packages/spec/src/ui/i18n-label-resolver.test.ts carries a verbatim copy of this file as its reference fixture, pinned to origin/main 50fa3766 / blob 9e5d92a. Because the copy is local rather than imported, that suite stays green while silently going stale — it will not alert anyone. Two of its assertions encode this file's old behaviour and will need to flip when the copy is synced, and the resolver's own module doc still describes both departures as live. Reported to the PM for upstream relay rather than actioned here.

Verification

  • Red-first, before any source edit: 40 failed / 50 passed. The 40 reds are exactly the new finding: pickLocalized resolves a locale naming an Object.prototype member to that member, and renders its source text as the label #3907 pins (22 in the i18n suite, 18 in the parity suite); the 50 greens are the pre-existing suite plus every control.
  • After the fix: 90/90 green, both files.
  • Reverse verification, commit-then-revert (never git stash), both directions predicted before running:
    • Full revert — restoring pickLocalized.ts from origin/main while keeping the new tests reproduced the red-first numbers exactly: 40 failed / 50 passed.
    • Half revert — keeping the own-property check but dropping the typeof === 'string' filter turned red only the value-filter pins and left every prototype-member pin green: 15 failed / 75 passed, 0 of the reds being prototype pins. The two guards are independently load-bearing; neither masks the other.
  • type-check across i18n, plugin-list and all six consumer packages: 36/36 tasks successful (app-shell runs both tsc --noEmit and tsc -p tsconfig.typetests.json).
  • Own suites (packages/i18n/, packages/plugin-list/): 71 files, 1240 passed.
  • Consumer sweep — every package importing pickLocalized. components, plugin-detail, plugin-dashboard, plugin-designer, react: 275 files, 2691 passed; app-shell: 349 files, 3331 passed / 1 skipped.
  • lint on both changed packages: 0 errors. check:control-bytes, check:i18n-keys, check:i18n-drift, check-changeset-presence, check-changeset-no-major: all green.

Generated by Claude Code

…ing values on every limb (#3907)

Four of the six limbs — exact tag, base language, `default`, `en` — used bare
bracket access, so a locale naming an `Object.prototype` member resolved to that
member and rendered its source text as the label, and a non-string value
short-circuited the chain into `[object Object]`. Both guards now apply
uniformly; a guarded limb misses rather than aborting, so the chain continues.

Zero change for any real BCP-47 tag, which collapses the two departures the
backend twin `resolveI18nLabel` (objectstack#6765) recorded against this
function to zero. The parity table now pins that convergence.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
@vercel

vercel Bot commented Aug 11, 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 11, 2026 9:10pm

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Main entry (gzip) 29.6 KB 350 KB
Entry file index-5s8QrWZh.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) 22.10KB 4.37KB
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) 35.76KB 9.11KB
auth (createAuthenticatedFetch.js) 4.37KB 1.69KB
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) 4.91KB 0.87KB
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) 489.12KB 108.41KB
core (index.js) 3.04KB 1.15KB
create-plugin (index.js) 10.08KB 3.26KB
data-objectstack (index.js) 150.04KB 39.79KB
fields (index.js) 228.37KB 56.62KB
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.33KB 1.20KB
i18n (useObjectLabel.js) 27.59KB 6.63KB
i18n (useSafeTranslation.js) 4.52KB 1.96KB
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) 8.75KB 3.06KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 3.67KB 1.12KB
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.55KB 0.71KB
plugin-ai (index.js) 15.71KB 3.79KB
plugin-calendar (index.js) 45.23KB 12.45KB
plugin-charts (index.js) 62.18KB 17.67KB
plugin-chatbot (index.js) 180.33KB 42.79KB
plugin-dashboard (index.js) 121.56KB 31.56KB
plugin-designer (index.js) 210.91KB 42.67KB
plugin-detail (index.js) 238.95KB 59.76KB
plugin-editor (index.js) 2.46KB 1.10KB
plugin-form (index.js) 114.58KB 27.68KB
plugin-gantt (index.js) 164.14KB 39.98KB
plugin-grid (index.js) 188.00KB 49.94KB
plugin-kanban (index.js) 48.60KB 13.41KB
plugin-list (index.js) 110.10KB 26.74KB
plugin-map (index.js) 18.05KB 5.80KB
plugin-markdown (index.js) 13.72KB 4.69KB
plugin-report (index.js) 40.60KB 10.58KB
plugin-timeline (index.js) 26.21KB 7.52KB
plugin-tree (index.js) 8.50KB 2.88KB
plugin-view (index.js) 84.03KB 20.55KB
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) 23.71KB 7.96KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 1.23KB 0.66KB
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 11, 2026 21:25
@yinlianghui
yinlianghui added this pull request to the merge queue Aug 11, 2026
Merged via the queue into main with commit 405e808 Aug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-3907-picklocalized-own-props branch August 11, 2026 21:26
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.

finding: pickLocalized resolves a locale naming an Object.prototype member to that member, and renders its source text as the label

2 participants