From fd97a7ebf03649be05a5d59817ecca6ee9007f1e Mon Sep 17 00:00:00 2001 From: Tim Kindberg Date: Fri, 14 Aug 2026 18:32:02 -0400 Subject: [PATCH] feat(react): lift array add/remove state out of the replaceable root (#145) Co-authored-by: Cursor --- packages/react/src/arrays.test.tsx | 47 ++++++++++++++++++++- packages/react/src/renderer.tsx | 68 ++++++++++++++++++++---------- 2 files changed, 92 insertions(+), 23 deletions(-) diff --git a/packages/react/src/arrays.test.tsx b/packages/react/src/arrays.test.tsx index f0606e1..1eb25de 100644 --- a/packages/react/src/arrays.test.tsx +++ b/packages/react/src/arrays.test.tsx @@ -10,7 +10,7 @@ import { describe, it, expect } from 'vitest' import { render } from 'vitest-browser-react' import { jsonSchemaToRuntimeTree } from '@formframe/input-jsonschema' import type { JSONSchema } from '@formframe/input-jsonschema' -import { SchemaFields } from './renderer' +import { SchemaFields, createRenderer, nativeDefaults } from './renderer' const schema: JSONSchema = { type: 'object', @@ -152,3 +152,48 @@ describe('dense array submission (ADR 018)', () => { expect(submitted).toEqual({ contacts: [{ name: 'Bob' }] }) }) }) + +describe('custom array.root template (#145 / #139)', () => { + const CustomFields = createRenderer({ + ...nativeDefaults, + array: { + ...nativeDefaults.array, + root: ({ node, children }) => ( +
+ {node.parts.label && node.parts.label.Default()} +
{children}
+ {node.parts.addButton.Default()} +
+ ), + }, + }) + + it('add/remove still work when the root template arranges parts without wrapping Default', async () => { + const form = jsonSchemaToRuntimeTree(schema) + const screen = await render() + + await expect + .element(screen.getByRole('group', { name: 'Contacts' })) + .toBeInTheDocument() + + const first = screen.getByRole('textbox', { name: 'Contact name' }) + await first.fill('Alice') + await expect.element(first).toHaveValue('Alice') + + await screen.getByRole('button', { name: /add/i }).click() + + const inputs = () => + document.querySelectorAll('input[name$=".name"]') + await expect.poll(() => inputs().length).toBe(2) + expect(inputs()[0].value).toBe('Alice') + expect(inputs()[1].value).toBe('') + + const name = screen.getByRole('textbox', { name: 'Contact name' }) + await name.nth(1).fill('Bob') + await screen.getByRole('button', { name: 'Remove' }).nth(0).click() + + await expect.poll(() => inputs().length).toBe(1) + expect(inputs()[0].value).toBe('Bob') + expect(inputs()[0].name).toBe('contacts.0.name') + }) +}) diff --git a/packages/react/src/renderer.tsx b/packages/react/src/renderer.tsx index 89fde82..df6dead 100644 --- a/packages/react/src/renderer.tsx +++ b/packages/react/src/renderer.tsx @@ -10,7 +10,7 @@ // makes a *fresh component type every render*, so any real re-render remounts the // subtree and discards uncontrolled DOM (typed values). Calling instead yields // markup composed only of module-level component types (`NodeRenderer`, -// `ArrayRoot`, `PartHost`, the intrinsic elements), which reconcile in place. The +// `ArrayStateProvider`, `PartHost`, the intrinsic elements), which reconcile in place. The // engine threads the active resolver as a parameter and each handle closes over // it, so a called `node.Default()` still sees the right (possibly scoped) // resolver with no Context — the vanilla probe (ADR 008) proved Context was @@ -376,7 +376,7 @@ function DefaultArrayLabel({ text }: { text: string }): ReactNode { } /** - * Per-array action handlers, supplied by the stateful `ArrayRoot` to the add / + * Per-array action handlers, supplied by `ArrayStateProvider` to the add / * remove button parts through Context. Interactivity is per-adapter, *not* part * of the markup contract (ADR 008/013) — the string oracle has no Context and * renders the same buttons inert. Routing behavior through Context (rather than @@ -421,7 +421,7 @@ function DefaultRemoveButton({ /** * Per-item Context boundary. Memoizing `actions` on `[remove, id]` — both stable - * — keeps the value referentially constant across `ArrayRoot` re-renders, so a + * — keeps the value referentially constant across `ArrayStateProvider` re-renders, so a * sibling add/remove can never re-render this item's Remove button (a Context * consumer) even though it sits below a memo-bailed `NodeRenderer`. */ @@ -467,9 +467,18 @@ interface ArraySlot { * Ids are never reused and are the React key only (identity); the item's path is * its dense position, re-minted on shift. This realizes ADR 016's lifted * constraint and reverses ADR 015 §6's stable-sparse paths (ADR 018). + * + * Lifted above the replaceable `array.root` template (ADR 051 §3): `createRenderer` + * always wraps the merged template in this provider so add/remove state survives + * custom layout. The template receives live slot children via the render prop. */ -function ArrayRoot({ node }: { node: EArray }): ReactNode { - const { label, description, addButton } = node.parts +function ArrayStateProvider({ + node, + children, +}: { + node: EArray + children: (items: ReactNode) => ReactNode +}): ReactNode { const seedCount = Object.keys(node.children).length // Monotonic id source — the React *key* only, never a path index. Seeded past // the initial items and advanced only in handlers (event-time, not in render). @@ -510,32 +519,36 @@ function ArrayRoot({ node }: { node: EArray }): ReactNode { ) const addActions = useMemo(() => ({ add }), [add]) + const items = slots.map((slot) => ( + + {node.renderItem(slot.core)} + + )) + return ( -
- {label && label.Default()} - {description && description.Default()} -
- {slots.map((slot) => ( - - {node.renderItem(slot.core)} - - ))} -
- - {addButton.Default()} - -
+ + {children(items)} + ) } -/** Compose an array: delegate to the stateful `ArrayRoot` (manages its items). */ +/** Compose an array from its parts and live slot children (like `DefaultGroupRoot`). */ function DefaultArrayRoot({ node, + children, }: { node: EArray children: ReactNode }): ReactNode { - return + const { label, description, addButton } = node.parts + return ( +
+ {label && label.Default()} + {description && description.Default()} +
{children}
+ {addButton.Default()} +
+ ) } /** Compose one array item: its content + the remove control. */ @@ -956,6 +969,17 @@ declare const process: { env: { NODE_ENV?: string } } | undefined */ export function createRenderer(defaults: ReactPartialDefaults) { const merged = mergeDefaults(diagnosticDefaults, defaults) + const rendererDefaults: ReactDefaults = { + ...merged, + array: { + ...merged.array, + root: (props) => ( + + {(items) => merged.array.root({ ...props, children: items })} + + ), + }, + } // Tie the knot: the engine renders each child through `renderChild`, which // emits this memoized per-node component; the component calls back into the @@ -976,7 +1000,7 @@ export function createRenderer(defaults: ReactPartialDefaults) { const NodeRenderer = memo(NodeRendererImpl) const engine: Continuation = createContinuation( - merged, + rendererDefaults, { renderChild: (core, resolver) => (