From 56a1f6fa1dfcb05f364ede0893d290fbeafba64d Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Sun, 17 May 2026 00:01:34 +0900 Subject: [PATCH 1/3] chore: support core 0.7 peer range --- package.json | 4 ++-- pnpm-lock.yaml | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/package.json b/package.json index ffcd136..842676d 100644 --- a/package.json +++ b/package.json @@ -45,13 +45,13 @@ "cicheck": "pnpm test && pnpm typecheck && pnpm format:fix" }, "peerDependencies": { - "@sigrea/core": "^0.6.0", + "@sigrea/core": "^0.7.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@biomejs/biome": "1.9.4", - "@sigrea/core": "^0.6.0", + "@sigrea/core": "^0.7.0", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b36617..0ba7c0f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,8 +12,8 @@ importers: specifier: 1.9.4 version: 1.9.4 '@sigrea/core': - specifier: ^0.6.0 - version: 0.6.0 + specifier: ^0.7.0 + version: 0.7.0 '@types/react': specifier: ^19.0.0 version: 19.2.2 @@ -726,8 +726,8 @@ packages: cpu: [x64] os: [win32] - '@sigrea/core@0.6.0': - resolution: {integrity: sha512-0zofSNihnmFiJ+4MNh6EvWo96bvpCMeTuNAaAx/X1bmosQZ2zztXRS7iEAqHX604cucEIahNBD54Babw5598ug==} + '@sigrea/core@0.7.0': + resolution: {integrity: sha512-9fEK8lje0svrcguy94vLfbID1ZZCnxL63o0fVEf+as2jp0EyeE2CGjvPq18BueHp53QxwsdTdnA3ELhPw4O/OQ==} engines: {node: '>=24'} '@types/babel__core@7.20.5': @@ -2571,7 +2571,7 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.52.5': optional: true - '@sigrea/core@0.6.0': + '@sigrea/core@0.7.0': dependencies: alien-signals: 3.1.1 From 96dea2d3e958ad2303f47dcc872b5037b012172d Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Sun, 17 May 2026 00:01:34 +0900 Subject: [PATCH 2/3] feat: support React live molecule props --- build.config.ts | 5 + packages/__tests__/useMolecule.ssr.test.tsx | 67 +++++++ .../__tests__/useMolecule.strict-mode.test.ts | 55 +++++- packages/__tests__/useMolecule.test.ts | 127 +++++++++++++- packages/useMolecule.ts | 163 ++++++++++++++++-- 5 files changed, 392 insertions(+), 25 deletions(-) diff --git a/build.config.ts b/build.config.ts index b4950da..dd613ee 100644 --- a/build.config.ts +++ b/build.config.ts @@ -1,10 +1,15 @@ import { defineBuildConfig } from "unbuild"; +const clientDirective = '"use client";'; + export default defineBuildConfig({ entries: ["index"], clean: true, declaration: true, rollup: { emitCJS: true, + output: { + banner: clientDirective, + }, }, }); diff --git a/packages/__tests__/useMolecule.ssr.test.tsx b/packages/__tests__/useMolecule.ssr.test.tsx index 78ae3a8..0e4917b 100644 --- a/packages/__tests__/useMolecule.ssr.test.tsx +++ b/packages/__tests__/useMolecule.ssr.test.tsx @@ -170,6 +170,73 @@ describe("useMolecule on the server", () => { expect(disposed).toHaveBeenCalledTimes(1); }); + it("uses the server cleanup path when window exists without document", async () => { + const globalWithWindow = globalThis as typeof globalThis & { + document?: unknown; + window?: unknown; + }; + const hadWindow = Object.prototype.hasOwnProperty.call( + globalThis, + "window", + ); + const hadDocument = Object.prototype.hasOwnProperty.call( + globalThis, + "document", + ); + const originalWindow = globalWithWindow.window; + const originalDocument = globalWithWindow.document; + + Object.defineProperty(globalThis, "window", { + configurable: true, + value: {}, + }); + Reflect.deleteProperty(globalThis, "document"); + vi.resetModules(); + + try { + const core = await import("@sigrea/core"); + const reactAdapter = await import("../useMolecule"); + const disposed = vi.fn(); + const DemoMolecule = core.molecule(() => { + core.onDispose(() => { + disposed(); + }); + return { label: "server" }; + }); + + function TestComponent() { + const instance = reactAdapter.useMolecule(DemoMolecule); + return createElement("span", null, instance.label); + } + + expect(renderToString(createElement(TestComponent))).toBe( + "server", + ); + + await flushMicrotasks(2); + + expect(disposed).toHaveBeenCalledTimes(1); + } finally { + vi.resetModules(); + if (hadWindow) { + Object.defineProperty(globalThis, "window", { + configurable: true, + value: originalWindow, + }); + } else { + Reflect.deleteProperty(globalThis, "window"); + } + if (hadDocument) { + Object.defineProperty(globalThis, "document", { + configurable: true, + value: originalDocument, + }); + } else { + Reflect.deleteProperty(globalThis, "document"); + } + } + }); + it("does not run mount-time watches during server rendering", async () => { const watchCallback = vi.fn(); const DemoMolecule = molecule(() => { diff --git a/packages/__tests__/useMolecule.strict-mode.test.ts b/packages/__tests__/useMolecule.strict-mode.test.ts index 8145262..48bbe27 100644 --- a/packages/__tests__/useMolecule.strict-mode.test.ts +++ b/packages/__tests__/useMolecule.strict-mode.test.ts @@ -1,9 +1,15 @@ import { StrictMode, createElement } from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { disposeTrackedMolecules, molecule, onDispose } from "@sigrea/core"; +import { + computed, + disposeTrackedMolecules, + molecule, + onDispose, +} from "@sigrea/core"; import { useMolecule } from "../useMolecule"; +import { useSignal } from "../useSignal"; import { createTestRoot, flushMicrotasks } from "./testUtils"; describe("useMolecule in StrictMode", () => { @@ -43,4 +49,51 @@ describe("useMolecule in StrictMode", () => { expect(cleanup).toHaveBeenCalledTimes(1); expect(cleanup).toHaveBeenCalledWith(1); }); + + it("does not replay live props sync while dependencies are stable", async () => { + const readProps = vi.fn((value: number) => ({ value })); + const counterMolecule = molecule((props: { value: number }) => { + return { value: computed(() => props.value) }; + }); + + function TestComponent({ value }: { value: number }) { + const instance = useMolecule(counterMolecule, () => readProps(value), [ + value, + ]); + const currentValue = useSignal(instance.value); + return createElement("span", null, String(currentValue)); + } + + await root.render( + createElement( + StrictMode, + null, + createElement(TestComponent, { value: 1 }), + ), + ); + + expect(readProps).toHaveBeenCalledTimes(1); + + await root.render( + createElement( + StrictMode, + null, + createElement(TestComponent, { value: 1 }), + ), + ); + + expect(root.container.textContent).toBe("1"); + expect(readProps).toHaveBeenCalledTimes(1); + + await root.render( + createElement( + StrictMode, + null, + createElement(TestComponent, { value: 2 }), + ), + ); + + expect(root.container.textContent).toBe("2"); + expect(readProps).toHaveBeenCalledTimes(2); + }); }); diff --git a/packages/__tests__/useMolecule.test.ts b/packages/__tests__/useMolecule.test.ts index d22c9dc..6c22736 100644 --- a/packages/__tests__/useMolecule.test.ts +++ b/packages/__tests__/useMolecule.test.ts @@ -3,12 +3,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { type MoleculeInstance, + computed, disposeTrackedMolecules, molecule, onUnmount, } from "@sigrea/core"; import { useMolecule } from "../useMolecule"; +import { useSignal } from "../useSignal"; import { createTestRoot, flushMicrotasks } from "./testUtils"; describe("useMolecule", () => { @@ -23,14 +25,14 @@ describe("useMolecule", () => { disposeTrackedMolecules(); }); - it("does not remount when re-rendered with updated props", async () => { + it("does not remount and keeps object props as an initial snapshot", async () => { const cleanup = vi.fn(); const counterMolecule = molecule((props: { value: number }) => { onUnmount(() => cleanup(props.value)); - return { value: props.value }; + return { value: computed(() => props.value) }; }); - const observed: Array> = []; + const observed: Array> = []; function TestComponent({ value }: { value: number }) { const instance = useMolecule(counterMolecule, { value }); @@ -45,8 +47,8 @@ describe("useMolecule", () => { expect(observed).toHaveLength(2); expect(observed[0]).toBe(observed[1]); - expect(observed[0].value).toBe(1); - expect(observed[1].value).toBe(1); + expect(observed[0].value.value).toBe(1); + expect(observed[1].value.value).toBe(1); expect(cleanup).not.toHaveBeenCalled(); await root.unmount(); @@ -84,6 +86,100 @@ describe("useMolecule", () => { expect(cleanup).toHaveBeenCalledWith(1); }); + it("accepts a props getter", async () => { + const counterMolecule = molecule((props: { value: number }) => { + return { value: computed(() => props.value) }; + }); + + const observed: Array> = []; + + function TestComponent({ value }: { value: number }) { + const instance = useMolecule( + counterMolecule, + () => ({ + value: value * 2, + }), + [value], + ); + observed.push(instance); + return null; + } + + await root.render(createElement(TestComponent, { value: 1 })); + await root.render(createElement(TestComponent, { value: 2 })); + + expect(observed).toHaveLength(2); + expect(observed[0]).toBe(observed[1]); + expect(observed[1].value.value).toBe(4); + }); + + it("rerenders signal consumers after committed props getter sync", async () => { + const dialogMolecule = molecule((props: { open: boolean }) => { + return { open: computed(() => props.open) }; + }); + + function TestComponent({ open }: { open: boolean }) { + const instance = useMolecule(dialogMolecule, () => ({ open }), [open]); + const currentOpen = useSignal(instance.open); + return createElement("span", null, String(currentOpen)); + } + + await root.render(createElement(TestComponent, { open: false })); + expect(root.container.textContent).toBe("false"); + + await root.render(createElement(TestComponent, { open: true })); + expect(root.container.textContent).toBe("true"); + }); + + it("does not resync referential props while dependencies are stable", async () => { + const itemMolecule = molecule((props: { item: { id: number } }) => { + return { item: computed(() => props.item) }; + }); + + const observed: Array<{ id: number }> = []; + + function TestComponent({ id }: { id: number }) { + const instance = useMolecule( + itemMolecule, + () => ({ + item: { id }, + }), + [id], + ); + const item = useSignal(instance.item); + observed.push(item); + return createElement("span", null, String(item.id)); + } + + await root.render(createElement(TestComponent, { id: 1 })); + const firstItem = observed.at(-1); + + await root.render(createElement(TestComponent, { id: 1 })); + + expect(root.container.textContent).toBe("1"); + expect(observed.at(-1)).toBe(firstItem); + + await root.render(createElement(TestComponent, { id: 2 })); + + expect(root.container.textContent).toBe("2"); + expect(observed.at(-1)).toEqual({ id: 2 }); + }); + + it("rejects a props getter without dependencies at runtime", async () => { + const counterMolecule = molecule((props: { value: number }) => { + return { value: computed(() => props.value) }; + }); + + function TestComponent() { + useMolecule(counterMolecule, (() => ({ value: 1 })) as never); + return null; + } + + await expect(root.render(createElement(TestComponent))).rejects.toThrow( + "useMolecule props getter in React requires a dependency list.", + ); + }); + it("remounts when the molecule factory changes", async () => { const mounts = vi.fn(); const cleanups = vi.fn(); @@ -125,3 +221,24 @@ describe("useMolecule", () => { expect(cleanups).toHaveBeenLastCalledWith("b"); }); }); + +function expectReactUseMoleculeTypeErrors() { + type OptionalProps = { value?: number }; + const optionalMolecule = molecule((props: { value?: number }) => { + return { value: computed(() => props.value) }; + }); + const callablePropMolecule = molecule((props: { call?: () => void }) => { + return { handler: props.call }; + }); + const optionalProps: OptionalProps | undefined = + Math.random() > 0.5 ? { value: 1 } : undefined; + + useMolecule(optionalMolecule); + useMolecule(optionalMolecule, undefined); + useMolecule(optionalMolecule, optionalProps); + useMolecule(optionalMolecule, { value: 1 }); + useMolecule(callablePropMolecule, { call: (): void => {} }); + + // @ts-expect-error React props getters require a dependency list. + useMolecule(optionalMolecule, () => ({ value: 1 })); +} diff --git a/packages/useMolecule.ts b/packages/useMolecule.ts index 1d7a199..7aace83 100644 --- a/packages/useMolecule.ts +++ b/packages/useMolecule.ts @@ -1,31 +1,60 @@ -import type { MutableRefObject } from "react"; +import type { DependencyList, MutableRefObject } from "react"; import { useEffect, useLayoutEffect, useRef } from "react"; -const useIsomorphicLayoutEffect = - typeof window !== "undefined" ? useLayoutEffect : useEffect; -const isServerEnvironment = typeof window === "undefined"; +const hasDocument = typeof globalThis.document !== "undefined"; +const useIsomorphicLayoutEffect = hasDocument ? useLayoutEffect : useEffect; +const isServerEnvironment = !hasDocument; import type { + IsAllOptional, MoleculeArgs, MoleculeFactory, MoleculeInstance, + MoleculePropsGetter, + ResolvedMoleculeProps, +} from "@sigrea/core"; +import { + disposeMolecule, + mountMolecule, + unmountMolecule, + updateMoleculeProps, } from "@sigrea/core"; -import { disposeMolecule, mountMolecule, unmountMolecule } from "@sigrea/core"; interface MoleculeState { - instance: MoleculeInstance; + instance: MoleculeInstance; molecule: MoleculeFactory; subscribers: number; disposed: boolean; pendingDisposeToken: symbol | null; + livePropsDeps: DependencyList | undefined; } +type StaticMoleculeProps< + TProps extends object | void, + TSource, +> = TSource extends (...args: never[]) => unknown + ? never + : TSource & ResolvedMoleculeProps; + +type ReactMoleculeArgs< + TProps extends object | void, + TSource = ResolvedMoleculeProps, +> = TProps extends void + ? [] + : IsAllOptional extends true + ? + | [props?: StaticMoleculeProps] + | [props: MoleculePropsGetter, deps: DependencyList] + : + | [props: StaticMoleculeProps] + | [props: MoleculePropsGetter, deps: DependencyList]; + function schedulePendingDispose< TReturn extends object, TProps extends object | void, >( stateRef: MutableRefObject | undefined>, - instance: MoleculeInstance, + instance: MoleculeInstance, token: symbol, ): void { queueMicrotask(() => { @@ -50,15 +79,13 @@ function schedulePendingDispose< export function useMolecule< TReturn extends object, TProps extends object | void = void, + TSource = ResolvedMoleculeProps, >( 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."); - } + ...args: ReactMoleculeArgs +): MoleculeInstance { + const propsSource = args[0]; + const propsDeps = resolvePropsDeps(propsSource, args[1]); const stateRef = useRef | undefined>( undefined, @@ -75,13 +102,11 @@ export function useMolecule< stateRef.current = undefined; } - const snapshot = - props === undefined ? undefined : ({ ...props } as Exclude); - + const initialProps = resolveProps(propsSource); const moleculeArgs = - snapshot === undefined + initialProps === undefined ? ([] as MoleculeArgs) - : ([snapshot as TProps] as MoleculeArgs); + : ([initialProps as TProps] as MoleculeArgs); const nextState: MoleculeState = { instance: molecule(...moleculeArgs), @@ -89,6 +114,8 @@ export function useMolecule< subscribers: 0, disposed: false, pendingDisposeToken: null, + livePropsDeps: + propsDeps === undefined ? undefined : snapshotDependencies(propsDeps), }; stateRef.current = nextState; @@ -108,6 +135,27 @@ export function useMolecule< const instance = state.instance; + useIsomorphicLayoutEffect(() => { + if (!isPropsGetter(propsSource)) { + return; + } + if (propsDeps === undefined) { + return; + } + + const state = stateRef.current; + if (state === undefined || state.instance !== instance) { + return; + } + + if (areDependencyListsEqual(state.livePropsDeps, propsDeps)) { + return; + } + + updateMoleculeProps(instance, resolvePropsForUpdate(propsSource)); + state.livePropsDeps = snapshotDependencies(propsDeps); + }, [instance, ...(propsDeps ?? [])]); + useIsomorphicLayoutEffect(() => { const state = stateRef.current; if (state === undefined || state.instance !== instance) { @@ -147,3 +195,80 @@ export function useMolecule< return instance; } + +function resolveProps< + TProps extends object | void, + TSource = ResolvedMoleculeProps, +>( + source: ReactMoleculeArgs[0], +): Exclude | undefined { + const props = typeof source === "function" ? source() : source; + if (props !== undefined && (typeof props !== "object" || props === null)) { + throw new TypeError("useMolecule props must be an object."); + } + return props as Exclude | undefined; +} + +function resolvePropsForUpdate< + TProps extends object | void, + TSource = ResolvedMoleculeProps, +>( + source: ReactMoleculeArgs[0], +): ResolvedMoleculeProps { + return (resolveProps(source) ?? + {}) as ResolvedMoleculeProps; +} + +function isPropsGetter< + TProps extends object | void, + TSource = ResolvedMoleculeProps, +>( + source: ReactMoleculeArgs[0], +): source is Extract[0], () => object> { + return typeof source === "function"; +} + +function resolvePropsDeps< + TProps extends object | void, + TSource = ResolvedMoleculeProps, +>( + source: ReactMoleculeArgs[0], + deps: DependencyList | undefined, +): DependencyList | undefined { + if (!isPropsGetter(source)) { + return undefined; + } + + if (!Array.isArray(deps)) { + throw new TypeError( + "useMolecule props getter in React requires a dependency list.", + ); + } + + return deps; +} + +function snapshotDependencies(deps: DependencyList): DependencyList { + return [...deps]; +} + +function areDependencyListsEqual( + current: DependencyList | undefined, + next: DependencyList | undefined, +): boolean { + if (current === undefined || next === undefined) { + return current === next; + } + + if (current.length !== next.length) { + return false; + } + + for (let index = 0; index < current.length; index += 1) { + if (!Object.is(current[index], next[index])) { + return false; + } + } + + return true; +} From a6b5a26ad7c67e8f750c86fbe61615c88c168e45 Mon Sep 17 00:00:00 2001 From: aose-yuu Date: Sun, 17 May 2026 00:01:34 +0900 Subject: [PATCH 3/3] docs: document React live molecule props --- README.md | 136 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 99 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index deac8f1..e2c2a2d 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,9 @@ npm install @sigrea/react @sigrea/core react react-dom ``` +Install `@sigrea/use` as well when shared molecules use utilities such as +`createEvents`. + Requires React 18+ and Node.js 24 or later. ## Quick Start @@ -51,51 +54,70 @@ export function CounterLabel() { ### Bridge Framework-Agnostic Molecules ```tsx -import { molecule, readonly, signal } from "@sigrea/core"; +import { + computed, + get, + molecule, + readonly, + signal, + toSignal, +} from "@sigrea/core"; import { useMolecule, useSignal } from "@sigrea/react"; +import { createEvents } from "@sigrea/use"; -type CounterProps = { - initialCount: number; - initialStep: number; +type DialogProps = { + open: boolean; + disabled?: boolean; }; -const CounterMolecule = molecule((props: CounterProps) => { - const count = signal(props.initialCount); - const step = signal(props.initialStep); +type DialogEvents = { + "update:open": [open: boolean]; +}; - function setStep(next: number) { - step.value = next; - } +const DialogMolecule = molecule((props) => { + const { send, on } = createEvents(); + const open = toSignal(props, "open"); + const disabled = computed(() => props.disabled ?? false); - function increment() { - count.value += step.value; - } + const requestOpenChange = async (nextOpen: boolean) => { + if (disabled.value) { + return; + } + await send("update:open", nextOpen); + }; - function reset() { - count.value = props.initialCount; - } + return { + disabled, + on, + open, + requestOpenChange, + }; +}); + +const DialogControllerMolecule = molecule(() => { + const open = signal(false); + const dialog = get(DialogMolecule, () => ({ + open: open.value, + })); + + dialog.on("update:open", (nextOpen) => { + open.value = nextOpen; + }); return { - count: readonly(count), - step: readonly(step), - setStep, - increment, - reset, + open: readonly(open), + requestOpenChange: dialog.requestOpenChange, }; }); -export function Counter(props: CounterProps) { - const counter = useMolecule(CounterMolecule, props); - const count = useSignal(counter.count); - const step = useSignal(counter.step); +export function DialogButton() { + const dialog = useMolecule(DialogControllerMolecule); + const currentOpen = useSignal(dialog.open); return ( -
- {count} - - - -
+ ); } ``` @@ -137,13 +159,16 @@ function useSignal( Subscribes to a signal or computed value and returns its current value. The component re-renders when the source changes. +Unlike the Vue adapter, this hook returns the unwrapped value `T` directly rather +than a ref. + ### useComputed ```tsx function useComputed(source: Computed): T ``` -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. +Subscribes to a computed value and returns its current value. Prefer this over `useSignal` when the source is statically known to be `Computed`, so type-checking enforces that only computed sources are passed. ### useDeepSignal @@ -156,10 +181,20 @@ Exposes a deep signal object for direct mutation within the component. Updates t ### useMolecule ```tsx -function useMolecule( +function useMolecule( + molecule: MoleculeFactory +): MoleculeInstance + +function useMolecule( + molecule: MoleculeFactory, + props: TProps +): MoleculeInstance + +function useMolecule( molecule: MoleculeFactory, - ...args: MoleculeArgs -): MoleculeInstance + props: () => TProps, + deps: DependencyList +): MoleculeInstance ``` 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. @@ -173,11 +208,38 @@ Molecule lifecycles are bound to React commits for precise timing control: - `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. +`onMount`, `watch`, and `watchEffect` run after the component commits. In the +browser, they run before paint. **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`). +`useMolecule` keeps the same molecule instance while the factory stays the same. +Molecules without props, and molecules whose props are all optional, can be +mounted without a props argument. Passing a props object directly creates an +initial snapshot. Passing a props getter requires a React dependency list, such +as `() => ({ open })` with `[open]`, and syncs top-level props only after those +dependencies change. This matches React's dependency model and avoids resyncing +referential props on every commit. + +Inside a molecule, read props as `props.name`; destructuring copies the current +value and loses reactivity. + +React components mount the root or controller molecule and use `useSignal()` to +read returned signals. Raw molecule events such as `dialog.on(...)` belong +inside the molecule graph, not in component bodies. If a UI wrapper needs a +React-controlled API such as `open` + `onOpenChange`, bridge it at the wrapper +boundary. + +**Client Components and SSR** + +`@sigrea/react` exports hooks and is intended for Client Components. Do not call +`useMolecule`, `useSignal`, `useComputed`, or `useDeepSignal` directly from a +React Server Component. + +During server rendering, molecule instances can be created for the render pass, +but they are not mounted. `onMount`, `watch`, and `watchEffect` registered during +setup do not run on the server. After server rendering completes, unmounted +molecules are disposed in a microtask. ## Testing