/** * The runtime boundary owns an Effect `ManagedRuntime` for the lifetime of the * mounted subtree: built once, disposed when the boundary is gone. The subtle * part is *when* "gone" is true. React's StrictMode (and offscreen/``) * tears a passive effect down and sets it up again in the same tick to flush out * unsafe cleanup logic — so a boundary that disposes eagerly in its effect * cleanup finalizes the runtime (releases scoped resources, closes layers) out * from under a component that is still mounted. These tests pin the contract: * the runtime survives a StrictMode remount, yet is still disposed on a real * unmount. */ import { StrictMode, act } from 'react'; import { createRoot } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import * as Context from 'effect/Context'; import * as Effect from 'effect/Effect'; import * as Layer from 'effect/Layer'; import * as ManagedRuntime from 'effect/ManagedRuntime'; import { atom, rec, mount } from '../../index.client.ts'; Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); class Stats extends Context.Service()('test/Stats') {} /** * A layer whose service is a scoped resource: `acquire` runs when the runtime * builds it, `release` runs when the runtime is disposed (its layer scope * closes). Counting both lets a test observe the runtime's real lifecycle. */ const trackedLayer = (onAcquire: () => void, onRelease: () => void): Layer.Layer => Layer.effect(Stats)( Effect.acquireRelease( Effect.sync(() => { onAcquire(); return { total: 42 }; }), () => Effect.sync(onRelease), ), ); /** Let any deferred (microtask-scheduled) disposal settle. */ const flushMicrotasks = (): Promise => act(async () => { await Promise.resolve(); await Promise.resolve(); }); let container: HTMLDivElement; beforeEach(() => { container = document.createElement('div'); document.body.appendChild(container); }); afterEach(() => container.remove()); describe('Runtime lifecycle', () => { it('does not dispose the runtime during a StrictMode remount (still mounted)', async () => { let acquired = 0; let released = 0; const Page = rec(function* () { const stats = yield* Stats; return {stats.total}; }); const root = createRoot(container); await act(async () => root.render( {mount( trackedLayer( () => void acquired++, () => void released++, ), Page, )} , ), ); await flushMicrotasks(); // StrictMode double-invokes effects (setup → cleanup → setup). The runtime // must survive that cycle: the tree rendered, and nothing was finalized. expect(container.textContent).toContain('42'); expect(acquired).toBeGreaterThan(0); expect(released).toBe(0); await act(async () => root.unmount()); }); it('disposes the runtime on a real unmount', async () => { let released = 0; const Page = rec(function* () { const stats = yield* Stats; return {stats.total}; }); const root = createRoot(container); await act(async () => root.render( mount( trackedLayer( () => {}, () => void released++, ), Page, ), ), ); await flushMicrotasks(); expect(released).toBe(0); // alive while mounted await act(async () => root.unmount()); await flushMicrotasks(); expect(released).toBe(1); // finalized exactly once when the boundary is gone }); it('disposes a runtime built during a discarded render (no leak)', async () => { // A render that reads a service (building the layer, so `acquire` runs) and // then throws before committing: React discards the subtree, so the boundary's // commit effect never adopts the runtime. `mount` wraps the tree under the // runtime provider, so the failure is a render error the provider's boundary // catches — it finalizes the never-adopted runtime and re-throws. let acquired = 0; let released = 0; const Boom = rec(function* () { const stats = yield* Stats; // resolves against the freshly-built runtime → acquire if (stats.total === 42) { throw new Error('boom'); // discard this render pre-commit } return {stats.total}; }); const Page = rec(function* () { return
{yield* Boom}
; }); const root = createRoot(container); let threw = false; try { await act(async () => root.render( mount( trackedLayer( () => void acquired++, () => void released++, ), Page, ), ), ); } catch { threw = true; } expect(threw).toBe(true); // the render threw and never committed expect(container.textContent).toBe(''); // nothing committed expect(acquired).toBeGreaterThan(0); // the discarded render built the layer (acquired) // Every runtime the discarded render built is finalized — nothing leaks. (The // finalizer runs asynchronously; give it a beat.) await act(async () => { await new Promise((r) => setTimeout(r, 10)); }); expect(released).toBe(acquired); }); it('accepts an injected runtime: shared across mounts, not disposed by effract (#51)', async () => { let released = 0; class Counter extends Context.Service()('test/InjCounter', { make: Effect.acquireRelease( Effect.sync(() => ({ n: atom('n', 0) })), () => Effect.sync(() => void (released += 1)), ), }) { static readonly layer = Layer.effect(Counter, Counter.make); } const runtime = ManagedRuntime.make(Counter.layer); const View = rec(function* () { const counter = yield* Counter; const n = yield* counter.n; return {n}; }); const a = document.createElement('div'); const b = document.createElement('div'); const rootA = createRoot(a); const rootB = createRoot(b); await act(async () => rootA.render(mount(runtime, View))); await act(async () => rootB.render(mount(runtime, View))); await flushMicrotasks(); expect(a.textContent).toBe('0'); expect(b.textContent).toBe('0'); // The two mounts share one runtime → one Counter service → one atom. Writing it // from the runtime updates both. await act(async () => { void runtime.runPromise( Effect.gen(function* () { const counter = yield* Counter; counter.n.set(5); }), ); }); await flushMicrotasks(); expect(a.textContent).toBe('5'); expect(b.textContent).toBe('5'); // Unmount both — effract must NOT dispose an injected runtime. await act(async () => rootA.unmount()); await act(async () => rootB.unmount()); await flushMicrotasks(); expect(released).toBe(0); // The caller owns it: disposing runs the finalizer. await runtime.dispose(); expect(released).toBe(1); }); }); // --- type-level: an injected runtime is still service-checked (checked by tsgo) --- { const runtime = ManagedRuntime.make(Layer.empty); const Needs = rec(function* () { const s = yield* Stats; return {s.total}; }); // ✗ the injected runtime (from Layer.empty) does not provide Stats: // @ts-expect-error effract: runtime is missing Stats void mount(runtime, Needs); }