Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-detail): synthesize page components in the spec properties carrier so Studio page-create persists (#4232) - #4290
Merged
Conversation
…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>
The latest updates on your projects. Learn more about Vercel for GitHub. |
Contributor
✅ Console Performance Budget
📦 Bundle Size Report
Size Limits
|
yinlianghui
marked this pull request as ready for review
August 11, 2026 11:31
Uh oh!
There was an error while loading. Please reload this page.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#4232
Creating a page in Studio never completed. The create path seeds a record page's
regionsfrombuildDefaultPageSchema(objectDef)(app-shellviews/metadata-admin/anchors.ts→createSeed) and PUTs the result; the serverrefused 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/spec17.0.0-rc.6,PageSchemafrompackages/spec/src/ui/page.zod.ts). Onorigin/mainthe seed payload fails onfour component nodes, not the two the report named:
The full message names the cause:
What ADR-0089 D3a sanctions
ADR-0089 (
docs/adr/0089-unify-visibility-predicate-naming.md, Accepted2026-07-14) records D3a as shipped:
and states the decision it belongs to:
So D3a is not about
recordChromeoritemsspecifically: it closes theview/page shapes, and a page component's shape is small and explicit —
PageComponentSchemadeclarestype,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
propertiesbag, declared in the same file:whose comment already names this synthesizer as the reason it is optional:
And both keys are declared props on that bag in
packages/spec/src/ui/component.zod.ts(ComponentPropsMap), added byobjectstack#6776:
PageHeaderProps.recordChrome: z.boolean().default(true)— "Render the recordchrome … 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.mdrecords the same, including the read points inthis repo:
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 filedecides 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:
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.slotsis thecaller'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
SchemaRendererhoistspropertiesback onto the node before dispatch(
packages/react/src/SchemaRenderer.tsx, "COMPAT: Hoist 'properties' up toschema level"), and that is the renderer every path uses: page regions render
through it (
components/renderers/layout/page.tsx→RegionContent), and tabpanels reach it through
renderChildren. So each renderer receives exactly theprops it received before.
The registry declaration is untouched, per the card.
packages/components/src/renderers/layout/containers.tsx:1036already readsso the renderer keeps working for schemas carrying either spelling, the
recordChromeinput declaration (:1644) stays, andapps/console/src/__tests__/registry-inputs-spec-parity.test.tsis 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.tswalksproperties.items/properties.childrenalongside 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.tsparses the emission with the REAL spec schemas rather than restating a key list,
so it follows the contract when the contract moves:
{ type: 'page:header', properties: { recordChrome: true } },and
PageComponentSchema.parse(...)returns it unchanged.page parse:
page.regions[0].components[0].properties.recordChrome === false.This is the half that "just strip the key" would have silently destroyed.
PageTabsProps.parse(node.properties)accepts the bagand 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 aprop 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:
The existing pin file goes red in the same direction, because its accessors read
node.propertieswith no fallback to the flat spelling — that is deliberate, so aregression 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: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)tsc --noEmit && tsc -p tsconfig.typetests.json, afterpnpm --filter '@object-ui/plugin-detail^...' build)npx eslinton the three changed files160 problems (0 errors, 160 warnings)— every warning is the file's pre-existingno-explicit-anystylenode scripts/check-changeset-presence.mjs.changeset/synth-page-canonical-properties-4232.mdnode scripts/check-control-bytes.mjsThe second and third rows are the consumer direction: every package that
renders or introspects this synthesizer's output (
components,app-shellviews + utils + metadata-admin,
apps/console), run as their own suites ratherthan through a
--filterprefix.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:
pageTypeon the page object — an objectui local fork, already declared assuch in
packages/types/src/zod/layout.zod.tsand pinned aslocalbypackages/types/src/__tests__/page-app-dashboard-spec-parity.test.ts. It neverreaches the PUT:
createSeedcontributes onlyregionsandtemplate.classNameon theasideregion —PageRegionSchemadeclaresname/width/componentsonly, so the rail region'shidden xl:flex flex-col gap-4has no spec carrier at region level, and moving it onto the child components
would leave an empty column below
xlinstead of hiding it. That region isnever 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
asideregion carriesclassName, whichPageRegionSchemarejects — a Reference Rail page has no persistable spelling #4286.Generated by Claude Code