Skip to content

fix(app-shell): the Interfaces pillar's nav rail renders a locale-map label instead of crashing, and resolveSurface resolves it once (objectui#11158) - #11179

Merged
objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-11158-interfaces-rail-label-map
Sep 30, 2026
Merged

objectstack-fleet[bot] merged 1 commit into
mainfrom
claude/issue-11158-interfaces-rail-label-map

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #11158

Clause-②: no

What was wrong

The spec types a nav item's label as I18nLabel: a plain string or an inline locale map. In the Studio's Interfaces pillar:

  • NavTree rendered the label raw in three places: the group heading, the entry text, and the title attribute of an entry with no surface. A map made React throw Objects are not valid as a React child (found: object with keys {en, zh-CN}), and the pillar's whole rail was gone.
  • resolveSurface read String(node.label ?? '') into Surface.label, so the canvas caption and the breadcrumb printed [object Object].

What changed

  • One helper. NavTree reads the heading, the entry text and the title attribute through navItemLabelText(label, locale) (previews/navItemLabel.ts), in the designer locale from useMetadataLocale(). It is the helper the canvas card and the nav-item inspector already share. There is no second resolver.
  • resolveSurface resolves once. It takes the locale and carries the resolved text in Surface.label. All three callers pass the designer locale: the rail, the first-leaf auto-open in the load effect, and findSurfaceInTree behind the ?surface= restore. findSurfaceInTree still matches on { type, name } alone. No String() of a label is left in navSurface.ts.
  • The type tells the truth. NavNode.label is typed as the spec's I18nLabel, so tsc refuses a raw render (measured below).
  • locale is deliberately not a dependency of the load effect. A language switch must not re-run the load, because the load rehydrates the nav edit buffer. A comment at the call says so.

NavNode, Surface, resolveSurface and findSurfaceInTree are internal to app-shell. packages/app-shell/src/index.ts exports none of them, and the package's exports map has only . and ./styles.css. So the type change moves no published contract, which matches the claim's Clause-②: no.

Pins

  • StudioDesignSurface.interfacesRailLabelMap-11158.test.tsx mounts the real InterfacesPillar over a mocked metadata client, under en and under zh. Every nav item in the fixture parses against NavigationItemSchema. The fixture has a map-labelled first leaf, a map-labelled group, a plain-string leaf (the control), a nested map-labelled leaf, and a map-labelled url item. The cases:
    • Every rail row renders: the map labels as their text, the control string as authored. The url row is disabled and titled by its text. No console.error says "not valid as a React child", and no [object Object] appears.
    • The first-leaf auto-open prints the map text in the canvas caption and the breadcrumb.
    • ?surface=page:reports opens the nested map-labelled leaf, with its caption in the designer locale.
  • navSurface.test.ts:
    • resolveSurface carries the text under en-US and under zh-CN.
    • A plain-string label is carried as authored (the control).
    • An unlabelled item carries '', as before.
    • findSurfaceInTree finds the same leaf in both locales.
    • A compile-time expectTypeOf pins NavNode['label'] to I18nLabel | undefined.
    • The existing binding cases now pass LOCALE.

Reverse verification (one-time; the fix was committed first)

Each leg changed one anchor through ablation-replace.mjs, which checks the anchor count and the blob hash on disk. It then ran the pin files (or tsc) and restored the file. Each restore was proven by a blob equal to HEAD and an empty git diff HEAD. All legs ran at e591e1dfd.

Leg Mutation Predicted Observed
A1 entry text back to node.label pillar cases red 6 failed / 30 passed; React logged the card's exact message
A2 resolveSurface back to String(node.label ?? '') caption, breadcrumb, restore and unit map cases red; control green 9 failed / 27 passed (6 pillar cases, 3 unit map cases); the plain-string control stayed green
A3 group heading back to node.label pillar cases red 6 failed / 30 passed; the same React message
A4 title back to node.label only the rows case red 2 failed / 34 passed
T1 NavNode.label back to string tsc -p tsconfig.test.json red TS2344 at the expectTypeOf pin; TS2322 on the map fixtures
T2 group heading back to {node.label} tsc --noEmit red TS2322: the label type "is not assignable to type 'ReactNode'"

tsc -p tsconfig.test.json --listFilesOnly lists both test files (2 of 4802), so the test type-check compiles them. The pillar pin imports the component by relative path from src/, so no dist/ is on its path.

