Skip to content

fix(components): record_picker emptyText resolves the inline locale map its contract admits - #5636

Merged
os-sales merged 1 commit into
mainfrom
claude/issue-5590-record-picker-emptytext
Aug 21, 2026
Merged

fix(components): record_picker emptyText resolves the inline locale map its contract admits#5636
os-sales merged 1 commit into
mainfrom
claude/issue-5590-record-picker-emptytext

Conversation

@claude

@claudeclaudeBot commented Aug 21, 2026

Copy link
Copy Markdown
Contributor

Fixes#5590

element:record_picker's emptyText rendered straight into a text node while the
contract has admitted the inline per-locale map since rc.6. This resolves it at the
render site and widens the declaration in the same change, which is the order the
repo's own ComponentInput.type rule prescribes.

Premise re-verified before implementing (triage asked for this explicitly)

The card's line numbers were located by code on origin/main (f52d36c96), not
taken on trust — all five are exact:

cardcode found there
:78emptyText?: string;
:213( p className="text-xs text-muted-foreground" ){props.emptyText ?? 'No records'}( /p )
:301name: 'emptyText',
:308// no locale resolution, so the map form does not render (objectui#4163).
:318the description ending … audit in objectui#4163; this input publishes the string form deliberately …

The inherited rationale holds at this site, and the contract half is measured
rather than inherited.
Against the installed pin (@objectstack/spec@17.0.0 GA,
not rc.6), ElementRecordPickerPropsSchema:

emptyText: "No records" ACCEPT "No records"
emptyText: { en, zh-CN } ACCEPT {"en":"None","zh-CN":"无记录"}
emptyText: { default: "None" } ACCEPT {"default":"None"}
emptyText: 42 REJECT [["invalid_union","emptyText","Invalid input"]]
emptyText: ["a"] REJECT [["invalid_union","emptyText","Invalid input"]]
emptyText: { en: 1 } REJECT [["invalid_union","emptyText","Invalid input"]]
emptyText def: optional -> union -> arms: string | record

So the declared-vs-enforced gap is real on the pin this repo actually resolves, and
pickLocalized is the right helper for the reason the ruling gives: the destination
is a text node, and pickLocalized spells a miss as '' where the spec's
resolveI18nLabel spells it undefined. No fork to report.

One correction to the card's framing, which strengthens rather than weakens it: the
dropped arm was not a silent mis-render. React refuses a plain object in a child
position rather than stringifying it, so the map form threw and took the whole picker
subtree with it — see the measured output under Reverse-verification.

What changed

  • record-picker.tsx read sitepickLocalized(props.emptyText ?? 'No records', language),
    with useObjectTranslation() placed above the block's early returns so hook order
    stays stable across resolution states. The default is applied before resolution
    deliberately: an absent key still means "No records", and an authored '' still
    renders empty rather than being promoted to the default (a || default would have
    broken the second case silently; it has its own test).
  • The local prop typeemptyText?: string | I18nLabel.
  • The ComponentMeta entrytype: 'string'type: ['string', 'object']. The
    narrowing was correct for exactly as long as it was true; with the map arm now
    reaching the screen resolved, withholding it would be the false declaration in the
    other direction — the manifest gate reporting type-mismatch on a legal write this
    same input's description teaches the author to make.
  • apps/console/.../component-input-union-specimens.test.ts — the pin that
    asserted the opposite (toEqual(['string']) + a type-mismatch on the map) is
    flipped. It named this exact release condition in its own words — "keeps its single
    'string' arm until the render site catches up"
    — so this is the pin firing as
    designed, not a pin being deleted to go green. Its controls are kept and extended
    (42 and ['No records'] must still report type-mismatch), and the property the
    file exists to guard — that the widening is per-key, not a blanket "strings may
    also be objects" — is still asserted by element:text_input.defaultValue, whose
    I18N_MAP control must stay red because its spec type has no object arm.

Stale-#4163 sweep — the whole class in this file, not the two the card names

The card named :308 and :318. Sweeping the file for the class found a third,
about fifty lines above, which names the narrowing without citing the number and would
have survived a grep for 4163:

:256 // arms (objectui#3832), and `emptyText` below, which keeps one arm for a
// render-site reason rather than a type-system one.

All three are rewritten. grep -c '4163' record-picker.tsx is now 0 (was 2 — the
third site carried no number).

One site outside this file, named here rather than left false

packages/types/src/base.ts:405 cited this very key as the ComponentInput.type
doc's worked example of an arm deliberately withheld ("element:record_picker.emptyText
keeps a single 'string' arm for exactly that reason — objectui#4163"
). Landing the
widening without it would leave a false statement about the key this PR just changed,
authored by this PR. It is corrected in the same change, keeping the teaching and the
attribution intact. This is not a fold of #5591: that finding names
packages/i18n/src/pickLocalized.ts:132 and
packages/plugin-dashboard/src/WidgetConfigPanel.tsx:518, and neither is touched here.

