import type { ReactNode } from 'react' import { Match, MutableRef, Option, Predicate } from 'effect' import { Wire, type WireTree } from '@playfast/reform/internal' import { type ClientConfig, type RemoteContract, type RemoteViewSet, renderWireTree, } from './client' import type { InvokeMessage, RemoteTransport, ServerMessage } from './transport' export interface ClientBinding { readonly node: () => ReactNode readonly subscribe: (listener: () => void) => () => void readonly snapshot: () => WireTree // Stop notifying React. The wire stays in sync — see `connect`. readonly dispose: () => void // Done with the connection: drop the transport subscription too. A binding that // has been closed never updates again. readonly close: () => void } export interface ConnectOptions { readonly transport: RemoteTransport readonly views: RemoteViewSet // How long the transport subscription outlives a `dispose()`. See `dispose` below. // oxlint-disable-next-line reform-rules/no-optional-fields -- presence-optional public config; absent falls back to the built-in default readonly releaseWindowMs?: number } // oxlint-disable-next-line reform-rules/no-magic-numbers -- the window's length is the value being named const defaultReleaseWindowMs = 10_000 // Browsers hand back a number, which has no `unref`. const detachTimer = (handle: unknown): void => { if (Predicate.hasProperty(handle, 'unref') && Predicate.isFunction(handle.unref)) { handle.unref() } } export const connect = (options: ConnectOptions): ClientBinding => { const { transport, views } = options const state: { tree: WireTree } = { tree: [] } const listeners = new Set<() => void>() const config: ClientConfig = { views, invoke: (handle, payload) => transport.send({ _tag: 'Invoke', handle, payload }), } const receive = (message: ServerMessage): void => { state.tree = Match.value(message).pipe( Match.tag('Snapshot', ({ tree }) => tree), Match.tag('Patches', ({ patches }) => Wire.apply(state.tree, patches)), Match.exhaustive, ) listeners.forEach((listener) => listener()) } // Subscribed for the binding's whole life, not for the span React happens to be // listening. Patches are deltas against the server's own baseline and a delete is // never re-sent, so a frame missed while detached is a permanent phantom node — // and an effect cleanup is not an unmount (StrictMode, , all // tear effects down and put them back). `dispose` therefore only goes quiet; the // transport subscription is released by `close`, which the connection's owner calls. const off = transport.onMessage(receive) const pending = MutableRef.make(Option.none>()) const close = (): void => { listeners.clear() MutableRef.set(pending, Option.none()) off() } // `dispose()` cannot know whether React is unmounting the host or merely hiding it, // so it goes quiet and starts a clock. A `subscribe` inside the window is the host // coming back and stops it; nothing inside the window means the host is gone, and // the transport subscription is released rather than left to accumulate one dead // handler per mount/unmount cycle. const cancelClose = (): void => Option.match(MutableRef.get(pending), { onNone: () => undefined, onSome: (handle) => { MutableRef.set(pending, Option.none()) clearTimeout(handle) }, }) return { node: () => renderWireTree(state.tree, config), subscribe: (listener) => { cancelClose() listeners.add(listener) return () => void listeners.delete(listener) }, snapshot: () => state.tree, dispose: () => { listeners.clear() if (Option.isSome(MutableRef.get(pending))) { return } // oxlint-disable-next-line reform-rules/no-set-timeout-interval -- must be unref-able; a binding waiting to be collected must not hold the host process open const handle = setTimeout(close, options.releaseWindowMs ?? defaultReleaseWindowMs) detachTimer(handle) MutableRef.set(pending, Option.some(handle)) }, close, } }