Verification at e591e1dfd

  • pnpm exec vitest run packages/app-shell/src/views/studio-design/ packages/app-shell/src/views/metadata-admin/previews/: 156 files and 1532 tests passed, including both pin files.
  • pnpm --filter @object-ui/app-shell type-check (tsc --noEmit && tsc -p tsconfig.test.json): exit 0. Its dependency closure was built first with turbo run build --filter=@object-ui/app-shell^... (28 of 28 tasks).
  • pnpm exec eslint on the four changed source and test files: 0 errors. There were 18 warnings before and 18 after, the same set. The load effect's existing exhaustive-deps warning now also names locale, which is deliberate (see above).
  • All passed:
    • node scripts/check-changeset-presence.mjs
    • pnpm check:control-bytes
    • pnpm check:new-line-citations (0 new citations)
    • check:pending-changeset-literals
    • check:vi-mock-specifiers, check:vi-mock-inherit, check:vi-mock-override-shape
    • check:test-path-roots
    • check:esm-specifiers (the specifier leg)
    • check:self-import
    • check-governed-queue-guard.mjs --test over the five changed paths: NOT GOVERNED
  • check:changeset-claims (report-only) names one pending changeset, 6681-declared-lazy-marketplace-routes.md, which cites StudioDesignSurface.tsx about its static import of SuggestedBindingsPanel. This diff adds no import to that file, so the paragraph still holds.

Declared narrowing. The repo-wide pnpm lint and pnpm test belong to CI. The eslint run is narrowed to the changed files, and that narrowing is itself a measurement:

  1. The repo config lints all four files: --format json returns 4 results, none of them ignored.
  2. The resolved config sets no parserOptions.project or projectService, so linting is not type-aware.
  3. No rule under eslint-rules/ reads from disk. A grep for fs reads matches none of them, and the same pattern matches scripts/check-control-bytes.mjs as a positive control.

So the diff cannot move a lint verdict on a file it does not touch.

NOT MEASURED

  • check:eager-closure, check:dist-completeness and check:published-dist: they need app-shell's own dist. The eager module set is unchanged, but only by reading the code: StudioDesignSurface.tsx already imported navItemLabel.ts eagerly.
  • check:phantom-deps: the new import is @objectstack/spec/ui. app-shell declares that package (^17.5.0), and 76 of its source files already import it.

Acceptance notes

  • Out of scope; measured; same family. Reported to the seat, not fixed here. The same load effect reads the app's own label with String(), and AppSchema.label is the same I18nLabel union. An uncommitted probe on this PR's harness labelled the app { en: 'Acme', 'zh-CN': '顶点' }. The rail heading then read [object Object] · Navigation under en and [object Object] · 导航 under zh. The probe was deleted and the tree is clean. The fix is outside this card's file surface, which covers NavTree and the auto-open caller only.
  • Observation; not measured. current holds the Surface as it was resolved when the leaf was opened. After a designer-language switch, the caption and breadcrumb keep the old locale's text until another leaf is picked, while the rail re-resolves on every render. A rename in nav editing leaves the caption stale in the same way, and that part predates this PR. This PR keeps the ruling's "resolves once" design; the note is for whoever next touches the caption.

Generated by Claude Code

…nd resolveSurface resolves it once (objectui#11158)

The spec types a nav item's label as I18nLabel (a string or an inline
locale map). NavTree rendered it raw, so a map threw "Objects are not
valid as a React child" and took the pillar's rail down; resolveSurface
read String(label), so the canvas caption printed [object Object].

- NavTree reads the group heading, the entry text and the title
  attribute through navItemLabelText in the designer locale, the helper
  the canvas card and the nav-item inspector already share.
- resolveSurface takes the locale and carries the resolved text in
  Surface.label, so the rail, the first-leaf auto-open and the
  ?surface= restore (findSurfaceInTree) carry the same text.
  findSurfaceInTree still matches on {type,name} alone.
- NavNode.label is typed as the spec's I18nLabel, so the compiler
  refuses the next raw render.

Pins: the real InterfacesPillar under en and zh (rail rows, caption,
breadcrumb, ?surface= restore, no React child error, the plain-string
control), and unit pins on resolveSurface / findSurfaceInTree.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011p7ikEivgXefNDaE5S5Uec
@github-actions

Copy link
Copy Markdown
Contributor

changeset-claim-re-read

⚠️ 1 pending changeset(s) describe a file this change touches

Their bodies publish verbatim into the CHANGELOG at the next release, so this is a request to re-read them against your diff — addressed here because you are the one seat that can answer it without re-deriving anything.

