// @vitest-environment happy-dom // // Wire-level identity/attribution defects in @playfast/reform-remote. // Every test here asserts the behaviour the package is *supposed* to have; each // one currently FAILS. Nothing in this file fixes anything. import { act, createElement, StrictMode, 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, provide, scene, slot, ui, } from '@playfast/reform' import type { WireNode, WireTree } from '@playfast/reform/internal' import { makeRemoteServer } from './server' import { remoteViews, renderWireTree } from './client' import { RemoteUI, type RemoteUIProps } from './react' import { connect, serve, type InvokeMessage, type ServerMessage } from './transport' import { inMemoryTransportPair } from './memory' import { CounterUi, counterScene } from './fixtures' // --------------------------------------------------------------------------- // polling helper — no fixed waits, every poll stops the moment it is satisfied // --------------------------------------------------------------------------- const PollTimeoutBase: new (args: { readonly detail: string }) => Error & { readonly _tag: 'regress/PollTimeout' } & Readonly<{ readonly detail: string }> = Data.TaggedError('regress/PollTimeout')<{ readonly detail: string }> class PollTimeout extends PollTimeoutBase { override get message(): string { return `condition never became true — ${this.detail}` } } const until = async (detail: string, predicate: () => boolean, rounds = 200): Promise => { if (predicate()) { return } if (rounds <= 0) { throw new PollTimeout({ detail }) } await new Promise((resolve) => { // oxlint-disable-next-line reform-rules/no-wall-clock-wait-in-test -- turn hop inside a bounded structural poll, not a fixed wait setTimeout(resolve, 0) }) return until(detail, predicate, rounds - 1) } // --------------------------------------------------------------------------- // a keyed, reorderable list scene // --------------------------------------------------------------------------- const Row = S.Struct({ id: S.String, label: S.String }) class Rows extends State.make('identity.rows', S.Array(Row)) {} class Reversed extends Event.make('identity.Reversed', S.Struct({})) {} class ReverseRows extends Reducer.make('identity.ReverseRows', { states: [Rows], events: [Reversed], }) {} class RowUi extends ui('RegressRow', { props: S.Struct({ label: S.String }), events: { ping: S.Struct({}) }, }) {} class RowComp extends Composition.make('RegressRow', { title: 'RegressRow', ui: RowUi, props: Row, })() {} class RowSlot extends slot('RegressRow')() {} class ListUi extends ui('RegressList', { props: S.Struct({}), events: { reverse: S.Struct({}) }, slots: { Row: RowSlot }, }) {} class ListComp extends Composition.make('RegressList', { title: 'RegressList', ui: ListUi, slots: { Row: RowSlot }, states: [Rows], })() {} const identityScene = (initial: ReadonlyArray<{ readonly id: string; readonly label: string }>) => { const presentation = Layer.mergeAll( provide( ListUi, Ui.make(ListUi, () => null), ), provide( RowUi, Ui.make(RowUi, ({ label }) => label), ), provide(RowSlot, RowComp), 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 const pingTrigger = yield* Event.trigger(Reversed) const ping = (): void => pingTrigger({}) return mount({ props: { label: rowProps.label }, slots: {}, events: { ping } }) }), Reducer.live(ReverseRows, (current) => [...current].reverse()), ).pipe(Layer.provideMerge(presentation), Layer.provideMerge(Engine)) return scene(ListComp, { provide: [app] }) } const rowsOf = (tree: WireTree): ReadonlyArray => tree.filter((node) => node.slot === 'Row') // --------------------------------------------------------------------------- // BUG 1 — a keyed slot child's client identity follows its POSITION, not its key. // // `each({ key: … })` labels every child, the server ships that label as // `WireNode.key`, and `@playfast/reform-react` (the local host for the same // scene) reconciles React by exactly that key — // packages/react/src/structure.ts:106 `key: placement.key`. // The remote client instead keys React by the wire node id // (packages/reform-remote/src/client.ts:158 `key: child.id`), and that id is // minted positionally as `${parent}.${slot}.${index}` // (packages/reform-remote/src/wireRender.ts:104). Reorder a keyed list and every // node id keeps its slot position while its `key` slides underneath it, so each // client component instance — and all of its local state — is silently // re-attached to a different item. // --------------------------------------------------------------------------- test('a reordered keyed slot keeps each item on its own client instance', async () => { const server = makeRemoteServer( identityScene([ { id: 'a', label: 'A' }, { id: 'b', label: 'B' }, { id: 'c', label: 'C' }, ]), ) const mounts = { n: 0 } const views = remoteViews<{ RegressList: typeof ListUi; RegressRow: typeof RowUi }>({ RegressList: Ui.make(ListUi, (_props, slots) => createElement('div', null, createElement(slots.Row)), ), // Per-row client state: which instance is rendering this row? RegressRow: Ui.make(RowUi, ({ label }) => { const [instance] = useState(() => { mounts.n += 1 return `instance-${mounts.n}` }) return createElement('span', { 'data-label': label, 'data-instance': instance }, label) }), }) const container = document.createElement('div') document.body.appendChild(container) const root = createRoot(container) const rendered = (): ReadonlyArray => [...container.querySelectorAll('[data-label]')].map( (element) => [ element.getAttribute('data-label') ?? '', element.getAttribute('data-instance') ?? '', ] as const, ) const instanceByLabel = (): Record => Object.fromEntries(rendered()) try { const before = await server.render() expect(rowsOf(before).map((node) => node.key)).toEqual(['a', 'b', 'c']) await act(async () => { root.render(renderWireTree(before, { views, invoke: () => undefined })) }) const ownerBefore = instanceByLabel() expect(ownerBefore).toEqual({ A: 'instance-1', B: 'instance-2', C: 'instance-3' }) // Reverse the list server-side: same three keys, new order. await server.invoke('0:reverse', {}) const after = await server.render() expect(rowsOf(after).map((node) => node.key)).toEqual(['c', 'b', 'a']) await act(async () => { root.render(renderWireTree(after, { views, invoke: () => undefined })) }) // The reorder itself did land in the DOM… expect(rendered().map(([label]) => label)).toEqual(['C', 'B', 'A']) // …and nothing remounted, so every instance is still alive. expect(mounts.n).toBe(3) // Each key must therefore still be rendered by the instance that owned it. // Actual today: A→instance-3, C→instance-1 — the state stayed at the slot // position and the items swapped underneath it. expect(instanceByLabel()).toEqual(ownerBefore) } finally { await act(async () => { root.unmount() }) await server.dispose() } }) // --------------------------------------------------------------------------- // BUG 2 — a failed invoke escapes serve()'s message pump as an unhandled // promise rejection. // // packages/reform-remote/src/transport.ts:108-113 (and the identical // `serveShared` pump at :215-220) wrap `server.invoke` in `Effect.promise`, // which turns a rejection into a defect, then drop the resulting promise with // `void Effect.runPromise(…)`. `registry.invoke` legitimately FAILS for a // revoked handle (`UnknownTrigger`) or a payload that misses its schema — // exactly the "stale client invoke fails cleanly" path the playbook documents — // so any client frame that loses that race detonates in the server process. // Under Node's default `--unhandled-rejections=throw` that takes down the // WebSocket server for every other connected client. // --------------------------------------------------------------------------- test('a stale invoke fails cleanly instead of escaping serve() unhandled', async () => { const rejections: Array = [] const onRejection = (reason: unknown): void => void rejections.push(reason) process.on('unhandledRejection', onRejection) const { server: serverTransport, client: clientTransport } = inMemoryTransportPair< ServerMessage, InvokeMessage >() const client = connect({ transport: clientTransport, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: Ui.make(CounterUi, () => null), }), }) const server = serve({ scene: counterScene(), transport: serverTransport }) const countOnClient = (): unknown => { const prop = client .snapshot() .find((node) => node.id === '0') ?.props.find((candidate) => candidate._tag === 'Data' && candidate.name === 'count') return prop !== undefined && 'value' in prop ? prop.value : undefined } try { await server.start() await until('the client received the initial snapshot', () => countOnClient() === 0) // A handle the server does not know: the everyday outcome of clicking a row // whose node was deleted by a background patch still in flight. clientTransport.send({ _tag: 'Invoke', handle: '0:gone', payload: {} }) // A well-formed invoke behind it, used purely as the landing signal. clientTransport.send({ _tag: 'Invoke', handle: '0:bump', payload: { by: 3 } }) await until('the following invoke round-tripped', () => countOnClient() === 3) expect(rejections).toEqual([]) } finally { process.off('unhandledRejection', onRejection) client.dispose() await server.dispose() } }) // --------------------------------------------------------------------------- // BUG 3 — renders nothing under . // // packages/reform-remote/src/react.ts:9-10 creates the transport subscription // inside a `useState` initializer — `connect()` calls `transport.onMessage` // eagerly — but tears it down from an effect cleanup. StrictMode double-invokes // the initializer (two bindings, one orphaned but still subscribed) and then // double-invokes the effect: setup → cleanup → setup. The cleanup calls // `binding.dispose()` on the binding React kept, and the second setup does // nothing to re-subscribe it, so the binding that is actually rendered is deaf // for the rest of its life. `@playfast/reform-react` deliberately avoids this // shape ("Capture once: committed construction avoids leaking runtimes from // abandoned renders", packages/react/src/index.ts:116). // --------------------------------------------------------------------------- const counterNode: WireNode = { id: '0', name: 'Counter', parentId: null, childIndex: 0, slot: null, key: null, props: [{ _tag: 'Data', name: 'count', value: 41 }], } type CounterContract = { Counter: typeof CounterUi } test('RemoteUI stays subscribed to its transport under StrictMode', async () => { const views = remoteViews({ Counter: Ui.make(CounterUi, ({ count }) => createElement('i', { 'data-count': String(count) }, String(count)), ), }) const control = inMemoryTransportPair() const controlContainer = document.createElement('div') document.body.appendChild(controlContainer) const controlRoot = createRoot(controlContainer) const strict = inMemoryTransportPair() const strictContainer = document.createElement('div') document.body.appendChild(strictContainer) const strictRoot = createRoot(strictContainer) try { // Control: the very same mount without StrictMode, so a failure below can // only be StrictMode's double-invocation and not a broken harness. await act(async () => { controlRoot.render( createElement>(RemoteUI, { transport: control.client, views, }), ) }) await act(async () => { control.server.send({ _tag: 'Snapshot', tree: [counterNode] }) }) expect(controlContainer.querySelector('[data-count]')?.getAttribute('data-count')).toBe('41') await act(async () => { strictRoot.render( createElement( StrictMode, null, createElement>(RemoteUI, { transport: strict.client, views, }), ), ) }) await act(async () => { strict.server.send({ _tag: 'Snapshot', tree: [counterNode] }) }) // Actual today: null — the snapshot reached an orphaned binding, and the // rendered one had already been unsubscribed by StrictMode's effect cleanup. expect(strictContainer.querySelector('[data-count]')?.getAttribute('data-count')).toBe('41') } finally { await act(async () => { controlRoot.unmount() strictRoot.unmount() }) } })