diff --git a/README.md b/README.md index 0f1e634..97c6387 100644 --- a/README.md +++ b/README.md @@ -51,32 +51,50 @@ export function CounterLabel() { ### Bridge Framework-Agnostic Molecules ```tsx -import { molecule, signal } from "@sigrea/core"; +import { molecule, readonly, signal } from "@sigrea/core"; import { useMolecule, useSignal } from "@sigrea/react"; -const CounterMolecule = molecule((props: { initialCount: number }) => { +type CounterProps = { + initialCount: number; + initialStep: number; +}; + +const CounterMolecule = molecule((props: CounterProps) => { const count = signal(props.initialCount); + const step = signal(props.initialStep); - const increment = () => { - count.value += 1; - }; + function setStep(next: number) { + step.value = next; + } - const reset = () => { + function increment() { + count.value += step.value; + } + + function reset() { count.value = props.initialCount; - }; + } - return { count, increment, reset }; + return { + count: readonly(count), + step: readonly(step), + setStep, + increment, + reset, + }; }); -export function Counter(props: { initialCount: number }) { +export function Counter(props: CounterProps) { const counter = useMolecule(CounterMolecule, props); - const value = useSignal(counter.count); + const count = useSignal(counter.count); + const step = useSignal(counter.step); return (
- {value} + {count} +
); } @@ -88,7 +106,7 @@ export function Counter(props: { initialCount: number }) { import { deepSignal } from "@sigrea/core"; import { useDeepSignal } from "@sigrea/react"; -const form = deepSignal({ name: "Sigrea" }); +const form = deepSignal({ name: "Mendako" }); export function ProfileForm() { const state = useDeepSignal(form); @@ -136,13 +154,26 @@ Exposes a deep signal object for direct mutation within the component. Updates t ### useMolecule ```tsx -function useMolecule( - molecule: MoleculeFactory, - props?: TProps -): TReturn +function useMolecule( + molecule: MoleculeFactory, + ...args: MoleculeArgs +): MoleculeInstance ``` -Mounts a molecule factory and returns its public API. The molecule's scope is bound to the component lifecycle: `onMount` callbacks run after the component mounts, and `onUnmount` callbacks run before it unmounts. +Mounts a molecule factory and returns its MoleculeInstance. The molecule's scope is bound to the component lifecycle: `onMount` callbacks run after the component mounts, and `onUnmount` callbacks run before it unmounts. + +**Lifecycle Timing** + +Molecule lifecycles are bound to React's layout effects 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. + +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. + +**Props Handling** + +Props are treated as an initial snapshot. Updating component props does not recreate the molecule instance or update the snapshot; model dynamic values via signals or explicit molecule methods (for example, `setStep`). ## Testing diff --git a/package.json b/package.json index 484edc7..1766179 100644 --- a/package.json +++ b/package.json @@ -29,16 +29,8 @@ }, "main": "./dist/index.cjs", "types": "./dist/index.d.ts", - "files": [ - "dist" - ], - "keywords": [ - "signals", - "reactivity", - "react", - "molecule", - "typescript" - ], + "files": ["dist"], + "keywords": ["signals", "reactivity", "react", "molecule", "typescript"], "scripts": { "dev": "vite --config playground/vite.config.ts", "build": "unbuild", @@ -53,17 +45,20 @@ "cicheck": "pnpm test && pnpm typecheck && pnpm format:fix" }, "peerDependencies": { - "@sigrea/core": "^0.4.3", + "@sigrea/core": "^0.5.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@biomejs/biome": "1.9.4", - "@vitejs/plugin-react": "^4.3.3", + "@sigrea/core": "^0.5.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", - "changelogen": "^0.6.2", + "@vitejs/plugin-react": "^4.3.3", "@vitest/coverage-v8": "^3.2.4", + "baseline-browser-mapping": "^2.9.13", + "changelogen": "^0.6.2", + "jsdom": "^24.1.3", "lefthook": "1.13.6", "react": "^19.0.0", "react-dom": "^19.0.0", @@ -71,13 +66,9 @@ "typescript": "5.9.3", "unbuild": "3.6.1", "vite": "^5.4.6", - "vitest": "^3.2.4", - "jsdom": "^24.1.3" + "vitest": "^3.2.4" }, "pnpm": { - "onlyBuiltDependencies": [ - "lefthook", - "@biomejs/biome" - ] + "onlyBuiltDependencies": ["lefthook", "@biomejs/biome"] } } diff --git a/packages/__tests__/useMolecule.mount-lifecycle.test.ts b/packages/__tests__/useMolecule.mount-lifecycle.test.ts new file mode 100644 index 0000000..79d5099 --- /dev/null +++ b/packages/__tests__/useMolecule.mount-lifecycle.test.ts @@ -0,0 +1,114 @@ +import { createElement } from "react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { + type MoleculeInstance, + type Signal, + disposeTrackedMolecules, + molecule, + onMount, + signal, + watch, +} from "@sigrea/core"; + +import { useMolecule } from "../useMolecule"; +import { createTestRoot, flushMicrotasks } from "./testUtils"; + +describe("useMolecule mount lifecycle", () => { + let root: ReturnType; + + beforeEach(() => { + root = createTestRoot(); + }); + + afterEach(async () => { + await root.unmount(); + disposeTrackedMolecules(); + }); + + it("calls onMount after component mounts", async () => { + const onMountCallback = vi.fn(); + const testMolecule = molecule(() => { + onMount(() => { + onMountCallback(); + }); + return {}; + }); + + function TestComponent() { + useMolecule(testMolecule); + return null; + } + + expect(onMountCallback).not.toHaveBeenCalled(); + + await root.render(createElement(TestComponent)); + + expect(onMountCallback).toHaveBeenCalledTimes(1); + }); + + it("defers watch execution until after mount", async () => { + const watchCallback = vi.fn(); + const setupCallback = vi.fn(); + + const testMolecule = molecule(() => { + const count = signal(0); + + setupCallback(); + + watch(count, (value) => { + watchCallback(value); + }); + + return { count }; + }); + + function TestComponent() { + useMolecule(testMolecule); + return null; + } + + await root.render(createElement(TestComponent)); + + expect(setupCallback).toHaveBeenCalledTimes(1); + expect(watchCallback).not.toHaveBeenCalled(); + + await flushMicrotasks(2); + + expect(watchCallback).not.toHaveBeenCalled(); + }); + + it("executes watch callback when signal changes after mount", async () => { + const watchCallback = vi.fn(); + + const testMolecule = molecule(() => { + const count = signal(0); + + watch(count, (value) => { + watchCallback(value); + }); + + return { count }; + }); + + const observed: Array }>> = []; + + function TestComponent() { + const instance = useMolecule(testMolecule); + observed.push(instance); + return null; + } + + await root.render(createElement(TestComponent)); + await flushMicrotasks(2); + + expect(watchCallback).not.toHaveBeenCalled(); + expect(observed).toHaveLength(1); + + observed[0].count.value = 42; + await flushMicrotasks(2); + + expect(watchCallback).toHaveBeenCalledTimes(1); + expect(watchCallback).toHaveBeenCalledWith(42); + }); +}); diff --git a/packages/__tests__/useMolecule.strict-mode.test.ts b/packages/__tests__/useMolecule.strict-mode.test.ts index 3aad738..8145262 100644 --- a/packages/__tests__/useMolecule.strict-mode.test.ts +++ b/packages/__tests__/useMolecule.strict-mode.test.ts @@ -1,7 +1,7 @@ import { StrictMode, createElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { disposeTrackedMolecules, molecule, onUnmount } from "@sigrea/core"; +import { disposeTrackedMolecules, molecule, onDispose } from "@sigrea/core"; import { useMolecule } from "../useMolecule"; import { createTestRoot, flushMicrotasks } from "./testUtils"; @@ -20,13 +20,13 @@ describe("useMolecule in StrictMode", () => { it("keeps the molecule instance alive across StrictMode effect replays", async () => { const cleanup = vi.fn(); - const counterMolecule = molecule((value: number) => { - onUnmount(() => cleanup(value)); - return { value }; + const counterMolecule = molecule((props: { value: number }) => { + onDispose(() => cleanup(props.value)); + return { value: props.value }; }); function TestComponent() { - useMolecule(counterMolecule, 1); + useMolecule(counterMolecule, { value: 1 }); return null; } diff --git a/packages/__tests__/useMolecule.test.ts b/packages/__tests__/useMolecule.test.ts index 2ce3de2..d22c9dc 100644 --- a/packages/__tests__/useMolecule.test.ts +++ b/packages/__tests__/useMolecule.test.ts @@ -23,28 +23,30 @@ describe("useMolecule", () => { disposeTrackedMolecules(); }); - it("does not dispose when re-rendered with identical props", async () => { + it("does not remount when re-rendered with updated props", async () => { const cleanup = vi.fn(); - const counterMolecule = molecule((value: number) => { - onUnmount(() => cleanup(value)); - return { value }; + const counterMolecule = molecule((props: { value: number }) => { + onUnmount(() => cleanup(props.value)); + return { value: props.value }; }); const observed: Array> = []; function TestComponent({ value }: { value: number }) { - const instance = useMolecule(counterMolecule, value); + const instance = useMolecule(counterMolecule, { value }); observed.push(instance); return null; } await root.render(createElement(TestComponent, { value: 1 })); - await root.render(createElement(TestComponent, { value: 1 })); + await root.render(createElement(TestComponent, { value: 2 })); await flushMicrotasks(2); expect(observed).toHaveLength(2); expect(observed[0]).toBe(observed[1]); + expect(observed[0].value).toBe(1); + expect(observed[1].value).toBe(1); expect(cleanup).not.toHaveBeenCalled(); await root.unmount(); @@ -56,15 +58,15 @@ describe("useMolecule", () => { it("mounts molecule and cleans up on unmount", async () => { const cleanup = vi.fn(); - const makeMolecule = molecule((value: number) => { - onUnmount(() => cleanup(value)); - return { value }; + const makeMolecule = molecule((props: { value: number }) => { + onUnmount(() => cleanup(props.value)); + return { value: props.value }; }); const observed: Array> = []; function TestComponent() { - const instance = useMolecule(makeMolecule, 1); + const instance = useMolecule(makeMolecule, { value: 1 }); observed.push(instance); return null; } @@ -82,42 +84,44 @@ describe("useMolecule", () => { expect(cleanup).toHaveBeenCalledWith(1); }); - it("remounts when arguments change and preserves instances otherwise", async () => { + it("remounts when the molecule factory changes", async () => { const mounts = vi.fn(); const cleanups = vi.fn(); - const counterMolecule = molecule((value: number) => { - mounts(value); - onUnmount(() => cleanups(value)); - return {}; + const moleculeA = molecule((props: { label: string }) => { + mounts(props.label); + onUnmount(() => cleanups(props.label)); + return { label: props.label }; }); - function TestComponent({ value }: { value: number }) { - useMolecule(counterMolecule, value); - return null; - } + const moleculeB = molecule((props: { label: string }) => { + mounts(props.label); + onUnmount(() => cleanups(props.label)); + return { label: props.label }; + }); - async function renderWithValue(value: number) { - await root.render(createElement(TestComponent, { value })); + function TestComponent({ mode }: { mode: "a" | "b" }) { + useMolecule(mode === "a" ? moleculeA : moleculeB, { label: mode }); + return null; } - await renderWithValue(1); + await root.render(createElement(TestComponent, { mode: "a" })); expect(mounts).toHaveBeenCalledTimes(1); - expect(mounts).toHaveBeenLastCalledWith(1); + expect(mounts).toHaveBeenLastCalledWith("a"); - await renderWithValue(1); + await root.render(createElement(TestComponent, { mode: "a" })); expect(mounts).toHaveBeenCalledTimes(1); - await renderWithValue(2); + await root.render(createElement(TestComponent, { mode: "b" })); expect(mounts).toHaveBeenCalledTimes(2); - expect(mounts).toHaveBeenLastCalledWith(2); + expect(mounts).toHaveBeenLastCalledWith("b"); expect(cleanups).toHaveBeenCalledTimes(1); - expect(cleanups).toHaveBeenLastCalledWith(1); + expect(cleanups).toHaveBeenLastCalledWith("a"); await root.unmount(); await flushMicrotasks(2); expect(cleanups).toHaveBeenCalledTimes(2); - expect(cleanups).toHaveBeenLastCalledWith(2); + expect(cleanups).toHaveBeenLastCalledWith("b"); }); }); diff --git a/packages/useMolecule.ts b/packages/useMolecule.ts index 10a3f0d..35cf759 100644 --- a/packages/useMolecule.ts +++ b/packages/useMolecule.ts @@ -1,35 +1,43 @@ -import { useEffect, useRef } from "react"; +import { useEffect, useLayoutEffect, useRef } from "react"; + +const useIsomorphicLayoutEffect = + typeof window !== "undefined" ? useLayoutEffect : useEffect; import type { MoleculeArgs, MoleculeFactory, MoleculeInstance, } from "@sigrea/core"; -import { disposeMolecule } from "@sigrea/core"; +import { disposeMolecule, mountMolecule, unmountMolecule } from "@sigrea/core"; -interface MoleculeState { +interface MoleculeState { instance: MoleculeInstance; molecule: MoleculeFactory; - props: TProps | undefined; subscribers: number; disposed: boolean; pendingDisposeToken: symbol | null; } -export function useMolecule( +export function useMolecule< + TReturn extends object, + TProps extends object | void = void, +>( molecule: MoleculeFactory, ...args: MoleculeArgs ): MoleculeInstance { const props = args.length === 0 ? undefined : (args[0] as TProps | undefined); + + if (props !== undefined && (typeof props !== "object" || props === null)) { + throw new TypeError("useMolecule props must be an object."); + } + const stateRef = useRef | undefined>( undefined, ); const currentState = stateRef.current; const shouldRemount = - currentState === undefined || - currentState.molecule !== molecule || - !Object.is(currentState.props, props); + currentState === undefined || currentState.molecule !== molecule; if (shouldRemount) { if (currentState !== undefined) { @@ -38,15 +46,17 @@ export function useMolecule( stateRef.current = undefined; } + const snapshot = + props === undefined ? undefined : ({ ...props } as Exclude); + const moleculeArgs = - props === undefined + snapshot === undefined ? ([] as MoleculeArgs) - : ([props] as unknown as MoleculeArgs); + : ([snapshot as TProps] as MoleculeArgs); stateRef.current = { instance: molecule(...moleculeArgs), molecule, - props, subscribers: 0, disposed: false, pendingDisposeToken: null, @@ -62,7 +72,7 @@ export function useMolecule( const instance = state.instance; - useEffect(() => { + useIsomorphicLayoutEffect(() => { const state = stateRef.current; if (state === undefined || state.instance !== instance) { return () => {}; @@ -73,6 +83,9 @@ export function useMolecule( } state.subscribers += 1; + if (state.subscribers === 1) { + mountMolecule(instance); + } return () => { const latest = stateRef.current; @@ -87,6 +100,8 @@ export function useMolecule( } if (!latest.disposed && latest.subscribers === 0) { + unmountMolecule(instance); + const token = Symbol("pending-dispose"); latest.pendingDisposeToken = token; diff --git a/playground/src/App.tsx b/playground/src/App.tsx index 2d274ed..e50c45b 100644 --- a/playground/src/App.tsx +++ b/playground/src/App.tsx @@ -1,24 +1,11 @@ -import { useMemo, useState } from "react"; +import { useState } from "react"; import { Counter } from "./Counter"; export function App() { const [showCounter, setShowCounter] = useState(true); const [initialCount, setInitialCount] = useState(0); - const [step, setStep] = useState(1); - - const counterKey = useMemo( - () => `${initialCount}:${step}:${showCounter ? "on" : "off"}`, - [initialCount, step, showCounter], - ); - - const handleInitialChange = (value: number) => { - setInitialCount(value); - }; - - const handleStepChange = (value: number) => { - setStep(value <= 0 ? 1 : value); - }; + const [initialStep, setInitialStep] = useState(1); return (
@@ -42,22 +29,25 @@ export function App() { Initial count - handleInitialChange( - Number.parseInt(event.target.value, 10) || 0, - ) + setInitialCount(Number.parseInt(event.target.value, 10) || 0) } /> @@ -66,7 +56,7 @@ export function App() {
{showCounter ? ( - + ) : (
Counter is currently unmounted. diff --git a/playground/src/Counter.tsx b/playground/src/Counter.tsx index dcbd4c8..53227dc 100644 --- a/playground/src/Counter.tsx +++ b/playground/src/Counter.tsx @@ -1,17 +1,10 @@ -import { useMemo } from "react"; - import { useMolecule, useSignal } from "@sigrea/react"; import { CounterMolecule, type CounterProps } from "./CounterMolecule"; export function Counter(props: CounterProps) { - const { initialCount, step } = props; - const moleculeProps = useMemo( - () => ({ initialCount, step }), - [initialCount, step], - ); - - const counter = useMolecule(CounterMolecule, moleculeProps); + const counter = useMolecule(CounterMolecule, props); const count = useSignal(counter.count); + const step = useSignal(counter.step); return (
@@ -19,6 +12,10 @@ export function Counter(props: CounterProps) { Count {count}

+

+ Step + {step} +