⛔ Nothing here blocks, and nothing here is a verdict on your change. This gate exits 0, is not a required context, and judges name resolution, never meaning: it asked whether a pending body names a file you touched. "Is this sentence still true?" is the one question it will not answer, and the one you are being asked to answer.

.changeset/6681-declared-lazy-marketplace-routes.md

  • names views/studio-design/StudioDesignSurface.tsx → packages/app-shell/src/views/studio-design/StudioDesignSurface.tsx — edited by this change

    Both marketplace chunks were held by CHUNK CO-TENANCY, not by any import of the page: rolldown had parked components/SuggestedBindingsPanel.tsx (statically imported by the eager views/studio-design/StudioDesignSurface.tsx) in the first, and console/marketplace/InstalledListWidget.tsx (bare-imported by the package barrel for its SDUI registration) in the second. An app-shell-eager-leaves group in apps/console/vite.config.ts isolates those co-tenants so the three declared-lazy pages chunk by their own dynamic-only reachability. The console eager closure moves 3180.2 KB → 3171.5 KB gzipped (−8,888 bytes, 48 → 45 eager chunks) with the three per-chunk ceilings unmoved.

Read the paragraph, not the line: both false halves of the objectui#8617 claim sat in one paragraph, and correcting either alone would have left it asserting the same wrong thing.

If a claim did go false, correct the body. That is precedented and prose-only, frontmatter untouched; check-changeset-overwrite.mjs will report the correction as its own case 2 ("correcting a declaration on purpose … legitimate"), which is the intended shape — one gate asks for the read, the other records the write.

