@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
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interpreter, exercised with no React at all. This is the payoff of
|
|
3
|
+
* keeping it in the application layer: the entire fiber-bridging logic — hook
|
|
4
|
+
* unwrapping, synchronous service resolution, async suspension, error
|
|
5
|
+
* propagation — is testable against plain fakes.
|
|
6
|
+
*/
|
|
7
|
+
import { describe, expect, it } from 'vitest';
|
|
8
|
+
import * as Context from 'effect/Context';
|
|
9
|
+
import * as Effect from 'effect/Effect';
|
|
10
|
+
import * as Layer from 'effect/Layer';
|
|
11
|
+
import * as ManagedRuntime from 'effect/ManagedRuntime';
|
|
12
|
+
import { hook } from '#domain/protocol.ts';
|
|
13
|
+
import { driveRec } from '#application/interpreter.ts';
|
|
14
|
+
import type { Executor, InterpreterDeps, RenderCache, Suspender } from '#application/ports.ts';
|
|
15
|
+
|
|
16
|
+
class Stats extends Context.Service<Stats, { readonly total: number }>()('test/Stats') {}
|
|
17
|
+
|
|
18
|
+
const executorWith = (layer: Layer.Layer<never, never, never>): Executor => {
|
|
19
|
+
const runtime = ManagedRuntime.make(layer) as unknown as ManagedRuntime.ManagedRuntime<
|
|
20
|
+
unknown,
|
|
21
|
+
unknown
|
|
22
|
+
>;
|
|
23
|
+
return {
|
|
24
|
+
runSyncExit: (effect) => runtime.runSyncExit(effect),
|
|
25
|
+
runPromise: (effect) => runtime.runPromise(effect),
|
|
26
|
+
};
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
const neverSuspend: Suspender = {
|
|
30
|
+
use: () => {
|
|
31
|
+
throw new Error('did not expect to suspend');
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
const makeDeps = (
|
|
36
|
+
layer: Layer.Layer<never, never, never>,
|
|
37
|
+
suspender: Suspender = neverSuspend,
|
|
38
|
+
): InterpreterDeps => ({
|
|
39
|
+
executor: executorWith(layer),
|
|
40
|
+
suspender,
|
|
41
|
+
cache: new Map(),
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('driveRec', () => {
|
|
45
|
+
it('unwraps lifted hooks without touching the runtime', () => {
|
|
46
|
+
const result = driveRec(
|
|
47
|
+
(function* () {
|
|
48
|
+
const a = yield* hook(10);
|
|
49
|
+
const b = yield* hook(20);
|
|
50
|
+
return a + b;
|
|
51
|
+
})(),
|
|
52
|
+
makeDeps(Layer.empty),
|
|
53
|
+
);
|
|
54
|
+
expect(result).toBe(30);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('resolves a service Tag synchronously from the runtime', () => {
|
|
58
|
+
const result = driveRec(
|
|
59
|
+
(function* () {
|
|
60
|
+
const stats = yield* Stats;
|
|
61
|
+
return stats.total;
|
|
62
|
+
})(),
|
|
63
|
+
makeDeps(Layer.succeed(Stats)({ total: 42 })),
|
|
64
|
+
);
|
|
65
|
+
expect(result).toBe(42);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('interleaves services and hooks in one render pass — the fiber bridge', () => {
|
|
69
|
+
const result = driveRec(
|
|
70
|
+
(function* () {
|
|
71
|
+
const stats = yield* Stats;
|
|
72
|
+
const label = yield* hook('overview');
|
|
73
|
+
const suffix = yield* hook('!');
|
|
74
|
+
return `${label}:${stats.total}${suffix}`;
|
|
75
|
+
})(),
|
|
76
|
+
makeDeps(Layer.succeed(Stats)({ total: 7 })),
|
|
77
|
+
);
|
|
78
|
+
expect(result).toBe('overview:7!');
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('suspends on an async effect, caching a stable promise, then returns on retry', async () => {
|
|
82
|
+
const deps = makeDeps(Layer.empty, {
|
|
83
|
+
use: (promise) => {
|
|
84
|
+
throw promise;
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
const body = function* () {
|
|
88
|
+
const value = yield* Effect.promise(() => Promise.resolve(99));
|
|
89
|
+
return value;
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
// First pass: the async effect cannot finish synchronously, so it suspends.
|
|
93
|
+
let suspended: unknown;
|
|
94
|
+
try {
|
|
95
|
+
driveRec(body(), deps);
|
|
96
|
+
expect.unreachable('should have suspended');
|
|
97
|
+
} catch (thrown) {
|
|
98
|
+
suspended = thrown;
|
|
99
|
+
}
|
|
100
|
+
expect(suspended).toBeInstanceOf(Promise);
|
|
101
|
+
expect(deps.cache.size).toBe(1);
|
|
102
|
+
|
|
103
|
+
// The cached promise settles to the effect's value.
|
|
104
|
+
await expect(deps.cache.get(0)?.promise).resolves.toBe(99);
|
|
105
|
+
|
|
106
|
+
// Retry with a suspender that returns settled values: now it resolves inline.
|
|
107
|
+
const retryDeps: InterpreterDeps = {
|
|
108
|
+
executor: deps.executor,
|
|
109
|
+
cache: deps.cache,
|
|
110
|
+
suspender: { use: () => 99 as never },
|
|
111
|
+
};
|
|
112
|
+
expect(driveRec(body(), retryDeps)).toBe(99);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('throws a genuine Effect failure to the error boundary', () => {
|
|
116
|
+
expect.assertions(1);
|
|
117
|
+
try {
|
|
118
|
+
driveRec(
|
|
119
|
+
(function* () {
|
|
120
|
+
const value = yield* Effect.fail('boom');
|
|
121
|
+
return value;
|
|
122
|
+
})(),
|
|
123
|
+
makeDeps(Layer.empty),
|
|
124
|
+
);
|
|
125
|
+
} catch (thrown) {
|
|
126
|
+
expect(thrown).toBe('boom');
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('rejects a yield that is neither an Effect nor a hook', () => {
|
|
131
|
+
const cache: RenderCache = new Map();
|
|
132
|
+
const body = function* () {
|
|
133
|
+
yield 5 as never; // a bare yield of a non-instruction: the interpreter must reject it
|
|
134
|
+
return null;
|
|
135
|
+
};
|
|
136
|
+
expect(() =>
|
|
137
|
+
driveRec(body(), { executor: executorWith(Layer.empty), suspender: neverSuspend, cache }),
|
|
138
|
+
).toThrow(TypeError);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The interpreter — the bridge between React's fiber and Effect's fiber.
|
|
3
|
+
*
|
|
4
|
+
* React drives a component by calling its function during a render pass.
|
|
5
|
+
* `driveRec` runs *inside* that pass: it walks the component's generator
|
|
6
|
+
* synchronously, and for every `yield*` it decides who answers.
|
|
7
|
+
*
|
|
8
|
+
* - a lifted hook → the React hook already ran inline; unwrap its value
|
|
9
|
+
* - a service Tag → resolve it synchronously from the runtime's context
|
|
10
|
+
* - a sync Effect → run it synchronously, return its value
|
|
11
|
+
* - an async Effect → suspend through React's `use`, resuming on the retry
|
|
12
|
+
*
|
|
13
|
+
* Because the walk is synchronous and deterministic, the user's hook calls keep
|
|
14
|
+
* a stable order across renders — they are, and remain, ordinary React hooks.
|
|
15
|
+
* Nothing here forks React's reconciler; it cooperates with it.
|
|
16
|
+
*/
|
|
17
|
+
import * as Cause from 'effect/Cause';
|
|
18
|
+
import * as Effect from 'effect/Effect';
|
|
19
|
+
import * as Exit from 'effect/Exit';
|
|
20
|
+
import { isHook, type AnyEffect, type RecGenerator } from '#domain/protocol.ts';
|
|
21
|
+
import type { InterpreterDeps } from '#application/ports.ts';
|
|
22
|
+
|
|
23
|
+
interface DriveState {
|
|
24
|
+
index: number;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Resolve a single yielded Effect against the runtime. Synchronous effects
|
|
29
|
+
* (services, pure computation, ref reads) return immediately. An effect that
|
|
30
|
+
* cannot finish synchronously surfaces as an `AsyncFiberError`; we route it
|
|
31
|
+
* through React Suspense with a promise cached by encounter order, so the
|
|
32
|
+
* retry after the promise settles returns the value inline. Any other failure
|
|
33
|
+
* is a real error and is thrown to the nearest React error boundary.
|
|
34
|
+
*/
|
|
35
|
+
export const resolveEffect = (
|
|
36
|
+
effect: AnyEffect,
|
|
37
|
+
deps: InterpreterDeps,
|
|
38
|
+
state: DriveState,
|
|
39
|
+
): unknown => {
|
|
40
|
+
if (!Effect.isEffect(effect)) {
|
|
41
|
+
throw new TypeError(
|
|
42
|
+
'effract: a component body yielded a value that is neither an Effect nor a hook(...). ' +
|
|
43
|
+
'Wrap React hooks with `hook(...)`, e.g. `yield* hook(useState(0))`.',
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const exit = deps.executor.runSyncExit(effect);
|
|
48
|
+
if (Exit.isSuccess(exit)) {
|
|
49
|
+
return exit.value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const squashed = Cause.squash(exit.cause);
|
|
53
|
+
if (Cause.isAsyncFiberError(squashed)) {
|
|
54
|
+
const index = state.index++;
|
|
55
|
+
let slot = deps.cache.get(index);
|
|
56
|
+
if (slot === undefined) {
|
|
57
|
+
slot = { promise: deps.executor.runPromise(effect) };
|
|
58
|
+
deps.cache.set(index, slot);
|
|
59
|
+
}
|
|
60
|
+
// Suspends the render until the cached promise settles, then returns inline.
|
|
61
|
+
return deps.suspender.use(slot.promise);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
throw squashed;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Run a React Effect Component body to its rendered result. Creates a fresh
|
|
69
|
+
* generator per render (generators are single-use); a Suspense retry simply
|
|
70
|
+
* runs this again from the top, replaying hooks in order and hitting the async
|
|
71
|
+
* cache for already-started work.
|
|
72
|
+
*/
|
|
73
|
+
export const driveRec = <A>(gen: RecGenerator<A>, deps: InterpreterDeps): A => {
|
|
74
|
+
const state: DriveState = { index: 0 };
|
|
75
|
+
let step = gen.next();
|
|
76
|
+
while (!step.done) {
|
|
77
|
+
const instruction = step.value;
|
|
78
|
+
const result = isHook(instruction)
|
|
79
|
+
? instruction.value
|
|
80
|
+
: resolveEffect(instruction, deps, state);
|
|
81
|
+
step = gen.next(result);
|
|
82
|
+
}
|
|
83
|
+
return step.value;
|
|
84
|
+
};
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ports the interpreter depends on. Both are capabilities the *infrastructure*
|
|
3
|
+
* supplies — an Effect runtime and React's `use` — so the reconciler itself
|
|
4
|
+
* stays free of any concrete runtime or renderer and can be unit-tested with
|
|
5
|
+
* plain fakes.
|
|
6
|
+
*/
|
|
7
|
+
import type * as Exit from 'effect/Exit';
|
|
8
|
+
import type { AnyEffect } from '#domain/protocol.ts';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Something that can run an Effect. Backed in production by a `ManagedRuntime`
|
|
12
|
+
* built once at the `<Runtime>` boundary, which already carries the resolved
|
|
13
|
+
* service environment — so `runSyncExit` resolves services synchronously and
|
|
14
|
+
* only genuinely asynchronous work falls through to `runPromise`.
|
|
15
|
+
*/
|
|
16
|
+
export interface Executor {
|
|
17
|
+
readonly runSyncExit: (effect: AnyEffect) => Exit.Exit<unknown, unknown>;
|
|
18
|
+
readonly runPromise: (effect: AnyEffect) => Promise<unknown>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* React's `use`, injected. Given a stable promise it suspends the render until
|
|
23
|
+
* the promise settles, then returns the value (or throws to the nearest error
|
|
24
|
+
* boundary). The interpreter never imports React — it asks for this instead.
|
|
25
|
+
*/
|
|
26
|
+
export interface Suspender {
|
|
27
|
+
readonly use: <A>(promise: Promise<A>) => A;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One suspended async slot: the stable promise React's `use` will track. */
|
|
31
|
+
interface AsyncSlot {
|
|
32
|
+
readonly promise: Promise<unknown>;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Per-component-instance cache of suspended async effects, keyed by the order
|
|
37
|
+
* in which they are encountered during a render. Persisted across renders (and
|
|
38
|
+
* across a Suspense retry) by a `useRef` in the React adapter, which gives
|
|
39
|
+
* async effects load-once semantics for the lifetime of the component.
|
|
40
|
+
*/
|
|
41
|
+
export type RenderCache = Map<number, AsyncSlot>;
|
|
42
|
+
|
|
43
|
+
export interface InterpreterDeps {
|
|
44
|
+
readonly executor: Executor;
|
|
45
|
+
readonly suspender: Suspender;
|
|
46
|
+
readonly cache: RenderCache;
|
|
47
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The yield protocol that lets a single generator body speak two languages at
|
|
3
|
+
* once: Effect (services, effects) and React (hooks). This module is the pure
|
|
4
|
+
* heart of the framework — it knows nothing about React or about how effects
|
|
5
|
+
* are executed. It only defines *what* can flow through `yield*` and how the
|
|
6
|
+
* interpreter recognises each kind.
|
|
7
|
+
*
|
|
8
|
+
* Two kinds of value travel through `yield*`:
|
|
9
|
+
*
|
|
10
|
+
* yield* SomeService // an Effect (a service Tag is itself an Effect)
|
|
11
|
+
* yield* hook(useState(0)) // a Hook: a real React hook call, lifted in
|
|
12
|
+
*
|
|
13
|
+
* Both implement the single-shot iterator protocol that `yield*` delegates to,
|
|
14
|
+
* so the interpreter receives the instruction, resolves it, and feeds the
|
|
15
|
+
* result back into the generator — exactly how `Effect.gen` drives Tags.
|
|
16
|
+
*/
|
|
17
|
+
import type * as Effect from 'effect/Effect';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The one unavoidable `any` in the framework. The heterogeneous yield protocol
|
|
21
|
+
* must accept an Effect of *any* error and requirement variance — there is no
|
|
22
|
+
* other way to express "some Effect" as a generic constraint, which is why
|
|
23
|
+
* Effect's own `Effect.gen` is typed exactly this way. Precise `E` and `R` are
|
|
24
|
+
* recovered below through conditional inference, so this never leaks to users.
|
|
25
|
+
*/
|
|
26
|
+
// oxlint-disable-next-line typescript/no-explicit-any
|
|
27
|
+
export type AnyEffect = Effect.Effect<any, any, any>;
|
|
28
|
+
|
|
29
|
+
/** Brand identifying a lifted React hook instruction. */
|
|
30
|
+
export const HookTypeId = Symbol.for('@tmonier/effract/Hook');
|
|
31
|
+
export type HookTypeId = typeof HookTypeId;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A React hook result lifted into the `yield*` channel. The hook itself has
|
|
35
|
+
* already executed (synchronously, during render) by the time the value is
|
|
36
|
+
* wrapped — `hook(useState(0))` calls `useState` inline. Wrapping it only makes
|
|
37
|
+
* the result yieldable, so a component body reads as one uniform stream of
|
|
38
|
+
* `yield*`s whether the value comes from Effect or from React.
|
|
39
|
+
*/
|
|
40
|
+
export interface Hook<out A> {
|
|
41
|
+
readonly [HookTypeId]: true;
|
|
42
|
+
readonly value: A;
|
|
43
|
+
[Symbol.iterator](): Iterator<Hook<A>, A>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Lift an already-evaluated React hook result into the effract yield channel.
|
|
48
|
+
*
|
|
49
|
+
* ```ts
|
|
50
|
+
* const [tab, setTab] = yield* hook(useState('overview'));
|
|
51
|
+
* const ref = yield* hook(useRef<HTMLDivElement>(null));
|
|
52
|
+
* ```
|
|
53
|
+
*
|
|
54
|
+
* Because the component body runs synchronously inside React's render pass, the
|
|
55
|
+
* wrapped hook call obeys the Rules of Hooks: same order on every render.
|
|
56
|
+
*/
|
|
57
|
+
export const hook = <A>(value: A): Hook<A> => {
|
|
58
|
+
const self: Hook<A> = {
|
|
59
|
+
[HookTypeId]: true,
|
|
60
|
+
value,
|
|
61
|
+
[Symbol.iterator]() {
|
|
62
|
+
let yielded = false;
|
|
63
|
+
return {
|
|
64
|
+
next(sent?: unknown): IteratorResult<Hook<A>, A> {
|
|
65
|
+
if (yielded) {
|
|
66
|
+
return { done: true, value: sent as A };
|
|
67
|
+
}
|
|
68
|
+
yielded = true;
|
|
69
|
+
return { done: false, value: self };
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
return self;
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
/** Type guard: is this yielded instruction a lifted hook? */
|
|
78
|
+
export const isHook = (u: unknown): u is Hook<unknown> =>
|
|
79
|
+
typeof u === 'object' && u !== null && HookTypeId in u;
|
|
80
|
+
|
|
81
|
+
/** Everything a component body may `yield*`: an Effect, or a lifted hook. */
|
|
82
|
+
export type Yieldable<A> = Effect.Effect<A, unknown, unknown> | Hook<A>;
|
|
83
|
+
|
|
84
|
+
/** The generator a React Effect Component body produces. */
|
|
85
|
+
export type RecGenerator<A> = Generator<AnyEffect | Hook<unknown>, A, unknown>;
|
|
86
|
+
|
|
87
|
+
/** A component body: props in, a generator of yields ending in a rendered `A`. */
|
|
88
|
+
export type RecBody<Props, A> = (props: Props) => RecGenerator<A>;
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Distribute over the yield union and keep only its Effect members. Hooks are
|
|
92
|
+
* not Effects, so they drop away — leaving just what carries `E` and `R`.
|
|
93
|
+
*/
|
|
94
|
+
type EffectsOnly<Eff> = Eff extends AnyEffect ? Eff : never;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Recover the Effect requirement channel `R` from everything a body yields.
|
|
98
|
+
* A body that needs both `A` and `B` requires `A & B`, which is exactly the
|
|
99
|
+
* intersection TypeScript infers from the contravariant requirement slot.
|
|
100
|
+
*/
|
|
101
|
+
export type RequirementsOf<Eff> = [EffectsOnly<Eff>] extends [
|
|
102
|
+
Effect.Effect<unknown, unknown, infer R>,
|
|
103
|
+
]
|
|
104
|
+
? R
|
|
105
|
+
: never;
|
|
106
|
+
|
|
107
|
+
/** Recover the Effect error channel `E` (a union — any yielded effect may fail). */
|
|
108
|
+
export type ErrorsOf<Eff> = [EffectsOnly<Eff>] extends [Effect.Effect<unknown, infer E, unknown>]
|
|
109
|
+
? E
|
|
110
|
+
: never;
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* effract — write React components as Effect programs.
|
|
3
|
+
*
|
|
4
|
+
* The same component runs in a SPA, on a Bun/Node server, in a Web Worker, or
|
|
5
|
+
* as a React Server Component. "Server vs client" is an Effect runtime detail,
|
|
6
|
+
* supplied by a `<Runtime>` boundary — not an architectural fork.
|
|
7
|
+
*
|
|
8
|
+
* @packageDocumentation
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const VERSION = '0.1.0';
|
|
12
|
+
|
|
13
|
+
// --- the yield protocol ---
|
|
14
|
+
export { hook, isHook, HookTypeId } from '#domain/protocol.ts';
|
|
15
|
+
export type {
|
|
16
|
+
AnyEffect,
|
|
17
|
+
Hook,
|
|
18
|
+
Yieldable,
|
|
19
|
+
RecBody,
|
|
20
|
+
RecGenerator,
|
|
21
|
+
RequirementsOf,
|
|
22
|
+
ErrorsOf,
|
|
23
|
+
} from '#domain/protocol.ts';
|
|
24
|
+
|
|
25
|
+
// --- components ---
|
|
26
|
+
export { component, view } from '#infrastructure/react/component.tsx';
|
|
27
|
+
export type { Component } from '#infrastructure/react/component.tsx';
|
|
28
|
+
|
|
29
|
+
// --- the runtime boundary ---
|
|
30
|
+
export { Runtime, useEffractRuntime } from '#infrastructure/react/runtime.tsx';
|
|
31
|
+
export type { RuntimeProps } from '#infrastructure/react/runtime.tsx';
|
|
32
|
+
|
|
33
|
+
// --- reactivity ---
|
|
34
|
+
export {
|
|
35
|
+
observe,
|
|
36
|
+
Observe,
|
|
37
|
+
atom,
|
|
38
|
+
useAtom,
|
|
39
|
+
useAtomValue,
|
|
40
|
+
useAtomSet,
|
|
41
|
+
} from '#infrastructure/react/reactivity.tsx';
|
|
42
|
+
export type { Read, ObserveProps } from '#infrastructure/react/reactivity.tsx';
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,109 @@
|
|
|
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
|
+
}
|