@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.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tmonier (get-tmonier)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,31 @@
1
+ # @tmonier/effract
2
+
3
+ Write React components as Effect programs. The same component runs in a SPA, on a Bun/Node server, in a Web
4
+ Worker, or as a React Server Component.
5
+
6
+ ```tsx
7
+ import { Runtime, component, hook } from '@tmonier/effract';
8
+ import { useState } from 'react';
9
+
10
+ const Dashboard = component(function* () {
11
+ const stats = yield* Stats; // an Effect service
12
+ const [tab, setTab] = yield* hook(useState('overview')); // a real React hook
13
+ return <Panel tab={tab} total={stats.total} onTab={setTab} />;
14
+ });
15
+
16
+ export const App = () => (
17
+ <Runtime layer={AppLive}>
18
+ <Dashboard />
19
+ </Runtime>
20
+ );
21
+ ```
22
+
23
+ - `component` / `view` — hook-capable and resolve-up-front components.
24
+ - `hook` — lift a React hook into the `yield*` channel.
25
+ - `<Runtime layer={...}>` — provide an Effect runtime to a subtree.
26
+ - `atom`, `observe`, `<Observe>`, `useAtom` — the signals bridge.
27
+
28
+ See the [project README](https://github.com/get-tmonier/effract#readme) and
29
+ [ADR 0001](https://github.com/get-tmonier/effract/blob/main/docs/adr/0001-fiber-reconciliation.md).
30
+
31
+ MIT © Tmonier
@@ -0,0 +1,169 @@
1
+ import { ReactNode } from "react";
2
+ import * as Effect from "effect/Effect";
3
+ import * as ManagedRuntime from "effect/ManagedRuntime";
4
+ import { AtomRef } from "effect/unstable/reactivity";
5
+ import * as Layer from "effect/Layer";
6
+
7
+ //#region src/domain/protocol.d.ts
8
+ /**
9
+ * The one unavoidable `any` in the framework. The heterogeneous yield protocol
10
+ * must accept an Effect of *any* error and requirement variance — there is no
11
+ * other way to express "some Effect" as a generic constraint, which is why
12
+ * Effect's own `Effect.gen` is typed exactly this way. Precise `E` and `R` are
13
+ * recovered below through conditional inference, so this never leaks to users.
14
+ */
15
+ type AnyEffect = Effect.Effect<any, any, any>;
16
+ /** Brand identifying a lifted React hook instruction. */
17
+ declare const HookTypeId: unique symbol;
18
+ type HookTypeId = typeof HookTypeId;
19
+ /**
20
+ * A React hook result lifted into the `yield*` channel. The hook itself has
21
+ * already executed (synchronously, during render) by the time the value is
22
+ * wrapped — `hook(useState(0))` calls `useState` inline. Wrapping it only makes
23
+ * the result yieldable, so a component body reads as one uniform stream of
24
+ * `yield*`s whether the value comes from Effect or from React.
25
+ */
26
+ interface Hook<out A> {
27
+ readonly [HookTypeId]: true;
28
+ readonly value: A;
29
+ [Symbol.iterator](): Iterator<Hook<A>, A>;
30
+ }
31
+ /**
32
+ * Lift an already-evaluated React hook result into the effract yield channel.
33
+ *
34
+ * ```ts
35
+ * const [tab, setTab] = yield* hook(useState('overview'));
36
+ * const ref = yield* hook(useRef<HTMLDivElement>(null));
37
+ * ```
38
+ *
39
+ * Because the component body runs synchronously inside React's render pass, the
40
+ * wrapped hook call obeys the Rules of Hooks: same order on every render.
41
+ */
42
+ declare const hook: <A>(value: A) => Hook<A>;
43
+ /** Type guard: is this yielded instruction a lifted hook? */
44
+ declare const isHook: (u: unknown) => u is Hook<unknown>;
45
+ /** Everything a component body may `yield*`: an Effect, or a lifted hook. */
46
+ type Yieldable<A> = Effect.Effect<A, unknown, unknown> | Hook<A>;
47
+ /** The generator a React Effect Component body produces. */
48
+ type RecGenerator<A> = Generator<AnyEffect | Hook<unknown>, A, unknown>;
49
+ /** A component body: props in, a generator of yields ending in a rendered `A`. */
50
+ type RecBody<Props, A> = (props: Props) => RecGenerator<A>;
51
+ /**
52
+ * Distribute over the yield union and keep only its Effect members. Hooks are
53
+ * not Effects, so they drop away — leaving just what carries `E` and `R`.
54
+ */
55
+ type EffectsOnly<Eff> = Eff extends AnyEffect ? Eff : never;
56
+ /**
57
+ * Recover the Effect requirement channel `R` from everything a body yields.
58
+ * A body that needs both `A` and `B` requires `A & B`, which is exactly the
59
+ * intersection TypeScript infers from the contravariant requirement slot.
60
+ */
61
+ type RequirementsOf<Eff> = [EffectsOnly<Eff>] extends [Effect.Effect<unknown, unknown, infer R>] ? R : never;
62
+ /** Recover the Effect error channel `E` (a union — any yielded effect may fail). */
63
+ type ErrorsOf<Eff> = [EffectsOnly<Eff>] extends [Effect.Effect<unknown, infer E, unknown>] ? E : never;
64
+ //#endregion
65
+ //#region src/infrastructure/react/component.d.ts
66
+ declare const RequirementsId: unique symbol;
67
+ /**
68
+ * A React component produced by effract. It renders like any other component;
69
+ * the phantom `R` records which Effect services it needs from its `<Runtime>`,
70
+ * available for type-level introspection and runtime-binding helpers.
71
+ */
72
+ interface Component<in Props, out R> {
73
+ (props: Props): ReactNode;
74
+ readonly [RequirementsId]?: R;
75
+ }
76
+ /**
77
+ * Define a React Effect Component. The body is a generator that may `yield*`
78
+ * Effect services and effects, and `yield* hook(...)` for React hooks.
79
+ *
80
+ * ```tsx
81
+ * const Dashboard = component(function* () {
82
+ * const stats = yield* Stats; // Effect service
83
+ * const [tab, setTab] = yield* hook(useState('overview')); // real React hook
84
+ * return <Panel tab={tab} total={stats.total} onTab={setTab} />;
85
+ * });
86
+ * ```
87
+ */
88
+ declare function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode>(body: () => Generator<Eff, A, never>): Component<Record<never, never>, RequirementsOf<Eff>>;
89
+ declare function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode, Props>(body: (props: Props) => Generator<Eff, A, never>): Component<Props, RequirementsOf<Eff>>;
90
+ /**
91
+ * Define a resolve-up-front component: a pure Effect (no hooks) rendered to a
92
+ * `ReactNode`. Services resolve synchronously from the runtime; async work
93
+ * suspends through React Suspense. This is the RSC-friendly mode.
94
+ *
95
+ * ```tsx
96
+ * const Header = view(Effect.gen(function* () {
97
+ * const user = yield* CurrentUser;
98
+ * return <h1>Welcome, {user.name}</h1>;
99
+ * }));
100
+ * ```
101
+ */
102
+ declare function view<A extends ReactNode, E, R>(render: Effect.Effect<A, E, R>): Component<Record<never, never>, R>;
103
+ declare function view<A extends ReactNode, E, R, Props>(render: (props: Props) => Effect.Effect<A, E, R>): Component<Props, R>;
104
+ //#endregion
105
+ //#region src/infrastructure/react/runtime.d.ts
106
+ type AnyManagedRuntime = ManagedRuntime.ManagedRuntime<unknown, unknown>;
107
+ interface RuntimeProps<ROut, E> {
108
+ /** A self-contained layer (no open requirements) providing the subtree's services. */
109
+ readonly layer: Layer.Layer<ROut, E, never>;
110
+ readonly children: ReactNode;
111
+ }
112
+ /**
113
+ * Provide an Effect runtime to a React subtree.
114
+ *
115
+ * ```tsx
116
+ * <Runtime layer={AppLive}>
117
+ * <Dashboard />
118
+ * </Runtime>
119
+ * ```
120
+ */
121
+ declare function Runtime<ROut, E>({
122
+ layer,
123
+ children
124
+ }: RuntimeProps<ROut, E>): ReactNode;
125
+ /** Escape hatch: the underlying `ManagedRuntime`, for imperative `runPromise`/`runFork`. */
126
+ declare const useEffractRuntime: () => AnyManagedRuntime;
127
+ //#endregion
128
+ //#region src/infrastructure/react/reactivity.d.ts
129
+ type Ref<A> = AtomRef.AtomRef<A>;
130
+ /** Read an atom inside `observe`, subscribing the component to it. */
131
+ type Read = <A>(ref: Ref<A>) => A;
132
+ /** Create a reactive cell. Sugar for `AtomRef.make`. */
133
+ declare const atom: <A>(initial: A) => Ref<A>;
134
+ /**
135
+ * Subscribe to a derived view over one or more atoms. Re-renders precisely when
136
+ * a read atom changes.
137
+ *
138
+ * ```tsx
139
+ * const doubled = observe(($) => $(count) * 2);
140
+ * ```
141
+ */
142
+ declare const observe: <A>(selector: (read: Read) => A) => A;
143
+ /** Read a single atom's value, subscribing the component to it. */
144
+ declare const useAtomValue: <A>(ref: Ref<A>) => A;
145
+ /** A stable setter for an atom, supporting both values and updater functions. */
146
+ declare const useAtomSet: <A>(ref: Ref<A>) => ((value: A | ((prev: A) => A)) => void);
147
+ /** Read and write a single atom — the `useState` shape, backed by Effect. */
148
+ declare const useAtom: <A>(ref: Ref<A>) => readonly [A, (value: A | ((prev: A) => A)) => void];
149
+ interface ObserveProps<A extends ReactNode> {
150
+ readonly children: (read: Read) => A;
151
+ }
152
+ /** The render-prop form of {@link observe}. */
153
+ declare const Observe: <A extends ReactNode>({
154
+ children
155
+ }: ObserveProps<A>) => ReactNode;
156
+ //#endregion
157
+ //#region src/index.d.ts
158
+ /**
159
+ * effract — write React components as Effect programs.
160
+ *
161
+ * The same component runs in a SPA, on a Bun/Node server, in a Web Worker, or
162
+ * as a React Server Component. "Server vs client" is an Effect runtime detail,
163
+ * supplied by a `<Runtime>` boundary — not an architectural fork.
164
+ *
165
+ * @packageDocumentation
166
+ */
167
+ declare const VERSION = "0.1.0";
168
+ //#endregion
169
+ export { type AnyEffect, type Component, type ErrorsOf, type Hook, HookTypeId, Observe, type ObserveProps, type Read, type RecBody, type RecGenerator, type RequirementsOf, Runtime, type RuntimeProps, VERSION, type Yieldable, atom, component, hook, isHook, observe, useAtom, useAtomSet, useAtomValue, useEffractRuntime, view };
package/dist/index.mjs ADDED
@@ -0,0 +1,317 @@
1
+ import { createContext, use, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
+ import * as Cause from "effect/Cause";
3
+ import * as Effect from "effect/Effect";
4
+ import * as Exit from "effect/Exit";
5
+ import * as ManagedRuntime from "effect/ManagedRuntime";
6
+ import { jsx } from "react/jsx-runtime";
7
+ import * as Equal from "effect/Equal";
8
+ import { AtomRef } from "effect/unstable/reactivity";
9
+ //#region src/domain/protocol.ts
10
+ /** Brand identifying a lifted React hook instruction. */
11
+ const HookTypeId = Symbol.for("@tmonier/effract/Hook");
12
+ /**
13
+ * Lift an already-evaluated React hook result into the effract yield channel.
14
+ *
15
+ * ```ts
16
+ * const [tab, setTab] = yield* hook(useState('overview'));
17
+ * const ref = yield* hook(useRef<HTMLDivElement>(null));
18
+ * ```
19
+ *
20
+ * Because the component body runs synchronously inside React's render pass, the
21
+ * wrapped hook call obeys the Rules of Hooks: same order on every render.
22
+ */
23
+ const hook = (value) => {
24
+ const self = {
25
+ [HookTypeId]: true,
26
+ value,
27
+ [Symbol.iterator]() {
28
+ let yielded = false;
29
+ return { next(sent) {
30
+ if (yielded) return {
31
+ done: true,
32
+ value: sent
33
+ };
34
+ yielded = true;
35
+ return {
36
+ done: false,
37
+ value: self
38
+ };
39
+ } };
40
+ }
41
+ };
42
+ return self;
43
+ };
44
+ /** Type guard: is this yielded instruction a lifted hook? */
45
+ const isHook = (u) => typeof u === "object" && u !== null && HookTypeId in u;
46
+ //#endregion
47
+ //#region src/application/interpreter.ts
48
+ /**
49
+ * The interpreter — the bridge between React's fiber and Effect's fiber.
50
+ *
51
+ * React drives a component by calling its function during a render pass.
52
+ * `driveRec` runs *inside* that pass: it walks the component's generator
53
+ * synchronously, and for every `yield*` it decides who answers.
54
+ *
55
+ * - a lifted hook → the React hook already ran inline; unwrap its value
56
+ * - a service Tag → resolve it synchronously from the runtime's context
57
+ * - a sync Effect → run it synchronously, return its value
58
+ * - an async Effect → suspend through React's `use`, resuming on the retry
59
+ *
60
+ * Because the walk is synchronous and deterministic, the user's hook calls keep
61
+ * a stable order across renders — they are, and remain, ordinary React hooks.
62
+ * Nothing here forks React's reconciler; it cooperates with it.
63
+ */
64
+ /**
65
+ * Resolve a single yielded Effect against the runtime. Synchronous effects
66
+ * (services, pure computation, ref reads) return immediately. An effect that
67
+ * cannot finish synchronously surfaces as an `AsyncFiberError`; we route it
68
+ * through React Suspense with a promise cached by encounter order, so the
69
+ * retry after the promise settles returns the value inline. Any other failure
70
+ * is a real error and is thrown to the nearest React error boundary.
71
+ */
72
+ const resolveEffect = (effect, deps, state) => {
73
+ if (!Effect.isEffect(effect)) throw new TypeError("effract: a component body yielded a value that is neither an Effect nor a hook(...). Wrap React hooks with `hook(...)`, e.g. `yield* hook(useState(0))`.");
74
+ const exit = deps.executor.runSyncExit(effect);
75
+ if (Exit.isSuccess(exit)) return exit.value;
76
+ const squashed = Cause.squash(exit.cause);
77
+ if (Cause.isAsyncFiberError(squashed)) {
78
+ const index = state.index++;
79
+ let slot = deps.cache.get(index);
80
+ if (slot === void 0) {
81
+ slot = { promise: deps.executor.runPromise(effect) };
82
+ deps.cache.set(index, slot);
83
+ }
84
+ return deps.suspender.use(slot.promise);
85
+ }
86
+ throw squashed;
87
+ };
88
+ /**
89
+ * Run a React Effect Component body to its rendered result. Creates a fresh
90
+ * generator per render (generators are single-use); a Suspense retry simply
91
+ * runs this again from the top, replaying hooks in order and hitting the async
92
+ * cache for already-started work.
93
+ */
94
+ const driveRec = (gen, deps) => {
95
+ const state = { index: 0 };
96
+ let step = gen.next();
97
+ while (!step.done) {
98
+ const instruction = step.value;
99
+ const result = isHook(instruction) ? instruction.value : resolveEffect(instruction, deps, state);
100
+ step = gen.next(result);
101
+ }
102
+ return step.value;
103
+ };
104
+ //#endregion
105
+ //#region src/infrastructure/react/runtime.tsx
106
+ /**
107
+ * The `<Runtime>` boundary. It builds an Effect `ManagedRuntime` once from a
108
+ * `Layer` and hands it down through React context, where every effract
109
+ * component reads it. This is the seam where "server vs client" lives: provide
110
+ * a browser layer and the same components run in a SPA; provide a server layer
111
+ * and they run under Node, Bun, or a Web Worker — the components never change.
112
+ *
113
+ * Services are resolved up-front into the runtime's context (the RSC-style
114
+ * "resolve near the root" mode), so reading a service inside a component is a
115
+ * synchronous context lookup, not an async round-trip.
116
+ */
117
+ const RuntimeContext = createContext(null);
118
+ const executorFromRuntime = (runtime) => ({
119
+ runSyncExit: (effect) => runtime.runSyncExit(effect),
120
+ runPromise: (effect) => runtime.runPromise(effect)
121
+ });
122
+ /**
123
+ * Provide an Effect runtime to a React subtree.
124
+ *
125
+ * ```tsx
126
+ * <Runtime layer={AppLive}>
127
+ * <Dashboard />
128
+ * </Runtime>
129
+ * ```
130
+ */
131
+ function Runtime({ layer, children }) {
132
+ const runtimeRef = useRef(null);
133
+ if (runtimeRef.current === null) runtimeRef.current = ManagedRuntime.make(layer);
134
+ const runtime = runtimeRef.current;
135
+ const value = useMemo(() => ({
136
+ executor: executorFromRuntime(runtime),
137
+ runtime
138
+ }), [runtime]);
139
+ useEffect(() => () => void runtime.dispose(), [runtime]);
140
+ return /* @__PURE__ */ jsx(RuntimeContext.Provider, {
141
+ value,
142
+ children
143
+ });
144
+ }
145
+ const useRuntimeContext = () => {
146
+ const value = useContext(RuntimeContext);
147
+ if (value === null) throw new Error("effract: no <Runtime> found above this component. Wrap your tree in <Runtime layer={...}>.");
148
+ return value;
149
+ };
150
+ /** Internal: the executor the interpreter runs effects through. */
151
+ const useExecutor = () => useRuntimeContext().executor;
152
+ /** Escape hatch: the underlying `ManagedRuntime`, for imperative `runPromise`/`runFork`. */
153
+ const useEffractRuntime = () => useRuntimeContext().runtime;
154
+ //#endregion
155
+ //#region src/infrastructure/react/component.tsx
156
+ /**
157
+ * The two ways to write a component as an Effect program.
158
+ *
159
+ * component(function* () { ... }) the headline: a *React Effect Component*
160
+ * whose body yields both Effect services
161
+ * and React hooks, interpreted inside the
162
+ * render pass.
163
+ *
164
+ * view(Effect | (props) => Effect) the simpler resolve-up-front mode: a pure
165
+ * Effect with no hooks, ideal for server /
166
+ * RSC rendering.
167
+ *
168
+ * Both produce a genuine React function component. There is no custom
169
+ * reconciler — `<Dashboard />` is a real element React renders, suspends, and
170
+ * reconciles like any other.
171
+ */
172
+ const useSuspender = () => ({ use });
173
+ const useRenderCache = () => {
174
+ const ref = useRef(void 0);
175
+ if (ref.current === void 0) ref.current = /* @__PURE__ */ new Map();
176
+ return ref.current;
177
+ };
178
+ function component(body) {
179
+ const Rec = (props) => {
180
+ const executor = useExecutor();
181
+ const suspender = useSuspender();
182
+ const cache = useRenderCache();
183
+ return driveRec(body(props), {
184
+ executor,
185
+ suspender,
186
+ cache
187
+ });
188
+ };
189
+ Rec.displayName = body.name || "EffractComponent";
190
+ return Rec;
191
+ }
192
+ function view(render) {
193
+ const View = (props) => {
194
+ const executor = useExecutor();
195
+ const suspender = useSuspender();
196
+ const cache = useRenderCache();
197
+ return resolveEffect(typeof render === "function" ? render(props) : render, {
198
+ executor,
199
+ suspender,
200
+ cache
201
+ }, { index: 0 });
202
+ };
203
+ View.displayName = "EffractView";
204
+ return View;
205
+ }
206
+ //#endregion
207
+ //#region src/infrastructure/react/reactivity.tsx
208
+ /**
209
+ * The signals bridge. Effect's reactive cell is `AtomRef`; this binds it to
210
+ * React so a component re-renders precisely when — and only when — an atom it
211
+ * actually read changes.
212
+ *
213
+ * observe($ => $(count) * 2) // a hook: read + auto-subscribe
214
+ * <Observe>{$ => <b>{$(count)}</b>}</Observe> // the same, as an element
215
+ * const [n, setN] = useAtom(count) // read + write a single atom
216
+ *
217
+ * `observe` tracks exactly the atoms touched during its selector and subscribes
218
+ * to that set, re-tracking on every change so dynamic dependencies stay
219
+ * correct. No `Effect.runSync` at the call site, no manual dependency arrays.
220
+ */
221
+ /** Create a reactive cell. Sugar for `AtomRef.make`. */
222
+ const atom = (initial) => AtomRef.make(initial);
223
+ const depsEqual = (a, b) => {
224
+ if (a.size !== b.size) return false;
225
+ for (const [ref, value] of a) if (!b.has(ref) || !Equal.equals(value, b.get(ref))) return false;
226
+ return true;
227
+ };
228
+ /**
229
+ * Run the selector, tracking which atoms it reads. Returns a *stable* reference
230
+ * when the tracked atoms and their values are unchanged, so it is safe to call
231
+ * from `getSnapshot` without provoking a render loop.
232
+ */
233
+ const compute = (state) => {
234
+ const nextDeps = /* @__PURE__ */ new Map();
235
+ const read = (ref) => {
236
+ const value = ref.value;
237
+ nextDeps.set(ref, value);
238
+ return value;
239
+ };
240
+ const next = state.selector(read);
241
+ if (state.initialized && depsEqual(state.deps, nextDeps)) {
242
+ state.deps = nextDeps;
243
+ return state.value;
244
+ }
245
+ state.deps = nextDeps;
246
+ state.value = next;
247
+ state.initialized = true;
248
+ return next;
249
+ };
250
+ /**
251
+ * Subscribe to a derived view over one or more atoms. Re-renders precisely when
252
+ * a read atom changes.
253
+ *
254
+ * ```tsx
255
+ * const doubled = observe(($) => $(count) * 2);
256
+ * ```
257
+ */
258
+ const observe = (selector) => {
259
+ const stateRef = useRef(null);
260
+ if (stateRef.current === null) stateRef.current = {
261
+ selector,
262
+ deps: /* @__PURE__ */ new Map(),
263
+ value: void 0,
264
+ initialized: false
265
+ };
266
+ stateRef.current.selector = selector;
267
+ const subscribe = useCallback((onStoreChange) => {
268
+ const state = stateRef.current;
269
+ if (state === null) return () => {};
270
+ let unsubscribes = [];
271
+ const resubscribe = () => {
272
+ for (const unsub of unsubscribes) unsub();
273
+ unsubscribes = [...state.deps.keys()].map((ref) => ref.subscribe(handleChange));
274
+ };
275
+ function handleChange() {
276
+ compute(state);
277
+ resubscribe();
278
+ onStoreChange();
279
+ }
280
+ compute(state);
281
+ resubscribe();
282
+ return () => {
283
+ for (const unsub of unsubscribes) unsub();
284
+ };
285
+ }, []);
286
+ const getSnapshot = useCallback(() => compute(stateRef.current), []);
287
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
288
+ };
289
+ /** Read a single atom's value, subscribing the component to it. */
290
+ const useAtomValue = (ref) => {
291
+ const subscribe = useCallback((onStoreChange) => ref.subscribe(onStoreChange), [ref]);
292
+ const getSnapshot = useCallback(() => ref.value, [ref]);
293
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
294
+ };
295
+ /** A stable setter for an atom, supporting both values and updater functions. */
296
+ const useAtomSet = (ref) => useCallback((value) => {
297
+ if (typeof value === "function") ref.update(value);
298
+ else ref.set(value);
299
+ }, [ref]);
300
+ /** Read and write a single atom — the `useState` shape, backed by Effect. */
301
+ const useAtom = (ref) => [useAtomValue(ref), useAtomSet(ref)];
302
+ /** The render-prop form of {@link observe}. */
303
+ const Observe = ({ children }) => observe(children);
304
+ //#endregion
305
+ //#region src/index.ts
306
+ /**
307
+ * effract — write React components as Effect programs.
308
+ *
309
+ * The same component runs in a SPA, on a Bun/Node server, in a Web Worker, or
310
+ * as a React Server Component. "Server vs client" is an Effect runtime detail,
311
+ * supplied by a `<Runtime>` boundary — not an architectural fork.
312
+ *
313
+ * @packageDocumentation
314
+ */
315
+ const VERSION = "0.1.0";
316
+ //#endregion
317
+ export { HookTypeId, Observe, Runtime, VERSION, atom, component, hook, isHook, observe, useAtom, useAtomSet, useAtomValue, useEffractRuntime, view };
package/package.json ADDED
@@ -0,0 +1,61 @@
1
+ {
2
+ "name": "@tmonier/effract",
3
+ "version": "0.1.0",
4
+ "description": "React components written as Effect programs. yield* services and React hooks in one render pass; run the same component anywhere.",
5
+ "keywords": [
6
+ "effect",
7
+ "fiber",
8
+ "react",
9
+ "react-server-components",
10
+ "reactivity",
11
+ "rsc",
12
+ "signals",
13
+ "ssr"
14
+ ],
15
+ "homepage": "https://github.com/get-tmonier/effract#readme",
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+https://github.com/get-tmonier/effract.git",
20
+ "directory": "packages/effract"
21
+ },
22
+ "files": [
23
+ "dist",
24
+ "src",
25
+ "README.md"
26
+ ],
27
+ "type": "module",
28
+ "sideEffects": false,
29
+ "imports": {
30
+ "#domain/*": "./src/domain/*",
31
+ "#application/*": "./src/application/*",
32
+ "#infrastructure/*": "./src/infrastructure/*"
33
+ },
34
+ "exports": {
35
+ ".": {
36
+ "types": "./dist/index.d.mts",
37
+ "import": "./dist/index.mjs"
38
+ }
39
+ },
40
+ "publishConfig": {
41
+ "access": "public"
42
+ },
43
+ "devDependencies": {
44
+ "@types/react": "19.2.17",
45
+ "@types/react-dom": "19.2.3",
46
+ "effect": "4.0.0-beta.88",
47
+ "happy-dom": "20.10.6",
48
+ "react": "19.2.7",
49
+ "react-dom": "19.2.7",
50
+ "tsdown": "0.22.3",
51
+ "vitest": "4.1.9"
52
+ },
53
+ "peerDependencies": {
54
+ "effect": "4.0.0-beta.88",
55
+ "react": "19.2.7"
56
+ },
57
+ "scripts": {
58
+ "build": "tsdown",
59
+ "test": "vitest run"
60
+ }
61
+ }