Not covered, stated so nobody reads this as more: a born-false claim that spells no line address at all (objectui#9495 coordinated one by ORDINAL — "a grep finds that member first" — and deciding that means reading what the sentence means), a claim spelled as a symbol or a package rather than a backticked file name, and a file named ambiguously.

Compared the checked-out tree with 0ffc423b1 (merge-base with origin/main): 4 file(s) changed outside .changeset/, read against 1788 pending declaration(s) that publish a body (2395 pending in total). · run

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 330 chunks) 3569.8 KB 3607.4 KB
Main entry chunk (gzip) 149.5 KB 350 KB
Entry file index-DrIntm03.js —
Status PASS —

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

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.88KB 6.25KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.11KB 3.87KB
auth (ActiveOrganizationStorage.js) 27.95KB 10.04KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.22KB 10.61KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.17KB 5.40KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.72KB 2.24KB
auth (SocialSignInButtons.js) 9.70KB 3.93KB
auth (UserMenu.js) 3.39KB 1.21KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.70KB 10.94KB
auth (createAuthenticatedFetch.js) 8.54KB 3.46KB
auth (index.js) 3.63KB 1.64KB
auth (invitation-status.js) 1.22KB 0.70KB
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.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 27.13KB 7.95KB
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.68KB 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) 559.46KB 134.18KB
core (index.js) 9.94KB 3.94KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 227.08KB 62.96KB
fields (index.js) 261.19KB 66.27KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 2.59KB 1.22KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.24KB 2.27KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 39.40KB 12.91KB
i18n (translateFn.js) 0.20KB 0.18KB
i18n (useDisplayLocale.js) 3.52KB 1.76KB
i18n (useObjectLabel.js) 34.35KB 9.18KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 39.32KB 11.09KB
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.75KB
mobile (index.js) 1.99KB 0.87KB
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) 6.62KB 2.45KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 5.52KB 2.10KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.58KB 4.90KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.25KB 2.17KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.33KB 3.07KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 16.01KB 3.93KB
plugin-calendar (index.js) 52.17KB 15.06KB
plugin-charts (index.js) 84.09KB 22.93KB
plugin-chatbot (index.js) 198.22KB 46.97KB
plugin-dashboard (index.js) 138.73KB 37.05KB
plugin-designer (index.js) 215.78KB 44.42KB
plugin-detail (index.js) 234.78KB 62.36KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 172.30KB 44.19KB
plugin-gantt (index.js) 172.43KB 42.85KB
plugin-grid (index.js) 229.83KB 63.15KB
plugin-kanban (index.js) 48.43KB 15.11KB
plugin-list (index.js) 115.82KB 28.67KB
plugin-map (index.js) 22.90KB 7.62KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 44.04KB 12.21KB
plugin-timeline (index.js) 32.26KB 9.42KB
plugin-tree (index.js) 11.21KB 3.89KB
plugin-view (index.js) 90.43KB 22.76KB
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.81KB 3.58KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 119.55KB 39.23KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.50KB 2.06KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (body-dialect.js) 4.78KB 2.09KB
sdui-parser (codegen.js) 9.45KB 3.76KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 6.17KB 2.73KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.84KB 1.90KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 22.61KB 7.40KB
types (ai.js) 4.39KB 2.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 3.83KB 1.49KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 3.19KB 1.62KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.26KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 5.00KB 2.39KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 2.52KB 1.31KB
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 (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 19.82KB 7.15KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
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

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: e591e1dfd8325eb48a5057c24192621fda4dbaee
Local-runs: none

Inputs: card #11158 (its body and its four comments: triage 5901917705, seat note 5902337716, claim 5904947481, os-dev-report 5905816901), PR #11179 (body, the five-file list, the net diff against main at the head), the head's 42 check-runs read with filter=latest, and head/base file reads through the contents API. Binding direction: triage 5901917705.

① Derived judgments

Each direction line of 5901917705, judged against the diff:

  1. One helper — right. NavTree computes const labelText = navItemLabelText(node.label, locale) once per node and uses it for the group heading ({labelText} beside the Folder icon), the entry text ({labelText || surface?.name}) and the title of an entry with no surface (labelText || undefined). locale is useMetadataLocale(), which NavTree already called at the base (the diff adds no hook), and each recursive NavTree for a group's children calls the hook itself. No second resolver: navSurface.ts imports the same navItemLabelText from ../metadata-admin/previews/navItemLabel.js, and that helper is the spec's resolveI18nLabel(label, locale) ?? ''.
  2. resolveSurface resolves once — right. resolveSurface(node: NavNode, locale: string) sets const label = navItemLabelText(node.label, locale) and carries it in Surface.label. All three callers pass the designer locale: NavTree (resolveSurface(node, locale)), the first-leaf auto-open inside InterfacesPillar's app-load effect (resolveSurface(n, locale)), and findSurfaceInTree(tree, initialSurface, locale) behind ?surface=. findSurfaceInTree still matches on s.type === target.type && s.name === target.name; locale only reaches the found leaf's label. No String() of a label is left in navSurface.ts: the five String() calls remaining at the head wrap node.pageName, node.objectName, node.dashboardName, node.reportName and node.actionDef.actionName — binding names, not labels. The base's const label = String(node.label ?? '') is gone.
  3. The type tells the truth — right; not Clause-②. NavNode.label?: I18nLabel (a type import from @objectstack/spec/ui); Surface.label stays string, so every reader of a Surface still prints text. On the export question: NavNode, Surface, resolveSurface and findSurfaceInTree are module exports of navSurface.ts only. At the head packages/app-shell/src/index.ts exports from studio-design/ exactly StudioDesignSurface plus type StudioDesignSurfaceProps (which is { aiSlot?: React.ReactNode }), the four studio-canvas-preview.js values and its two prop types (neither names Surface or NavNode), and BuilderLanding; package.json exports is . and ./styles.css. InterfacesPillar is a module export, not a root export. The widened type and the added locale parameter move no published contract, as the dev said.
  4. The load effect's dependency array — deliberate, consistent with the ruling; the consequence is real. The deps stay [client, packageId, publishNonce, draftNonce, metadataRefreshNonce]; locale is read inside and not listed, with a comment saying why (a re-run rehydrates the nav edit buffer). Consequence: the setCurrent updater cur ?? deepLinked ?? firstLeaf stores a Surface whose label was resolved in that load's locale, and current is only replaced by a rail pick. After a designer-language switch the rail re-resolves on every render (it reads the hook), while the canvas caption {current.label || current.name} · {translateMetadataType(current.type, locale)} and the breadcrumb {current.label || current.name} keep the old locale's label text until another leaf is picked; the type half of the caption does switch, so the caption reads half-switched. The dependency array is not the cause: with locale listed, cur ?? … would keep the stored Surface just the same, so the staleness is a property of the ruling's "resolves once, the Surface carries text" design, not of this diff's dependency choice. Taken to ③ (b).
  5. Pins — right, and they cover the direction's three. StudioDesignSurface.interfacesRailLabelMap-11158.test.tsx mounts the real InterfacesPillar over a mocked metadata client under I18nProvider language en and zh (useMetadataLocale maps zh* to zh-CN, else en-US; the spec resolver hits zh-CN exactly and en as the base of en-US, so the fixture's { en, 'zh-CN' } maps resolve under both). Per language: (i) every rail row renders, map rows by their text, the plain-string Landing as authored (the control), the url row disabled and titled by its text, zero console.error containing "not valid as a React child", and no [object Object] in document.body; (ii) the first-leaf auto-open prints the map text in if-canvas-caption and if-breadcrumb; (iii) ?surface=page:reports opens the nested map-labelled leaf, captioned in the designer locale and not as the first leaf. A fixture-parse case pins every item, maps included, as NavigationItemSchema-valid. navSurface.test.ts adds 7 cases: the expectTypeOf pin on NavNode['label'] equal to I18nLabel | undefined, the map fixture parses, resolveSurface under en-US and zh-CN carries the text, the plain-string control in both locales, the unlabelled item carries '', and findSurfaceInTree finds the same {type,name} leaf in both locales; the 22 existing binding cases now pass LOCALE and are otherwise untouched.
  6. The six reverse-verification legs — consistent with the diff; judged by reading, nothing re-run. Each mutation names an anchor present in the diff, and the observed counts fit the pin structure of 36 cases (29 in navSurface.test.ts: 18 it less the 3 templated ones, plus 4 variants × 3, plus the 2-row it.each; 7 in the pillar file). A1 and A3 (entry text, heading raw): 6 red = 3 pillar cases × 2 languages, with the card's React message. A2 (String() back in resolveSurface): 9 red = those 6 (the rows case also asserts no [object Object] in the body) plus the 3 unit map cases, with the control, the '' case and the type pin green. A4 (title raw): 2 red = the rows case × 2, since React stringifies an object attribute rather than throwing. T1 (label back to string): TS2344 at the expectTypeOf pin plus TS2322 on the map fixtures. T2 (heading as {node.label}): TS2322 against ReactNode. Predicted direction matches observed in all six.
  7. Changeset — right. .changeset/interfaces-rail-label-map-11158.md declares '@object-ui/app-shell': patch; the PR body carries Clause-②: no.

