@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 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 { Runtime, component, hook } from '@tmonier/effract';
9
+ import { mount, rec, hook } from '@tmonier/effract';
10
+ import { createRoot } from 'react-dom/client';
8
11
  import { useState } from 'react';
9
12
 
10
- const Dashboard = component(function* () {
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
- export const App = () => (
17
- <Runtime layer={AppLive}>
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
- - `component` / `view` hook-capable and resolve-up-front components.
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
- - `<Runtime layer={...}>`provide an Effect runtime to a subtree.
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/component.d.ts
66
- declare const RequirementsId: unique symbol;
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 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.
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
- interface Component<in Props, out R> {
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, 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
- * ```
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 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>>;
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
- * 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
+ * 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
- * const Header = view(Effect.gen(function* () {
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 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>;
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: ReactNode;
124
+ readonly children?: ReactNode;
111
125
  }
112
126
  /**
113
- * Provide an Effect runtime to a React subtree.
114
- *
115
- * ```tsx
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 a `<Runtime>` boundary — not an architectural fork.
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 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 };
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 `<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.
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
- * ```tsx
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 <Runtime> found above this component. Wrap your tree in <Runtime layer={...}>.");
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/component.tsx
153
+ //#region src/infrastructure/react/rec.tsx
156
154
  /**
157
- * The two ways to write a component as an Effect program.
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
- * 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
+ * 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
- * view(Effect | (props) => Effect) the simpler resolve-up-front mode: a pure
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
- * 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
+ * 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(void 0);
175
- if (ref.current === void 0) ref.current = /* @__PURE__ */ new Map();
178
+ const ref = useRef(null);
179
+ if (ref.current === null) ref.current = /* @__PURE__ */ new Map();
176
180
  return ref.current;
177
181
  };
178
- function component(body) {
179
- const Rec = (props) => {
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
- Rec.displayName = body.name || "EffractComponent";
190
- return Rec;
216
+ return makeRec(fc, body.name || "EffractComponent");
191
217
  }
192
218
  function view(render) {
193
- const View = (props) => {
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
- View.displayName = "EffractView";
204
- return View;
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 a `<Runtime>` boundary — not an architectural fork.
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, component, hook, isHook, observe, useAtom, useAtomSet, useAtomValue, useEffractRuntime, view };
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.0",
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://github.com/get-tmonier/effract#readme",
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.2.7"
54
+ "effect": ">=4.0.0-beta.88 <5.0.0",
55
+ "react": "^19.0.0"
56
56
  },
57
57
  "scripts": {
58
58
  "build": "tsdown",
@@ -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 `<Runtime>` boundary, which already carries the resolved
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 a `<Runtime>` boundary — not an architectural fork.
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 { component, view } from '#infrastructure/react/component.tsx';
27
- export type { Component } from '#infrastructure/react/component.tsx';
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 `<Runtime>` boundary. It builds an Effect `ManagedRuntime` once from a
5
- * `Layer` and hands it down through React context, where every effract
6
- * component reads it. This is the seam where "server vs client" lives: provide
7
- * a browser layer and the same components run in a SPA; provide a server layer
8
- * and they run under Node, Bun, or a Web Worker — the components never change.
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: ReactNode;
38
+ readonly children?: ReactNode;
37
39
  }
38
40
 
39
41
  /**
40
- * Provide an Effect runtime to a React subtree.
41
- *
42
- * ```tsx
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 <Runtime> found above this component. Wrap your tree in <Runtime layer={...}>.',
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
- }