Reverse-verification — three legs, all mutated on disk and proven both ways

Each leg proves the mutation landed with anchored counts in both directions
(injected text present ≥1, deleted text absent = 0) before any reading is taken, and a
a trap ... EXIT INT TERM restores the tree on every exit path including a cap
SIGTERM. No rebuild leg is owed: both vitest projects alias the workspace packages to
src, not dist (vitest.config.mts:264, apps/console/vite.config.ts:389) — and
the legs going red is itself the proof that the mutated source is what ran.

Leg A — remove pickLocalized from the read site. Predicted red; observed red,
3 failed | 4 passed (7):

× resolves the map to the active language — the case that THREW before
× resolves the SAME map differently for a different language
× falls back from a region tag to the base language the author wrote
Error: Objects are not valid as a React child (found: object with keys {en, zh-CN}).

That message is byte-identical to the pre-fix harm inline-locale-label-read-sites.test.tsx
recorded for schema.label, one key over in the same class.

Leg B — revert the declared arms to a single 'string'. Predicted red; observed
red, 1 failed | 9 passed (10):

× `element:record_picker.emptyText` accepts the inline translation map once its render site resolves one
AssertionError: expected [ 'string' ] to deeply equal ArrayContaining ["string", "object"]

Leg C — swap pickLocalized for this file's own toText. This is the leg that
justifies the test design, and its result is asymmetric by prediction: toText
reaches o.en as its fourth fallback, so a toText-based "fix" resolves English
correctly and ignores the active language. Observed 2 failed | 5 passed (7) — the
zh-CN case and the base-language fallback go red while resolves the SAME map differently for a different language (the en case) stays GREEN. A test asserting
only the English string would have shipped green against the wrong helper; the
non-English case is what pins the helper the ruling names.

Restore leg proven with the same two-way anchoring, and git status on both mutated
files is empty — byte-identical to the committed fix.

Tests and gates — run at d91c19ddd