Other derived checks: the title attribute's labelText || undefined keeps the base's behaviour for an unlabelled entry (no attribute) and gives a map its text; the entry text keeps the objectui#7254 fallback to surface.name when the label resolves to ''; the diff touches nothing outside the five listed files and carries no probe file.

Check-runs on the head (43 with filter=latest, all completed by 07:06Z; the last eight were still in progress when this review started and were waited on): 40 success, 3 skipped (dependabot, Test (coverage) and its shard job, skipped by design), 0 failures. The gate verdicts this diff can move are all green: Lint, Type Check, Test (shards 1 to 8 of 8) and the Test rollup, Test (dist pins), Build & E2E, Bundle Analysis, Changeset Bump Policy, Changeset Declaration, Changeset Claim Re-read, Changeset Fixed Group Check, Changeset Overwrite Report, Line Citation Gate, Control Byte Scan, Governed Surface Queue Guard, Inert vi.mock Specifier Check, Pre-Install Import Graph Check, Docs Route Eager Closure Check and Spec Main Shape Gate, with the doc and skill gates beside them.

② Semver level

Clause-②: no — confirmed against what the diff publishes. The only public-surface candidates (NavNode.label widened; resolveSurface and findSurfaceInTree gaining a required locale) live in navSurface.ts, which the package root never re-exports, and the one root-exported type from StudioDesignSurface.tsx does not reference them. What a consumer of @object-ui/app-shell sees is a behaviour fix in a Studio view: a rail that no longer crashes on spec-valid input and a caption that prints text. '@object-ui/app-shell': patch is the right level for the one released package whose source changed; the Changeset Bump Policy, Declaration, Claim Re-read and Fixed Group gates on the head agree.

③ Boundary flags

open_questions: none in 5905816901.

Deviations (five):

  • The directory test run passed the 10-minute foreground cap and finished in the background with exit 0. Process note, no bearing on the diff; CI's eight Test shards are the gate verdict here.
  • One lock queue-timeout (exit 99) on the out-of-scope probe, retried and then measured. Process note; nothing to act on.
  • The out-of-scope app-label finding was measured with an uncommitted probe, deleted afterwards. The PR's file list is the five files above; no probe file is in the diff. Answered.
  • Attribution: the harness reminder asked for a model-named trailer; the one commit e591e1dfd carries Co-authored-by: Claude plus a Claude-Session: line, and the PR body ends with the session-URL footer, per objectui AGENTS.md (read from pulls/11179/commits). Answered; no change asked.
  • The worktree was removed after the PR opened; the PR head is e591e1dfd and the branch is intact. Answered.

