Skip to content

Commit c04a0c8

Browse files
authored
feat: support React live molecule props (#21)
1 parent 817b52e commit c04a0c8

8 files changed

Lines changed: 498 additions & 69 deletions

File tree

README.md

Lines changed: 99 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@
3030
npm install @sigrea/react @sigrea/core react react-dom
3131
```
3232

33+
Install `@sigrea/use` as well when shared molecules use utilities such as
34+
`createEvents`.
35+
3336
Requires React 18+ and Node.js 24 or later.
3437

3538
## Quick Start
@@ -51,51 +54,70 @@ export function CounterLabel() {
5154
### Bridge Framework-Agnostic Molecules
5255

5356
```tsx
54-
import { molecule, readonly, signal } from "@sigrea/core";
57+
import {
58+
computed,
59+
get,
60+
molecule,
61+
readonly,
62+
signal,
63+
toSignal,
64+
} from "@sigrea/core";
5565
import { useMolecule, useSignal } from "@sigrea/react";
66+
import { createEvents } from "@sigrea/use";
5667

57-
type CounterProps = {
58-
initialCount: number;
59-
initialStep: number;
68+
type DialogProps = {
69+
open: boolean;
70+
disabled?: boolean;
6071
};
6172

62-
const CounterMolecule = molecule((props: CounterProps) => {
63-
const count = signal(props.initialCount);
64-
const step = signal(props.initialStep);
73+
type DialogEvents = {
74+
"update:open": [open: boolean];
75+
};
6576

66-
function setStep(next: number) {
67-
step.value = next;
68-
}
77+
const DialogMolecule = molecule<DialogProps>((props) => {
78+
const { send, on } = createEvents<DialogEvents>();
79+
const open = toSignal(props, "open");
80+
const disabled = computed(() => props.disabled ?? false);
6981

70-
function increment() {
71-
count.value += step.value;
72-
}
82+
const requestOpenChange = async (nextOpen: boolean) => {
83+
if (disabled.value) {
84+
return;
85+
}
86+
await send("update:open", nextOpen);
87+
};
7388

74-
function reset() {
75-
count.value = props.initialCount;
76-
}
89+
return {
90+
disabled,
91+
on,
92+
open,
93+
requestOpenChange,
94+
};
95+
});
96+
97+
const DialogControllerMolecule = molecule(() => {
98+
const open = signal(false);
99+
const dialog = get(DialogMolecule, () => ({
100+
open: open.value,
101+
}));
102+
103+
dialog.on("update:open", (nextOpen) => {
104+
open.value = nextOpen;
105+
});
77106

78107
return {
79-
count: readonly(count),
80-
step: readonly(step),
81-
setStep,
82-
increment,
83-
reset,
108+
open: readonly(open),
109+
requestOpenChange: dialog.requestOpenChange,
84110
};
85111
});
86112

87-
export function Counter(props: CounterProps) {
88-
const counter = useMolecule(CounterMolecule, props);
89-
const count = useSignal(counter.count);
90-
const step = useSignal(counter.step);
113+
export function DialogButton() {
114+
const dialog = useMolecule(DialogControllerMolecule);
115+
const currentOpen = useSignal(dialog.open);
91116

92117
return (
93-
<div>
94-
<span>{count}</span>
95-
<button onClick={counter.increment}>Increment</button>
96-
<button onClick={counter.reset}>Reset</button>
97-
<button onClick={() => counter.setStep(step + 1)}>Step +</button>
98-
</div>
118+
<button onClick={() => dialog.requestOpenChange(!currentOpen)}>
119+
{currentOpen ? "Close" : "Open"}
120+
</button>
99121
);
100122
}
101123
```
@@ -137,13 +159,16 @@ function useSignal<T>(
137159

138160
Subscribes to a signal or computed value and returns its current value. The component re-renders when the source changes.
139161

162+
Unlike the Vue adapter, this hook returns the unwrapped value `T` directly rather
163+
than a ref.
164+
140165
### useComputed
141166

142167
```tsx
143168
function useComputed<T>(source: Computed<T>): T
144169
```
145170

146-
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.
171+
Subscribes to a computed value and returns its current value. Prefer this over `useSignal` when the source is statically known to be `Computed<T>`, so type-checking enforces that only computed sources are passed.
147172

148173
### useDeepSignal
149174

@@ -156,10 +181,20 @@ Exposes a deep signal object for direct mutation within the component. Updates t
156181
### useMolecule
157182

158183
```tsx
159-
function useMolecule<TReturn extends object, TProps extends object | void = void>(
184+
function useMolecule<TReturn extends object>(
185+
molecule: MoleculeFactory<TReturn, void>
186+
): MoleculeInstance<TReturn, void>
187+
188+
function useMolecule<TReturn extends object, TProps extends object>(
189+
molecule: MoleculeFactory<TReturn, TProps>,
190+
props: TProps
191+
): MoleculeInstance<TReturn, TProps>
192+
193+
function useMolecule<TReturn extends object, TProps extends object>(
160194
molecule: MoleculeFactory<TReturn, TProps>,
161-
...args: MoleculeArgs<TProps>
162-
): MoleculeInstance<TReturn>
195+
props: () => TProps,
196+
deps: DependencyList
197+
): MoleculeInstance<TReturn, TProps>
163198
```
164199

165200
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:
173208
- `onMount`, `watch`, and `watchEffect` registered during setup do not run during server rendering.
174209
- 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.
175210

176-
This design ensures that `onMount` callbacks and `watch` effects activate at the right momentearly enough to set up subscriptions before the first paint, yet safely after the component has committed to the DOM.
211+
`onMount`, `watch`, and `watchEffect` run after the component commits. In the
212+
browser, they run before paint.
177213

178214
**Props Handling**
179215

180-
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`).
216+
`useMolecule` keeps the same molecule instance while the factory stays the same.
217+
Molecules without props, and molecules whose props are all optional, can be
218+
mounted without a props argument. Passing a props object directly creates an
219+
initial snapshot. Passing a props getter requires a React dependency list, such
220+
as `() => ({ open })` with `[open]`, and syncs top-level props only after those
221+
dependencies change. This matches React's dependency model and avoids resyncing
222+
referential props on every commit.
223+
224+
Inside a molecule, read props as `props.name`; destructuring copies the current
225+
value and loses reactivity.
226+
227+
React components mount the root or controller molecule and use `useSignal()` to
228+
read returned signals. Raw molecule events such as `dialog.on(...)` belong
229+
inside the molecule graph, not in component bodies. If a UI wrapper needs a
230+
React-controlled API such as `open` + `onOpenChange`, bridge it at the wrapper
231+
boundary.
232+
233+
**Client Components and SSR**
234+
235+
`@sigrea/react` exports hooks and is intended for Client Components. Do not call
236+
`useMolecule`, `useSignal`, `useComputed`, or `useDeepSignal` directly from a
237+
React Server Component.
238+
239+
During server rendering, molecule instances can be created for the render pass,
240+
but they are not mounted. `onMount`, `watch`, and `watchEffect` registered during
241+
setup do not run on the server. After server rendering completes, unmounted
242+
molecules are disposed in a microtask.
181243

182244
## Testing
183245

build.config.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
import { defineBuildConfig } from "unbuild";
22

3+
const clientDirective = '"use client";';
4+
35
export default defineBuildConfig({
46
entries: ["index"],
57
clean: true,
68
declaration: true,
79
rollup: {
810
emitCJS: true,
11+
output: {
12+
banner: clientDirective,
13+
},
914
},
1015
});

package.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,13 @@
4545
"cicheck": "pnpm test && pnpm typecheck && pnpm format:fix"
4646
},
4747
"peerDependencies": {
48-
"@sigrea/core": "^0.6.0",
48+
"@sigrea/core": "^0.7.0",
4949
"react": "^18.0.0 || ^19.0.0",
5050
"react-dom": "^18.0.0 || ^19.0.0"
5151
},
5252
"devDependencies": {
5353
"@biomejs/biome": "1.9.4",
54-
"@sigrea/core": "^0.6.0",
54+
"@sigrea/core": "^0.7.0",
5555
"@types/react": "^19.0.0",
5656
"@types/react-dom": "^19.0.0",
5757
"@vitejs/plugin-react": "^4.3.3",

packages/__tests__/useMolecule.ssr.test.tsx

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,73 @@ describe("useMolecule on the server", () => {
170170
expect(disposed).toHaveBeenCalledTimes(1);
171171
});
172172

173+
it("uses the server cleanup path when window exists without document", async () => {
174+
const globalWithWindow = globalThis as typeof globalThis & {
175+
document?: unknown;
176+
window?: unknown;
177+
};
178+
const hadWindow = Object.prototype.hasOwnProperty.call(
179+
globalThis,
180+
"window",
181+
);
182+
const hadDocument = Object.prototype.hasOwnProperty.call(
183+
globalThis,
184+
"document",
185+
);
186+
const originalWindow = globalWithWindow.window;
187+
const originalDocument = globalWithWindow.document;
188+
189+
Object.defineProperty(globalThis, "window", {
190+
configurable: true,
191+
value: {},
192+
});
193+
Reflect.deleteProperty(globalThis, "document");
194+
vi.resetModules();
195+
196+
try {
197+
const core = await import("@sigrea/core");
198+
const reactAdapter = await import("../useMolecule");
199+
const disposed = vi.fn();
200+
const DemoMolecule = core.molecule(() => {
201+
core.onDispose(() => {
202+
disposed();
203+
});
204+
return { label: "server" };
205+
});
206+
207+
function TestComponent() {
208+
const instance = reactAdapter.useMolecule(DemoMolecule);
209+
return createElement("span", null, instance.label);
210+
}
211+
212+
expect(renderToString(createElement(TestComponent))).toBe(
213+
"<span>server</span>",
214+
);
215+
216+
await flushMicrotasks(2);
217+
218+
expect(disposed).toHaveBeenCalledTimes(1);
219+
} finally {
220+
vi.resetModules();
221+
if (hadWindow) {
222+
Object.defineProperty(globalThis, "window", {
223+
configurable: true,
224+
value: originalWindow,
225+
});
226+
} else {
227+
Reflect.deleteProperty(globalThis, "window");
228+
}
229+
if (hadDocument) {
230+
Object.defineProperty(globalThis, "document", {
231+
configurable: true,
232+
value: originalDocument,
233+
});
234+
} else {
235+
Reflect.deleteProperty(globalThis, "document");
236+
}
237+
}
238+
});
239+
173240
it("does not run mount-time watches during server rendering", async () => {
174241
const watchCallback = vi.fn();
175242
const DemoMolecule = molecule(() => {

packages/__tests__/useMolecule.strict-mode.test.ts

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,15 @@
11
import { StrictMode, createElement } from "react";
22
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
33

4-
import { disposeTrackedMolecules, molecule, onDispose } from "@sigrea/core";
4+
import {
5+
computed,
6+
disposeTrackedMolecules,
7+
molecule,
8+
onDispose,
9+
} from "@sigrea/core";
510

611
import { useMolecule } from "../useMolecule";
12+
import { useSignal } from "../useSignal";
713
import { createTestRoot, flushMicrotasks } from "./testUtils";
814

915
describe("useMolecule in StrictMode", () => {
@@ -43,4 +49,51 @@ describe("useMolecule in StrictMode", () => {
4349
expect(cleanup).toHaveBeenCalledTimes(1);
4450
expect(cleanup).toHaveBeenCalledWith(1);
4551
});
52+
53+
it("does not replay live props sync while dependencies are stable", async () => {
54+
const readProps = vi.fn((value: number) => ({ value }));
55+
const counterMolecule = molecule((props: { value: number }) => {
56+
return { value: computed(() => props.value) };
57+
});
58+
59+
function TestComponent({ value }: { value: number }) {
60+
const instance = useMolecule(counterMolecule, () => readProps(value), [
61+
value,
62+
]);
63+
const currentValue = useSignal(instance.value);
64+
return createElement("span", null, String(currentValue));
65+
}
66+
67+
await root.render(
68+
createElement(
69+
StrictMode,
70+
null,
71+
createElement(TestComponent, { value: 1 }),
72+
),
73+
);
74+
75+
expect(readProps).toHaveBeenCalledTimes(1);
76+
77+
await root.render(
78+
createElement(
79+
StrictMode,
80+
null,
81+
createElement(TestComponent, { value: 1 }),
82+
),
83+
);
84+
85+
expect(root.container.textContent).toBe("1");
86+
expect(readProps).toHaveBeenCalledTimes(1);
87+
88+
await root.render(
89+
createElement(
90+
StrictMode,
91+
null,
92+
createElement(TestComponent, { value: 2 }),
93+
),
94+
);
95+
96+
expect(root.container.textContent).toBe("2");
97+
expect(readProps).toHaveBeenCalledTimes(2);
98+
});
4699
});

0 commit comments

Comments
 (0)