import { createElement, Fragment, type ReactNode } from 'react' import { renderToStaticMarkup } from 'react-dom/server' import { expect, test, vi } from 'vitest' import { Schema as S } from 'effect' import { Ui, ui } from '@playfast/reform' import type { InvokeMessage, ServerMessage } from './transport' import { connect, serve, serveShared } from './transport' import { inMemoryTransportPair } from './memory' import { remoteViews } from './client' import { asyncCounterScene, CounterUi, counterScene } from './fixtures' const draw = (node: ReactNode): void => void renderToStaticMarkup(createElement(Fragment, null, node)) interface Probe { props?: Record events?: Record void> } const probeView = (probe: Probe) => Ui.make(CounterUi, (props, _slots, events) => { probe.props = props probe.events = events return null }) test('remote UI over a transport: client renders server state and drives it back', async () => { const { server: serverTransport, client: clientTransport } = inMemoryTransportPair< ServerMessage, InvokeMessage >() const probe: Probe = {} const client = connect({ transport: clientTransport, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }), }) const server = serve({ scene: counterScene(), transport: serverTransport }) try { await server.start() draw(client.node()) expect(probe.props).toEqual({ count: 0 }) probe.events?.['bump']?.({ by: 6 }) await vi.waitFor(() => { draw(client.node()) expect(probe.props).toEqual({ count: 6 }) }) } finally { client.dispose() await server.dispose() } }) test('server-initiated state change streams to the client with NO invoke (background load)', async () => { const { server: serverTransport, client: clientTransport } = inMemoryTransportPair< ServerMessage, InvokeMessage >() const invokes = { count: 0 } const countingClientTransport = { ...clientTransport, send: (message: InvokeMessage) => { invokes.count += 1 clientTransport.send(message) }, } const probe: Probe = {} const client = connect({ transport: countingClientTransport, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }), }) const server = serve({ scene: asyncCounterScene('30 millis'), transport: serverTransport }) try { await server.start() draw(client.node()) expect(probe.props).toEqual({ count: 0 }) await vi.waitFor(() => { draw(client.node()) expect(probe.props).toEqual({ count: 7 }) }) expect(invokes.count).toBe(0) } finally { client.dispose() await server.dispose() } }) test('a fresh Snapshot replaces the client tree — stale nodes are dropped (reconnect semantics)', () => { const { server: serverTransport, client: clientTransport } = inMemoryTransportPair< ServerMessage, unknown >() const rendered: Array = [] class AUi extends ui('A', { props: S.Struct({}) }) {} class BUi extends ui('B', { props: S.Struct({}) }) {} const client = connect({ transport: clientTransport, views: remoteViews<{ A: typeof AUi; B: typeof BUi }>({ A: Ui.make(AUi, () => (rendered.push('A'), null)), B: Ui.make(BUi, () => (rendered.push('B'), null)), }), }) const node = (id: string, name: string) => ({ id, name, parentId: null, childIndex: 0, slot: null, key: null, props: [], }) serverTransport.send({ _tag: 'Snapshot', tree: [node('a', 'A'), node('b', 'B')] }) draw(client.node()) expect(rendered).toEqual(['A', 'B']) rendered.length = 0 serverTransport.send({ _tag: 'Snapshot', tree: [node('a', 'A')] }) draw(client.node()) expect(rendered).toEqual(['A']) client.dispose() }) test('two clients each get their own server runtime — state is isolated', async () => { const one = inMemoryTransportPair() const two = inMemoryTransportPair() const probeOne: Probe = {} const probeTwo: Probe = {} const clientOne = connect({ transport: one.client, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }), }) const clientTwo = connect({ transport: two.client, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }), }) const serverOne = serve({ scene: counterScene(), transport: one.server }) const serverTwo = serve({ scene: counterScene(), transport: two.server }) try { await serverOne.start() await serverTwo.start() draw(clientOne.node()) draw(clientTwo.node()) probeOne.events?.['bump']?.({ by: 9 }) await vi.waitFor(() => { draw(clientOne.node()) expect(probeOne.props).toEqual({ count: 9 }) }) draw(clientTwo.node()) expect(probeTwo.props).toEqual({ count: 0 }) } finally { clientOne.dispose() clientTwo.dispose() await serverOne.dispose() await serverTwo.dispose() } }) test('serveShared: two clients share ONE runtime — one client drives state for both', async () => { const one = inMemoryTransportPair() const two = inMemoryTransportPair() const probeOne: Probe = {} const probeTwo: Probe = {} const clientOne = connect({ transport: one.client, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }), }) const clientTwo = connect({ transport: two.client, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }), }) const shared = serveShared({ scene: counterScene() }) const handleOne = shared.addClient(one.server) const handleTwo = shared.addClient(two.server) try { await vi.waitFor(() => { draw(clientOne.node()) draw(clientTwo.node()) expect(probeOne.props).toEqual({ count: 0 }) expect(probeTwo.props).toEqual({ count: 0 }) }) probeOne.events?.['bump']?.({ by: 5 }) await vi.waitFor(() => { draw(clientOne.node()) draw(clientTwo.node()) expect(probeOne.props).toEqual({ count: 5 }) expect(probeTwo.props).toEqual({ count: 5 }) }) probeTwo.events?.['bump']?.({ by: 3 }) await vi.waitFor(() => { draw(clientOne.node()) draw(clientTwo.node()) expect(probeOne.props).toEqual({ count: 8 }) expect(probeTwo.props).toEqual({ count: 8 }) }) } finally { handleOne.remove() handleTwo.remove() clientOne.dispose() clientTwo.dispose() await shared.dispose() } }) test('serveShared: a client connecting mid-stream snapshots the current shared state', async () => { const one = inMemoryTransportPair() const probeOne: Probe = {} const clientOne = connect({ transport: one.client, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeOne) }), }) const shared = serveShared({ scene: counterScene() }) const handleOne = shared.addClient(one.server) try { await vi.waitFor(() => { draw(clientOne.node()) expect(probeOne.props).toEqual({ count: 0 }) }) probeOne.events?.['bump']?.({ by: 4 }) await vi.waitFor(() => { draw(clientOne.node()) expect(probeOne.props).toEqual({ count: 4 }) }) const two = inMemoryTransportPair() const probeTwo: Probe = {} const clientTwo = connect({ transport: two.client, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probeTwo) }), }) const handleTwo = shared.addClient(two.server) try { await vi.waitFor(() => { draw(clientTwo.node()) expect(probeTwo.props).toEqual({ count: 4 }) }) } finally { handleTwo.remove() clientTwo.dispose() } } finally { handleOne.remove() clientOne.dispose() await shared.dispose() } }) test('dispose detaches both ends: a post-dispose invoke no longer reaches the server', async () => { const { server: serverTransport, client: clientTransport } = inMemoryTransportPair< ServerMessage, InvokeMessage >() const probe: Probe = {} const client = connect({ transport: clientTransport, views: remoteViews<{ Counter: typeof CounterUi }>({ Counter: probeView(probe) }), }) const server = serve({ scene: counterScene(), transport: serverTransport }) await server.start() draw(client.node()) await server.dispose() client.dispose() expect(() => probe.events?.['bump']?.({ by: 1 })).not.toThrow() })