(a) InterfacesPillar's app heading through String() — real at the head, pre-existing, out of this card's scope; enumeration below. At the head, InterfacesPillar's app-load effect reads String(published[0].label ?? published[0].name ?? packageId) and, guarded only by typeof body.label === 'string' || typeof body.name === 'string', setAppLabel(String(body.label ?? body.name ?? label)); a map label beside a string name passes the guard and stringifies. appLabel feeds tFormat('engine.studio.if.navHeading', locale, { app: appLabel }) in the rail heading. Both statements are identical at the base 0ffc423b1 and sit outside every hunk of this diff: pre-existing and untouched. AppSchema.label is I18nLabelSchema in the spec, the same union as a nav item's, so the input is spec-valid. Out of scope by the claim's file surface (NavTree, resolveSurface, its three callers). Correctly named for the family's closure card rather than filed alone.

Enumeration for the closure card's nail: every remaining site in packages/app-shell/src/views/studio-design/ (non-test sources at the head) that reads a spec-typed I18nLabel through String(). Classification by the spec's own label types: AppSchema.label, PageSchema.label, DashboardSchema.label, ReportSchema.label and ActionSchema.label are I18nLabelSchema; ObjectSchema.label, FieldSchema.label, HookSchema.label, FlowSchema.label and PermissionSetSchema.label are z.string().

Sites that read an I18nLabel through String() — four, all the app's own label, all in StudioDesignSurface.tsx:

  1. StudioDesignSurface component, resolvePackageApp callback: String(a.label ?? a.name ?? '') over shellClient.list('app', { packageId }), landing in packageApp (the topbar's app detection). At the head packageApp is read only as a boolean gate, so this string never reaches the screen: a latent mis-read.
  2. StudioDesignSurface component, resolvePackageApp callback: String(d.label ?? d.name) over the first listDrafts({ type: 'app' }) header; same sink as site 1.
  3. InterfacesPillar, app-load effect: String(published[0].label ?? published[0].name ?? packageId) into the local label, then setAppLabel(label); visible in the rail heading.
  4. InterfacesPillar, app-load effect: setAppLabel(String(body.label ?? body.name ?? label)) under the typeof guard; visible in the rail heading (the dev's measured [object Object] · Navigation).

String(x.label …) sites that are NOT I18nLabel reads by the spec, listed so the nail is exact: object labels (z.string()) in StudioDesignSurface's loadPackageObjects, in InterfacesPillar's package-objects effect (pkgObjects, which feeds the inspector's object picker), in DataPillar's rail load, and in PackageOwdOverviewPanel (the published-label map and the per-row body.label); the field label in ObjectFormDesigner's fieldLabelOf; the hook label in ObjectHooksPanel's save toast and header span; the permission-set label in AccessPillar's rail load; and loadPackageSurfaces in packageSurfaces.ts, whose only caller passes 'flow'. navSurface.ts's remaining String() calls wrap names. Two adjacent notes for the same card: ObjectActionsPanel resolves ActionSchema.label (an I18nLabel) through its own local labelText with a fixed key order rather than the spec's resolveI18nLabel — not a String() read, but a second resolver of the same type; and StudioNavItemInspector's object picker renders {o.label} ({o.name}) from the object list, a z.string() label, so not a map.

(b) Caption staleness after a language switch or a rename — real by reading, not measured, not a blocker; escalated as the follow-up the dev named. Mechanism in ① item 4. The rename half predates this PR: at the base Surface.label was already a snapshot. The language-switch half is reachable only for map labels, which crashed at the base, so it is a property of the newly working path and of the ruling's design (a Surface carrying text), not a regression of a working behaviour; the card's pins are per-locale mounts and do not cover a live switch. A fix would re-derive current against the live tree by {type,name} when locale changes or the tree is edited, which is a ruling-level choice. Carrier per the dev: objectui#11167, the next card queued on StudioDesignSurface.tsx; the seat confirms the carrier.

Implemented-by: claude/issue-11158-interfaces-rail-label-map
Reviewed-by: session_011p7ikEivgXefNDaE5S5Uec

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 30, 2026 07:10
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 30, 2026
Merged via the queue into main with commit 3ab5150 Sep 30, 2026
45 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-11158-interfaces-rail-label-map branch September 30, 2026 07:30
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(app-shell): Studio's Interfaces pillar crashes its nav rail when a nav item's label is a locale map ("Objects are not valid as a React child")

2 participants