// @vitest-environment happy-dom // // Two root causes, three proofs. BUG 1 and BUG 2 are the same defect seen from // two ends; BUG 3 is independent. // // ---- BUGS 1 & 2 ---- // // A wire node's id is minted from its POSITION — // `${parent.id}.${slotName}.${index}` (wireRender.ts:104 / :116) — while its // semantic identity travels beside it in `WireNode.key`, which `each({ key })` // fills in and `Wire.diff` already compares (wire/tree.ts:79). Everything // downstream is then keyed off the positional id rather than off that identity: // // * a trigger handle is `${mounted.id}:${eventName}` (wireNode.ts:102), so a // handle survives the item it was minted for and re-points at whoever slid // into that slot position; // * the remote client reconciles React by `child.key ?? child.id` // (client.ts:160), so a child with no key of its own — every `one(...)` // fill — inherits its ancestors' positions and remounts when they move. // // Nothing here fixes anything. Every test asserts behaviour the package already // promises elsewhere, and every one currently FAILS. import { act, createElement, useState } from 'react' import { createRoot } from 'react-dom/client' import { expect, test } from 'vitest' import { Data, Layer, Schema as S } from 'effect' import { Composition, Engine, Event, Reducer, State, Ui, each, mount, one, 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' import { listScene } from './fixtures' // act() only flushes effects when the environment opts in; without this the two // renders below can report a tree React has not finished committing. Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) const MissingBase: new (args: { readonly detail: string }) => Error & { readonly _tag: 'hunt/Missing' } & Readonly<{ readonly detail: string }> = Data.TaggedError('hunt/Missing')<{ readonly detail: string }> class Missing extends MissingBase { override get message(): string { return `wire-hunt: ${this.detail}` } } // --------------------------------------------------------------------------- // BUG 1 — a trigger handle outlives the item it was minted for. // // `renderDiff` revokes the handles of nodes that VANISH (server.ts:144), which // is what makes the playbook's "deleted nodes' handles are revoked, so a stale // client invoke fails cleanly" true for the tail of a list. An interior removal // deletes only the LAST position: every row after the removed one slides down // one slot, so its node id — and therefore its handle — is silently reissued to // a different item. `WireNode.key` records that the occupant changed, and // `Wire.diff` even ships that change to the client, but the registry entry is // just overwritten in place (wire/triggers.ts:52). // // The window is one round trip wide and opens on every background change: a // procedure completing, another session on a `serveShared` runtime, a push. The // invoke that loses the race does not fail — it deletes somebody else's row. // --------------------------------------------------------------------------- const rowsOf = (tree: WireTree): WireTree => tree.filter((node) => node.slot === 'Item') const labelsOf = (tree: WireTree): ReadonlyArray => rowsOf(tree).map((node) => { const prop = node.props.find( (candidate) => candidate._tag === 'Data' && candidate.name === 'label', ) return prop !== undefined && 'value' in prop ? prop.value : undefined }) const rowWithKey = (tree: WireTree, key: string): WireNode => { const found = rowsOf(tree).find((node) => node.key === key) if (found === undefined) { throw new Missing({ detail: `no row keyed '${key}'` }) } return found } const handleFor = (node: WireNode, event: string): string => { const prop = node.props.find( (candidate) => candidate._tag === 'Event' && candidate.name === event, ) if (prop === undefined || !('handle' in prop)) { throw new Missing({ detail: `node ${node.id} exposes no '${event}' handle` }) } return prop.handle } const seed = [ { id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C' }, ] test('a trigger handle keeps firing the item it was minted for', async () => { // ---- CONTROL — the handle really is B's, and against a synced tree it removes B. const control = makeRemoteServer(listScene(seed)) try { const controlFrame = await control.render() await control.invoke(handleFor(rowWithKey(controlFrame, 'b'), 'remove'), {}) expect(labelsOf(await control.render())).toEqual(['A', 'C']) } finally { await control.dispose() } // ---- DEFECT const server = makeRemoteServer(listScene(seed)) try { const frame0 = await server.render() // The handle a connected client is holding for row 'b' once this frame paints. const clickB = handleFor(rowWithKey(frame0, 'b'), 'remove') // A change this client did not cause drops row 'a' while its click is in flight. // Rows 'b' and 'c' are untouched — they only slide down one slot position. await server.invoke(handleFor(rowWithKey(frame0, 'a'), 'remove'), {}) const frame1 = Wire.apply(frame0, await server.renderDiff()) expect(rowsOf(frame1).map((node) => node.key)).toEqual(['b', 'c']) // Now the in-flight click on 'b' lands. It must remove 'b' — or fail cleanly, the // way the playbook says a stale handle does. What it must never do is delete a row // the click was not aimed at. // Actual today: 'C' is gone and 'B', the row that was clicked, survives — the // handle was silently reissued to whoever slid into slot position 1. await server.invoke(clickB, {}) expect(labelsOf(await server.render())).toContain('C') } finally { await server.dispose() } }) // --------------------------------------------------------------------------- // BUG 2 — reordering a keyed list remounts every un-keyed descendant. // // The client keys React by `child.key ?? child.id` (client.ts:160) so that a // keyed row survives a reorder. A `one(...)` fill carries no key // (wireRender.ts:120 `key: Option.none()`), so its React key falls back to the // node id — which embeds the id of every ancestor, and therefore their // POSITIONS. Move a row and its child's key changes from `0.Row.0.Detail.0` to // `0.Row.1.Detail.0`, so React tears the whole subtree down and rebuilds it: // local state, DOM focus, scroll offsets, uncontrolled input values. // // `@playfast/reform-react` renders the identical scene without that loss — it // keys a `One` fill `${slotName}.0` (packages/react/src/structure.ts:89), a key // relative to the parent that a reorder cannot disturb. // --------------------------------------------------------------------------- const RowValue = S.Struct({ id: S.String, label: S.String }) class Rows extends State.make('hunt.rows', S.Array(RowValue)) {} class Reversed extends Event.make('hunt.Reversed', S.Struct({})) {} class ReverseRows extends Reducer.make('hunt.ReverseRows', { states: [Rows], events: [Reversed], }) {} class DetailUi extends ui('HuntDetail', { props: S.Struct({ label: S.String }) }) {} class DetailComp extends Composition.make('HuntDetail', { title: 'HuntDetail', ui: DetailUi, props: S.Struct({ label: S.String }), })() {} class DetailSlot extends slot('Detail')() {} class RowUi extends ui('HuntRow', { props: S.Struct({ label: S.String }), slots: { Detail: DetailSlot }, }) {} class RowComp extends Composition.make('HuntRow', { title: 'HuntRow', ui: RowUi, props: RowValue, slots: { Detail: DetailSlot }, })() {} class RowSlot extends slot('Row')() {} class ListUi extends ui('HuntList', { props: S.Struct({}), events: { reverse: S.Struct({}) }, slots: { Row: RowSlot }, }) {} class ListComp extends Composition.make('HuntList', { title: 'HuntList', ui: ListUi, slots: { Row: RowSlot }, states: [Rows], })() {} const nestedScene = (initial: ReadonlyArray<{ readonly id: string; readonly label: string }>) => { const presentation = Layer.mergeAll( provide( ListUi, Ui.make(ListUi, () => null), ), provide( RowUi, Ui.make(RowUi, () => null), ), provide( DetailUi, Ui.make(DetailUi, () => null), ), provide(RowSlot, RowComp), provide(DetailSlot, DetailComp), State.live(Rows, initial), ) const app = Layer.mergeAll( Composition.live(ListComp, function* () { const rows = yield* Rows const reverse = yield* Event.trigger(Reversed) return mount({ props: {}, events: { reverse }, 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: { Detail: one({ label: rowProps.label }) }, }) }), Composition.live(DetailComp, function* () { const detailProps = yield* DetailComp.props return mount({ props: { label: detailProps.label }, slots: {} }) }), Reducer.live(ReverseRows, (current) => [...current].reverse()), ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine)) return scene(ListComp, { provide: [app] }) } test('reordering a keyed list keeps every descendant mounted', async () => { const server = makeRemoteServer(nestedScene(seed)) const rowMounts = { n: 0 } const detailMounts = { n: 0 } const views = remoteViews<{ HuntList: typeof ListUi HuntRow: typeof RowUi HuntDetail: typeof DetailUi }>({ HuntList: Ui.make(ListUi, (_props, slots) => createElement('div', null, createElement(slots.Row)), ), // Per-row client state: which instance is rendering this row? HuntRow: Ui.make(RowUi, ({ label }, slots) => { const [instance] = useState(() => { rowMounts.n += 1 return `row-${rowMounts.n}` }) return createElement( 'div', { 'data-row': label, 'data-row-instance': instance }, createElement(slots.Detail), ) }), // …and the same question one level below, in the row's un-keyed `one(...)` child. HuntDetail: Ui.make(DetailUi, ({ label }) => { const [instance] = useState(() => { detailMounts.n += 1 return `detail-${detailMounts.n}` }) return createElement('span', { 'data-detail': label, 'data-detail-instance': instance }) }), }) const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) const ownersOf = (attribute: string, instance: string): Record => Object.fromEntries( [...container.querySelectorAll(`[${attribute}]`)].map((element) => [ element.getAttribute(attribute) ?? '', element.getAttribute(instance) ?? '', ]), ) const rowOwners = (): Record => ownersOf('data-row', 'data-row-instance') const detailOwners = (): Record => ownersOf('data-detail', 'data-detail-instance') try { const before = await server.render() await act(async () => { root.render(renderWireTree(before, { views, invoke: () => undefined })) }) const rowsBefore = rowOwners() const detailsBefore = detailOwners() expect(rowsBefore).toEqual({ A: 'row-1', B: 'row-2', C: 'row-3' }) expect(detailsBefore).toEqual({ A: 'detail-1', B: 'detail-2', C: 'detail-3' }) // Same three rows, reversed. Every row keeps its key; only positions move. await server.invoke('0:reverse', {}) const after = await server.render() // The server kept every row's identity; only the positional ids moved. expect(after.filter((node) => node.slot === 'Row').map((node) => node.key)).toEqual([ 'c', 'b', 'a', ]) expect(after.filter((node) => node.slot === 'Detail').map((node) => node.key)).toEqual([ null, null, null, ]) await act(async () => { root.render(renderWireTree(after, { views, invoke: () => undefined })) }) // The reorder landed… expect(Object.keys(rowOwners())).toEqual(['C', 'B', 'A']) // ---- CONTROL — the keyed layer behaves: every row is still its own instance, // so React really did reconcile by key and this harness can see it. expect(rowMounts.n).toBe(3) expect(rowOwners()).toEqual(rowsBefore) // ---- DEFECT — one level down, nothing moved and nothing changed, yet the two // rows that changed position had their children destroyed and rebuilt. // Actual today: A→detail-5, C→detail-4, and detailMounts.n === 5. expect(detailOwners()).toEqual(detailsBefore) expect(detailMounts.n).toBe(3) } finally { await act(async () => { root.unmount() }) await server.dispose() } }) // --------------------------------------------------------------------------- // BUG 3 — a view's contract binding lives ON the function, so two contracts // sharing one implementation collapse onto whichever was made last. // // `Ui.make` attaches its reflection statics with `Object.assign(view, …)` // (compose/ui.ts:230) — it MUTATES the function it was handed and returns that // same object. Make two contracts from one function value and the second // `Ui.make` overwrites `[UiViewContract]` on the object the first one also // returned, so both `MadeView`s now report the second contract. // // `remoteViews` builds its registry by asking each view which contract it // implements and keying by that contract's name (client.ts:64, :88). Two views // that both answer "Footer" produce ONE entry, and `renderWireTree` resolves a // node by `config.views[node.name]` and returns `null` when it misses // (client.ts:120-122) — so the view that lost its identity renders nothing at all, // with no error anywhere. // // Sharing a trivial view function is not exotic: the repo's own house rule asks // for hook-free view shells, and hoisting one `const shell = () => …` out of two // `Ui.make` calls is the obvious next edit. // --------------------------------------------------------------------------- class HeaderUi extends ui('HuntHeader', { props: S.Struct({ label: S.String }) }) {} class FooterUi extends ui('HuntFooter', { props: S.Struct({ label: S.String }) }) {} type ChromeContract = { HuntHeader: typeof HeaderUi; HuntFooter: typeof FooterUi } const chromeNode = (name: string, label: string): WireNode => ({ id: name, name, parentId: null, childIndex: name === 'HuntHeader' ? 0 : 1, slot: null, key: null, props: [{ _tag: 'Data', name: 'label', value: label }], }) const chromeTree: WireTree = [chromeNode('HuntHeader', 'top'), chromeNode('HuntFooter', 'bottom')] const paint = async (views: ReturnType>): Promise => { const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) await act(async () => { root.render(renderWireTree(chromeTree, { views, invoke: () => undefined })) }) const painted = [...container.querySelectorAll('[data-shell]')] .map((element) => element.getAttribute('data-shell') ?? '') .join(',') await act(async () => { root.unmount() }) return painted } test('two contracts can share one view function without erasing each other', async () => { const renderShell = ({ label }: { readonly label: string }): ReturnType => createElement('i', { 'data-shell': label }) // ---- CONTROL — two separate function values with identical bodies. Both // contracts keep their own identity and both nodes paint. expect( await paint( remoteViews({ HuntHeader: Ui.make(HeaderUi, (props) => renderShell(props)), HuntFooter: Ui.make(FooterUi, (props) => renderShell(props)), }), ), ).toBe('top,bottom') // ---- DEFECT — the same body, hoisted to one shared value. // Actual today: 'bottom' — `Ui.make(FooterUi, …)` rebranded the object // `Ui.make(HeaderUi, …)` had already returned, the registry holds a single // 'HuntFooter' entry, and the header node silently resolves to no view. const shell = (props: { readonly label: string }): ReturnType => renderShell(props) expect( await paint( remoteViews({ HuntHeader: Ui.make(HeaderUi, shell), HuntFooter: Ui.make(FooterUi, shell), }), ), ).toBe('top,bottom') })