@tmonier/effract 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,103 @@
1
+ /**
2
+ * The signals bridge: a change to an atom re-renders precisely the components
3
+ * that read it — and leaves the rest untouched.
4
+ */
5
+ import { act, type ReactNode } from 'react';
6
+ import { createRoot } from 'react-dom/client';
7
+ import { afterEach, beforeEach, describe, expect, it } from 'vitest';
8
+ import { Observe, atom, observe, useAtom } from '../../index.ts';
9
+
10
+ Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true);
11
+
12
+ let container: HTMLDivElement;
13
+ beforeEach(() => {
14
+ container = document.createElement('div');
15
+ document.body.appendChild(container);
16
+ });
17
+ afterEach(() => {
18
+ container.remove();
19
+ });
20
+
21
+ describe('reactivity', () => {
22
+ it('observe re-renders when a read atom changes', async () => {
23
+ const count = atom(1);
24
+ const Doubled = (): ReactNode => {
25
+ const doubled = observe(($) => $(count) * 2);
26
+ return <span>{doubled}</span>;
27
+ };
28
+
29
+ const root = createRoot(container);
30
+ await act(async () => {
31
+ root.render(<Doubled />);
32
+ });
33
+ expect(container.textContent).toBe('2');
34
+
35
+ await act(async () => {
36
+ count.set(5);
37
+ });
38
+ expect(container.textContent).toBe('10');
39
+
40
+ await act(async () => {
41
+ root.unmount();
42
+ });
43
+ });
44
+
45
+ it('useAtom reads and writes like useState, backed by Effect', async () => {
46
+ const name = atom('ada');
47
+ const Field = (): ReactNode => {
48
+ const [value, setValue] = useAtom(name);
49
+ return (
50
+ <button type="button" onClick={() => setValue((prev) => `${prev}!`)}>
51
+ {value}
52
+ </button>
53
+ );
54
+ };
55
+
56
+ const root = createRoot(container);
57
+ await act(async () => {
58
+ root.render(<Field />);
59
+ });
60
+ expect(container.textContent).toBe('ada');
61
+
62
+ await act(async () => {
63
+ container.querySelector('button')?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
64
+ });
65
+ expect(container.textContent).toBe('ada!');
66
+
67
+ await act(async () => {
68
+ root.unmount();
69
+ });
70
+ });
71
+
72
+ it('<Observe> tracks only the atoms it actually reads', async () => {
73
+ const a = atom(1);
74
+ const b = atom(100);
75
+ let renders = 0;
76
+ const View = (): ReactNode => {
77
+ renders += 1;
78
+ return <Observe>{($) => <em>{$(a)}</em>}</Observe>;
79
+ };
80
+
81
+ const root = createRoot(container);
82
+ await act(async () => {
83
+ root.render(<View />);
84
+ });
85
+ const baseline = renders;
86
+ expect(container.textContent).toBe('1');
87
+
88
+ // b is never read by the selector, so changing it must not re-render.
89
+ await act(async () => {
90
+ b.set(200);
91
+ });
92
+ expect(renders).toBe(baseline);
93
+
94
+ await act(async () => {
95
+ a.set(2);
96
+ });
97
+ expect(container.textContent).toBe('2');
98
+
99
+ await act(async () => {
100
+ root.unmount();
101
+ });
102
+ });
103
+ });
@@ -0,0 +1,148 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The signals bridge. Effect's reactive cell is `AtomRef`; this binds it to
5
+ * React so a component re-renders precisely when — and only when — an atom it
6
+ * actually read changes.
7
+ *
8
+ * observe($ => $(count) * 2) // a hook: read + auto-subscribe
9
+ * <Observe>{$ => <b>{$(count)}</b>}</Observe> // the same, as an element
10
+ * const [n, setN] = useAtom(count) // read + write a single atom
11
+ *
12
+ * `observe` tracks exactly the atoms touched during its selector and subscribes
13
+ * to that set, re-tracking on every change so dynamic dependencies stay
14
+ * correct. No `Effect.runSync` at the call site, no manual dependency arrays.
15
+ */
16
+ import { useCallback, useRef, useSyncExternalStore, type ReactNode } from 'react';
17
+ import * as Equal from 'effect/Equal';
18
+ import { AtomRef } from 'effect/unstable/reactivity';
19
+
20
+ type Ref<A> = AtomRef.AtomRef<A>;
21
+
22
+ /** Read an atom inside `observe`, subscribing the component to it. */
23
+ export type Read = <A>(ref: Ref<A>) => A;
24
+
25
+ /** Create a reactive cell. Sugar for `AtomRef.make`. */
26
+ export const atom = <A,>(initial: A): Ref<A> => AtomRef.make(initial);
27
+
28
+ interface ObserveState<A> {
29
+ selector: (read: Read) => A;
30
+ deps: Map<Ref<unknown>, unknown>;
31
+ value: A;
32
+ initialized: boolean;
33
+ }
34
+
35
+ const depsEqual = (a: Map<Ref<unknown>, unknown>, b: Map<Ref<unknown>, unknown>): boolean => {
36
+ if (a.size !== b.size) {
37
+ return false;
38
+ }
39
+ for (const [ref, value] of a) {
40
+ if (!b.has(ref) || !Equal.equals(value, b.get(ref))) {
41
+ return false;
42
+ }
43
+ }
44
+ return true;
45
+ };
46
+
47
+ /**
48
+ * Run the selector, tracking which atoms it reads. Returns a *stable* reference
49
+ * when the tracked atoms and their values are unchanged, so it is safe to call
50
+ * from `getSnapshot` without provoking a render loop.
51
+ */
52
+ const compute = <A,>(state: ObserveState<A>): A => {
53
+ const nextDeps = new Map<Ref<unknown>, unknown>();
54
+ const read: Read = (ref) => {
55
+ const value = ref.value;
56
+ nextDeps.set(ref as Ref<unknown>, value);
57
+ return value;
58
+ };
59
+ const next = state.selector(read);
60
+ if (state.initialized && depsEqual(state.deps, nextDeps)) {
61
+ state.deps = nextDeps;
62
+ return state.value;
63
+ }
64
+ state.deps = nextDeps;
65
+ state.value = next;
66
+ state.initialized = true;
67
+ return next;
68
+ };
69
+
70
+ /**
71
+ * Subscribe to a derived view over one or more atoms. Re-renders precisely when
72
+ * a read atom changes.
73
+ *
74
+ * ```tsx
75
+ * const doubled = observe(($) => $(count) * 2);
76
+ * ```
77
+ */
78
+ export const observe = <A,>(selector: (read: Read) => A): A => {
79
+ const stateRef = useRef<ObserveState<A> | null>(null);
80
+ if (stateRef.current === null) {
81
+ stateRef.current = { selector, deps: new Map(), value: undefined as A, initialized: false };
82
+ }
83
+ stateRef.current.selector = selector;
84
+
85
+ const subscribe = useCallback((onStoreChange: () => void) => {
86
+ const state = stateRef.current;
87
+ if (state === null) {
88
+ return () => {};
89
+ }
90
+ let unsubscribes: Array<() => void> = [];
91
+ const resubscribe = (): void => {
92
+ for (const unsub of unsubscribes) {
93
+ unsub();
94
+ }
95
+ unsubscribes = [...state.deps.keys()].map((ref) => ref.subscribe(handleChange));
96
+ };
97
+ function handleChange(): void {
98
+ compute(state as ObserveState<A>);
99
+ resubscribe();
100
+ onStoreChange();
101
+ }
102
+ compute(state);
103
+ resubscribe();
104
+ return () => {
105
+ for (const unsub of unsubscribes) {
106
+ unsub();
107
+ }
108
+ };
109
+ }, []);
110
+
111
+ const getSnapshot = useCallback(() => compute(stateRef.current as ObserveState<A>), []);
112
+
113
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
114
+ };
115
+
116
+ /** Read a single atom's value, subscribing the component to it. */
117
+ export const useAtomValue = <A,>(ref: Ref<A>): A => {
118
+ const subscribe = useCallback((onStoreChange: () => void) => ref.subscribe(onStoreChange), [ref]);
119
+ const getSnapshot = useCallback(() => ref.value, [ref]);
120
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
121
+ };
122
+
123
+ /** A stable setter for an atom, supporting both values and updater functions. */
124
+ export const useAtomSet = <A,>(ref: Ref<A>): ((value: A | ((prev: A) => A)) => void) =>
125
+ useCallback(
126
+ (value) => {
127
+ if (typeof value === 'function') {
128
+ ref.update(value as (prev: A) => A);
129
+ } else {
130
+ ref.set(value);
131
+ }
132
+ },
133
+ [ref],
134
+ );
135
+
136
+ /** Read and write a single atom — the `useState` shape, backed by Effect. */
137
+ export const useAtom = <A,>(ref: Ref<A>): readonly [A, (value: A | ((prev: A) => A)) => void] => [
138
+ useAtomValue(ref),
139
+ useAtomSet(ref),
140
+ ];
141
+
142
+ export interface ObserveProps<A extends ReactNode> {
143
+ readonly children: (read: Read) => A;
144
+ }
145
+
146
+ /** The render-prop form of {@link observe}. */
147
+ export const Observe = <A extends ReactNode>({ children }: ObserveProps<A>): ReactNode =>
148
+ observe(children);
@@ -0,0 +1,81 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * The `<Runtime>` boundary. It builds an Effect `ManagedRuntime` once from a
5
+ * `Layer` and hands it down through React context, where every effract
6
+ * component reads it. This is the seam where "server vs client" lives: provide
7
+ * a browser layer and the same components run in a SPA; provide a server layer
8
+ * and they run under Node, Bun, or a Web Worker — the components never change.
9
+ *
10
+ * Services are resolved up-front into the runtime's context (the RSC-style
11
+ * "resolve near the root" mode), so reading a service inside a component is a
12
+ * synchronous context lookup, not an async round-trip.
13
+ */
14
+ import { createContext, useContext, useEffect, useMemo, useRef, type ReactNode } from 'react';
15
+ import type * as Layer from 'effect/Layer';
16
+ import * as ManagedRuntime from 'effect/ManagedRuntime';
17
+ import type { Executor } from '#application/ports.ts';
18
+
19
+ type AnyManagedRuntime = ManagedRuntime.ManagedRuntime<unknown, unknown>;
20
+
21
+ interface RuntimeContextValue {
22
+ readonly executor: Executor;
23
+ readonly runtime: AnyManagedRuntime;
24
+ }
25
+
26
+ const RuntimeContext = createContext<RuntimeContextValue | null>(null);
27
+
28
+ const executorFromRuntime = (runtime: AnyManagedRuntime): Executor => ({
29
+ runSyncExit: (effect) => runtime.runSyncExit(effect),
30
+ runPromise: (effect) => runtime.runPromise(effect),
31
+ });
32
+
33
+ export interface RuntimeProps<ROut, E> {
34
+ /** A self-contained layer (no open requirements) providing the subtree's services. */
35
+ readonly layer: Layer.Layer<ROut, E, never>;
36
+ readonly children: ReactNode;
37
+ }
38
+
39
+ /**
40
+ * Provide an Effect runtime to a React subtree.
41
+ *
42
+ * ```tsx
43
+ * <Runtime layer={AppLive}>
44
+ * <Dashboard />
45
+ * </Runtime>
46
+ * ```
47
+ */
48
+ export function Runtime<ROut, E>({ layer, children }: RuntimeProps<ROut, E>): ReactNode {
49
+ // Build the runtime exactly once for this boundary instance.
50
+ const runtimeRef = useRef<AnyManagedRuntime | null>(null);
51
+ if (runtimeRef.current === null) {
52
+ runtimeRef.current = ManagedRuntime.make(layer) as AnyManagedRuntime;
53
+ }
54
+ const runtime = runtimeRef.current;
55
+
56
+ const value = useMemo<RuntimeContextValue>(
57
+ () => ({ executor: executorFromRuntime(runtime), runtime }),
58
+ [runtime],
59
+ );
60
+
61
+ // Tear the runtime down (close scopes, finalize layers) when the boundary unmounts.
62
+ useEffect(() => () => void runtime.dispose(), [runtime]);
63
+
64
+ return <RuntimeContext.Provider value={value}>{children}</RuntimeContext.Provider>;
65
+ }
66
+
67
+ const useRuntimeContext = (): RuntimeContextValue => {
68
+ const value = useContext(RuntimeContext);
69
+ if (value === null) {
70
+ throw new Error(
71
+ 'effract: no <Runtime> found above this component. Wrap your tree in <Runtime layer={...}>.',
72
+ );
73
+ }
74
+ return value;
75
+ };
76
+
77
+ /** Internal: the executor the interpreter runs effects through. */
78
+ export const useExecutor = (): Executor => useRuntimeContext().executor;
79
+
80
+ /** Escape hatch: the underlying `ManagedRuntime`, for imperative `runPromise`/`runFork`. */
81
+ export const useEffractRuntime = (): AnyManagedRuntime => useRuntimeContext().runtime;