Skip to content

SchemaFields layout prop (ADRs 053–055) - #172

Open
timkindberg wants to merge 3 commits into
mainfrom
feat/layout-adrs-053-055
Open

SchemaFields layout prop (ADRs 053–055)#172
timkindberg wants to merge 3 commits into
mainfrom
feat/layout-adrs-053-055

Conversation

@timkindberg

Copy link
Copy Markdown
Owner

Summary

Root place-yourself is layout={(root, { Default }) => …}, not JSX children and not intercept. I think that's the right split after #160: hide/show vs rearrange are different jobs, and JSX children never inferred the callback.

Placements inside layout go through intercept then the template (Resolve). Nested <Default layout> is the same pair on a group. Bound useFormTree layout keys root.children off FormShape. Unbranded trees stay EGroup.

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 gate locally
  • Gallery App_08 sections 3 and 4: layout={…} reorders fields; scoped intercept on address still fires
  • Bound layout: root.children.email autocompletes from useFormTree(jsonSchemaToTree(schema)); jsonSchemaToRuntimeTree stays EGroup
  • Nested <Default of={group} layout={…} /> still hits a path intercept on a child of that group

Made with Cursor

timkindberg and others added 2 commits September 2, 2026 21:37
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>
@timkindberg

Copy link
Copy Markdown
Owner Author

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. Resolve({ renderNode }) scopes one call, so <Children of={node} /> inside a scoped layout still resolved through the outer resolver, which contradicts ADR 054's own nearest-scope claim.

Fixed by exposing Core's existing enrich as rebind(renderNode) on a handle. The layout callback now receives a rebound handle, so Children, child, children.x, Resolve, and an array's renderItem all agree, and navigation from it stays scoped. That was a smaller change than threading an options bag through three methods, and it fixes the whole class rather than the one path that goes through <Default>.

Finding Disposition
Children ignores scoped intercept Fixed, rebind. Regression test
Array layout kills add/remove Fixed, layout re-installs the provider; <Children of={array}/> yields live slots. Regression test
parts placement skips intercept Behavior kept, precedence pinned. They cannot compose, so the inline overlay wins as the more specific instruction. ADR 054 + test
Stale nested intercept toggle Fixed. Root cause was the variable-length dep list, so Default now caches resolvers in a module-level WeakMap and interceptStabilityDeps returns one entry for undefined. Closes #168
Unkeyed reorder swaps values Documented, not code-fixed. The engine cannot key these: absolute-path keys would remount re-pathed array-item survivors and discard their values (engine.ts:337). Consumers key reorderable placements
FormShape lies for $ref / boolean schemas Real but pre-existinguseInterceptRules already keys off the same brand on main. Noted in ADR 055, filed separately
Speculative LayoutGroup / LayoutNode exports Removed from the public index
Place vs Default naming Not changed. Recorded honestly in ADR 054 alternatives: the identity objection was weak, the real argument is that placement and re-entry are the same intent. The cost (an extracted <Default> helper behaves differently by mount site) is now written down with a revisit trigger
ADR 053 blames React.FC for the inference loss Fair hit. The bound prop declared a 1-arg callback while unbound declared 2. Left alone in this PR since the naming decision stands on the other two reasons

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>
@timkindberg

Copy link
Copy Markdown
Owner Author

Kind guards are gone from layout

Follow-up on the contacts.isArray guard in the array test — it was an artifact of the typing, not of arrays.

Cause: only useFormTree threaded the tree's FormShape brand. The batteries-included <SchemaFields form={…} layout={…} /> typed layout as plain EGroup, so every child came back as Core's disjoint ENode union and the tests had to write address.isGroup ? … : null before touching anything.

Fix 1 — brand the unbound component. SchemaFieldsProps<F extends AnyGroupNode> infers F from the form prop and keys layout off TreeShapeOf<F>. ADR 055 had rejected this ("form is AnyGroupNode at that seam; inference is unreliable") — it isn't; it typechecks across every existing call site, and useFormTree is now just partial application rather than the only place typing happens. Amended the ADR.

Fix 2 — kind-unknown handles carry every kind's surface. Two places genuinely can't know a node's kind: an unbranded runtime tree and a dynamic child(path). Those now resolve to AnyKindNode (internal to layoutShape.ts): the same four handles, each carrying the other kinds' keys typed undefined. So

<Default
  of={root.children.contacts}
  layout={(contacts, { Default: D, Children }) => (
    <section>
      <Children of={contacts} />
      <D of={contacts.parts.addButton} />
    </section>
  )}
/>

compiles against a runtime tree with no guard, addr.children?.street needs only ?., and if (node.isArray) still narrows because the discriminants are untouched. It composes with the re-entry layer's existing null-safety — <Default of={undefined}/> renders nothing, so "this kind has no addButton" already had a correct rendering.

Every layout guard is gone from renderer.test.tsx and from gallery app 08. Added type tests pinning the unbound keyed door (root.children.nope is still an error) and the kind-unknown surface.

Core is untouched. ENode stays a plain discriminated union for walk / intercept. Same trick would work there, but it widens a Core public type to fix a React ergonomics problem, so ADR 055 lists it as deferred until intercept guards prove as annoying as layout guards were.

One honest cost, noted in the ADR: parts callbacks on a kind-unknown handle don't get contextual parameter types (DefaultOptsOf<H> over a union is a union of override maps, which TS won't contextually type against). Excess-property checks still fire. This predates the change — the ENode union behaved identically — so narrow, or use a keyed child, when you want typed part callbacks.

Gate green: typecheck + lint + format + 220 react tests / 369 total.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

SchemaFields layout prop (ADRs 053–055)

1 participant