'use client'; /** * The runtime provider that `mount` wraps your tree in. It builds an Effect * `ManagedRuntime` once from a `Layer` and hands it down through React context, * where every effract component reads it. This is the seam where "server vs * client" lives: provide a browser layer and the same components run in a SPA; * provide a server layer and they run under Node or Bun — the * components never change. Use `mount(layer, Root)`; `Runtime` is the low-level * provider underneath it. * * Services are resolved up-front into the runtime's context (the RSC-style * "resolve near the root" mode), so reading a service inside a component is a * synchronous context lookup, not an async round-trip. */ import { Component, createContext, useContext, useEffect, useMemo, useRef, type ReactNode, } from 'react'; import * as Effect from 'effect/Effect'; import * as Exit from 'effect/Exit'; import * as Fiber from 'effect/Fiber'; import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import * as Scope from 'effect/Scope'; import type { Executor } from '#application/ports.ts'; import { QueryCache } from '#infrastructure/query-cache.ts'; import { makeQueryCache } from '#infrastructure/react/query-cache-live.ts'; import { runtimeScopeOf } from '#infrastructure/react/suspensable-store.ts'; type AnyManagedRuntime = ManagedRuntime.ManagedRuntime; export interface RuntimeContextValue { readonly executor: Executor; readonly runtime: AnyManagedRuntime; } const RuntimeContext = createContext(null); const executorFromRuntime = (runtime: AnyManagedRuntime): Executor => ({ runSyncExit: (effect) => runtime.runSyncExit(effect), // The built context, once the runtime's layer has resolved — the interpreter's // direct-lookup fast path for service reads. `undefined` (still building) maps // to `null`, which sends the read through `runSyncExit` instead. serviceContext: () => runtime.cachedContext ?? null, runPromise: (effect, signal) => runtime.runPromise(effect, signal ? { signal } : undefined), // The runtime provides `R`, and its layer already built at mount — so the layer // error `ER` (which the runtime folds into the exit) cannot occur here. Erasing // both is the one Effect-boundary step, so the effect's `A`/`E` survive to the // caller (a `mutation`) without a cast at the call site. runPromiseExit: (effect: Effect.Effect): Promise> => runtime.runPromiseExit(effect as Effect.Effect) as Promise>, // Join an already-running fiber (raw async): await its result without re-running. join: (fiber) => runtime.runPromise(Fiber.join(fiber)), // Interrupt fire-and-forget; the interrupt effect cannot fail, so nothing to observe. interrupt: (fiber) => void runtime.runFork(Fiber.interrupt(fiber)), withScope: (effect) => { // A child of the runtime's scope, so disposing the runtime also closes it — // the finalizer runs whether the component unmounts (explicit close) or the // whole runtime is torn down, with no race between the two on unmount. const scope = runtime.runSync(Scope.fork(runtime.scope)); const promise = runtime.runPromise(Effect.provideService(effect, Scope.Scope, scope)); return { promise, close: () => void runtime.runFork(Scope.close(scope, Exit.void)) }; }, }); export interface RuntimeProps { /** * A self-contained layer providing the subtree's services. Its one permitted * open requirement is {@link QueryCache}, which the boundary itself provides — * so a service may `yield* QueryCache` in its `make`. */ readonly layer?: Layer.Layer; /** * A pre-built `ManagedRuntime` to use instead of building one from a `layer` * (share one runtime across mounts / with non-React code). effract does **not** * dispose an injected runtime — you own its lifecycle. Built outside effract, * it does not get a `QueryCache` merged in: build with the `layer` form if * your services use the cache. */ readonly runtime?: ManagedRuntime.ManagedRuntime; readonly children?: ReactNode; } /** * A render-phase error boundary that disposes an *un-adopted* runtime before the * error propagates. The interpreter needs the runtime *during* render, so it is * built in render — but if a child throws before the boundary ever commits (the * classic pre-commit discard: a sibling under the same runtime fails), no commit * effect runs to take ownership, and the built runtime's scopes/finalizers would * leak. Because `mount` wraps the whole tree under this provider, that failure is * a render error this boundary catches: it finalizes the runtime (only if it was * never adopted — a committed runtime is owned by the unmount latch) and re-throws * so the error still reaches the app's real boundary. StrictMode is unaffected: a * committed runtime is `adopted`, so a later child error never disposes it here. */ class DisposeUnadoptedOnError extends Component< { readonly onUnadoptedError: () => void; readonly children: ReactNode }, { readonly error: unknown; readonly caught: boolean } > { override state: { readonly error: unknown; readonly caught: boolean } = { error: null, caught: false, }; static getDerivedStateFromError(error: unknown): { readonly error: unknown; readonly caught: boolean; } { return { error, caught: true }; } override render(): ReactNode { if (this.state.caught) { this.props.onUnadoptedError(); throw this.state.error; // re-propagate to the app's boundary } return this.props.children; } } /** * Provide an Effect runtime to a React subtree. Prefer `mount(layer, Root)`, * which wraps the root REC in this provider and checks the tree's services at * compile time. Reach for `Runtime` directly only to wrap non-REC React trees. */ export function Runtime({ layer, runtime: injected, children, }: RuntimeProps): ReactNode { // Build the runtime exactly once for this boundary instance (unless one is // injected). It has to exist during render (services resolve synchronously from // it), so it is built here; `adopted` records whether a commit has taken // ownership, so the error boundary knows whether a mid-render failure should // finalize it (see above). effract only ever disposes a runtime it built. const runtimeRef = useRef(null); if (runtimeRef.current === null) { if (injected !== undefined) { runtimeRef.current = injected as AnyManagedRuntime; } else { // Merge the built-in QueryCache into the user's layer (provideMerge, so a // service may *require* it in its `make`). The cache is scoped to this // runtime through a thunk: `ManagedRuntime.make` is lazy, so no service // resolves the cache before the scope is set just below. const scopeRef: { current: string | null } = { current: null }; const withCache = Layer.provideMerge( layer as Layer.Layer, Layer.succeed( QueryCache, makeQueryCache(() => scopeRef.current), ), ); runtimeRef.current = ManagedRuntime.make(withCache) as AnyManagedRuntime; scopeRef.current = runtimeScopeOf(runtimeRef.current); } } const runtime = runtimeRef.current; const owned = injected === undefined; // an injected runtime is the caller's to dispose const adopted = useRef(false); const disposeIfUnadopted = (): void => { if (owned && !adopted.current) { void runtime.dispose(); if (runtimeRef.current === runtime) { runtimeRef.current = null; // let a retry build a fresh one } } }; const value = useMemo( () => ({ executor: executorFromRuntime(runtime), runtime }), [runtime], ); // Tear the runtime down (close scopes, finalize layers) when the boundary is // *truly* gone — but survive React's StrictMode/offscreen remount, where a // passive effect is torn down and immediately set up again in the same tick. // Disposing eagerly in cleanup would finalize the runtime (release scoped // resources, close layers) out from under a component that is still mounted, // leaving every later `yield*` running against a dead runtime. So we *defer* // disposal to a microtask and cancel it if the effect is set up again first: // on a real unmount nothing re-arms `alive`, so the runtime is disposed; on a // StrictMode/offscreen remount the re-setup flips it back and disposal is a // no-op. `alive` lives in a ref so both closures see the same latch. const alive = useRef(true); useEffect(() => { adopted.current = true; // this render committed — the unmount latch now owns it alive.current = true; return () => { alive.current = false; queueMicrotask(() => { if (!alive.current && owned) { void runtime.dispose(); // Allow a future render of this same boundary to build a fresh one. if (runtimeRef.current === runtime) { runtimeRef.current = null; } } }); }; }, [runtime, owned]); return ( {children} ); } /** Internal: runtime + executor in one context read (one hook, not two). */ export const useRuntimeHandles = (): RuntimeContextValue => { const value = useContext(RuntimeContext); if (value === null) { throw new Error( 'effract: no runtime found above this component. Mount your root with mount(layer, Root).', ); } return value; }; /** Escape hatch: the underlying `ManagedRuntime`, for imperative `runPromise`/`runFork`. */ export const useEffractRuntime = (): AnyManagedRuntime => useRuntimeHandles().runtime;