Skip to content

fix(plugin-detail): synthesize page components in the spec properties carrier so Studio page-create persists (#4232) - #4290

Merged
yinlianghui merged 1 commit into
mainfrom
claude/issue-4232-studio-page-create-strict
Aug 11, 2026
Merged

fix(plugin-detail): synthesize page components in the spec properties carrier so Studio page-create persists (#4232)#4290
yinlianghui merged 1 commit into
mainfrom
claude/issue-4232-studio-page-create-strict

Conversation

@yinlianghui

Copy link
Copy Markdown
Collaborator

Fixes#4232

Creating a page in Studio never completed. The create path seeds a record page's
regions from buildDefaultPageSchema(objectDef) (app-shell
views/metadata-admin/anchors.tscreateSeed) and PUTs the result; the server
refused the body and no page row was ever stored.

What the server actually rejects — measured, not assumed

I parsed the synthesizer's output with the schema the server enforces (the
vendored @objectstack/spec 17.0.0-rc.6, PageSchema from
packages/spec/src/ui/page.zod.ts). On origin/main the seed payload fails on
four component nodes, not the two the report named:

unrecognized_keys | regions.0.components.0 | Unrecognized key(s) on this view/page schema: `recordChrome`, `actions`.
unrecognized_keys | regions.0.components.1 | Unrecognized key(s) on this view/page schema: `fields`.
unrecognized_keys | regions.0.components.2 | Unrecognized key(s) on this view/page schema: `statusField`, `stages`.
unrecognized_keys | regions.0.components.3 | Unrecognized key(s) on this view/page schema: `items`.

The full message names the cause:

Before ADR-0089 D3a these were dropped silently, shipping inert metadata; a
mis-layered or stale key is now a loud parse error.

What ADR-0089 D3a sanctions

ADR-0089 (docs/adr/0089-unify-visibility-predicate-naming.md, Accepted
2026-07-14) records D3a as shipped:

D3a (.strict() flip) implemented (#2902) — the monorepo + examples sweep
found a single offender (a test fixture), and the strict flip ships as a major
(@objectstack/spec 15.0.0).

and states the decision it belongs to:

Decision: make visibleWhen the single canonical name across all three
layers; keep visibleOn and visibility as @deprecated aliases normalized
to visibleWhen at parse time; add .strict() + a lint rule so a mis-layered
or mis-rooted key becomes a loud error instead of a silent no-op.

So D3a is not about recordChrome or items specifically: it closes the
view/page shapes, and a page component's shape is small and explicit —
PageComponentSchema declares type, id, label, properties, events,
style, className, responsiveStyles, visibleWhen, visibility,
dataSource, responsive, aria, and nothing else.

Both semantics have a spec-shaped carrier, so nothing had to be dropped. The
carrier is the node's own properties bag, declared in the same file:

properties: z.record(z.string(),z.unknown()).optional().default({}).describe('Component props passed to the widget. See component.zod.ts for schemas.'),

whose comment already names this synthesizer as the reason it is optional:

the platform's own default-page synthesizer (buildDefaultPageSchema) emits
nodes with props at the top level rather than under properties. Requiring
properties forced properties: {} boilerplate and — worse — made every
Studio attempt to seed a record page from its object's synthesized default
layout fail validation ("regions.N.components.M.properties: expected record"),
which was the real reason record/home/app pages couldn't be created in Studio.

And both keys are declared props on that bag in
packages/spec/src/ui/component.zod.ts (ComponentPropsMap), added by
objectstack#6776:

  • PageHeaderProps.recordChrome: z.boolean().default(true) — "Render the record
    chrome … Set false on a non-record page (dashboard, landing) to fall back to
    the bare heading layout."
  • PageTabsProps.items — the tab array (label, icon, visibleWhen, value,
    count, children).

docs/protocol-upgrade-guide.md records the same, including the read points in
this repo:

Four are plain additions with no behaviour change (page:header
recordChrome/showStar/showCopyId, which select between the record-chip
header and the bare heading a dashboard wants …)

So the fix shape is emit-canonical: put the props where the spec declares
them. No lenient consumer, no lift window, no lost semantics.

The fix

One helper, componentNode(type, props), is now the single place this file
decides where a component's props live — every node it builds goes through it,
rather than each call site spelling a page-write payload its own way:

functioncomponentNode(type: string,props: Record<string,unknown>={}): any{constproperties: Record<string,unknown>={};for(constkeyofObject.keys(props)){if(props[key]!==undefined)properties[key]=props[key];}returnObject.keys(properties).length>0 ? { type, properties } : { type };}

Thirteen node kinds move onto it: page:header, page:tabs, record:highlights,
record:path, record:details, record:related_list, record:quick_actions,
record:history, record:activity, record:attachments, record:approvals,
record:discussion, record:reference_rail.

Slot overrides stay verbatim. A node handed in through options.slots is the
caller's, and is placed untouched — this canonicalizes the nodes the file
builds, not the ones it is given. That is pinned.

Nothing on screen changes

SchemaRenderer hoists properties back onto the node before dispatch
(packages/react/src/SchemaRenderer.tsx, "COMPAT: Hoist 'properties' up to
schema level"), and that is the renderer every path uses: page regions render
through it (components/renderers/layout/page.tsxRegionContent), and tab
panels reach it through renderChildren. So each renderer receives exactly the
props it received before.

The registry declaration is untouched, per the card.
packages/components/src/renderers/layout/containers.tsx:1036 already reads

schema?.recordChrome===false||schema?.properties?.recordChrome===false

so the renderer keeps working for schemas carrying either spelling, the
recordChrome input declaration (:1644) stays, and
apps/console/src/__tests__/registry-inputs-spec-parity.test.ts is untouched.
This is a payload-shape change, not a renderer-property removal. (The console's
own preview sample already authors the canonical spelling:
{ type: 'page:header', properties: { title: 'Welcome to the CRM', recordChrome: false } }.)

app-shell's introspection already reads both carriers —
pageSchemaIntrospect.ts walks properties.items / properties.children
alongside the flat keys — so the discussion / attachments / approvals
auto-append decisions are unaffected. No app-shell file is touched.

Semantics preserved — pinned three ways

New file packages/plugin-detail/src/synth/__tests__/buildDefaultPageSchema.strictPayload.test.ts
parses the emission with the REAL spec schemas rather than restating a key list,
so it follows the contract when the contract moves:

  • chrome ON (default){ type: 'page:header', properties: { recordChrome: true } },
    and PageComponentSchema.parse(...) returns it unchanged.
  • chrome explicitly OFF — survives both the component parse and the whole
    page parse: page.regions[0].components[0].properties.recordChrome === false.
    This is the half that "just strip the key" would have silently destroyed.
  • tabs items intactPageTabsProps.parse(node.properties) accepts the bag
    and returns the same labels/values/children, i.e. the items are the declared
    props surface, not merely tolerated as unknown values inside an opaque record.

Plus a coverage pin that walks both synthesis branches, asserts the set of
emitted component types, and parses each node as a PageComponentSchema — so a
prop added at the top level of any future node is caught by name.

Reverse verification

Direction predicted before running: the new payload pins should be red on
origin/main's emitter and green with it
— a plain before-red/after-green,
because the pins assert an accept where the old shape produced unrecognized_keys.

Ran it that way — git checkout origin/main -- packages/plugin-detail/src/synth/buildDefaultPageSchema.ts,
re-run the new pins, restore. 10 of 10 red, and the failure output is the
server's own message rather than a synthetic one:

 Test Files 1 failed (1)
Tests 10 failed (10)
AssertionError: expected [ …(4) ] to deeply equal []
+ "unrecognized_keys @ regions.0.components.0: Unrecognized key(s) on this view/page schema: `recordChrome`, `actions`. …"
+ "unrecognized_keys @ regions.0.components.1: Unrecognized key(s) on this view/page schema: `fields`. …"
+ "unrecognized_keys @ regions.0.components.2: Unrecognized key(s) on this view/page schema: `statusField`, `stages`. …"
+ "unrecognized_keys @ regions.0.components.3: Unrecognized key(s) on this view/page schema: `items`. …"

The existing pin file goes red in the same direction, because its accessors read
node.properties with no fallback to the flat spelling — that is deliberate, so a
regression cannot pass by reading the shape the server refuses.

Tests

All from the repo root (objectui#3378), serialized under the shared verify lock,
NODE_OPTIONS=--max-old-space-size=4096 --maxWorkers=2:

commandresult
pnpm exec vitest run packages/plugin-detail/Test Files 76 passed (76) / Tests 756 passed (756)
pnpm exec vitest run packages/components/src/__tests__/page-header-title.test.tsx packages/components/src/__tests__/page-single-h1.test.tsx packages/app-shell/src/utils/ packages/app-shell/src/views/metadata-admin/ apps/console/src/__tests__/record-block-record-reach.test.tsxTest Files 166 passed (166) / Tests 1871 passed | 1 skipped (1872)
pnpm exec vitest run packages/app-shell/src/views/Test Files 207 passed (207) / Tests 2063 passed | 1 skipped (2064)
pnpm --workspace-concurrency=2 --filter @object-ui/plugin-detail type-check (both tsc projects)exit 0 (tsc --noEmit && tsc -p tsconfig.typetests.json, after pnpm --filter '@object-ui/plugin-detail^...' build)
npx eslint on the three changed files160 problems (0 errors, 160 warnings) — every warning is the file's pre-existing no-explicit-any style
node scripts/check-changeset-presence.mjsdeclares .changeset/synth-page-canonical-properties-4232.md
node scripts/check-control-bytes.mjsOK (3994 files scanned)

The second and third rows are the consumer direction: every package that
renders or introspects this synthesizer's output (components, app-shell
views + utils + metadata-admin, apps/console), run as their own suites rather
than through a --filter prefix.

Measured but deliberately out of this card's path

Two keys the same probe reports, both outside the create PUT and neither part of
this fix:

  • pageType on the page object — an objectui local fork, already declared as
    such in packages/types/src/zod/layout.zod.ts and pinned as local by
    packages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts. It never
    reaches the PUT: createSeed contributes only regions and template.
  • className on the aside regionPageRegionSchema declares name /
    width / components only, so the rail region's hidden xl:flex flex-col gap-4
    has no spec carrier at region level, and moving it onto the child components
    would leave an empty column below xl instead of hiding it. That region is
    never emitted by the create path (the seed calls the synthesizer with no
    options), so it is not part of this user-blocking bug. Filed separately as
    Synthesized aside region carries className, which PageRegionSchema rejects — a Reference Rail page has no persistable spelling #4286.

Generated by Claude Code

…s` carrier so Studio page-create persists (#4232)
Creating a page in Studio never completed. The create path seeds a record
page's `regions` from `buildDefaultPageSchema(objectDef)` (app-shell
`views/metadata-admin/anchors.ts` -> `createSeed`) and PUTs the result, and
every node the synthesizer emitted carried its widget props at the TOP level
of the component node. ADR-0089 D3a closed `PageComponentSchema` with
`.strict()`, so those keys are not stripped, they are a parse error — the
server refused the body and no page row was ever stored.
Measured against the schema the server actually enforces (the vendored
`@objectstack/spec` 17.0.0-rc.6), the seed payload failed on four nodes, not
the two the report named:
unrecognized_keys | regions.0.components.0 | `recordChrome`, `actions`
unrecognized_keys | regions.0.components.1 | `fields`
unrecognized_keys | regions.0.components.2 | `statusField`, `stages`
unrecognized_keys | regions.0.components.3 | `items`
The canonical carrier is the node's own `properties` bag — which is where the
spec declares these props in the first place (`ComponentPropsMap`:
`PageHeaderProps.recordChrome`, `PageTabsProps.items`, both added by
objectstack#6776), and what objectui's own console preview sample already
authors. Nothing is dropped: chrome still defaults ON, an explicit
`recordChrome: false` is still carried and now actually persists, the tabs
keep their items, and `SchemaRenderer` hoists `properties` back onto the node
before dispatch, so every renderer receives exactly the props it did before.
One helper (`componentNode`) does the wrapping for every node this file
builds, so there is a single answer to "what may go in a page write" rather
than one per call site. Slot overrides stay verbatim — a node handed in by a
caller is the caller's, and is placed untouched.
The registry declaration in `packages/components` is untouched, per the card:
`containers.tsx:1036` already reads `schema?.recordChrome === false ||
schema?.properties?.recordChrome === false`, so the renderer keeps working for
schemas carrying either spelling. This is a payload-shape change, not a
renderer-property removal.
New pin `buildDefaultPageSchema.strictPayload.test.ts` parses the emitted
payload with the real `PageSchema` / `PageComponentSchema` / `PageTabsProps`,
so it follows the contract when it moves instead of restating it.
Claude-Session: https://claude.ai/code/session_017Qqyix2QcnpUC9XeYVDzx3
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
@vercel

vercelBot commented Aug 11, 2026

Copy link
Copy Markdown

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

1 Skipped Deployment
ProjectDeploymentActionsUpdated (UTC)
objectuiIgnoredIgnoredAug 11, 2026 11:17am

Request Review

@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Main entry (gzip)28.9 KB350 KB
Entry fileindex-BviEEulJ.js
StatusPASS

📦 Bundle Size Report

PackageSizeGzipped
app-shell (index.js)8.88KB3.25KB
app-shell (runtime-config.js)7.42KB2.32KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)7.57KB2.97KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)1.17KB0.53KB
auth (AuthProvider.js)22.10KB4.37KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.13KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.64KB2.21KB
auth (SocialSignInButtons.js)9.60KB3.89KB
auth (UserMenu.js)3.40KB1.22KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)35.76KB9.11KB
auth (createAuthenticatedFetch.js)4.37KB1.69KB
auth (index.js)2.35KB1.07KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)4.91KB0.87KB
auth (useIsWorkspaceAdmin.js)1.61KB0.85KB
collaboration (CommentThread.js)26.07KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.65KB0.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)488.62KB108.26KB
core (index.js)3.04KB1.15KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)150.04KB39.79KB
fields (index.js)228.45KB56.62KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (i18n.js)4.32KB1.77KB
i18n (index.js)2.65KB1.06KB
i18n (pickLocalized.js)1.70KB0.83KB
i18n (provider.js)16.38KB5.47KB
i18n (useObjectLabel.js)27.59KB6.63KB
i18n (useSafeTranslation.js)4.52KB1.96KB
layout (index.js)38.98KB10.85KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.74KB
mobile (index.js)1.50KB0.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.71KB0.42KB
mobile (useResponsiveConfig.js)1.36KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)8.75KB3.06KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)3.67KB1.12KB
permissions (evaluator.js)4.41KB1.44KB
permissions (index.js)0.91KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.52KB
permissions (usePermissions.js)1.55KB0.71KB
plugin-ai (index.js)15.71KB3.79KB
plugin-calendar (index.js)45.23KB12.45KB
plugin-charts (index.js)61.73KB17.54KB
plugin-chatbot (index.js)180.33KB42.79KB
plugin-dashboard (index.js)118.79KB30.79KB
plugin-designer (index.js)210.91KB42.67KB
plugin-detail (index.js)238.98KB59.76KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)114.58KB27.68KB
plugin-gantt (index.js)164.14KB39.98KB
plugin-grid (index.js)187.97KB49.90KB
plugin-kanban (index.js)48.60KB13.41KB
plugin-list (index.js)109.18KB26.48KB
plugin-map (index.js)17.00KB5.32KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)40.60KB10.58KB
plugin-timeline (index.js)26.21KB7.52KB
plugin-tree (index.js)8.50KB2.88KB
plugin-view (index.js)84.03KB20.55KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.71KB3.53KB
providers (index.js)0.44KB0.22KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.67KB2.37KB
react (LazyPluginLoader.js)3.77KB1.33KB
react (SchemaRenderer.js)23.71KB7.96KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)1.23KB0.66KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)4.09KB1.74KB
sdui-parser (index.js)4.47KB2.03KB
sdui-parser (parse.js)10.04KB2.82KB
sdui-parser (types.js)0.29KB0.24KB
sdui-parser (validate.js)4.69KB1.48KB
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.05KB1.52KB
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

@yinlianghui
yinlianghui marked this pull request as ready for review August 11, 2026 11:31
@yinlianghui
yinlianghui added this pull request to the merge queueAug 11, 2026
Merged via the queue into main with commit 35997ceAug 11, 2026
21 checks passed
@yinlianghui
yinlianghui deleted the claude/issue-4232-studio-page-create-strict branch August 11, 2026 11:31
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Studio page-create never completes — the console PUTs recordChrome / items, which the server strictly rejects (ADR-0089 D3a)

2 participants

@yinlianghui@claude