Skip to content

fix(components): declare the I18nLabel arm on element:text_input's label / placeholder / description - #5736

Merged
os-zhuang merged 2 commits into
mainfrom
claude/issue-5717-text-input-declared-arms
Aug 23, 2026
Merged

fix(components): declare the I18nLabel arm on element:text_input's label / placeholder / description#5736
os-zhuang merged 2 commits into
mainfrom
claude/issue-5717-text-input-declared-arms

Conversation

@os-zhuang

@os-zhuangos-zhuang commented Aug 23, 2026

Copy link
Copy Markdown
Contributor

Fixes#5717

element:text_input declares the inline-translation arm on label, placeholder and description, so the manifest gate stops reporting type-mismatch on a locale map its own renderer has always resolved correctly.

The defect, measured

@objectstack/spec types all three keys as the I18nLabel union. Measured here on the installed 17.1.0 pin, per key, from the schema's own verdicts rather than copied from the card:

label -> ["string","object"]
placeholder -> ["string","object"]
description -> ["string","object"]
defaultValue -> ["string","number"]

text-input.tsx has resolved all three through pickLocalized at their read sites (:96 / :97 / :98) since it was written. Only the ComponentMeta entries stayed at a single 'string' arm (:146 / :147 / :183). Driven through the same manifestFromConfigs + validateTree pair the JSX-page compiler (renderers/layout/page.tsx:462) and the save gate use, an author writing { en: 'Owner', 'zh-CN': '负责人' } got three warnings on a write the screen renders correctly. Before, on d8afbe519:

PROBE label declaredArms="string" diags=["type-mismatch :: element:text_input prop \"label\" expected a string"]
PROBE placeholder declaredArms="string" diags=["type-mismatch :: element:text_input prop \"placeholder\" expected a string"]
PROBE description declaredArms="string" diags=["type-mismatch :: element:text_input prop \"description\" expected a string"]
PROBE defaultValue declaredArms=["string","number"] diags=["type-mismatch :: element:text_input prop \"defaultValue\" expected a string or a number"]

After, same probe, same path:

PROBE label declaredArms=["string","object"] diags=[]
PROBE placeholder declaredArms=["string","object"] diags=[]
PROBE description declaredArms=["string","object"] diags=[]
PROBE defaultValue declaredArms=["string","number"] diags=["type-mismatch :: element:text_input prop \"defaultValue\" expected a string or a number"]

The gate is the subject here, not the types: the defect is a diagnostic emitted by validateTree over a manifest, and no type-level or declaration-level assertion runs that path.

Why this is the inverse of #5590 / #5637

