SchemaFields layout prop (ADRs 053–055) - #172
Conversation
Root place-yourself is `layout`, not JSX children. Placements Resolve through intercept; bound root.children is FormShape-keyed. Co-authored-by: Cursor <cursoragent@cursor.com>
Adversarial review found the placement scope was only half a scope. `Resolve({ renderNode })` scopes one call, so `<Children of={node}/>` inside a scoped layout still rendered through the outer resolver, contradicting ADR 054's nearest-scope claim.
Core gains `rebind(renderNode)` — re-enrich a node against another resolver — so `Children`, `child`, `children.x`, `Resolve`, and `renderItem` all agree. `enrich` was already the primitive; this makes it reachable from a handle.
Also:
- `layout` on an array re-installs add/remove state. It replaces `array.root`, which is where that state lives (ADR 051 §3 / #145), so the Add button was inert and `<Children/>` showed static seed items.
- `Default` no longer memoizes resolvers with a variable-length dep list. React compares only the overlapping prefix of a resized list, so an `undefined` -> function intercept toggle kept the stale resolver. Module-level WeakMap on the lowered fn instead, and `interceptStabilityDeps` returns a one-entry list for `undefined` (also fixes #168).
- Pin `parts`-beats-ambient-intercept precedence at a placement; they cannot compose.
- Document that reordering placements needs `key=`, and why the engine cannot key them (absolute-path keys would remount re-pathed array items).
- Keep `layoutShape`'s structural types internal until a consumer needs them.
Closes #168.
Co-authored-by: Cursor <cursoragent@cursor.com>
|
Note Review Agent: Adversarial review findings addressed in 346b2cc. Gate green (219 React tests, was 214). The central finding was right: a placement scope was only half a scope. Fixed by exposing Core's existing
Three of the five new tests fail against 529bef1, verified by reverting only the source files. The other two are characterization tests for behavior this PR pins rather than changes. |
The array-layout test needed `contacts.isArray ? … : null` and the group
tests needed `address.isGroup ? … : null`. Both were artifacts of the
typing, not of arrays: only `useFormTree` threaded the tree's FormShape
brand, so the batteries-included `<SchemaFields form={…}/>` fell back to
`EGroup` (`children: Record<string, ENode>`) and every child was Core's
disjoint union.
Two fixes, one principle — a guard the consumer has no information to
answer is not a guard, it is ceremony:
1. `SchemaFieldsProps<F>` infers the tree from the `form` prop and keys
`layout` off `TreeShapeOf<F>`. Branded trees now get the same keyed,
kind-resolved children whether or not the hook bound them; ADR 055
had rejected this as "inference is unreliable" — it isn't.
2. Where the kind genuinely can't be known (unbranded runtime tree,
dynamic `child(path)`), the fallback is no longer Core's disjoint
`ENode` union but the same four handles each carrying the other
kinds' keys typed `undefined` (`AnyKindNode`, internal). So
`node.parts.addButton` / `node.children?.street` compile and yield
`T | undefined`, composing with the already-null-safe `<Default
of={undefined}/>`, while `if (node.isArray)` still narrows.
Every layout guard is gone from the tests and from gallery app 08. Also
pins the unbound keyed door and the kind-unknown surface as type tests.
Core is untouched: `ENode` stays a plain discriminated union for `walk`
and `intercept`. Loosening those is deferred (ADR 055 alternatives).
Co-authored-by: Cursor <cursoragent@cursor.com>
Kind guards are gone from
|
Summary
Root place-yourself is
layout={(root, { Default }) => …}, not JSX children and notintercept. I think that's the right split after #160: hide/show vs rearrange are different jobs, and JSXchildrennever inferred the callback.Placements inside
layoutgo through intercept then the template (Resolve). Nested<Default layout>is the same pair on a group. BounduseFormTreelayoutkeysroot.childrenoff FormShape. Unbranded trees stayEGroup.Array-item
${number}children is #171, not this PR. Mode 2 factory tags still live on ADR 010.Field-mode (#74 / PR 169) is not in here. Neither is
useFieldRootSlots(#163) or the intercept-toggle bug (#168). Those are still on the spike working tree.Closes #170.
Test plan
npm run gatelocallylayout={…}reorders fields; scoped intercept on address still firesroot.children.emailautocompletes fromuseFormTree(jsonSchemaToTree(schema));jsonSchemaToRuntimeTreestaysEGroup<Default of={group} layout={…} />still hits a path intercept on a child of that groupMade with Cursor