import { createContext, createElement, useCallback, useContext, useEffect, useRef, useState, useSyncExternalStore, type ComponentType, type PropsWithChildren, } from "react"; import { useSyncExternalStoreWithSelector } from "use-sync-external-store/with-selector"; import { EntityDisposalRegistry, type EntityDisposer } from "./entity_disposal"; import type { KeyedController } from "./keyed_host"; export interface DisposableManager { start?(): void; stop?(): void; dispose(): void; } type MachineProviderProps = PropsWithChildren< | ({ manager: M } & Partial) | ({ manager?: undefined } & OwnedProps) >; export function createMachineProvider< M extends DisposableManager, OwnedProps extends object = object, >(options: { name: string; useOwnedManager: (props: OwnedProps) => M; useOnMount?: (manager: M) => void; }): { Provider: ComponentType>; useManager: () => M; } { const ManagerContext = createContext(null); const useOwnedManager = options.useOwnedManager; const useOnMount = options.useOnMount ?? (() => undefined); function ManagerBoundary({ manager, children, }: PropsWithChildren<{ manager: M }>) { useManagerLifecycle(manager); useOnMount(manager); return createElement(ManagerContext.Provider, { value: manager }, children); } function OwnedManagerBoundary({ ownedProps, children, }: PropsWithChildren<{ ownedProps: OwnedProps }>) { const manager = useOwnedManager(ownedProps); return createElement(ManagerBoundary, { manager }, children); } function Provider(props: MachineProviderProps) { if (props.manager) { return createElement( ManagerBoundary, { manager: props.manager }, props.children, ); } return createElement( OwnedManagerBoundary, { ownedProps: props as OwnedProps }, props.children, ); } function useManager(): M { const manager = useContext(ManagerContext); if (!manager) { throw new Error( `use${options.name}Manager requires ${options.name}Provider`, ); } return manager; } return { Provider, useManager }; } const EntityDisposalContext = createContext( null, ); export function EntityDisposalProvider({ children }: PropsWithChildren) { const [registry] = useState(() => new EntityDisposalRegistry()); return createElement( EntityDisposalContext.Provider, { value: registry }, children, ); } export function useEntityDisposal(): EntityDisposalRegistry { const registry = useContext(EntityDisposalContext); if (!registry) { throw new Error("useEntityDisposal requires EntityDisposalProvider"); } return registry; } export function useRegisterEntityDisposer( scope: "app" | "chat", dispose: EntityDisposer, ): void { const registry = useContext(EntityDisposalContext); useEffect(() => { if (!registry) return; return scope === "app" ? registry.onAppDeleted(dispose) : registry.onChatDeleted(dispose); }, [dispose, registry, scope]); } /** * Starts a manager after commit and disposes it after its final unmount. * * React StrictMode immediately replays effects without recreating hook state, * so irreversible disposal is deferred until a replayed setup has had a * chance to claim the same manager. A genuinely replaced manager is still * disposed even though the new manager's effect has already started. */ export function useManagerLifecycle(manager: DisposableManager): void { const lifecycle = useRef({ generations: new Map(), }); useEffect(() => { const generation = (lifecycle.current.generations.get(manager) ?? 0) + 1; lifecycle.current.generations.set(manager, generation); manager.start?.(); return () => { manager.stop?.(); queueMicrotask(() => { const current = lifecycle.current; if (current.generations.get(manager) === generation) { current.generations.delete(manager); manager.dispose(); } }); }; }, [manager]); } function usePagehideTeardown(teardown: () => void): void { useEffect(() => { const handlePagehide = (event: PageTransitionEvent) => { if (!event.persisted) teardown(); }; window.addEventListener("pagehide", handlePagehide); return () => window.removeEventListener("pagehide", handlePagehide); }, [teardown]); } /** * Disposes a renderer-owned manager before document teardown. Main-hosted * managers must use useMainHostedSubscriptionPagehideRelease instead. */ export function useManagerPagehideDisposal(manager: DisposableManager): void { const dispose = useCallback(() => manager.dispose(), [manager]); usePagehideTeardown(dispose); } /** * Releases this renderer's subscription to main-hosted state without * disposing the authoritative main actor. */ export function useMainHostedSubscriptionPagehideRelease( releaseSubscription: () => void, ): void { usePagehideTeardown(releaseSubscription); } export interface KeyedSnapshotSource { subscribeKey(key: K, listener: () => void): () => void; getSnapshot(key: K): Snapshot; } export interface ProjectionSource { subscribe(listener: () => void): () => void; getSnapshot(): Snapshot; } type EqualityFn = (previous: Value, next: Value) => boolean; /** Selects a reference-stable view from one machine controller. */ export function useMachineSelector( controller: ProjectionSource, selector: (snapshot: Snapshot) => Selection, isEqual?: EqualityFn, ): Selection { return useSyncExternalStoreWithSelector( controller.subscribe, controller.getSnapshot, undefined, selector, isEqual, ); } /** Selects a view from one key without subscribing to unrelated keys. */ export function useKeyedMachineSelector( source: KeyedSnapshotSource, key: K, selector: (snapshot: Snapshot) => Selection, isEqual?: EqualityFn, ): Selection { const subscribe = useCallback( (listener: () => void) => source.subscribeKey(key, listener), [source, key], ); const getSnapshot = useCallback(() => source.getSnapshot(key), [source, key]); return useSyncExternalStoreWithSelector( subscribe, getSnapshot, undefined, selector, isEqual, ); } /** Subscribes to a reference-stable projection snapshot without Jotai. */ export function useProjectionSource( source: ProjectionSource, ): Snapshot { return useSyncExternalStore(source.subscribe, source.getSnapshot); } function defaultSelectSnapshot( source: KeyedSnapshotSource, key: K, ): Snapshot { return source.getSnapshot(key); } export function useKeyedController( source: KeyedSnapshotSource, key: K, selectSnapshot: ( source: KeyedSnapshotSource, key: K, ) => Snapshot = defaultSelectSnapshot, ): Snapshot { return useSyncExternalStore( useCallback( (listener) => source.subscribeKey(key, listener), [source, key], ), useCallback( () => selectSnapshot(source, key), [source, key, selectSnapshot], ), ); } /** React binding for a controller that does not need keyed host selection. */ export function useControllerSnapshot( controller: KeyedController, ): Snapshot { return useProjectionSource(controller); }