Affected suites, from the repo root (package-cwd vitest is refused by the repo's own
guard, objectui#3378) — Test Files 7 passed (7), Tests 251 passed (251), exit 0:
the new pin, record-picker-element-data-source, record-picker-inputs-spec-parity,
page-variables, inline-locale-label-read-sites, widget-dom-leak-sweep, and the
console specimens.

  • packages/types suite — Test Files 40 passed (40), Tests 460 passed (460).
  • type-check: packages/components + packages/types exit 0; apps/console exit 0.
    ⚠️ The console leg is red until its dependency closure is built — the first run
    produced fourteen TS2307: Cannot find module '@object-ui/*', which is the unbuilt-dist
    trap and not a finding; pnpm --filter '@object-ui/console^...' build then exit 0.
  • check:control-bytes✅ check-control-bytes: OK (scanned 4681 tracked text file(s); skipped 85 binary).
  • check:phantom-deps✅ Every in-scope import is declared by the package that publishes it.
    (the leg that matters for the two new imports: @object-ui/i18n and @objectstack/spec
    are both already declared by packages/components.)
  • check:self-import✅ No package names itself inside its own src/.
  • check:esm-specifiersSpecifier leg: no un-ledgered package emits an extensionless relative specifier.
  • check:spec-symbols✅ spec symbol derivation: 1290 files scanned against 4912 spec export names.
  • Repo-wide eslint . --no-inline-config was run in full rather than narrowed:
    3505 files, 89 errors across 73 files, none of them in this diff (all
    pre-existing on origin/main). This PR's four files: 0 errors, 35 warnings, all
    @typescript-eslint/no-explicit-any matching the surrounding code.
  • Declared narrowing, one item: the wholepackages/components suite (177 test
    files) did not finish inside the foreground budget under contention with the sibling
    agents on this container (timeout 540 → exit 124, still collecting). Narrowed to the
    suites this diff touches, listed above. CI runs the farm regardless.

DOM-leak ledger (#5574 / PR #5629)

widget-dom-leak-sweep.test.tsx ran and is green — it is inside the 251 above. No
ledger row needed editing
: this change moves the text content of the empty-state
p element, not record-picker's emitted attribute set, so the exact-set-equality assertion
in both directions is unmoved. The gate was run, not skipped or worked around.

Published-surface reachability

Yes — the widened declaration is reachable from the package's published exports,
flagged here for the contract-review rule before landing. packages/components exports
exactly "." and "./style.css"; src/index.ts:36 is import './renderers';, so any
consumer of @object-ui/components gets element:record_picker registered with the
widened inputs. It does not reach sdui.manifest.jsonelement:record_picker
is deliberately absent from PUBLIC_BLOCKS ("record picking is a field widget, not a
page block") — but the inputs are a live prop whitelist anyway: renderers/layout/page.tsx:462
builds the JSX-page compiler's whitelist from getKnownTypes() plus these same entries.
Net effect for an author: a JSX page or saved node writing the map form stops drawing
type-mismatch on a write the contract accepts and the renderer now resolves.

Changeset

.changeset/record-picker-empty-text-i18n-5590.md, @object-ui/components: patch.
scripts/check-changeset-presence.mjs verdict, quoted:

✅ 3 source file(s) of 3 released package(s) changed, and this change declares 1 changeset(s): .changeset/record-picker-empty-text-i18n-5590.md.

No @object-ui/types entry: that file's change is a doc comment with no emitted
behaviour, and the gate above is satisfied.

Out of scope — filed, not fixed here

label and placeholder on this same block carry the samestring | Record< string, string >
union (measured in the same probe run) and neither is resolved: placeholder is passed
through raw, and label goes through this file's toText, which reaches o.en
unconditionally and so renders English to every locale. Both are separate keys with their
own render-site questions, outside this card's fence — filed unassigned rather than folded
in, and listed in the dev report.


Generated by Claude Code

…ap its contract admits
`packages/components/src/renderers/basic/record-picker.tsx` rendered
`props.emptyText` straight into a text node while the contract has admitted
`I18nLabel` (`string | Record< string, string >`) since spec 17.0.0-rc.6 —
measured on the installed 17.0.0 GA pin, where
`ElementRecordPickerPropsSchema.safeParse({ object: 'account', emptyText: { en, 'zh-CN' } })`
succeeds and the member resolves to `optional(union(string, record))`.
React refuses a plain object in a child position rather than stringifying it, so
the dropped arm was not a mis-rendered empty state: the whole picker subtree
threw `Objects are not valid as a React child (found: object with keys {en, zh-CN})`.
The read site now resolves through `pickLocalized` — the objectui-side helper the
sibling text-node sites already read through, `''` on a miss rather than the spec
resolver's `undefined`. The default is applied before resolution, so an absent key
still means "No records" and an authored empty string still renders empty.
The `ComponentMeta` entry, narrowed to a single `'string'` arm precisely because
the renderer dropped the other one, now declares `['string', 'object']`. The
`apps/console` specimen that pinned the narrow arm named this release condition in
its own words ("keeps its single `'string'` arm until the render site catches up")
and is flipped here with its controls kept.
Three comments in this file deferred the gap to objectui#4163, which closed as
completed on 2026-08-15 while the gap was still open; the file now carries no
reference to it. The `ComponentInput.type` doc in `@object-ui/types` cited this
same key as its worked example of an arm deliberately withheld, and is corrected
in the same change so the example stays true.
Fixes#5590
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3785.8 KB3867.2 KB
Main entry chunk (gzip)151.6 KB350 KB
Entry fileindex-d34_UdYR.js
StatusPASS

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

PackageSizeGzipped
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)8.91KB2.99KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)29.34KB7.05KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)6.35KB2.43KB
auth (index.js)2.77KB1.22KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.02KB0.89KB
auth (useIsWorkspaceAdmin.js)3.04KB1.45KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)506.96KB113.71KB
core (index.js)4.51KB1.80KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)159.80KB44.33KB
fields (index.js)238.85KB60.13KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.22KB3.08KB
i18n (provider.js)23.13KB7.63KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)7.77KB3.13KB
layout (index.js)38.95KB10.97KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)9.35KB3.31KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.42KB1.42KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)1.81KB0.83KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.62KB12.83KB
plugin-charts (index.js)64.65KB18.32KB
plugin-chatbot (index.js)181.41KB43.22KB
plugin-dashboard (index.js)128.36KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.15KB60.89KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.07KB30.43KB
plugin-gantt (index.js)164.10KB39.87KB
plugin-grid (index.js)200.79KB54.26KB
plugin-kanban (index.js)52.93KB14.60KB
plugin-list (index.js)111.70KB27.17KB
plugin-map (index.js)20.06KB6.62KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.49KB11.93KB
plugin-timeline (index.js)26.68KB7.66KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.50KB20.68KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)43.66KB14.77KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.33KB0.69KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (index.js)4.77KB2.16KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)10.76KB3.17KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)6.92KB2.40KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)0.20KB0.18KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)0.20KB0.18KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.87KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.08KB1.53KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)0.20KB0.18KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

Sign up for freeto 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.

components: record-picker's emptyText drops the inline-locale-map arm the contract admits, and the audit tracking it (#4163) is closed

2 participants

@os-sales@claude