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
65 changes: 48 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div>
<span>{value}</span>
<span>{count}</span>
<button onClick={counter.increment}>Increment</button>
<button onClick={counter.reset}>Reset</button>
<button onClick={() => counter.setStep(step + 1)}>Step +</button>
</div>
);
}
Expand All @@ -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);
Expand Down Expand Up @@ -136,13 +154,26 @@ Exposes a deep signal object for direct mutation within the component. Updates t
### useMolecule

```tsx
function useMolecule<TProps, TReturn>(
molecule: MoleculeFactory<TProps, TReturn>,
props?: TProps
): TReturn
function useMolecule<TReturn extends object, TProps extends object | void = void>(
molecule: MoleculeFactory<TReturn, TProps>,
...args: MoleculeArgs<TProps>
): MoleculeInstance<TReturn>
```

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

Expand Down
29 changes: 10 additions & 19 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -53,31 +45,30 @@
"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",
"tsx": "^4.20.5",
"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"]
}
}
114 changes: 114 additions & 0 deletions packages/__tests__/useMolecule.mount-lifecycle.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof createTestRoot>;

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<MoleculeInstance<{ count: Signal<number> }>> = [];

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);
});
});
10 changes: 5 additions & 5 deletions packages/__tests__/useMolecule.strict-mode.test.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -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;
}

Expand Down
Loading