Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 11 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -130,18 +130,20 @@ export function ProfileForm() {
### useSignal

```tsx
function useSignal<T>(signal: Signal<T> | ReadonlySignal<T>): T
function useSignal<T>(
signal: Signal<T> | ReadonlySignal<T> | Computed<T>
): 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

```tsx
function useComputed<T>(source: Computed<T>): 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

Expand All @@ -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.

Expand Down
262 changes: 262 additions & 0 deletions packages/__tests__/useMolecule.ssr.test.tsx
Original file line number Diff line number Diff line change
@@ -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<void> {
for (let index = 0; index < times; index += 1) {
await Promise.resolve();
}
}

async function renderPipeable(element: ReturnType<typeof createElement>) {
const stream = new PassThrough();
let html = "";
stream.on("data", (chunk: unknown) => {
html += String(chunk);
});

await new Promise<void>((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<typeof createElement>) {
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(
"<span>server</span>",
);
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("<span>1</span>");

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("<span>1</span>");

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(
"<span>1</span>",
);
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(
"<span>1</span>",
);
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);
});
});
27 changes: 26 additions & 1 deletion packages/__tests__/useSignal.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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]);
});
});
Loading
Loading