@tmonier/effract 0.1.0 → 0.2.1
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/README.md +17 -9
- package/dist/index.d.mts +50 -40
- package/dist/index.mjs +77 -38
- package/package.json +4 -4
- package/src/application/ports.ts +1 -1
- package/src/index.ts +4 -4
- package/src/infrastructure/react/rec.test.tsx +150 -0
- package/src/infrastructure/react/rec.tsx +179 -0
- package/src/infrastructure/react/runtime.tsx +12 -14
- package/src/infrastructure/react/component.test.tsx +0 -140
- package/src/infrastructure/react/component.tsx +0 -109
package/README.md
CHANGED
|
@@ -3,26 +3,34 @@
|
|
|
3
3
|
Write React components as Effect programs. The same component runs in a SPA, on a Bun/Node server, in a Web
|
|
4
4
|
Worker, or as a React Server Component.
|
|
5
5
|
|
|
6
|
+
**Docs & guide → [effract.tmonier.com](https://effract.tmonier.com)**
|
|
7
|
+
|
|
6
8
|
```tsx
|
|
7
|
-
import {
|
|
9
|
+
import { mount, rec, hook } from '@tmonier/effract';
|
|
10
|
+
import { createRoot } from 'react-dom/client';
|
|
8
11
|
import { useState } from 'react';
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
// Panel is a PLAIN React component — an ordinary function, used as JSX, left untouched
|
|
14
|
+
// Dashboard is a REC — it reaches for a service, so it's written with `rec(...)`
|
|
15
|
+
const Dashboard = rec(function* () {
|
|
11
16
|
const stats = yield* Stats; // an Effect service
|
|
12
17
|
const [tab, setTab] = yield* hook(useState('overview')); // a real React hook
|
|
13
18
|
return <Panel tab={tab} total={stats.total} onTab={setTab} />;
|
|
14
19
|
});
|
|
15
20
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
<Dashboard />
|
|
19
|
-
</Runtime>
|
|
20
|
-
);
|
|
21
|
+
// wire the runtime in once at the boundary — `mount` returns a ReactNode
|
|
22
|
+
createRoot(document.getElementById('root')!).render(mount(AppLive, Dashboard));
|
|
21
23
|
```
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
effract is **incremental, not a rewrite.** Plain React components stay exactly as they are (ordinary
|
|
26
|
+
`<Component />` JSX). You write a REC with `rec(...)` _only_ where a component reaches for the runtime,
|
|
27
|
+
and place one by `yield*`-ing it: `{yield* Dashboard}`, or `{yield* Dashboard.with({ ... })}` with props.
|
|
28
|
+
|
|
29
|
+
- `component` / `view` — hook-capable and resolve-up-front RECs. A REC is **not** a JSX element; place it
|
|
30
|
+
with `{yield* Rec}` inside another component's JSX.
|
|
24
31
|
- `hook` — lift a React hook into the `yield*` channel.
|
|
25
|
-
-
|
|
32
|
+
- `mount(layer, RootRec)` — build the Effect runtime once and return a `ReactNode` to render. Verifies at
|
|
33
|
+
compile time that the layer provides every service the tree needs.
|
|
26
34
|
- `atom`, `observe`, `<Observe>`, `useAtom` — the signals bridge.
|
|
27
35
|
|
|
28
36
|
See the [project README](https://github.com/get-tmonier/effract#readme) and
|
package/dist/index.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ReactNode } from "react";
|
|
1
|
+
import { ReactElement, ReactNode } from "react";
|
|
2
2
|
import * as Effect from "effect/Effect";
|
|
3
3
|
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
4
4
|
import { AtomRef } from "effect/unstable/reactivity";
|
|
@@ -62,61 +62,71 @@ type RequirementsOf<Eff> = [EffectsOnly<Eff>] extends [Effect.Effect<unknown, un
|
|
|
62
62
|
/** Recover the Effect error channel `E` (a union — any yielded effect may fail). */
|
|
63
63
|
type ErrorsOf<Eff> = [EffectsOnly<Eff>] extends [Effect.Effect<unknown, infer E, unknown>] ? E : never;
|
|
64
64
|
//#endregion
|
|
65
|
-
//#region src/infrastructure/react/
|
|
66
|
-
|
|
65
|
+
//#region src/infrastructure/react/rec.d.ts
|
|
66
|
+
/** Brand identifying a React Effect Component. */
|
|
67
|
+
declare const RecTypeId: unique symbol;
|
|
68
|
+
type RecTypeId = typeof RecTypeId;
|
|
69
|
+
/** A rendered child: an Effect producing a React element, carrying its requirements. */
|
|
70
|
+
type Rendered<R> = Effect.Effect<ReactElement, never, R>;
|
|
71
|
+
interface RecCore<P, R> {
|
|
72
|
+
readonly [RecTypeId]: true;
|
|
73
|
+
/** Place this REC with props: `yield* Child.with({ ... })`. */
|
|
74
|
+
with(props: P): Rendered<R>;
|
|
75
|
+
}
|
|
76
|
+
interface RecBareYield<R> {
|
|
77
|
+
/** Place this REC without props: `yield* Child`. */
|
|
78
|
+
[Symbol.iterator](): Iterator<Rendered<R>, ReactElement>;
|
|
79
|
+
}
|
|
67
80
|
/**
|
|
68
|
-
* A React
|
|
69
|
-
*
|
|
70
|
-
*
|
|
81
|
+
* A React Effect Component. Yieldable (so `R` propagates), never a JSX element
|
|
82
|
+
* type — `<Rec />` is a compile error by design. Props-free RECs can be yielded
|
|
83
|
+
* directly (`yield* Child`); RECs with props use `yield* Child.with(props)`.
|
|
71
84
|
*/
|
|
72
|
-
|
|
73
|
-
(props: Props): ReactNode;
|
|
74
|
-
readonly [RequirementsId]?: R;
|
|
75
|
-
}
|
|
85
|
+
type REC<P, R> = RecCore<P, R> & ([Record<never, never>] extends [P] ? RecBareYield<R> : unknown);
|
|
76
86
|
/**
|
|
77
87
|
* Define a React Effect Component. The body is a generator that may `yield*`
|
|
78
|
-
* Effect services and effects,
|
|
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
|
-
* ```
|
|
88
|
+
* Effect services and effects, `yield* hook(...)` for React hooks, and
|
|
89
|
+
* `yield* Child` / `yield* Child.with(props)` to place other RECs.
|
|
87
90
|
*/
|
|
88
|
-
declare function
|
|
89
|
-
declare function
|
|
91
|
+
declare function rec<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode>(body: () => Generator<Eff, A, never>): REC<Record<never, never>, RequirementsOf<Eff>>;
|
|
92
|
+
declare function rec<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode, Props extends object>(body: (props: Props) => Generator<Eff, A, never>): REC<Props, RequirementsOf<Eff>>;
|
|
90
93
|
/**
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
|
|
94
|
+
* The resolve-up-front mode: a pure Effect (no hooks) rendered to a node.
|
|
95
|
+
* Returns a REC, composed the same way (`yield* Banner`).
|
|
96
|
+
*/
|
|
97
|
+
declare function view<A extends ReactNode, E, R>(render: Effect.Effect<A, E, R>): REC<Record<never, never>, R>;
|
|
98
|
+
declare function view<A extends ReactNode, E, R, Props extends object>(render: (props: Props) => Effect.Effect<A, E, R>): REC<Props, R>;
|
|
99
|
+
/** A type error naming the services a runtime is missing for a REC's tree. */
|
|
100
|
+
type MissingServices<Missing> = readonly ['effract: runtime is missing', Missing];
|
|
101
|
+
/**
|
|
102
|
+
* A no-service tree's requirement infers as `unknown` (Effect's requirement
|
|
103
|
+
* channel is contravariant, so `never` widens). Normalise that to `never` so a
|
|
104
|
+
* runtime-free tree mounts under any layer.
|
|
105
|
+
*/
|
|
106
|
+
type Effective<R> = [unknown] extends [R] ? never : R;
|
|
107
|
+
/**
|
|
108
|
+
* Mount a root REC under an Effect runtime. This is the boundary between
|
|
109
|
+
* effract and React: it returns an ordinary React node and, at compile time,
|
|
110
|
+
* verifies that `layer` provides every service the REC's tree requires — the
|
|
111
|
+
* check lives on the `rec` argument, so there is no cast on the result.
|
|
94
112
|
*
|
|
95
113
|
* ```tsx
|
|
96
|
-
*
|
|
97
|
-
* const user = yield* CurrentUser;
|
|
98
|
-
* return <h1>Welcome, {user.name}</h1>;
|
|
99
|
-
* }));
|
|
114
|
+
* createRoot(el).render(mount(AppLive, Dashboard));
|
|
100
115
|
* ```
|
|
101
116
|
*/
|
|
102
|
-
declare function
|
|
103
|
-
declare function view<A extends ReactNode, E, R, Props>(render: (props: Props) => Effect.Effect<A, E, R>): Component<Props, R>;
|
|
117
|
+
declare function mount<ROut, E, R>(layer: Layer.Layer<ROut, E, never>, rec: REC<Record<never, never>, R> & ([Effective<R>] extends [ROut] ? unknown : MissingServices<Exclude<Effective<R>, ROut>>)): ReactNode;
|
|
104
118
|
//#endregion
|
|
105
119
|
//#region src/infrastructure/react/runtime.d.ts
|
|
106
120
|
type AnyManagedRuntime = ManagedRuntime.ManagedRuntime<unknown, unknown>;
|
|
107
121
|
interface RuntimeProps<ROut, E> {
|
|
108
122
|
/** A self-contained layer (no open requirements) providing the subtree's services. */
|
|
109
123
|
readonly layer: Layer.Layer<ROut, E, never>;
|
|
110
|
-
readonly children
|
|
124
|
+
readonly children?: ReactNode;
|
|
111
125
|
}
|
|
112
126
|
/**
|
|
113
|
-
* Provide an Effect runtime to a React subtree.
|
|
114
|
-
*
|
|
115
|
-
*
|
|
116
|
-
* <Runtime layer={AppLive}>
|
|
117
|
-
* <Dashboard />
|
|
118
|
-
* </Runtime>
|
|
119
|
-
* ```
|
|
127
|
+
* Provide an Effect runtime to a React subtree. Prefer `mount(layer, Root)`,
|
|
128
|
+
* which wraps the root REC in this provider and checks the tree's services at
|
|
129
|
+
* compile time. Reach for `Runtime` directly only to wrap non-REC React trees.
|
|
120
130
|
*/
|
|
121
131
|
declare function Runtime<ROut, E>({
|
|
122
132
|
layer,
|
|
@@ -160,10 +170,10 @@ declare const Observe: <A extends ReactNode>({
|
|
|
160
170
|
*
|
|
161
171
|
* The same component runs in a SPA, on a Bun/Node server, in a Web Worker, or
|
|
162
172
|
* as a React Server Component. "Server vs client" is an Effect runtime detail,
|
|
163
|
-
* supplied by
|
|
173
|
+
* supplied by `mount(...)` — not an architectural fork.
|
|
164
174
|
*
|
|
165
175
|
* @packageDocumentation
|
|
166
176
|
*/
|
|
167
177
|
declare const VERSION = "0.1.0";
|
|
168
178
|
//#endregion
|
|
169
|
-
export { type AnyEffect, type
|
|
179
|
+
export { type AnyEffect, type ErrorsOf, type Hook, HookTypeId, type MissingServices, Observe, type ObserveProps, type REC, type Read, type RecBody, type RecGenerator, RecTypeId, type RequirementsOf, Runtime, type RuntimeProps, VERSION, type Yieldable, atom, hook, isHook, mount, observe, rec, useAtom, useAtomSet, useAtomValue, useEffractRuntime, view };
|
package/dist/index.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { createContext, use, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
-
import * as Cause from "effect/Cause";
|
|
1
|
+
import { createContext, createElement, use, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
3
2
|
import * as Effect from "effect/Effect";
|
|
3
|
+
import * as Cause from "effect/Cause";
|
|
4
4
|
import * as Exit from "effect/Exit";
|
|
5
5
|
import * as ManagedRuntime from "effect/ManagedRuntime";
|
|
6
6
|
import { jsx } from "react/jsx-runtime";
|
|
@@ -104,11 +104,13 @@ const driveRec = (gen, deps) => {
|
|
|
104
104
|
//#endregion
|
|
105
105
|
//#region src/infrastructure/react/runtime.tsx
|
|
106
106
|
/**
|
|
107
|
-
* The
|
|
108
|
-
* `Layer` and hands it down through React context,
|
|
109
|
-
* component reads it. This is the seam where "server vs
|
|
110
|
-
* a browser layer and the same components run in a SPA;
|
|
111
|
-
* and they run under Node, Bun, or a Web Worker — the
|
|
107
|
+
* The runtime provider that `mount` wraps your tree in. It builds an Effect
|
|
108
|
+
* `ManagedRuntime` once from a `Layer` and hands it down through React context,
|
|
109
|
+
* where every effract component reads it. This is the seam where "server vs
|
|
110
|
+
* client" lives: provide a browser layer and the same components run in a SPA;
|
|
111
|
+
* provide a server layer and they run under Node, Bun, or a Web Worker — the
|
|
112
|
+
* components never change. Use `mount(layer, Root)`; `Runtime` is the low-level
|
|
113
|
+
* provider underneath it.
|
|
112
114
|
*
|
|
113
115
|
* Services are resolved up-front into the runtime's context (the RSC-style
|
|
114
116
|
* "resolve near the root" mode), so reading a service inside a component is a
|
|
@@ -120,13 +122,9 @@ const executorFromRuntime = (runtime) => ({
|
|
|
120
122
|
runPromise: (effect) => runtime.runPromise(effect)
|
|
121
123
|
});
|
|
122
124
|
/**
|
|
123
|
-
* Provide an Effect runtime to a React subtree.
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
* <Runtime layer={AppLive}>
|
|
127
|
-
* <Dashboard />
|
|
128
|
-
* </Runtime>
|
|
129
|
-
* ```
|
|
125
|
+
* Provide an Effect runtime to a React subtree. Prefer `mount(layer, Root)`,
|
|
126
|
+
* which wraps the root REC in this provider and checks the tree's services at
|
|
127
|
+
* compile time. Reach for `Runtime` directly only to wrap non-REC React trees.
|
|
130
128
|
*/
|
|
131
129
|
function Runtime({ layer, children }) {
|
|
132
130
|
const runtimeRef = useRef(null);
|
|
@@ -144,7 +142,7 @@ function Runtime({ layer, children }) {
|
|
|
144
142
|
}
|
|
145
143
|
const useRuntimeContext = () => {
|
|
146
144
|
const value = useContext(RuntimeContext);
|
|
147
|
-
if (value === null) throw new Error("effract: no
|
|
145
|
+
if (value === null) throw new Error("effract: no runtime found above this component. Mount your root with mount(layer, Root).");
|
|
148
146
|
return value;
|
|
149
147
|
};
|
|
150
148
|
/** Internal: the executor the interpreter runs effects through. */
|
|
@@ -152,31 +150,60 @@ const useExecutor = () => useRuntimeContext().executor;
|
|
|
152
150
|
/** Escape hatch: the underlying `ManagedRuntime`, for imperative `runPromise`/`runFork`. */
|
|
153
151
|
const useEffractRuntime = () => useRuntimeContext().runtime;
|
|
154
152
|
//#endregion
|
|
155
|
-
//#region src/infrastructure/react/
|
|
153
|
+
//#region src/infrastructure/react/rec.tsx
|
|
156
154
|
/**
|
|
157
|
-
*
|
|
155
|
+
* React Effect Components (RECs) and the boundary that mounts them.
|
|
156
|
+
*
|
|
157
|
+
* A REC is the unit of composition in effract. Crucially it is **not** a React
|
|
158
|
+
* element type — `<Dashboard />` is a compile error. You compose RECs the way
|
|
159
|
+
* you compose Effects: with `yield*`. That is what lets a component's Effect
|
|
160
|
+
* requirements (`R`) bubble up the tree to the one place that knows the runtime,
|
|
161
|
+
* the `mount` boundary, where they are verified at compile time.
|
|
158
162
|
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
161
|
-
*
|
|
162
|
-
*
|
|
163
|
+
* const Dashboard = rec(function* () {
|
|
164
|
+
* const stats = yield* Stats; // a service
|
|
165
|
+
* const [n, setN] = yield* hook(useState(0)); // a real React hook
|
|
166
|
+
* return <main>{yield* StatBadge}{n}</main>; // a child REC, yielded
|
|
167
|
+
* });
|
|
163
168
|
*
|
|
164
|
-
*
|
|
165
|
-
* Effect with no hooks, ideal for server /
|
|
166
|
-
* RSC rendering.
|
|
169
|
+
* mount(AppLive, Dashboard); // ← compile error if AppLive lacks a needed service
|
|
167
170
|
*
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
* reconciles like any other.
|
|
171
|
+
* At runtime a REC still renders as an ordinary React component (own fiber,
|
|
172
|
+
* hooks, reconciliation); the yield is only how it is placed and how `R` flows.
|
|
171
173
|
*/
|
|
174
|
+
/** Brand identifying a React Effect Component. */
|
|
175
|
+
const RecTypeId = Symbol.for("@tmonier/effract/Rec");
|
|
172
176
|
const useSuspender = () => ({ use });
|
|
173
177
|
const useRenderCache = () => {
|
|
174
|
-
const ref = useRef(
|
|
175
|
-
if (ref.current ===
|
|
178
|
+
const ref = useRef(null);
|
|
179
|
+
if (ref.current === null) ref.current = /* @__PURE__ */ new Map();
|
|
176
180
|
return ref.current;
|
|
177
181
|
};
|
|
178
|
-
|
|
179
|
-
const
|
|
182
|
+
const makeRec = (fc, name) => {
|
|
183
|
+
const rendered = (props) => Effect.succeed(createElement(fc, props));
|
|
184
|
+
const rec = {
|
|
185
|
+
[RecTypeId]: true,
|
|
186
|
+
with: rendered,
|
|
187
|
+
[Symbol.iterator]() {
|
|
188
|
+
let yielded = false;
|
|
189
|
+
return { next(sent) {
|
|
190
|
+
if (yielded) return {
|
|
191
|
+
done: true,
|
|
192
|
+
value: sent
|
|
193
|
+
};
|
|
194
|
+
yielded = true;
|
|
195
|
+
return {
|
|
196
|
+
done: false,
|
|
197
|
+
value: rendered({})
|
|
198
|
+
};
|
|
199
|
+
} };
|
|
200
|
+
}
|
|
201
|
+
};
|
|
202
|
+
Object.defineProperty(rec, "name", { value: name });
|
|
203
|
+
return rec;
|
|
204
|
+
};
|
|
205
|
+
function rec(body) {
|
|
206
|
+
const fc = (props) => {
|
|
180
207
|
const executor = useExecutor();
|
|
181
208
|
const suspender = useSuspender();
|
|
182
209
|
const cache = useRenderCache();
|
|
@@ -186,11 +213,10 @@ function component(body) {
|
|
|
186
213
|
cache
|
|
187
214
|
});
|
|
188
215
|
};
|
|
189
|
-
|
|
190
|
-
return Rec;
|
|
216
|
+
return makeRec(fc, body.name || "EffractComponent");
|
|
191
217
|
}
|
|
192
218
|
function view(render) {
|
|
193
|
-
const
|
|
219
|
+
const fc = (props) => {
|
|
194
220
|
const executor = useExecutor();
|
|
195
221
|
const suspender = useSuspender();
|
|
196
222
|
const cache = useRenderCache();
|
|
@@ -200,8 +226,21 @@ function view(render) {
|
|
|
200
226
|
cache
|
|
201
227
|
}, { index: 0 });
|
|
202
228
|
};
|
|
203
|
-
|
|
204
|
-
|
|
229
|
+
return makeRec(fc, "EffractView");
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Mount a root REC under an Effect runtime. This is the boundary between
|
|
233
|
+
* effract and React: it returns an ordinary React node and, at compile time,
|
|
234
|
+
* verifies that `layer` provides every service the REC's tree requires — the
|
|
235
|
+
* check lives on the `rec` argument, so there is no cast on the result.
|
|
236
|
+
*
|
|
237
|
+
* ```tsx
|
|
238
|
+
* createRoot(el).render(mount(AppLive, Dashboard));
|
|
239
|
+
* ```
|
|
240
|
+
*/
|
|
241
|
+
function mount(layer, rec) {
|
|
242
|
+
const element = Effect.runSync(rec.with({}));
|
|
243
|
+
return createElement(Runtime, { layer }, element);
|
|
205
244
|
}
|
|
206
245
|
//#endregion
|
|
207
246
|
//#region src/infrastructure/react/reactivity.tsx
|
|
@@ -308,10 +347,10 @@ const Observe = ({ children }) => observe(children);
|
|
|
308
347
|
*
|
|
309
348
|
* The same component runs in a SPA, on a Bun/Node server, in a Web Worker, or
|
|
310
349
|
* as a React Server Component. "Server vs client" is an Effect runtime detail,
|
|
311
|
-
* supplied by
|
|
350
|
+
* supplied by `mount(...)` — not an architectural fork.
|
|
312
351
|
*
|
|
313
352
|
* @packageDocumentation
|
|
314
353
|
*/
|
|
315
354
|
const VERSION = "0.1.0";
|
|
316
355
|
//#endregion
|
|
317
|
-
export { HookTypeId, Observe, Runtime, VERSION, atom,
|
|
356
|
+
export { HookTypeId, Observe, RecTypeId, Runtime, VERSION, atom, hook, isHook, mount, observe, rec, useAtom, useAtomSet, useAtomValue, useEffractRuntime, view };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tmonier/effract",
|
|
3
|
-
"version": "0.1
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "React components written as Effect programs. yield* services and React hooks in one render pass; run the same component anywhere.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"effect",
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
"signals",
|
|
13
13
|
"ssr"
|
|
14
14
|
],
|
|
15
|
-
"homepage": "https://
|
|
15
|
+
"homepage": "https://effract.tmonier.com",
|
|
16
16
|
"license": "MIT",
|
|
17
17
|
"repository": {
|
|
18
18
|
"type": "git",
|
|
@@ -51,8 +51,8 @@
|
|
|
51
51
|
"vitest": "4.1.9"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
|
-
"effect": "4.0.0-beta.88",
|
|
55
|
-
"react": "19.
|
|
54
|
+
"effect": ">=4.0.0-beta.88 <5.0.0",
|
|
55
|
+
"react": "^19.0.0"
|
|
56
56
|
},
|
|
57
57
|
"scripts": {
|
|
58
58
|
"build": "tsdown",
|
package/src/application/ports.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type { AnyEffect } from '#domain/protocol.ts';
|
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* Something that can run an Effect. Backed in production by a `ManagedRuntime`
|
|
12
|
-
* built once at the
|
|
12
|
+
* built once at the `mount` boundary, which already carries the resolved
|
|
13
13
|
* service environment — so `runSyncExit` resolves services synchronously and
|
|
14
14
|
* only genuinely asynchronous work falls through to `runPromise`.
|
|
15
15
|
*/
|
package/src/index.ts
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
*
|
|
4
4
|
* The same component runs in a SPA, on a Bun/Node server, in a Web Worker, or
|
|
5
5
|
* as a React Server Component. "Server vs client" is an Effect runtime detail,
|
|
6
|
-
* supplied by
|
|
6
|
+
* supplied by `mount(...)` — not an architectural fork.
|
|
7
7
|
*
|
|
8
8
|
* @packageDocumentation
|
|
9
9
|
*/
|
|
@@ -23,10 +23,10 @@ export type {
|
|
|
23
23
|
} from '#domain/protocol.ts';
|
|
24
24
|
|
|
25
25
|
// --- components ---
|
|
26
|
-
export {
|
|
27
|
-
export type {
|
|
26
|
+
export { rec, view, mount, RecTypeId } from '#infrastructure/react/rec.tsx';
|
|
27
|
+
export type { REC, MissingServices } from '#infrastructure/react/rec.tsx';
|
|
28
28
|
|
|
29
|
-
// --- the runtime boundary ---
|
|
29
|
+
// --- the runtime boundary (mount is canonical; Runtime is the low-level provider) ---
|
|
30
30
|
export { Runtime, useEffractRuntime } from '#infrastructure/react/runtime.tsx';
|
|
31
31
|
export type { RuntimeProps } from '#infrastructure/react/runtime.tsx';
|
|
32
32
|
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The canonical model: RECs compose with `yield*`, requirements bubble to the
|
|
3
|
+
* root, and `mount` is the typed boundary. RECs are not JSX element types —
|
|
4
|
+
* `<Rec />` is a compile error (asserted at the bottom) — so this is the only
|
|
5
|
+
* way to use one, and it stays 100% real React underneath.
|
|
6
|
+
*/
|
|
7
|
+
import { Suspense, act, useState, type ReactNode } from 'react';
|
|
8
|
+
import { createRoot } from 'react-dom/client';
|
|
9
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
10
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
11
|
+
import * as Context from 'effect/Context';
|
|
12
|
+
import * as Effect from 'effect/Effect';
|
|
13
|
+
import * as Layer from 'effect/Layer';
|
|
14
|
+
import { rec, hook, mount } from '../../index.ts';
|
|
15
|
+
|
|
16
|
+
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true);
|
|
17
|
+
|
|
18
|
+
class Stats extends Context.Service<Stats, { readonly total: number }>()('test/Stats') {}
|
|
19
|
+
const statsLayer = (total: number): Layer.Layer<Stats> => Layer.succeed(Stats)({ total });
|
|
20
|
+
|
|
21
|
+
let container: HTMLDivElement;
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
container = document.createElement('div');
|
|
24
|
+
document.body.appendChild(container);
|
|
25
|
+
});
|
|
26
|
+
afterEach(() => container.remove());
|
|
27
|
+
|
|
28
|
+
describe('REC composition + mount', () => {
|
|
29
|
+
it('mounts a tree, resolving services and yield-composed children', () => {
|
|
30
|
+
const Badge = rec(function* () {
|
|
31
|
+
const stats = yield* Stats;
|
|
32
|
+
return <span>{stats.total}</span>;
|
|
33
|
+
});
|
|
34
|
+
const Page = rec(function* () {
|
|
35
|
+
return <main>online: {yield* Badge}</main>;
|
|
36
|
+
});
|
|
37
|
+
const html = renderToStaticMarkup(mount(statsLayer(42), Page));
|
|
38
|
+
expect(html).toContain('42');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('passes props with .with(), type-checked', () => {
|
|
42
|
+
const Greet = rec(function* (props: { name: string }) {
|
|
43
|
+
const stats = yield* Stats;
|
|
44
|
+
return (
|
|
45
|
+
<p>
|
|
46
|
+
hi {props.name} ({stats.total})
|
|
47
|
+
</p>
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
const Page = rec(function* () {
|
|
51
|
+
return <main>{yield* Greet.with({ name: 'Ada' })}</main>;
|
|
52
|
+
});
|
|
53
|
+
expect(renderToStaticMarkup(mount(statsLayer(3), Page))).toContain('hi Ada');
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('keeps a yield-composed child a real React child (hook state survives re-render)', async () => {
|
|
57
|
+
const Counter = rec(function* () {
|
|
58
|
+
const [n, setN] = yield* hook(useState(0));
|
|
59
|
+
return (
|
|
60
|
+
<button data-x="counter" onClick={() => setN(n + 1)}>
|
|
61
|
+
{n}
|
|
62
|
+
</button>
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
const Parent = rec(function* () {
|
|
66
|
+
const [p, setP] = yield* hook(useState(0));
|
|
67
|
+
return (
|
|
68
|
+
<div>
|
|
69
|
+
<button data-x="parent" onClick={() => setP(p + 1)}>
|
|
70
|
+
{p}
|
|
71
|
+
</button>
|
|
72
|
+
{yield* Counter}
|
|
73
|
+
</div>
|
|
74
|
+
);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
const root = createRoot(container);
|
|
78
|
+
const click = async (x: string) =>
|
|
79
|
+
act(async () => {
|
|
80
|
+
container
|
|
81
|
+
.querySelector(`[data-x="${x}"]`)
|
|
82
|
+
?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
await act(async () => root.render(mount(Layer.empty, Parent)));
|
|
86
|
+
expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('0');
|
|
87
|
+
await click('counter');
|
|
88
|
+
expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('1');
|
|
89
|
+
await click('parent'); // Parent re-renders → re-runs yield* Counter
|
|
90
|
+
expect(container.querySelector('[data-x="parent"]')?.textContent).toBe('1');
|
|
91
|
+
expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('1'); // preserved
|
|
92
|
+
await act(async () => root.unmount());
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('suspends an async effect through Suspense, then resolves', async () => {
|
|
96
|
+
let release: (v: number) => void = () => {};
|
|
97
|
+
const gate = new Promise<number>((r) => {
|
|
98
|
+
release = r;
|
|
99
|
+
});
|
|
100
|
+
const Async = rec(function* () {
|
|
101
|
+
const v = yield* Effect.promise(() => gate);
|
|
102
|
+
return <span>val:{v}</span>;
|
|
103
|
+
});
|
|
104
|
+
const Page = rec(function* () {
|
|
105
|
+
return <Suspense fallback={<i>loading</i>}>{yield* Async}</Suspense>;
|
|
106
|
+
});
|
|
107
|
+
const root = createRoot(container);
|
|
108
|
+
await act(async () => root.render(mount(Layer.empty, Page)));
|
|
109
|
+
expect(container.textContent).toContain('loading');
|
|
110
|
+
await act(async () => {
|
|
111
|
+
release(7);
|
|
112
|
+
await gate;
|
|
113
|
+
});
|
|
114
|
+
expect(container.textContent).toContain('val:7');
|
|
115
|
+
await act(async () => root.unmount());
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('runs the SAME tree under two runtimes — server vs client is the mount', () => {
|
|
119
|
+
const Total = rec(function* () {
|
|
120
|
+
const stats = yield* Stats;
|
|
121
|
+
return <output>{stats.total}</output>;
|
|
122
|
+
});
|
|
123
|
+
const Page = rec(function* () {
|
|
124
|
+
return <div>{yield* Total}</div>;
|
|
125
|
+
});
|
|
126
|
+
expect(renderToStaticMarkup(mount(statsLayer(100), Page))).toContain('100');
|
|
127
|
+
expect(renderToStaticMarkup(mount(statsLayer(1), Page))).toContain('1');
|
|
128
|
+
});
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
// --- type-level guarantees (checked by tsgo, not run) ---
|
|
132
|
+
{
|
|
133
|
+
const Needs = rec(function* () {
|
|
134
|
+
const s = yield* Stats;
|
|
135
|
+
return <i>{s.total}</i>;
|
|
136
|
+
});
|
|
137
|
+
const Root = rec(function* () {
|
|
138
|
+
return <main>{yield* Needs}</main>;
|
|
139
|
+
});
|
|
140
|
+
// ✓ AppLive provides Stats:
|
|
141
|
+
void mount(statsLayer(1), Root);
|
|
142
|
+
// ✗ empty layer is missing Stats — mount returns a non-ReactNode error type:
|
|
143
|
+
// @ts-expect-error effract: runtime is missing Stats
|
|
144
|
+
const _bad: ReactNode = mount(Layer.empty, Root);
|
|
145
|
+
void _bad;
|
|
146
|
+
// ✗ a REC is not a JSX element type:
|
|
147
|
+
// @ts-expect-error RECs cannot be used as JSX
|
|
148
|
+
const _jsx = <Needs />;
|
|
149
|
+
void _jsx;
|
|
150
|
+
}
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* React Effect Components (RECs) and the boundary that mounts them.
|
|
5
|
+
*
|
|
6
|
+
* A REC is the unit of composition in effract. Crucially it is **not** a React
|
|
7
|
+
* element type — `<Dashboard />` is a compile error. You compose RECs the way
|
|
8
|
+
* you compose Effects: with `yield*`. That is what lets a component's Effect
|
|
9
|
+
* requirements (`R`) bubble up the tree to the one place that knows the runtime,
|
|
10
|
+
* the `mount` boundary, where they are verified at compile time.
|
|
11
|
+
*
|
|
12
|
+
* const Dashboard = rec(function* () {
|
|
13
|
+
* const stats = yield* Stats; // a service
|
|
14
|
+
* const [n, setN] = yield* hook(useState(0)); // a real React hook
|
|
15
|
+
* return <main>{yield* StatBadge}{n}</main>; // a child REC, yielded
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* mount(AppLive, Dashboard); // ← compile error if AppLive lacks a needed service
|
|
19
|
+
*
|
|
20
|
+
* At runtime a REC still renders as an ordinary React component (own fiber,
|
|
21
|
+
* hooks, reconciliation); the yield is only how it is placed and how `R` flows.
|
|
22
|
+
*/
|
|
23
|
+
import {
|
|
24
|
+
createElement,
|
|
25
|
+
use,
|
|
26
|
+
useRef,
|
|
27
|
+
type FunctionComponent,
|
|
28
|
+
type ReactElement,
|
|
29
|
+
type ReactNode,
|
|
30
|
+
} from 'react';
|
|
31
|
+
import * as Effect from 'effect/Effect';
|
|
32
|
+
import type * as Layer from 'effect/Layer';
|
|
33
|
+
import { driveRec, resolveEffect } from '#application/interpreter.ts';
|
|
34
|
+
import type { RenderCache, Suspender } from '#application/ports.ts';
|
|
35
|
+
import type { AnyEffect, Hook, RequirementsOf } from '#domain/protocol.ts';
|
|
36
|
+
import { Runtime, useExecutor } from '#infrastructure/react/runtime.tsx';
|
|
37
|
+
|
|
38
|
+
/** Brand identifying a React Effect Component. */
|
|
39
|
+
export const RecTypeId: unique symbol = Symbol.for('@tmonier/effract/Rec');
|
|
40
|
+
export type RecTypeId = typeof RecTypeId;
|
|
41
|
+
|
|
42
|
+
/** A rendered child: an Effect producing a React element, carrying its requirements. */
|
|
43
|
+
type Rendered<R> = Effect.Effect<ReactElement, never, R>;
|
|
44
|
+
|
|
45
|
+
interface RecCore<P, R> {
|
|
46
|
+
readonly [RecTypeId]: true;
|
|
47
|
+
/** Place this REC with props: `yield* Child.with({ ... })`. */
|
|
48
|
+
with(props: P): Rendered<R>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface RecBareYield<R> {
|
|
52
|
+
/** Place this REC without props: `yield* Child`. */
|
|
53
|
+
[Symbol.iterator](): Iterator<Rendered<R>, ReactElement>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* A React Effect Component. Yieldable (so `R` propagates), never a JSX element
|
|
58
|
+
* type — `<Rec />` is a compile error by design. Props-free RECs can be yielded
|
|
59
|
+
* directly (`yield* Child`); RECs with props use `yield* Child.with(props)`.
|
|
60
|
+
*/
|
|
61
|
+
export type REC<P, R> = RecCore<P, R> &
|
|
62
|
+
([Record<never, never>] extends [P] ? RecBareYield<R> : unknown);
|
|
63
|
+
|
|
64
|
+
const useSuspender = (): Suspender => ({ use });
|
|
65
|
+
|
|
66
|
+
const useRenderCache = (): RenderCache => {
|
|
67
|
+
const ref = useRef<RenderCache | null>(null);
|
|
68
|
+
if (ref.current === null) {
|
|
69
|
+
ref.current = new Map();
|
|
70
|
+
}
|
|
71
|
+
return ref.current;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const makeRec = <P extends object, R>(fc: FunctionComponent<P>, name: string): REC<P, R> => {
|
|
75
|
+
// The child resolves its own services when React renders it, so this
|
|
76
|
+
// render-effect requires nothing at runtime. The phantom `R` is asserted here
|
|
77
|
+
// — the single intentional assertion — so requirements propagate as a type.
|
|
78
|
+
const rendered = (props: P): Rendered<R> =>
|
|
79
|
+
Effect.succeed(createElement(fc, props)) as Effect.Effect<ReactElement, never, R>;
|
|
80
|
+
|
|
81
|
+
const rec: RecCore<P, R> & RecBareYield<R> = {
|
|
82
|
+
[RecTypeId]: true,
|
|
83
|
+
with: rendered,
|
|
84
|
+
[Symbol.iterator](): Iterator<Rendered<R>, ReactElement> {
|
|
85
|
+
let yielded = false;
|
|
86
|
+
return {
|
|
87
|
+
next(sent?: unknown): IteratorResult<Rendered<R>, ReactElement> {
|
|
88
|
+
if (yielded) {
|
|
89
|
+
return { done: true, value: sent as ReactElement };
|
|
90
|
+
}
|
|
91
|
+
yielded = true;
|
|
92
|
+
return { done: false, value: rendered({} as P) };
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
Object.defineProperty(rec, 'name', { value: name });
|
|
98
|
+
return rec;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Define a React Effect Component. The body is a generator that may `yield*`
|
|
103
|
+
* Effect services and effects, `yield* hook(...)` for React hooks, and
|
|
104
|
+
* `yield* Child` / `yield* Child.with(props)` to place other RECs.
|
|
105
|
+
*/
|
|
106
|
+
export function rec<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode>(
|
|
107
|
+
body: () => Generator<Eff, A, never>,
|
|
108
|
+
): REC<Record<never, never>, RequirementsOf<Eff>>;
|
|
109
|
+
export function rec<
|
|
110
|
+
Eff extends AnyEffect | Hook<unknown>,
|
|
111
|
+
A extends ReactNode,
|
|
112
|
+
Props extends object,
|
|
113
|
+
>(body: (props: Props) => Generator<Eff, A, never>): REC<Props, RequirementsOf<Eff>>;
|
|
114
|
+
export function rec<
|
|
115
|
+
Eff extends AnyEffect | Hook<unknown>,
|
|
116
|
+
A extends ReactNode,
|
|
117
|
+
Props extends object,
|
|
118
|
+
>(body: (props: Props) => Generator<Eff, A, never>): REC<Props, RequirementsOf<Eff>> {
|
|
119
|
+
const fc: FunctionComponent<Props> = (props) => {
|
|
120
|
+
const executor = useExecutor();
|
|
121
|
+
const suspender = useSuspender();
|
|
122
|
+
const cache = useRenderCache();
|
|
123
|
+
return driveRec(body(props), { executor, suspender, cache });
|
|
124
|
+
};
|
|
125
|
+
return makeRec<Props, RequirementsOf<Eff>>(fc, body.name || 'EffractComponent');
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The resolve-up-front mode: a pure Effect (no hooks) rendered to a node.
|
|
130
|
+
* Returns a REC, composed the same way (`yield* Banner`).
|
|
131
|
+
*/
|
|
132
|
+
export function view<A extends ReactNode, E, R>(
|
|
133
|
+
render: Effect.Effect<A, E, R>,
|
|
134
|
+
): REC<Record<never, never>, R>;
|
|
135
|
+
export function view<A extends ReactNode, E, R, Props extends object>(
|
|
136
|
+
render: (props: Props) => Effect.Effect<A, E, R>,
|
|
137
|
+
): REC<Props, R>;
|
|
138
|
+
export function view<A extends ReactNode, E, R, Props extends object>(
|
|
139
|
+
render: Effect.Effect<A, E, R> | ((props: Props) => Effect.Effect<A, E, R>),
|
|
140
|
+
): REC<Props, R> {
|
|
141
|
+
const fc: FunctionComponent<Props> = (props) => {
|
|
142
|
+
const executor = useExecutor();
|
|
143
|
+
const suspender = useSuspender();
|
|
144
|
+
const cache = useRenderCache();
|
|
145
|
+
const effect = (typeof render === 'function' ? render(props) : render) as AnyEffect;
|
|
146
|
+
return resolveEffect(effect, { executor, suspender, cache }, { index: 0 }) as ReactNode;
|
|
147
|
+
};
|
|
148
|
+
return makeRec<Props, R>(fc, 'EffractView');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** A type error naming the services a runtime is missing for a REC's tree. */
|
|
152
|
+
export type MissingServices<Missing> = readonly ['effract: runtime is missing', Missing];
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A no-service tree's requirement infers as `unknown` (Effect's requirement
|
|
156
|
+
* channel is contravariant, so `never` widens). Normalise that to `never` so a
|
|
157
|
+
* runtime-free tree mounts under any layer.
|
|
158
|
+
*/
|
|
159
|
+
type Effective<R> = [unknown] extends [R] ? never : R;
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Mount a root REC under an Effect runtime. This is the boundary between
|
|
163
|
+
* effract and React: it returns an ordinary React node and, at compile time,
|
|
164
|
+
* verifies that `layer` provides every service the REC's tree requires — the
|
|
165
|
+
* check lives on the `rec` argument, so there is no cast on the result.
|
|
166
|
+
*
|
|
167
|
+
* ```tsx
|
|
168
|
+
* createRoot(el).render(mount(AppLive, Dashboard));
|
|
169
|
+
* ```
|
|
170
|
+
*/
|
|
171
|
+
export function mount<ROut, E, R>(
|
|
172
|
+
layer: Layer.Layer<ROut, E, never>,
|
|
173
|
+
rec: REC<Record<never, never>, R> &
|
|
174
|
+
([Effective<R>] extends [ROut] ? unknown : MissingServices<Exclude<Effective<R>, ROut>>),
|
|
175
|
+
): ReactNode {
|
|
176
|
+
// The render-effect requires nothing at runtime (asserted: `R` is phantom).
|
|
177
|
+
const element = Effect.runSync(rec.with({}) as Rendered<never>);
|
|
178
|
+
return createElement(Runtime<ROut, E>, { layer }, element);
|
|
179
|
+
}
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* The
|
|
5
|
-
* `Layer` and hands it down through React context,
|
|
6
|
-
* component reads it. This is the seam where "server vs
|
|
7
|
-
* a browser layer and the same components run in a SPA;
|
|
8
|
-
* and they run under Node, Bun, or a Web Worker — the
|
|
4
|
+
* The runtime provider that `mount` wraps your tree in. It builds an Effect
|
|
5
|
+
* `ManagedRuntime` once from a `Layer` and hands it down through React context,
|
|
6
|
+
* where every effract component reads it. This is the seam where "server vs
|
|
7
|
+
* client" lives: provide a browser layer and the same components run in a SPA;
|
|
8
|
+
* provide a server layer and they run under Node, Bun, or a Web Worker — the
|
|
9
|
+
* components never change. Use `mount(layer, Root)`; `Runtime` is the low-level
|
|
10
|
+
* provider underneath it.
|
|
9
11
|
*
|
|
10
12
|
* Services are resolved up-front into the runtime's context (the RSC-style
|
|
11
13
|
* "resolve near the root" mode), so reading a service inside a component is a
|
|
@@ -33,17 +35,13 @@ const executorFromRuntime = (runtime: AnyManagedRuntime): Executor => ({
|
|
|
33
35
|
export interface RuntimeProps<ROut, E> {
|
|
34
36
|
/** A self-contained layer (no open requirements) providing the subtree's services. */
|
|
35
37
|
readonly layer: Layer.Layer<ROut, E, never>;
|
|
36
|
-
readonly children
|
|
38
|
+
readonly children?: ReactNode;
|
|
37
39
|
}
|
|
38
40
|
|
|
39
41
|
/**
|
|
40
|
-
* Provide an Effect runtime to a React subtree.
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* <Runtime layer={AppLive}>
|
|
44
|
-
* <Dashboard />
|
|
45
|
-
* </Runtime>
|
|
46
|
-
* ```
|
|
42
|
+
* Provide an Effect runtime to a React subtree. Prefer `mount(layer, Root)`,
|
|
43
|
+
* which wraps the root REC in this provider and checks the tree's services at
|
|
44
|
+
* compile time. Reach for `Runtime` directly only to wrap non-REC React trees.
|
|
47
45
|
*/
|
|
48
46
|
export function Runtime<ROut, E>({ layer, children }: RuntimeProps<ROut, E>): ReactNode {
|
|
49
47
|
// Build the runtime exactly once for this boundary instance.
|
|
@@ -68,7 +66,7 @@ const useRuntimeContext = (): RuntimeContextValue => {
|
|
|
68
66
|
const value = useContext(RuntimeContext);
|
|
69
67
|
if (value === null) {
|
|
70
68
|
throw new Error(
|
|
71
|
-
'effract: no
|
|
69
|
+
'effract: no runtime found above this component. Mount your root with mount(layer, Root).',
|
|
72
70
|
);
|
|
73
71
|
}
|
|
74
72
|
return value;
|
|
@@ -1,140 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* The proof that effract is 100% real React: a React Effect Component is an
|
|
3
|
-
* ordinary component that React renders, holds hook state for, suspends, and
|
|
4
|
-
* re-renders — with Effect services resolved in the same pass.
|
|
5
|
-
*/
|
|
6
|
-
import { Suspense, act, useState } from 'react';
|
|
7
|
-
import { createRoot } from 'react-dom/client';
|
|
8
|
-
import { renderToStaticMarkup } from 'react-dom/server';
|
|
9
|
-
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
10
|
-
import * as Context from 'effect/Context';
|
|
11
|
-
import * as Effect from 'effect/Effect';
|
|
12
|
-
import * as Layer from 'effect/Layer';
|
|
13
|
-
import { Runtime, component, hook } from '../../index.ts';
|
|
14
|
-
|
|
15
|
-
Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true);
|
|
16
|
-
|
|
17
|
-
class Stats extends Context.Service<Stats, { readonly total: number }>()('test/Stats') {}
|
|
18
|
-
const statsLayer = (total: number): Layer.Layer<never, never, never> =>
|
|
19
|
-
Layer.succeed(Stats)({ total });
|
|
20
|
-
|
|
21
|
-
let container: HTMLDivElement;
|
|
22
|
-
beforeEach(() => {
|
|
23
|
-
container = document.createElement('div');
|
|
24
|
-
document.body.appendChild(container);
|
|
25
|
-
});
|
|
26
|
-
afterEach(() => {
|
|
27
|
-
container.remove();
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
describe('component (React Effect Component)', () => {
|
|
31
|
-
it('renders a service + hook REC to static markup in one pass', () => {
|
|
32
|
-
const Dashboard = component(function* () {
|
|
33
|
-
const stats = yield* Stats;
|
|
34
|
-
const [tab] = yield* hook(useState('overview'));
|
|
35
|
-
return (
|
|
36
|
-
<div>
|
|
37
|
-
{tab}:{stats.total}
|
|
38
|
-
</div>
|
|
39
|
-
);
|
|
40
|
-
});
|
|
41
|
-
|
|
42
|
-
const html = renderToStaticMarkup(
|
|
43
|
-
<Runtime layer={statsLayer(42)}>
|
|
44
|
-
<Dashboard />
|
|
45
|
-
</Runtime>,
|
|
46
|
-
);
|
|
47
|
-
expect(html).toContain('overview');
|
|
48
|
-
expect(html).toContain('42');
|
|
49
|
-
});
|
|
50
|
-
|
|
51
|
-
it('holds genuine hook state across re-renders while re-resolving services', async () => {
|
|
52
|
-
const Counter = component(function* () {
|
|
53
|
-
const stats = yield* Stats;
|
|
54
|
-
const [n, setN] = yield* hook(useState(0));
|
|
55
|
-
return (
|
|
56
|
-
<button type="button" onClick={() => setN(n + 1)}>
|
|
57
|
-
{n}/{stats.total}
|
|
58
|
-
</button>
|
|
59
|
-
);
|
|
60
|
-
});
|
|
61
|
-
|
|
62
|
-
const root = createRoot(container);
|
|
63
|
-
await act(async () => {
|
|
64
|
-
root.render(
|
|
65
|
-
<Runtime layer={statsLayer(42)}>
|
|
66
|
-
<Counter />
|
|
67
|
-
</Runtime>,
|
|
68
|
-
);
|
|
69
|
-
});
|
|
70
|
-
expect(container.textContent).toBe('0/42');
|
|
71
|
-
|
|
72
|
-
// Clicking drives a real useState update: the generator re-runs, the hook
|
|
73
|
-
// remembers its state, and the Stats service resolves again in the new pass.
|
|
74
|
-
const button = container.querySelector('button');
|
|
75
|
-
await act(async () => {
|
|
76
|
-
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
|
77
|
-
});
|
|
78
|
-
expect(container.textContent).toBe('1/42');
|
|
79
|
-
|
|
80
|
-
await act(async () => {
|
|
81
|
-
root.unmount();
|
|
82
|
-
});
|
|
83
|
-
});
|
|
84
|
-
|
|
85
|
-
it('suspends an async effect through React Suspense, then resolves it', async () => {
|
|
86
|
-
let release: (value: number) => void = () => {};
|
|
87
|
-
const gate = new Promise<number>((resolve) => {
|
|
88
|
-
release = resolve;
|
|
89
|
-
});
|
|
90
|
-
|
|
91
|
-
const AsyncPanel = component(function* () {
|
|
92
|
-
const value = yield* Effect.promise(() => gate);
|
|
93
|
-
return <span>val:{value}</span>;
|
|
94
|
-
});
|
|
95
|
-
|
|
96
|
-
const root = createRoot(container);
|
|
97
|
-
await act(async () => {
|
|
98
|
-
root.render(
|
|
99
|
-
<Runtime layer={Layer.empty}>
|
|
100
|
-
<Suspense fallback={<i>loading</i>}>
|
|
101
|
-
<AsyncPanel />
|
|
102
|
-
</Suspense>
|
|
103
|
-
</Runtime>,
|
|
104
|
-
);
|
|
105
|
-
});
|
|
106
|
-
expect(container.textContent).toContain('loading');
|
|
107
|
-
|
|
108
|
-
await act(async () => {
|
|
109
|
-
release(7);
|
|
110
|
-
await gate;
|
|
111
|
-
});
|
|
112
|
-
expect(container.textContent).toContain('val:7');
|
|
113
|
-
|
|
114
|
-
await act(async () => {
|
|
115
|
-
root.unmount();
|
|
116
|
-
});
|
|
117
|
-
});
|
|
118
|
-
|
|
119
|
-
it('runs the SAME component under two runtimes — server vs client is a runtime detail', () => {
|
|
120
|
-
const Total = component(function* () {
|
|
121
|
-
const stats = yield* Stats;
|
|
122
|
-
return <output>{stats.total}</output>;
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
const onServer = renderToStaticMarkup(
|
|
126
|
-
<Runtime layer={statsLayer(100)}>
|
|
127
|
-
<Total />
|
|
128
|
-
</Runtime>,
|
|
129
|
-
);
|
|
130
|
-
const onClient = renderToStaticMarkup(
|
|
131
|
-
<Runtime layer={statsLayer(1)}>
|
|
132
|
-
<Total />
|
|
133
|
-
</Runtime>,
|
|
134
|
-
);
|
|
135
|
-
|
|
136
|
-
expect(onServer).toContain('100');
|
|
137
|
-
expect(onClient).toContain('1');
|
|
138
|
-
expect(onServer).not.toBe(onClient);
|
|
139
|
-
});
|
|
140
|
-
});
|
|
@@ -1,109 +0,0 @@
|
|
|
1
|
-
'use client';
|
|
2
|
-
|
|
3
|
-
/**
|
|
4
|
-
* The two ways to write a component as an Effect program.
|
|
5
|
-
*
|
|
6
|
-
* component(function* () { ... }) the headline: a *React Effect Component*
|
|
7
|
-
* whose body yields both Effect services
|
|
8
|
-
* and React hooks, interpreted inside the
|
|
9
|
-
* render pass.
|
|
10
|
-
*
|
|
11
|
-
* view(Effect | (props) => Effect) the simpler resolve-up-front mode: a pure
|
|
12
|
-
* Effect with no hooks, ideal for server /
|
|
13
|
-
* RSC rendering.
|
|
14
|
-
*
|
|
15
|
-
* Both produce a genuine React function component. There is no custom
|
|
16
|
-
* reconciler — `<Dashboard />` is a real element React renders, suspends, and
|
|
17
|
-
* reconciles like any other.
|
|
18
|
-
*/
|
|
19
|
-
import { use, useRef, type ReactNode } from 'react';
|
|
20
|
-
import type * as Effect from 'effect/Effect';
|
|
21
|
-
import { driveRec, resolveEffect } from '#application/interpreter.ts';
|
|
22
|
-
import type { RenderCache, Suspender } from '#application/ports.ts';
|
|
23
|
-
import type { AnyEffect, Hook, RequirementsOf } from '#domain/protocol.ts';
|
|
24
|
-
import { useExecutor } from '#infrastructure/react/runtime.tsx';
|
|
25
|
-
|
|
26
|
-
declare const RequirementsId: unique symbol;
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* A React component produced by effract. It renders like any other component;
|
|
30
|
-
* the phantom `R` records which Effect services it needs from its `<Runtime>`,
|
|
31
|
-
* available for type-level introspection and runtime-binding helpers.
|
|
32
|
-
*/
|
|
33
|
-
export interface Component<in Props, out R> {
|
|
34
|
-
(props: Props): ReactNode;
|
|
35
|
-
readonly [RequirementsId]?: R;
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const useSuspender = (): Suspender => ({ use });
|
|
39
|
-
|
|
40
|
-
const useRenderCache = (): RenderCache => {
|
|
41
|
-
const ref = useRef<RenderCache>(undefined as unknown as RenderCache);
|
|
42
|
-
if (ref.current === undefined) {
|
|
43
|
-
ref.current = new Map();
|
|
44
|
-
}
|
|
45
|
-
return ref.current;
|
|
46
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Define a React Effect Component. The body is a generator that may `yield*`
|
|
50
|
-
* Effect services and effects, and `yield* hook(...)` for React hooks.
|
|
51
|
-
*
|
|
52
|
-
* ```tsx
|
|
53
|
-
* const Dashboard = component(function* () {
|
|
54
|
-
* const stats = yield* Stats; // Effect service
|
|
55
|
-
* const [tab, setTab] = yield* hook(useState('overview')); // real React hook
|
|
56
|
-
* return <Panel tab={tab} total={stats.total} onTab={setTab} />;
|
|
57
|
-
* });
|
|
58
|
-
* ```
|
|
59
|
-
*/
|
|
60
|
-
export function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode>(
|
|
61
|
-
body: () => Generator<Eff, A, never>,
|
|
62
|
-
): Component<Record<never, never>, RequirementsOf<Eff>>;
|
|
63
|
-
export function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode, Props>(
|
|
64
|
-
body: (props: Props) => Generator<Eff, A, never>,
|
|
65
|
-
): Component<Props, RequirementsOf<Eff>>;
|
|
66
|
-
export function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode, Props>(
|
|
67
|
-
body: (props: Props) => Generator<Eff, A, never>,
|
|
68
|
-
): Component<Props, RequirementsOf<Eff>> {
|
|
69
|
-
const Rec = (props: Props): ReactNode => {
|
|
70
|
-
const executor = useExecutor();
|
|
71
|
-
const suspender = useSuspender();
|
|
72
|
-
const cache = useRenderCache();
|
|
73
|
-
return driveRec(body(props), { executor, suspender, cache });
|
|
74
|
-
};
|
|
75
|
-
Rec.displayName = body.name || 'EffractComponent';
|
|
76
|
-
return Rec as Component<Props, RequirementsOf<Eff>>;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
/**
|
|
80
|
-
* Define a resolve-up-front component: a pure Effect (no hooks) rendered to a
|
|
81
|
-
* `ReactNode`. Services resolve synchronously from the runtime; async work
|
|
82
|
-
* suspends through React Suspense. This is the RSC-friendly mode.
|
|
83
|
-
*
|
|
84
|
-
* ```tsx
|
|
85
|
-
* const Header = view(Effect.gen(function* () {
|
|
86
|
-
* const user = yield* CurrentUser;
|
|
87
|
-
* return <h1>Welcome, {user.name}</h1>;
|
|
88
|
-
* }));
|
|
89
|
-
* ```
|
|
90
|
-
*/
|
|
91
|
-
export function view<A extends ReactNode, E, R>(
|
|
92
|
-
render: Effect.Effect<A, E, R>,
|
|
93
|
-
): Component<Record<never, never>, R>;
|
|
94
|
-
export function view<A extends ReactNode, E, R, Props>(
|
|
95
|
-
render: (props: Props) => Effect.Effect<A, E, R>,
|
|
96
|
-
): Component<Props, R>;
|
|
97
|
-
export function view<A extends ReactNode, E, R, Props>(
|
|
98
|
-
render: Effect.Effect<A, E, R> | ((props: Props) => Effect.Effect<A, E, R>),
|
|
99
|
-
): Component<Props, R> {
|
|
100
|
-
const View = (props: Props): ReactNode => {
|
|
101
|
-
const executor = useExecutor();
|
|
102
|
-
const suspender = useSuspender();
|
|
103
|
-
const cache = useRenderCache();
|
|
104
|
-
const effect = (typeof render === 'function' ? render(props) : render) as AnyEffect;
|
|
105
|
-
return resolveEffect(effect, { executor, suspender, cache }, { index: 0 }) as ReactNode;
|
|
106
|
-
};
|
|
107
|
-
View.displayName = 'EffractView';
|
|
108
|
-
return View as Component<Props, R>;
|
|
109
|
-
}
|