Every other member of this family earned its object arm in the change that taught its render site to resolve the map. element:record_picker.emptyText (#5590) and that block's label / placeholder (#5637) each held one arm for exactly as long as their renderer dropped the map — declaring an arm the renderer drops advertises a shape that never reaches the screen.

Here the render site was never behind. That is the same rule's other half, and ComponentInput.type states it in its own words: withholding an arm the renderer resolves makes the gate contradict itself on the write it just recommended, at warning severity — "which is worse than it sounds, because noise on legal writes trains authors (AI authors included) to dismiss the unknown-prop and type-mismatch reports that ARE real."

No renderer behaviour changed. This is a declaration fix.

Look-item 1 — does description travel with the two label-ish keys?

Yes, all three travel together. The card was right that description has a different destination, and the destinations really are three different things:

keydestination in the rendered output
labela label element above the field, tied to it by htmlFor
placeholderthe native placeholderattribute, inside the field
descriptiona p element below the field

Destination is not what decides an arm. ComponentInput.type names exactly two conditions — the contract accepts the shape, and the renderer resolves it — and description satisfies both identically to label: the same pickLocalized(props.X, language) call shape one line apart, and the same string-or-locale-map contract (I18nLabel, a union of a string with a string-valued record), each measured per key rather than inherited from its neighbours. A destination-based split would have declared an arm on one key and withheld it on another for a difference neither the gate nor the contract can observe, and would have left description emitting type-mismatch on a value that renders correctly — the very defect this card is about, kept alive on one third of it.

The destinations are pinned anyway, in text-input-i18n-label-arms.test.tsx: one case writes a distinct map per key and asserts each lands in its own place, because three identical maps cannot tell "each key reached its own destination" from "one value was rendered three times".

There is one behavioural difference between them that the arms cannot carry, so it went into the descriptions instead: label is announced to assistive technology and description is not. Filed separately as #5735 — not fixed here.

Look-item 2 — the specimen pin

Added to apps/console/src/__tests__/component-input-union-specimens.test.ts as its own describe, shaped like the #4970 block next to it — arms derived from ElementTextInputPropsSchema's own verdicts, never restated, so either side moving turns it red: a spec release that drops the map arm, or a declaration that grows one the spec rejects. It carries the derivation guard the house style requires (a schema that refused every probe would return [] and make the comparisons agree vacuously), a per-arm control on every specimen, and a whole-node case reproducing the exact write the card reported.

Two files, two halves, neither subsuming the other: text-input-inputs-spec-parity.test.ts goes red when the declaration and the contract disagree; the specimen file goes red when the gate and the declaration do.

The defaultValue control — verdict after this change

Still reports type-mismatch on an I18N_MAP, unchanged and green as a failure assertion. That is the assertion at component-input-union-specimens.test.ts which keeps this widening per-key rather than blanket, and it was run first and last. defaultValue's spec type is string | number — a contract with no object arm, re-measured here — so its arms did not move and must not. This PR also adds the separation as evidence rather than convention: both files now assert specAcceptedArms(..., 'defaultValue') === ['string','number'] from the contract, next to the trio's ['string','object'].

Ablations — both legs confirmed on disk

Vitest resolves @object-ui/* to sibling src/ through the root config's alias table, so these run against source and no dist is involved. Each leg confirmed its mutation on disk with anchored counts before the run, and restored through a trap ... EXIT INT TERM.

Ablation A — revert the three arms to 'string', keep the tests. Predicted: the new declaration assertions go red, the renderer pin stays green. Observed: mutation 3 -> 0 widened / 0 -> 3 narrow on disk; 7 failed, 34 passed; the seven are exactly the four new specimen cases and the three parity it.each arms. text-input-i18n-label-arms.test.tsx stayed entirely green — the renderer was untouched.

Ablation B — drop pickLocalized at the three read sites, keep the declaration. Predicted: the renderer pin goes red, both declaration files stay green; failure mode a throw for the two React-child positions and [object Object] for the attribute. Observed: mutation 3 read sites replaced, markers confirmed on disk; 6 failed, 35 passed, all six in the renderer pin, and the mode was the throw:

Error: Objects are not valid as a React child (found: object with keys {en, zh-CN}).

byte-consistent with what inline-locale-label-read-sites.test.tsx measured for the same class one key over. Both declaration files stayed green. That asymmetry is why the renderer pin exists: after this change the declaration rests on "the renderer resolves it", and nothing pinned that for these three keys — delete the resolution today and every arm assertion still passes.

Family sweep

Swept packages/** for the same shape — a renderer resolving pickLocalized(props.X, ...) while declaring a single 'string' arm for X. No hits after this change. The instrument was controlled against the pre-fix file, where it returns exactly label, placeholder, description, so the empty result is a measurement rather than a broken regex.

Verification

Union re-run on the final commit 9711f5ca8, after the last commit, quoting each gate's own verdict line:

gateverdict
pnpm --filter @object-ui/components --filter @object-ui/console type-checkpackages/components type-check: Done · apps/console type-check: Done
pnpm --filter @object-ui/components --filter @object-ui/console lint0 errors, 910 warnings · 0 errors, 202 warnings
vitest run packages/components/Test Files 183 passed (183) · Tests 1654 passed (1654)
vitest run apps/console/Test Files 72 passed (72) · Tests 790 passed (790)
check-changeset-presence.mjs4 source file(s) of 2 released package(s) changed, and this change declares 1 changeset(s)
check-changeset-fixed.mjs / check-changeset-no-major.mjsexit 0
check-control-bytes.mjsOK (scanned 4784 tracked text file(s); skipped 85 binary)
check-doc-component-types.mjsEvery documented component type is registered.
check:i18n-keysEvery in-scope call-site key resolves against the en pack (2924 keys)
check:spec-symbols1295 files scanned against 4966 spec export names

vitest run apps/console/ was green on be690be13; the only delta to 9711f5ca8 is a JSX comment inside a packages/components test file, and the four directly-affected test files were re-run on 9711f5ca8 (47 passed). The lint numbers are the repo's known warning debt — lint.yml sets no --max-warnings deliberately; this gate is about errors, and there are none. packages/components dropped 911 to 910 because this PR removes an inert eslint-disable directive that ESLint itself reported as unused.

Lint scope note: objectui's pnpm lint is turbo run lint over per-package eslint ., so the two packages above are a complete cover of every file in this diff, not a sample. ESLint here is not type-aware — eslint.config.js configures no project / projectService — so this diff cannot move the verdict on any file it does not touch.

Out of scope


Generated by Claude Code

os-project-managerand others added 2 commits August 23, 2026 01:48
…bel / placeholder / description (#5717)
The renderer has resolved all three keys through `pickLocalized` since it was
written, while the `ComponentMeta` entries still declared a single `'string'`
arm — so `validateTree` reported `type-mismatch` on a locale map the contract
admits and the screen renders correctly in the viewer's language.
The inverse of #5590 / #5637, where the declaration correctly stayed narrow
until the render site caught up. Here the render site was never behind.
Per-key, not blanket: `defaultValue` keeps `['string','number']` (no object arm
in its contract) and the console specimen control asserting that stays green.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
… an inert lint directive
ESLint reports the copied `eslint-disable-next-line react-hooks/static-components`
as UNUSED under this repo's own config — the rule never fires at that site. The
reason the pattern is safe is worth keeping; the suppression that never
suppressed anything is not.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EuPCi56cnGyykygi3z9w4m
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 52 chunks)3916.2 KB3990.2 KB
Main entry chunk (gzip)152.5 KB350 KB
Entry fileindex-CMLBWANN.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 (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)10.04KB3.72KB
app-shell (runtime-config.js)12.80KB4.47KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)32.45KB8.06KB
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)11.67KB4.55KB
auth (index.js)3.19KB1.44KB
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.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
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)510.39KB114.67KB
core (index.js)4.92KB1.97KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)164.55KB45.67KB
fields (index.js)238.40KB59.89KB
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.62KB3.26KB
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.53KB3.38KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)4.64KB1.50KB
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.93KB0.88KB
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.41KB32.95KB
plugin-designer (index.js)212.30KB42.80KB
plugin-detail (index.js)242.34KB60.98KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)125.63KB30.64KB
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.80KB27.20KB
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.61KB20.74KB
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)12.13KB3.65KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
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-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (index.js)3.59KB1.79KB
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

2 participants

@os-zhuang@os-project-manager