// @vitest-environment happy-dom // // Round-5 adversarial audit — `reform-remote`'s half of the keyed-identity fix set. // // Under audit: // * `wireRender.ts` `enqueueStructureSlot` — an `Each` child's id is now // `${parent.id}.${slotName}.${entry.key}`, with a `used` Set that appends // `#${index}` when a key repeats. // * `client.ts` `WireNodeView` — a slot child is now reconciled by React on // `child.key ?? child.id` instead of `child.id`. // // Both findings below survived an attempt to refute them. Each test states the // behaviour the FIXED code is supposed to have, fails on the current working // tree, and carries a passing CONTROL in the same body that proves the harness // works and isolates the defect. Every listener/observer is attached before // anything is awaited. import { act, createElement, useState } from 'react' import { createRoot } from 'react-dom/client' import { expect, test } from 'vitest' import { Layer, Schema as S } from 'effect' import { Composition, Engine, State, Ui, each, mount, provide, scene, slot, ui, } from '@playfast/reform' import { Wire, type WireNode, type WireTree } from '@playfast/reform/internal' import { makeRemoteServer } from './server' import { remoteViews, renderWireTree } from './client' Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) // --------------------------------------------------------------------------- // One scene, seeded per server: a list whose rows are keyed by `entry.id`, the // shape every `each(...)` call in the repo uses. A "reorder" is modelled as a // second server over the same rows in a different order — which is exactly the // frame a reordering server emits now that ids are minted from keys rather than // positions. // --------------------------------------------------------------------------- const RowValue = S.Struct({ id: S.String, label: S.String }) class Rows extends State.make('audit5.rows', S.Array(RowValue)) {} class RowUi extends ui('audit5.Row', { props: S.Struct({ label: S.String }) }) {} class RowComp extends Composition.make('audit5.Row', { title: 'Row', ui: RowUi, props: RowValue, })() {} class RowSlot extends slot('audit5.Row')() {} class ListUi extends ui('audit5.List', { props: S.Struct({}), slots: { Row: RowSlot } }) {} class ListComp extends Composition.make('audit5.List', { title: 'List', ui: ListUi, slots: { Row: RowSlot }, states: [Rows], })() {} interface Row { readonly id: string readonly label: string } const listScene = (seed: ReadonlyArray) => { const presentation = Layer.mergeAll( provide( ListUi, Ui.make(ListUi, () => null), ), provide( RowUi, Ui.make(RowUi, () => null), ), provide(RowSlot, RowComp), State.live(Rows, seed), ) const app = Layer.mergeAll( Composition.live(ListComp, function* () { const rows = yield* Rows return mount({ props: {}, slots: { Row: each(rows, { key: (entry) => entry.id, props: (entry) => ({ id: entry.id, label: entry.label }), }), }, }) }), Composition.live(RowComp, function* () { const rowProps = yield* RowComp.props return mount({ props: { label: rowProps.label }, slots: {} }) }), ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine)) return scene(ListComp, { provide: [app] }) } const frameFor = async (seed: ReadonlyArray): Promise => { const server = makeRemoteServer(listScene(seed)) try { return await server.render() } finally { await server.dispose() } } const rowsOf = (tree: WireTree): WireTree => tree.filter((node) => node.slot === 'Row') const labelOf = (node: WireNode): string => { const prop = node.props.find( (candidate) => candidate._tag === 'Data' && candidate.name === 'label', ) return prop !== undefined && 'value' in prop ? String(prop.value) : '' } // --------------------------------------------------------------------------- // FINDING 1 — `wireRender.ts:104-108`. The `used` dedupe is not collision-safe. // // const preferred = `${parent.id}.${slotName}.${entry.key ?? index}` // const id = used.has(preferred) ? `${preferred}#${index}` : preferred // used.add(id) // // The repeat branch mints `${preferred}#${index}` and never asks whether THAT // string is already taken. It can be: an earlier row whose own key literally // ends in `#` already claimed it, where `n` is the index of a later repeat. // Keys carrying a `#` are ordinary — an issue ref (`PROJ#12`), a channel or // thread name, a URL fragment, a label with a discriminator — and the whole // point of the `used` branch is that the framework tolerates a duplicate key // instead of trusting the app to produce unique ones. // // The result is two wire NODES sharing one id. Node id is the wire's primary // key everywhere downstream: `Wire.diff` builds `previousById`/`nextById` from // it, `Wire.apply`'s `upsertNode` finds-and-replaces by it, and a trigger // handle is `${id}:${eventName}`. So the row that loses the tie is not merely // mis-reconciled, it is DELETED from the client's tree by the very first frame, // and its `remove` handle points at the row that overwrote it. // --------------------------------------------------------------------------- test('every wire node in a frame has its own id, whatever the app keys rows by', async () => { // ---- CONTROL — three rows, two of them sharing the key `dup`. The `used` // branch does its job: three rows, three ids, and a client that applies the // opening diff sees all three. This is the harness, the scene and the whole // duplicate-key path working. const control = await frameFor([ { id: 'dup', label: 'A' }, { id: 'dup', label: 'B' }, { id: 'plain', label: 'C' }, ]) const controlRows = rowsOf(control) expect(controlRows.map(labelOf)).toEqual(['A', 'B', 'C']) expect(new Set(controlRows.map((node) => node.id)).size).toBe(3) expect(rowsOf(Wire.apply([], Wire.diff([], control))).map(labelOf)).toEqual(['A', 'B', 'C']) // ---- DEFECT — the same three rows. The only change is that the first row's // key already spells the collision the dedupe is about to mint for index 2. // index 0 key 'tag#2' -> '0.audit5.Row.tag#2' (taken) // index 1 key 'tag' -> '0.audit5.Row.tag' // index 2 key 'tag' -> taken, so '0.audit5.Row.tag#2' <- same id const defect = await frameFor([ { id: 'tag#2', label: 'A' }, { id: 'tag', label: 'B' }, { id: 'tag', label: 'C' }, ]) const defectRows = rowsOf(defect) expect(defectRows.map(labelOf)).toEqual(['A', 'B', 'C']) // Two nodes, one id. Actual today: 2 — `0.audit5.Row.tag#2` is minted twice. expect(new Set(defectRows.map((node) => node.id)).size).toBe(3) // ...and the consequence a connected client actually sees: `Wire.apply` keys // by id, so the opening frame loses a row outright. // Actual today: ['C', 'B'] — row A was overwritten in place by row C. expect(rowsOf(Wire.apply([], Wire.diff([], defect))).map(labelOf)).toEqual(['A', 'B', 'C']) }) // --------------------------------------------------------------------------- // FINDING 2 — `client.ts:172`. `key: child.key ?? child.id`. // // `child.id` is unique across a frame by construction (that is what FINDING 1 // is about). `child.key` is whatever the app's `key:` callback returned and // carries no uniqueness guarantee at all — `enqueueStructureSlot` was hardened // in this same change set precisely BECAUSE a duplicate key happens. // // Handing React a duplicate key is documented-unsupported: "Non-unique keys may // cause children to be duplicated and/or omitted". It does. A reorder across a // duplicate key makes React emit a row the frame does not contain and keep an // element the frame deleted, so the painted list stops matching the wire tree. // // The change is also unnecessary. Its stated reason — "node ids are positional, // so a reorder would otherwise slide item state between rows" — was true of the // OLD id scheme and is no longer true of the one shipped in the same commit: // `${parent.id}.${slotName}.${key}` is already reorder-stable, and stable for // un-keyed `one(...)` descendants too, which `child.key` is not (it is null for // them). `key: child.id` satisfies both round-4 bugs and stays unique. // --------------------------------------------------------------------------- const paint = async ( before: WireTree, after: WireTree, ): Promise<{ readonly painted: ReadonlyArray readonly expected: ReadonlyArray }> => { const seat = { n: 0 } const views = remoteViews<{ 'audit5.List': typeof ListUi; 'audit5.Row': typeof RowUi }>({ 'audit5.List': Ui.make(ListUi, (_props, slots) => createElement('div', null, createElement(slots.Row)), ), 'audit5.Row': Ui.make(RowUi, ({ label }) => { const [seatId] = useState(() => { seat.n += 1 return `seat-${seat.n}` }) return createElement('span', { 'data-row': label, 'data-seat': seatId }) }), }) const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) const config = { views, invoke: () => undefined } try { await act(async () => { root.render(renderWireTree(before, config)) }) await act(async () => { root.render(renderWireTree(after, config)) }) return { painted: [...container.querySelectorAll('[data-row]')].map( (element) => element.getAttribute('data-row') ?? '', ), expected: Wire.childrenOf(after, '0').map(labelOf), } } finally { await act(async () => { root.unmount() }) container.remove() } } test('a reordered frame paints exactly the rows the frame contains', async () => { // ---- CONTROL — three rows with distinct keys, reordered. The client keys // React by `child.key`, every key is unique, and the painted list is the // frame's list. Same scene, same two renders, same assertion as the arm below. const uniqueBefore = await frameFor([ { id: 'one', label: 'A' }, { id: 'two', label: 'B' }, { id: 'three', label: 'C' }, ]) const uniqueAfter = await frameFor([ { id: 'two', label: 'B' }, { id: 'one', label: 'A' }, { id: 'three', label: 'C' }, ]) const control = await paint(uniqueBefore, uniqueAfter) expect(control.expected).toEqual(['B', 'A', 'C']) expect(control.painted).toEqual(control.expected) // ---- DEFECT — the same reorder over a list where two rows share a key. The // server handled it: three nodes, three distinct ids, correct order. const dupBefore = await frameFor([ { id: 'dup', label: 'A' }, { id: 'other', label: 'B' }, { id: 'dup', label: 'C' }, ]) const dupAfter = await frameFor([ { id: 'other', label: 'B' }, { id: 'dup', label: 'A' }, { id: 'dup', label: 'C' }, ]) expect(new Set(rowsOf(dupAfter).map((node) => node.id)).size).toBe(3) const defect = await paint(dupBefore, dupAfter) expect(defect.expected).toEqual(['B', 'A', 'C']) // Actual today: ['A', 'B', 'A', 'C'] — React duplicated the row it could not // tell apart, so the browser shows four rows for a three-row frame. expect(defect.painted).toEqual(defect.expected) })