diff --git a/README.md b/README.md index 97c6387..735bd83 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # @sigrea/react -`@sigrea/react` adapts [@sigrea/core](https://www.npmjs.com/package/@sigrea/core) molecule modules and signals for use in React components. It binds scope-aware lifecycles to `useEffect`, synchronizes signal subscriptions with React rendering, and provides hooks for both shallow and deep reactivity. +`@sigrea/react` adapts [@sigrea/core](https://www.npmjs.com/package/@sigrea/core) molecule modules and signals for use in React components. It binds scope-aware lifecycles to React commits, synchronizes signal subscriptions with React rendering, and provides hooks for both shallow and deep reactivity. - **Signal subscriptions.** `useSignal` subscribes to signals and computed values, triggering re-renders when they change. - **Computed subscriptions.** `useComputed` subscribes to computed values and memoizes them per component instance. @@ -130,10 +130,12 @@ export function ProfileForm() { ### useSignal ```tsx -function useSignal(signal: Signal | ReadonlySignal): T +function useSignal( + signal: Signal | ReadonlySignal | Computed +): T ``` -Subscribes to a signal or computed value and returns its current value. The component re-renders when the signal changes. +Subscribes to a signal or computed value and returns its current value. The component re-renders when the source changes. ### useComputed @@ -141,7 +143,7 @@ Subscribes to a signal or computed value and returns its current value. The comp function useComputed(source: Computed): T ``` -Subscribes to a computed value and returns its current value. The component re-renders when the computed value changes, and the subscription is cleaned up when the component unmounts. +Subscribes to a computed value and returns its current value. This behaves like `useSignal(source)` for computed sources, but keeps the call site explicit when the source is known to be computed. ### useDeepSignal @@ -164,10 +166,12 @@ Mounts a molecule factory and returns its MoleculeInstance. The molecule's scope **Lifecycle Timing** -Molecule lifecycles are bound to React's layout effects for precise timing control: +Molecule lifecycles are bound to React commits for precise timing control: -- In **browser environments**, molecule mounting happens synchronously after DOM updates but before paint (via `useLayoutEffect`). This matches Vue 3's `onMounted` timing, ensuring consistent behavior across frameworks. -- In **SSR environments**, lifecycle callbacks are deferred to `useEffect` to avoid hydration warnings while maintaining the same cleanup guarantees. +- In **browser environments**, molecule mounting happens synchronously after the component commits but before paint (via `useLayoutEffect`). This matches Vue 3's `onMounted` timing, ensuring consistent behavior across frameworks. +- In **SSR environments**, `useMolecule` supports both `renderToString` and streaming server rendering (`renderToPipeableStream`, `renderToReadableStream`). The molecule instance is created during render so components can read its state, but it is never mounted on the server. +- `onMount`, `watch`, and `watchEffect` registered during setup do not run during server rendering. +- After a **server render** finishes, the unmounted molecule instance is disposed automatically in a microtask so setup-scope `onDispose` cleanups do not leak across requests. This design ensures that `onMount` callbacks and `watch` effects activate at the right moment—early enough to set up subscriptions before the first paint, yet safely after the component has committed to the DOM. diff --git a/packages/__tests__/useMolecule.ssr.test.tsx b/packages/__tests__/useMolecule.ssr.test.tsx new file mode 100644 index 0000000..3ef1f4a --- /dev/null +++ b/packages/__tests__/useMolecule.ssr.test.tsx @@ -0,0 +1,262 @@ +// @vitest-environment node + +// @ts-expect-error This node-only test imports a built-in module without node types in the package tsconfig. +import { PassThrough } from "node:stream"; +import { createElement } from "react"; +import { + renderToPipeableStream, + renderToReadableStream, + renderToString, +} from "react-dom/server"; +import { describe, expect, it, vi } from "vitest"; + +import { + molecule, + onDispose, + onMount, + signal, + watch, + watchEffect, +} from "@sigrea/core"; + +import { useMolecule } from "../useMolecule"; + +async function flushMicrotasks(times = 1): Promise { + for (let index = 0; index < times; index += 1) { + await Promise.resolve(); + } +} + +async function renderPipeable(element: ReturnType) { + const stream = new PassThrough(); + let html = ""; + stream.on("data", (chunk: unknown) => { + html += String(chunk); + }); + + await new Promise((resolve, reject) => { + const { pipe } = renderToPipeableStream(element, { + onAllReady() { + pipe(stream); + }, + onError(error) { + reject(error); + }, + }); + + stream.on("end", () => resolve()); + stream.on("error", reject); + }); + + return html; +} + +async function renderReadable(element: ReturnType) { + const stream = await renderToReadableStream(element); + const reader = stream.getReader(); + const decoder = new TextDecoder(); + let html = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + html += decoder.decode(value, { stream: true }); + } + + html += decoder.decode(); + return html; +} + +describe("useMolecule on the server", () => { + it("disposes the unmounted molecule after server rendering", async () => { + const mounted = vi.fn(); + const disposed = vi.fn(); + const DemoMolecule = molecule(() => { + onMount(() => { + mounted(); + }); + onDispose(() => { + disposed(); + }); + return { label: "server" }; + }); + + function TestComponent() { + const instance = useMolecule(DemoMolecule); + return createElement("span", null, instance.label); + } + + expect(renderToString(createElement(TestComponent))).toBe( + "server", + ); + expect(mounted).not.toHaveBeenCalled(); + expect(disposed).not.toHaveBeenCalled(); + + await flushMicrotasks(2); + + expect(mounted).not.toHaveBeenCalled(); + expect(disposed).toHaveBeenCalledTimes(1); + + await flushMicrotasks(2); + + expect(disposed).toHaveBeenCalledTimes(1); + }); + + it("does not run mount-time watches during server rendering", async () => { + const watchCallback = vi.fn(); + const DemoMolecule = molecule(() => { + const count = signal(1); + + watch( + count, + (value) => { + watchCallback(value); + }, + { immediate: true }, + ); + + return { count }; + }); + + function TestComponent() { + const instance = useMolecule(DemoMolecule); + return createElement("span", null, instance.count.value); + } + + expect(renderToString(createElement(TestComponent))).toBe("1"); + + await flushMicrotasks(2); + + expect(watchCallback).not.toHaveBeenCalled(); + }); + + it("does not run watchEffect during string server rendering", async () => { + const effectRuns = vi.fn(); + const DemoMolecule = molecule(() => { + const count = signal(1); + + watchEffect(() => { + effectRuns(count.value); + }); + + return { count }; + }); + + function TestComponent() { + const instance = useMolecule(DemoMolecule); + return createElement("span", null, instance.count.value); + } + + expect(renderToString(createElement(TestComponent))).toBe("1"); + + await flushMicrotasks(2); + + expect(effectRuns).not.toHaveBeenCalled(); + }); + + it("preserves the server contract for renderToPipeableStream", async () => { + const mounted = vi.fn(); + const disposed = vi.fn(); + const watchCallback = vi.fn(); + const effectRuns = vi.fn(); + const DemoMolecule = molecule(() => { + const count = signal(1); + + onMount(() => { + mounted(); + }); + onDispose(() => { + disposed(); + }); + watch( + count, + (value) => { + watchCallback(value); + }, + { immediate: true }, + ); + watchEffect(() => { + effectRuns(count.value); + }); + + return { count }; + }); + + function TestComponent() { + const instance = useMolecule(DemoMolecule); + return createElement("span", null, instance.count.value); + } + + expect(await renderPipeable(createElement(TestComponent))).toBe( + "1", + ); + expect(mounted).not.toHaveBeenCalled(); + expect(watchCallback).not.toHaveBeenCalled(); + expect(effectRuns).not.toHaveBeenCalled(); + + await flushMicrotasks(2); + + expect(mounted).not.toHaveBeenCalled(); + expect(disposed).toHaveBeenCalledTimes(1); + expect(watchCallback).not.toHaveBeenCalled(); + expect(effectRuns).not.toHaveBeenCalled(); + + await flushMicrotasks(2); + + expect(disposed).toHaveBeenCalledTimes(1); + }); + + it("preserves the server contract for renderToReadableStream", async () => { + const mounted = vi.fn(); + const disposed = vi.fn(); + const watchCallback = vi.fn(); + const effectRuns = vi.fn(); + const DemoMolecule = molecule(() => { + const count = signal(1); + + onMount(() => { + mounted(); + }); + onDispose(() => { + disposed(); + }); + watch( + count, + (value) => { + watchCallback(value); + }, + { immediate: true }, + ); + watchEffect(() => { + effectRuns(count.value); + }); + + return { count }; + }); + + function TestComponent() { + const instance = useMolecule(DemoMolecule); + return createElement("span", null, instance.count.value); + } + + expect(await renderReadable(createElement(TestComponent))).toBe( + "1", + ); + expect(mounted).not.toHaveBeenCalled(); + expect(watchCallback).not.toHaveBeenCalled(); + expect(effectRuns).not.toHaveBeenCalled(); + + await flushMicrotasks(2); + + expect(mounted).not.toHaveBeenCalled(); + expect(disposed).toHaveBeenCalledTimes(1); + expect(watchCallback).not.toHaveBeenCalled(); + expect(effectRuns).not.toHaveBeenCalled(); + + await flushMicrotasks(2); + + expect(disposed).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/__tests__/useSignal.test.ts b/packages/__tests__/useSignal.test.ts index fd89005..bde06c0 100644 --- a/packages/__tests__/useSignal.test.ts +++ b/packages/__tests__/useSignal.test.ts @@ -1,7 +1,7 @@ import { act, createElement } from "react"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; -import { readonly, signal } from "@sigrea/core"; +import { computed, readonly, signal } from "@sigrea/core"; import { useSignal } from "../useSignal"; import { createTestRoot, flushMicrotasks } from "./testUtils"; @@ -41,4 +41,29 @@ describe("useSignal", () => { expect(root.container.textContent).toBe("1"); expect(renders).toEqual([0, 1]); }); + + it("accepts computed values and re-renders on dependency changes", async () => { + const count = signal(2); + const doubled = computed(() => count.value * 2); + const renders: number[] = []; + + function TestComponent() { + const value = useSignal(doubled); + renders.push(value); + return createElement("span", null, value); + } + + await root.render(createElement(TestComponent)); + + expect(root.container.textContent).toBe("4"); + expect(renders).toEqual([4]); + + await act(async () => { + count.value = 3; + }); + await flushMicrotasks(); + + expect(root.container.textContent).toBe("6"); + expect(renders).toEqual([4, 6]); + }); }); diff --git a/packages/useMolecule.ts b/packages/useMolecule.ts index 35cf759..1d7a199 100644 --- a/packages/useMolecule.ts +++ b/packages/useMolecule.ts @@ -1,7 +1,9 @@ +import type { MutableRefObject } from "react"; import { useEffect, useLayoutEffect, useRef } from "react"; const useIsomorphicLayoutEffect = typeof window !== "undefined" ? useLayoutEffect : useEffect; +const isServerEnvironment = typeof window === "undefined"; import type { MoleculeArgs, @@ -18,6 +20,33 @@ interface MoleculeState { pendingDisposeToken: symbol | null; } +function schedulePendingDispose< + TReturn extends object, + TProps extends object | void, +>( + stateRef: MutableRefObject | undefined>, + instance: MoleculeInstance, + token: symbol, +): void { + queueMicrotask(() => { + const current = stateRef.current; + if ( + current === undefined || + current.instance !== instance || + current.subscribers > 0 || + current.disposed || + current.pendingDisposeToken !== token + ) { + return; + } + + current.disposed = true; + current.pendingDisposeToken = null; + stateRef.current = undefined; + disposeMolecule(instance); + }); +} + export function useMolecule< TReturn extends object, TProps extends object | void = void, @@ -54,13 +83,20 @@ export function useMolecule< ? ([] as MoleculeArgs) : ([snapshot as TProps] as MoleculeArgs); - stateRef.current = { + const nextState: MoleculeState = { instance: molecule(...moleculeArgs), molecule, subscribers: 0, disposed: false, pendingDisposeToken: null, }; + stateRef.current = nextState; + + if (isServerEnvironment) { + const token = Symbol("pending-server-dispose"); + nextState.pendingDisposeToken = token; + schedulePendingDispose(stateRef, nextState.instance, token); + } } const state = stateRef.current; @@ -104,24 +140,7 @@ export function useMolecule< const token = Symbol("pending-dispose"); latest.pendingDisposeToken = token; - - queueMicrotask(() => { - const current = stateRef.current; - if ( - current === undefined || - current.instance !== instance || - current.subscribers > 0 || - current.disposed || - current.pendingDisposeToken !== token - ) { - return; - } - - current.disposed = true; - current.pendingDisposeToken = null; - stateRef.current = undefined; - disposeMolecule(instance); - }); + schedulePendingDispose(stateRef, instance, token); } }; }, [instance]); diff --git a/packages/useSignal.ts b/packages/useSignal.ts index 7c0b820..7392f4c 100644 --- a/packages/useSignal.ts +++ b/packages/useSignal.ts @@ -1,11 +1,11 @@ import { useMemo } from "react"; -import type { ReadonlySignal, Signal } from "@sigrea/core"; +import type { Computed, ReadonlySignal, Signal } from "@sigrea/core"; import { createSignalHandler } from "@sigrea/core"; import { useSnapshot } from "./useSnapshot"; -type ReadableSignal = Signal | ReadonlySignal; +type ReadableSignal = Signal | ReadonlySignal | Computed; export function useSignal(source: ReadableSignal) { const handler = useMemo(