@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 +21 -0
- package/README.md +31 -0
- package/dist/index.d.mts +169 -0
- package/dist/index.mjs +317 -0
- package/package.json +61 -0
- package/src/application/interpreter.test.ts +140 -0
- package/src/application/interpreter.ts +84 -0
- package/src/application/ports.ts +47 -0
- package/src/domain/protocol.ts +110 -0
- package/src/index.ts +42 -0
- package/src/infrastructure/react/component.test.tsx +140 -0
- package/src/infrastructure/react/component.tsx +109 -0
- package/src/infrastructure/react/reactivity.test.tsx +103 -0
- package/src/infrastructure/react/reactivity.tsx +148 -0
- package/src/infrastructure/react/runtime.tsx +81 -0
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
|
package/dist/index.d.mts
ADDED
|
@@ -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
|
+
}
|