@tmonier/effract
Advanced tools
| /** | ||
| * The canonical model: RECs compose with `yield*`, requirements bubble to the | ||
| * root, and `mount` is the typed boundary. RECs are not JSX element types — | ||
| * `<Rec />` is a compile error (asserted at the bottom) — so this is the only | ||
| * way to use one, and it stays 100% real React underneath. | ||
| */ | ||
| import { Suspense, act, useState, type ReactNode } from 'react'; | ||
| import { createRoot } from 'react-dom/client'; | ||
| import { renderToStaticMarkup } from 'react-dom/server'; | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import * as Context from 'effect/Context'; | ||
| import * as Effect from 'effect/Effect'; | ||
| import * as Layer from 'effect/Layer'; | ||
| import { rec, hook, mount } from '../../index.ts'; | ||
| Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); | ||
| class Stats extends Context.Service<Stats, { readonly total: number }>()('test/Stats') {} | ||
| const statsLayer = (total: number): Layer.Layer<Stats> => Layer.succeed(Stats)({ total }); | ||
| let container: HTMLDivElement; | ||
| beforeEach(() => { | ||
| container = document.createElement('div'); | ||
| document.body.appendChild(container); | ||
| }); | ||
| afterEach(() => container.remove()); | ||
| describe('REC composition + mount', () => { | ||
| it('mounts a tree, resolving services and yield-composed children', () => { | ||
| const Badge = rec(function* () { | ||
| const stats = yield* Stats; | ||
| return <span>{stats.total}</span>; | ||
| }); | ||
| const Page = rec(function* () { | ||
| return <main>online: {yield* Badge}</main>; | ||
| }); | ||
| const html = renderToStaticMarkup(mount(statsLayer(42), Page)); | ||
| expect(html).toContain('42'); | ||
| }); | ||
| it('passes props with .with(), type-checked', () => { | ||
| const Greet = rec(function* (props: { name: string }) { | ||
| const stats = yield* Stats; | ||
| return ( | ||
| <p> | ||
| hi {props.name} ({stats.total}) | ||
| </p> | ||
| ); | ||
| }); | ||
| const Page = rec(function* () { | ||
| return <main>{yield* Greet.with({ name: 'Ada' })}</main>; | ||
| }); | ||
| expect(renderToStaticMarkup(mount(statsLayer(3), Page))).toContain('hi Ada'); | ||
| }); | ||
| it('keeps a yield-composed child a real React child (hook state survives re-render)', async () => { | ||
| const Counter = rec(function* () { | ||
| const [n, setN] = yield* hook(useState(0)); | ||
| return ( | ||
| <button data-x="counter" onClick={() => setN(n + 1)}> | ||
| {n} | ||
| </button> | ||
| ); | ||
| }); | ||
| const Parent = rec(function* () { | ||
| const [p, setP] = yield* hook(useState(0)); | ||
| return ( | ||
| <div> | ||
| <button data-x="parent" onClick={() => setP(p + 1)}> | ||
| {p} | ||
| </button> | ||
| {yield* Counter} | ||
| </div> | ||
| ); | ||
| }); | ||
| const root = createRoot(container); | ||
| const click = async (x: string) => | ||
| act(async () => { | ||
| container | ||
| .querySelector(`[data-x="${x}"]`) | ||
| ?.dispatchEvent(new MouseEvent('click', { bubbles: true })); | ||
| }); | ||
| await act(async () => root.render(mount(Layer.empty, Parent))); | ||
| expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('0'); | ||
| await click('counter'); | ||
| expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('1'); | ||
| await click('parent'); // Parent re-renders → re-runs yield* Counter | ||
| expect(container.querySelector('[data-x="parent"]')?.textContent).toBe('1'); | ||
| expect(container.querySelector('[data-x="counter"]')?.textContent).toBe('1'); // preserved | ||
| await act(async () => root.unmount()); | ||
| }); | ||
| it('suspends an async effect through Suspense, then resolves', async () => { | ||
| let release: (v: number) => void = () => {}; | ||
| const gate = new Promise<number>((r) => { | ||
| release = r; | ||
| }); | ||
| const Async = rec(function* () { | ||
| const v = yield* Effect.promise(() => gate); | ||
| return <span>val:{v}</span>; | ||
| }); | ||
| const Page = rec(function* () { | ||
| return <Suspense fallback={<i>loading</i>}>{yield* Async}</Suspense>; | ||
| }); | ||
| const root = createRoot(container); | ||
| await act(async () => root.render(mount(Layer.empty, Page))); | ||
| expect(container.textContent).toContain('loading'); | ||
| await act(async () => { | ||
| release(7); | ||
| await gate; | ||
| }); | ||
| expect(container.textContent).toContain('val:7'); | ||
| await act(async () => root.unmount()); | ||
| }); | ||
| it('runs the SAME tree under two runtimes — server vs client is the mount', () => { | ||
| const Total = rec(function* () { | ||
| const stats = yield* Stats; | ||
| return <output>{stats.total}</output>; | ||
| }); | ||
| const Page = rec(function* () { | ||
| return <div>{yield* Total}</div>; | ||
| }); | ||
| expect(renderToStaticMarkup(mount(statsLayer(100), Page))).toContain('100'); | ||
| expect(renderToStaticMarkup(mount(statsLayer(1), Page))).toContain('1'); | ||
| }); | ||
| }); | ||
| // --- type-level guarantees (checked by tsgo, not run) --- | ||
| { | ||
| const Needs = rec(function* () { | ||
| const s = yield* Stats; | ||
| return <i>{s.total}</i>; | ||
| }); | ||
| const Root = rec(function* () { | ||
| return <main>{yield* Needs}</main>; | ||
| }); | ||
| // ✓ AppLive provides Stats: | ||
| void mount(statsLayer(1), Root); | ||
| // ✗ empty layer is missing Stats — mount returns a non-ReactNode error type: | ||
| // @ts-expect-error effract: runtime is missing Stats | ||
| const _bad: ReactNode = mount(Layer.empty, Root); | ||
| void _bad; | ||
| // ✗ a REC is not a JSX element type: | ||
| // @ts-expect-error RECs cannot be used as JSX | ||
| const _jsx = <Needs />; | ||
| void _jsx; | ||
| } |
| 'use client'; | ||
| /** | ||
| * React Effect Components (RECs) and the boundary that mounts them. | ||
| * | ||
| * A REC is the unit of composition in effract. Crucially it is **not** a React | ||
| * element type — `<Dashboard />` is a compile error. You compose RECs the way | ||
| * you compose Effects: with `yield*`. That is what lets a component's Effect | ||
| * requirements (`R`) bubble up the tree to the one place that knows the runtime, | ||
| * the `mount` boundary, where they are verified at compile time. | ||
| * | ||
| * const Dashboard = rec(function* () { | ||
| * const stats = yield* Stats; // a service | ||
| * const [n, setN] = yield* hook(useState(0)); // a real React hook | ||
| * return <main>{yield* StatBadge}{n}</main>; // a child REC, yielded | ||
| * }); | ||
| * | ||
| * mount(AppLive, Dashboard); // ← compile error if AppLive lacks a needed service | ||
| * | ||
| * At runtime a REC still renders as an ordinary React component (own fiber, | ||
| * hooks, reconciliation); the yield is only how it is placed and how `R` flows. | ||
| */ | ||
| import { | ||
| createElement, | ||
| use, | ||
| useRef, | ||
| type FunctionComponent, | ||
| type ReactElement, | ||
| type ReactNode, | ||
| } from 'react'; | ||
| import * as Effect from 'effect/Effect'; | ||
| import type * as Layer from 'effect/Layer'; | ||
| import { driveRec, resolveEffect } from '#application/interpreter.ts'; | ||
| import type { RenderCache, Suspender } from '#application/ports.ts'; | ||
| import type { AnyEffect, Hook, RequirementsOf } from '#domain/protocol.ts'; | ||
| import { Runtime, useExecutor } from '#infrastructure/react/runtime.tsx'; | ||
| /** Brand identifying a React Effect Component. */ | ||
| export const RecTypeId: unique symbol = Symbol.for('@tmonier/effract/Rec'); | ||
| export type RecTypeId = typeof RecTypeId; | ||
| /** A rendered child: an Effect producing a React element, carrying its requirements. */ | ||
| type Rendered<R> = Effect.Effect<ReactElement, never, R>; | ||
| interface RecCore<P, R> { | ||
| readonly [RecTypeId]: true; | ||
| /** Place this REC with props: `yield* Child.with({ ... })`. */ | ||
| with(props: P): Rendered<R>; | ||
| } | ||
| interface RecBareYield<R> { | ||
| /** Place this REC without props: `yield* Child`. */ | ||
| [Symbol.iterator](): Iterator<Rendered<R>, ReactElement>; | ||
| } | ||
| /** | ||
| * A React Effect Component. Yieldable (so `R` propagates), never a JSX element | ||
| * type — `<Rec />` is a compile error by design. Props-free RECs can be yielded | ||
| * directly (`yield* Child`); RECs with props use `yield* Child.with(props)`. | ||
| */ | ||
| export type REC<P, R> = RecCore<P, R> & | ||
| ([Record<never, never>] extends [P] ? RecBareYield<R> : unknown); | ||
| const useSuspender = (): Suspender => ({ use }); | ||
| const useRenderCache = (): RenderCache => { | ||
| const ref = useRef<RenderCache | null>(null); | ||
| if (ref.current === null) { | ||
| ref.current = new Map(); | ||
| } | ||
| return ref.current; | ||
| }; | ||
| const makeRec = <P extends object, R>(fc: FunctionComponent<P>, name: string): REC<P, R> => { | ||
| // The child resolves its own services when React renders it, so this | ||
| // render-effect requires nothing at runtime. The phantom `R` is asserted here | ||
| // — the single intentional assertion — so requirements propagate as a type. | ||
| const rendered = (props: P): Rendered<R> => | ||
| Effect.succeed(createElement(fc, props)) as Effect.Effect<ReactElement, never, R>; | ||
| const rec: RecCore<P, R> & RecBareYield<R> = { | ||
| [RecTypeId]: true, | ||
| with: rendered, | ||
| [Symbol.iterator](): Iterator<Rendered<R>, ReactElement> { | ||
| let yielded = false; | ||
| return { | ||
| next(sent?: unknown): IteratorResult<Rendered<R>, ReactElement> { | ||
| if (yielded) { | ||
| return { done: true, value: sent as ReactElement }; | ||
| } | ||
| yielded = true; | ||
| return { done: false, value: rendered({} as P) }; | ||
| }, | ||
| }; | ||
| }, | ||
| }; | ||
| Object.defineProperty(rec, 'name', { value: name }); | ||
| return rec; | ||
| }; | ||
| /** | ||
| * Define a React Effect Component. The body is a generator that may `yield*` | ||
| * Effect services and effects, `yield* hook(...)` for React hooks, and | ||
| * `yield* Child` / `yield* Child.with(props)` to place other RECs. | ||
| */ | ||
| export function rec<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode>( | ||
| body: () => Generator<Eff, A, never>, | ||
| ): REC<Record<never, never>, RequirementsOf<Eff>>; | ||
| export function rec< | ||
| Eff extends AnyEffect | Hook<unknown>, | ||
| A extends ReactNode, | ||
| Props extends object, | ||
| >(body: (props: Props) => Generator<Eff, A, never>): REC<Props, RequirementsOf<Eff>>; | ||
| export function rec< | ||
| Eff extends AnyEffect | Hook<unknown>, | ||
| A extends ReactNode, | ||
| Props extends object, | ||
| >(body: (props: Props) => Generator<Eff, A, never>): REC<Props, RequirementsOf<Eff>> { | ||
| const fc: FunctionComponent<Props> = (props) => { | ||
| const executor = useExecutor(); | ||
| const suspender = useSuspender(); | ||
| const cache = useRenderCache(); | ||
| return driveRec(body(props), { executor, suspender, cache }); | ||
| }; | ||
| return makeRec<Props, RequirementsOf<Eff>>(fc, body.name || 'EffractComponent'); | ||
| } | ||
| /** | ||
| * The resolve-up-front mode: a pure Effect (no hooks) rendered to a node. | ||
| * Returns a REC, composed the same way (`yield* Banner`). | ||
| */ | ||
| export function view<A extends ReactNode, E, R>( | ||
| render: Effect.Effect<A, E, R>, | ||
| ): REC<Record<never, never>, R>; | ||
| export function view<A extends ReactNode, E, R, Props extends object>( | ||
| render: (props: Props) => Effect.Effect<A, E, R>, | ||
| ): REC<Props, R>; | ||
| export function view<A extends ReactNode, E, R, Props extends object>( | ||
| render: Effect.Effect<A, E, R> | ((props: Props) => Effect.Effect<A, E, R>), | ||
| ): REC<Props, R> { | ||
| const fc: FunctionComponent<Props> = (props) => { | ||
| const executor = useExecutor(); | ||
| const suspender = useSuspender(); | ||
| const cache = useRenderCache(); | ||
| const effect = (typeof render === 'function' ? render(props) : render) as AnyEffect; | ||
| return resolveEffect(effect, { executor, suspender, cache }, { index: 0 }) as ReactNode; | ||
| }; | ||
| return makeRec<Props, R>(fc, 'EffractView'); | ||
| } | ||
| /** A type error naming the services a runtime is missing for a REC's tree. */ | ||
| export type MissingServices<Missing> = readonly ['effract: runtime is missing', Missing]; | ||
| /** | ||
| * A no-service tree's requirement infers as `unknown` (Effect's requirement | ||
| * channel is contravariant, so `never` widens). Normalise that to `never` so a | ||
| * runtime-free tree mounts under any layer. | ||
| */ | ||
| type Effective<R> = [unknown] extends [R] ? never : R; | ||
| /** | ||
| * Mount a root REC under an Effect runtime. This is the boundary between | ||
| * effract and React: it returns an ordinary React node and, at compile time, | ||
| * verifies that `layer` provides every service the REC's tree requires — the | ||
| * check lives on the `rec` argument, so there is no cast on the result. | ||
| * | ||
| * ```tsx | ||
| * createRoot(el).render(mount(AppLive, Dashboard)); | ||
| * ``` | ||
| */ | ||
| export 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 { | ||
| // The render-effect requires nothing at runtime (asserted: `R` is phantom). | ||
| const element = Effect.runSync(rec.with({}) as Rendered<never>); | ||
| return createElement(Runtime<ROut, E>, { layer }, element); | ||
| } |
+50
-40
@@ -1,2 +0,2 @@ | ||
| import { ReactNode } from "react"; | ||
| import { ReactElement, ReactNode } from "react"; | ||
| import * as Effect from "effect/Effect"; | ||
@@ -65,41 +65,55 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; | ||
| //#endregion | ||
| //#region src/infrastructure/react/component.d.ts | ||
| declare const RequirementsId: unique symbol; | ||
| //#region src/infrastructure/react/rec.d.ts | ||
| /** Brand identifying a React Effect Component. */ | ||
| declare const RecTypeId: unique symbol; | ||
| type RecTypeId = typeof RecTypeId; | ||
| /** A rendered child: an Effect producing a React element, carrying its requirements. */ | ||
| type Rendered<R> = Effect.Effect<ReactElement, never, R>; | ||
| interface RecCore<P, R> { | ||
| readonly [RecTypeId]: true; | ||
| /** Place this REC with props: `yield* Child.with({ ... })`. */ | ||
| with(props: P): Rendered<R>; | ||
| } | ||
| interface RecBareYield<R> { | ||
| /** Place this REC without props: `yield* Child`. */ | ||
| [Symbol.iterator](): Iterator<Rendered<R>, ReactElement>; | ||
| } | ||
| /** | ||
| * A React component produced by effract. It renders like any other component; | ||
| * the phantom `R` records which Effect services it needs from its `<Runtime>`, | ||
| * available for type-level introspection and runtime-binding helpers. | ||
| * A React Effect Component. Yieldable (so `R` propagates), never a JSX element | ||
| * type — `<Rec />` is a compile error by design. Props-free RECs can be yielded | ||
| * directly (`yield* Child`); RECs with props use `yield* Child.with(props)`. | ||
| */ | ||
| interface Component<in Props, out R> { | ||
| (props: Props): ReactNode; | ||
| readonly [RequirementsId]?: R; | ||
| } | ||
| type REC<P, R> = RecCore<P, R> & ([Record<never, never>] extends [P] ? RecBareYield<R> : unknown); | ||
| /** | ||
| * Define a React Effect Component. The body is a generator that may `yield*` | ||
| * Effect services and effects, and `yield* hook(...)` for React hooks. | ||
| * | ||
| * ```tsx | ||
| * const Dashboard = component(function* () { | ||
| * const stats = yield* Stats; // Effect service | ||
| * const [tab, setTab] = yield* hook(useState('overview')); // real React hook | ||
| * return <Panel tab={tab} total={stats.total} onTab={setTab} />; | ||
| * }); | ||
| * ``` | ||
| * Effect services and effects, `yield* hook(...)` for React hooks, and | ||
| * `yield* Child` / `yield* Child.with(props)` to place other RECs. | ||
| */ | ||
| declare function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode>(body: () => Generator<Eff, A, never>): Component<Record<never, never>, RequirementsOf<Eff>>; | ||
| declare function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode, Props>(body: (props: Props) => Generator<Eff, A, never>): Component<Props, RequirementsOf<Eff>>; | ||
| declare function rec<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode>(body: () => Generator<Eff, A, never>): REC<Record<never, never>, RequirementsOf<Eff>>; | ||
| 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>>; | ||
| /** | ||
| * Define a resolve-up-front component: a pure Effect (no hooks) rendered to a | ||
| * `ReactNode`. Services resolve synchronously from the runtime; async work | ||
| * suspends through React Suspense. This is the RSC-friendly mode. | ||
| * The resolve-up-front mode: a pure Effect (no hooks) rendered to a node. | ||
| * Returns a REC, composed the same way (`yield* Banner`). | ||
| */ | ||
| declare function view<A extends ReactNode, E, R>(render: Effect.Effect<A, E, R>): REC<Record<never, never>, R>; | ||
| declare function view<A extends ReactNode, E, R, Props extends object>(render: (props: Props) => Effect.Effect<A, E, R>): REC<Props, R>; | ||
| /** A type error naming the services a runtime is missing for a REC's tree. */ | ||
| type MissingServices<Missing> = readonly ['effract: runtime is missing', Missing]; | ||
| /** | ||
| * A no-service tree's requirement infers as `unknown` (Effect's requirement | ||
| * channel is contravariant, so `never` widens). Normalise that to `never` so a | ||
| * runtime-free tree mounts under any layer. | ||
| */ | ||
| type Effective<R> = [unknown] extends [R] ? never : R; | ||
| /** | ||
| * Mount a root REC under an Effect runtime. This is the boundary between | ||
| * effract and React: it returns an ordinary React node and, at compile time, | ||
| * verifies that `layer` provides every service the REC's tree requires — the | ||
| * check lives on the `rec` argument, so there is no cast on the result. | ||
| * | ||
| * ```tsx | ||
| * const Header = view(Effect.gen(function* () { | ||
| * const user = yield* CurrentUser; | ||
| * return <h1>Welcome, {user.name}</h1>; | ||
| * })); | ||
| * createRoot(el).render(mount(AppLive, Dashboard)); | ||
| * ``` | ||
| */ | ||
| declare function view<A extends ReactNode, E, R>(render: Effect.Effect<A, E, R>): Component<Record<never, never>, R>; | ||
| declare function view<A extends ReactNode, E, R, Props>(render: (props: Props) => Effect.Effect<A, E, R>): Component<Props, R>; | ||
| 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; | ||
| //#endregion | ||
@@ -111,12 +125,8 @@ //#region src/infrastructure/react/runtime.d.ts | ||
| readonly layer: Layer.Layer<ROut, E, never>; | ||
| readonly children: ReactNode; | ||
| readonly children?: ReactNode; | ||
| } | ||
| /** | ||
| * Provide an Effect runtime to a React subtree. | ||
| * | ||
| * ```tsx | ||
| * <Runtime layer={AppLive}> | ||
| * <Dashboard /> | ||
| * </Runtime> | ||
| * ``` | ||
| * Provide an Effect runtime to a React subtree. Prefer `mount(layer, Root)`, | ||
| * which wraps the root REC in this provider and checks the tree's services at | ||
| * compile time. Reach for `Runtime` directly only to wrap non-REC React trees. | ||
| */ | ||
@@ -165,3 +175,3 @@ declare function Runtime<ROut, E>({ | ||
| * as a React Server Component. "Server vs client" is an Effect runtime detail, | ||
| * supplied by a `<Runtime>` boundary — not an architectural fork. | ||
| * supplied by `mount(...)` — not an architectural fork. | ||
| * | ||
@@ -172,2 +182,2 @@ * @packageDocumentation | ||
| //#endregion | ||
| 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 }; | ||
| 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 }; |
+77
-38
@@ -1,4 +0,4 @@ | ||
| import { createContext, use, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; | ||
| import { createContext, createElement, use, useCallback, useContext, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Cause from "effect/Cause"; | ||
| import * as Effect from "effect/Effect"; | ||
| import * as Exit from "effect/Exit"; | ||
@@ -107,7 +107,9 @@ import * as ManagedRuntime from "effect/ManagedRuntime"; | ||
| /** | ||
| * The `<Runtime>` boundary. It builds an Effect `ManagedRuntime` once from a | ||
| * `Layer` and hands it down through React context, where every effract | ||
| * component reads it. This is the seam where "server vs client" lives: provide | ||
| * a browser layer and the same components run in a SPA; provide a server layer | ||
| * and they run under Node, Bun, or a Web Worker — the components never change. | ||
| * The runtime provider that `mount` wraps your tree in. It builds an Effect | ||
| * `ManagedRuntime` once from a `Layer` and hands it down through React context, | ||
| * where every effract component reads it. This is the seam where "server vs | ||
| * client" lives: provide a browser layer and the same components run in a SPA; | ||
| * provide a server layer and they run under Node, Bun, or a Web Worker — the | ||
| * components never change. Use `mount(layer, Root)`; `Runtime` is the low-level | ||
| * provider underneath it. | ||
| * | ||
@@ -124,9 +126,5 @@ * Services are resolved up-front into the runtime's context (the RSC-style | ||
| /** | ||
| * Provide an Effect runtime to a React subtree. | ||
| * | ||
| * ```tsx | ||
| * <Runtime layer={AppLive}> | ||
| * <Dashboard /> | ||
| * </Runtime> | ||
| * ``` | ||
| * Provide an Effect runtime to a React subtree. Prefer `mount(layer, Root)`, | ||
| * which wraps the root REC in this provider and checks the tree's services at | ||
| * compile time. Reach for `Runtime` directly only to wrap non-REC React trees. | ||
| */ | ||
@@ -149,3 +147,3 @@ function Runtime({ layer, children }) { | ||
| const value = useContext(RuntimeContext); | ||
| if (value === null) throw new Error("effract: no <Runtime> found above this component. Wrap your tree in <Runtime layer={...}>."); | ||
| if (value === null) throw new Error("effract: no runtime found above this component. Mount your root with mount(layer, Root)."); | ||
| return value; | ||
@@ -158,27 +156,56 @@ }; | ||
| //#endregion | ||
| //#region src/infrastructure/react/component.tsx | ||
| //#region src/infrastructure/react/rec.tsx | ||
| /** | ||
| * The two ways to write a component as an Effect program. | ||
| * React Effect Components (RECs) and the boundary that mounts them. | ||
| * | ||
| * component(function* () { ... }) the headline: a *React Effect Component* | ||
| * whose body yields both Effect services | ||
| * and React hooks, interpreted inside the | ||
| * render pass. | ||
| * A REC is the unit of composition in effract. Crucially it is **not** a React | ||
| * element type — `<Dashboard />` is a compile error. You compose RECs the way | ||
| * you compose Effects: with `yield*`. That is what lets a component's Effect | ||
| * requirements (`R`) bubble up the tree to the one place that knows the runtime, | ||
| * the `mount` boundary, where they are verified at compile time. | ||
| * | ||
| * view(Effect | (props) => Effect) the simpler resolve-up-front mode: a pure | ||
| * Effect with no hooks, ideal for server / | ||
| * RSC rendering. | ||
| * const Dashboard = rec(function* () { | ||
| * const stats = yield* Stats; // a service | ||
| * const [n, setN] = yield* hook(useState(0)); // a real React hook | ||
| * return <main>{yield* StatBadge}{n}</main>; // a child REC, yielded | ||
| * }); | ||
| * | ||
| * Both produce a genuine React function component. There is no custom | ||
| * reconciler — `<Dashboard />` is a real element React renders, suspends, and | ||
| * reconciles like any other. | ||
| * mount(AppLive, Dashboard); // ← compile error if AppLive lacks a needed service | ||
| * | ||
| * At runtime a REC still renders as an ordinary React component (own fiber, | ||
| * hooks, reconciliation); the yield is only how it is placed and how `R` flows. | ||
| */ | ||
| /** Brand identifying a React Effect Component. */ | ||
| const RecTypeId = Symbol.for("@tmonier/effract/Rec"); | ||
| const useSuspender = () => ({ use }); | ||
| const useRenderCache = () => { | ||
| const ref = useRef(void 0); | ||
| if (ref.current === void 0) ref.current = /* @__PURE__ */ new Map(); | ||
| const ref = useRef(null); | ||
| if (ref.current === null) ref.current = /* @__PURE__ */ new Map(); | ||
| return ref.current; | ||
| }; | ||
| function component(body) { | ||
| const Rec = (props) => { | ||
| const makeRec = (fc, name) => { | ||
| const rendered = (props) => Effect.succeed(createElement(fc, props)); | ||
| const rec = { | ||
| [RecTypeId]: true, | ||
| with: rendered, | ||
| [Symbol.iterator]() { | ||
| let yielded = false; | ||
| return { next(sent) { | ||
| if (yielded) return { | ||
| done: true, | ||
| value: sent | ||
| }; | ||
| yielded = true; | ||
| return { | ||
| done: false, | ||
| value: rendered({}) | ||
| }; | ||
| } }; | ||
| } | ||
| }; | ||
| Object.defineProperty(rec, "name", { value: name }); | ||
| return rec; | ||
| }; | ||
| function rec(body) { | ||
| const fc = (props) => { | ||
| const executor = useExecutor(); | ||
@@ -193,7 +220,6 @@ const suspender = useSuspender(); | ||
| }; | ||
| Rec.displayName = body.name || "EffractComponent"; | ||
| return Rec; | ||
| return makeRec(fc, body.name || "EffractComponent"); | ||
| } | ||
| function view(render) { | ||
| const View = (props) => { | ||
| const fc = (props) => { | ||
| const executor = useExecutor(); | ||
@@ -208,5 +234,18 @@ const suspender = useSuspender(); | ||
| }; | ||
| View.displayName = "EffractView"; | ||
| return View; | ||
| return makeRec(fc, "EffractView"); | ||
| } | ||
| /** | ||
| * Mount a root REC under an Effect runtime. This is the boundary between | ||
| * effract and React: it returns an ordinary React node and, at compile time, | ||
| * verifies that `layer` provides every service the REC's tree requires — the | ||
| * check lives on the `rec` argument, so there is no cast on the result. | ||
| * | ||
| * ```tsx | ||
| * createRoot(el).render(mount(AppLive, Dashboard)); | ||
| * ``` | ||
| */ | ||
| function mount(layer, rec) { | ||
| const element = Effect.runSync(rec.with({})); | ||
| return createElement(Runtime, { layer }, element); | ||
| } | ||
| //#endregion | ||
@@ -317,3 +356,3 @@ //#region src/infrastructure/react/reactivity.tsx | ||
| * as a React Server Component. "Server vs client" is an Effect runtime detail, | ||
| * supplied by a `<Runtime>` boundary — not an architectural fork. | ||
| * supplied by `mount(...)` — not an architectural fork. | ||
| * | ||
@@ -324,2 +363,2 @@ * @packageDocumentation | ||
| //#endregion | ||
| export { HookTypeId, Observe, Runtime, VERSION, atom, component, hook, isHook, observe, useAtom, useAtomSet, useAtomValue, useEffractRuntime, view }; | ||
| export { HookTypeId, Observe, RecTypeId, Runtime, VERSION, atom, hook, isHook, mount, observe, rec, useAtom, useAtomSet, useAtomValue, useEffractRuntime, view }; |
+2
-2
| { | ||
| "name": "@tmonier/effract", | ||
| "version": "0.1.0", | ||
| "version": "0.2.0", | ||
| "description": "React components written as Effect programs. yield* services and React hooks in one render pass; run the same component anywhere.", | ||
@@ -15,3 +15,3 @@ "keywords": [ | ||
| ], | ||
| "homepage": "https://github.com/get-tmonier/effract#readme", | ||
| "homepage": "https://effract.tmonier.com", | ||
| "license": "MIT", | ||
@@ -18,0 +18,0 @@ "repository": { |
+17
-9
@@ -6,7 +6,12 @@ # @tmonier/effract | ||
| **Docs & guide → [effract.tmonier.com](https://effract.tmonier.com)** | ||
| ```tsx | ||
| import { Runtime, component, hook } from '@tmonier/effract'; | ||
| import { mount, rec, hook } from '@tmonier/effract'; | ||
| import { createRoot } from 'react-dom/client'; | ||
| import { useState } from 'react'; | ||
| const Dashboard = component(function* () { | ||
| // Panel is a PLAIN React component — an ordinary function, used as JSX, left untouched | ||
| // Dashboard is a REC — it reaches for a service, so it's written with `rec(...)` | ||
| const Dashboard = rec(function* () { | ||
| const stats = yield* Stats; // an Effect service | ||
@@ -17,12 +22,15 @@ const [tab, setTab] = yield* hook(useState('overview')); // a real React hook | ||
| export const App = () => ( | ||
| <Runtime layer={AppLive}> | ||
| <Dashboard /> | ||
| </Runtime> | ||
| ); | ||
| // wire the runtime in once at the boundary — `mount` returns a ReactNode | ||
| createRoot(document.getElementById('root')!).render(mount(AppLive, Dashboard)); | ||
| ``` | ||
| - `component` / `view` — hook-capable and resolve-up-front components. | ||
| effract is **incremental, not a rewrite.** Plain React components stay exactly as they are (ordinary | ||
| `<Component />` JSX). You write a REC with `rec(...)` _only_ where a component reaches for the runtime, | ||
| and place one by `yield*`-ing it: `{yield* Dashboard}`, or `{yield* Dashboard.with({ ... })}` with props. | ||
| - `component` / `view` — hook-capable and resolve-up-front RECs. A REC is **not** a JSX element; place it | ||
| with `{yield* Rec}` inside another component's JSX. | ||
| - `hook` — lift a React hook into the `yield*` channel. | ||
| - `<Runtime layer={...}>` — provide an Effect runtime to a subtree. | ||
| - `mount(layer, RootRec)` — build the Effect runtime once and return a `ReactNode` to render. Verifies at | ||
| compile time that the layer provides every service the tree needs. | ||
| - `atom`, `observe`, `<Observe>`, `useAtom` — the signals bridge. | ||
@@ -29,0 +37,0 @@ |
@@ -12,3 +12,3 @@ /** | ||
| * Something that can run an Effect. Backed in production by a `ManagedRuntime` | ||
| * built once at the `<Runtime>` boundary, which already carries the resolved | ||
| * built once at the `mount` boundary, which already carries the resolved | ||
| * service environment — so `runSyncExit` resolves services synchronously and | ||
@@ -15,0 +15,0 @@ * only genuinely asynchronous work falls through to `runPromise`. |
+4
-4
@@ -6,3 +6,3 @@ /** | ||
| * as a React Server Component. "Server vs client" is an Effect runtime detail, | ||
| * supplied by a `<Runtime>` boundary — not an architectural fork. | ||
| * supplied by `mount(...)` — not an architectural fork. | ||
| * | ||
@@ -27,6 +27,6 @@ * @packageDocumentation | ||
| // --- components --- | ||
| export { component, view } from '#infrastructure/react/component.tsx'; | ||
| export type { Component } from '#infrastructure/react/component.tsx'; | ||
| export { rec, view, mount, RecTypeId } from '#infrastructure/react/rec.tsx'; | ||
| export type { REC, MissingServices } from '#infrastructure/react/rec.tsx'; | ||
| // --- the runtime boundary --- | ||
| // --- the runtime boundary (mount is canonical; Runtime is the low-level provider) --- | ||
| export { Runtime, useEffractRuntime } from '#infrastructure/react/runtime.tsx'; | ||
@@ -33,0 +33,0 @@ export type { RuntimeProps } from '#infrastructure/react/runtime.tsx'; |
| 'use client'; | ||
| /** | ||
| * The `<Runtime>` boundary. It builds an Effect `ManagedRuntime` once from a | ||
| * `Layer` and hands it down through React context, where every effract | ||
| * component reads it. This is the seam where "server vs client" lives: provide | ||
| * a browser layer and the same components run in a SPA; provide a server layer | ||
| * and they run under Node, Bun, or a Web Worker — the components never change. | ||
| * The runtime provider that `mount` wraps your tree in. It builds an Effect | ||
| * `ManagedRuntime` once from a `Layer` and hands it down through React context, | ||
| * where every effract component reads it. This is the seam where "server vs | ||
| * client" lives: provide a browser layer and the same components run in a SPA; | ||
| * provide a server layer and they run under Node, Bun, or a Web Worker — the | ||
| * components never change. Use `mount(layer, Root)`; `Runtime` is the low-level | ||
| * provider underneath it. | ||
| * | ||
@@ -36,13 +38,9 @@ * Services are resolved up-front into the runtime's context (the RSC-style | ||
| readonly layer: Layer.Layer<ROut, E, never>; | ||
| readonly children: ReactNode; | ||
| readonly children?: ReactNode; | ||
| } | ||
| /** | ||
| * Provide an Effect runtime to a React subtree. | ||
| * | ||
| * ```tsx | ||
| * <Runtime layer={AppLive}> | ||
| * <Dashboard /> | ||
| * </Runtime> | ||
| * ``` | ||
| * Provide an Effect runtime to a React subtree. Prefer `mount(layer, Root)`, | ||
| * which wraps the root REC in this provider and checks the tree's services at | ||
| * compile time. Reach for `Runtime` directly only to wrap non-REC React trees. | ||
| */ | ||
@@ -72,3 +70,3 @@ export function Runtime<ROut, E>({ layer, children }: RuntimeProps<ROut, E>): ReactNode { | ||
| throw new Error( | ||
| 'effract: no <Runtime> found above this component. Wrap your tree in <Runtime layer={...}>.', | ||
| 'effract: no runtime found above this component. Mount your root with mount(layer, Root).', | ||
| ); | ||
@@ -75,0 +73,0 @@ } |
| /** | ||
| * The proof that effract is 100% real React: a React Effect Component is an | ||
| * ordinary component that React renders, holds hook state for, suspends, and | ||
| * re-renders — with Effect services resolved in the same pass. | ||
| */ | ||
| import { Suspense, act, useState } from 'react'; | ||
| import { createRoot } from 'react-dom/client'; | ||
| import { renderToStaticMarkup } from 'react-dom/server'; | ||
| import { afterEach, beforeEach, describe, expect, it } from 'vitest'; | ||
| import * as Context from 'effect/Context'; | ||
| import * as Effect from 'effect/Effect'; | ||
| import * as Layer from 'effect/Layer'; | ||
| import { Runtime, component, hook } from '../../index.ts'; | ||
| Reflect.set(globalThis, 'IS_REACT_ACT_ENVIRONMENT', true); | ||
| class Stats extends Context.Service<Stats, { readonly total: number }>()('test/Stats') {} | ||
| const statsLayer = (total: number): Layer.Layer<never, never, never> => | ||
| Layer.succeed(Stats)({ total }); | ||
| let container: HTMLDivElement; | ||
| beforeEach(() => { | ||
| container = document.createElement('div'); | ||
| document.body.appendChild(container); | ||
| }); | ||
| afterEach(() => { | ||
| container.remove(); | ||
| }); | ||
| describe('component (React Effect Component)', () => { | ||
| it('renders a service + hook REC to static markup in one pass', () => { | ||
| const Dashboard = component(function* () { | ||
| const stats = yield* Stats; | ||
| const [tab] = yield* hook(useState('overview')); | ||
| return ( | ||
| <div> | ||
| {tab}:{stats.total} | ||
| </div> | ||
| ); | ||
| }); | ||
| const html = renderToStaticMarkup( | ||
| <Runtime layer={statsLayer(42)}> | ||
| <Dashboard /> | ||
| </Runtime>, | ||
| ); | ||
| expect(html).toContain('overview'); | ||
| expect(html).toContain('42'); | ||
| }); | ||
| it('holds genuine hook state across re-renders while re-resolving services', async () => { | ||
| const Counter = component(function* () { | ||
| const stats = yield* Stats; | ||
| const [n, setN] = yield* hook(useState(0)); | ||
| return ( | ||
| <button type="button" onClick={() => setN(n + 1)}> | ||
| {n}/{stats.total} | ||
| </button> | ||
| ); | ||
| }); | ||
| const root = createRoot(container); | ||
| await act(async () => { | ||
| root.render( | ||
| <Runtime layer={statsLayer(42)}> | ||
| <Counter /> | ||
| </Runtime>, | ||
| ); | ||
| }); | ||
| expect(container.textContent).toBe('0/42'); | ||
| // Clicking drives a real useState update: the generator re-runs, the hook | ||
| // remembers its state, and the Stats service resolves again in the new pass. | ||
| const button = container.querySelector('button'); | ||
| await act(async () => { | ||
| button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); | ||
| }); | ||
| expect(container.textContent).toBe('1/42'); | ||
| await act(async () => { | ||
| root.unmount(); | ||
| }); | ||
| }); | ||
| it('suspends an async effect through React Suspense, then resolves it', async () => { | ||
| let release: (value: number) => void = () => {}; | ||
| const gate = new Promise<number>((resolve) => { | ||
| release = resolve; | ||
| }); | ||
| const AsyncPanel = component(function* () { | ||
| const value = yield* Effect.promise(() => gate); | ||
| return <span>val:{value}</span>; | ||
| }); | ||
| const root = createRoot(container); | ||
| await act(async () => { | ||
| root.render( | ||
| <Runtime layer={Layer.empty}> | ||
| <Suspense fallback={<i>loading</i>}> | ||
| <AsyncPanel /> | ||
| </Suspense> | ||
| </Runtime>, | ||
| ); | ||
| }); | ||
| expect(container.textContent).toContain('loading'); | ||
| await act(async () => { | ||
| release(7); | ||
| await gate; | ||
| }); | ||
| expect(container.textContent).toContain('val:7'); | ||
| await act(async () => { | ||
| root.unmount(); | ||
| }); | ||
| }); | ||
| it('runs the SAME component under two runtimes — server vs client is a runtime detail', () => { | ||
| const Total = component(function* () { | ||
| const stats = yield* Stats; | ||
| return <output>{stats.total}</output>; | ||
| }); | ||
| const onServer = renderToStaticMarkup( | ||
| <Runtime layer={statsLayer(100)}> | ||
| <Total /> | ||
| </Runtime>, | ||
| ); | ||
| const onClient = renderToStaticMarkup( | ||
| <Runtime layer={statsLayer(1)}> | ||
| <Total /> | ||
| </Runtime>, | ||
| ); | ||
| expect(onServer).toContain('100'); | ||
| expect(onClient).toContain('1'); | ||
| expect(onServer).not.toBe(onClient); | ||
| }); | ||
| }); |
| 'use client'; | ||
| /** | ||
| * The two ways to write a component as an Effect program. | ||
| * | ||
| * component(function* () { ... }) the headline: a *React Effect Component* | ||
| * whose body yields both Effect services | ||
| * and React hooks, interpreted inside the | ||
| * render pass. | ||
| * | ||
| * view(Effect | (props) => Effect) the simpler resolve-up-front mode: a pure | ||
| * Effect with no hooks, ideal for server / | ||
| * RSC rendering. | ||
| * | ||
| * Both produce a genuine React function component. There is no custom | ||
| * reconciler — `<Dashboard />` is a real element React renders, suspends, and | ||
| * reconciles like any other. | ||
| */ | ||
| import { use, useRef, type ReactNode } from 'react'; | ||
| import type * as Effect from 'effect/Effect'; | ||
| import { driveRec, resolveEffect } from '#application/interpreter.ts'; | ||
| import type { RenderCache, Suspender } from '#application/ports.ts'; | ||
| import type { AnyEffect, Hook, RequirementsOf } from '#domain/protocol.ts'; | ||
| import { useExecutor } from '#infrastructure/react/runtime.tsx'; | ||
| declare const RequirementsId: unique symbol; | ||
| /** | ||
| * A React component produced by effract. It renders like any other component; | ||
| * the phantom `R` records which Effect services it needs from its `<Runtime>`, | ||
| * available for type-level introspection and runtime-binding helpers. | ||
| */ | ||
| export interface Component<in Props, out R> { | ||
| (props: Props): ReactNode; | ||
| readonly [RequirementsId]?: R; | ||
| } | ||
| const useSuspender = (): Suspender => ({ use }); | ||
| const useRenderCache = (): RenderCache => { | ||
| const ref = useRef<RenderCache>(undefined as unknown as RenderCache); | ||
| if (ref.current === undefined) { | ||
| ref.current = new Map(); | ||
| } | ||
| return ref.current; | ||
| }; | ||
| /** | ||
| * Define a React Effect Component. The body is a generator that may `yield*` | ||
| * Effect services and effects, and `yield* hook(...)` for React hooks. | ||
| * | ||
| * ```tsx | ||
| * const Dashboard = component(function* () { | ||
| * const stats = yield* Stats; // Effect service | ||
| * const [tab, setTab] = yield* hook(useState('overview')); // real React hook | ||
| * return <Panel tab={tab} total={stats.total} onTab={setTab} />; | ||
| * }); | ||
| * ``` | ||
| */ | ||
| export function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode>( | ||
| body: () => Generator<Eff, A, never>, | ||
| ): Component<Record<never, never>, RequirementsOf<Eff>>; | ||
| export function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode, Props>( | ||
| body: (props: Props) => Generator<Eff, A, never>, | ||
| ): Component<Props, RequirementsOf<Eff>>; | ||
| export function component<Eff extends AnyEffect | Hook<unknown>, A extends ReactNode, Props>( | ||
| body: (props: Props) => Generator<Eff, A, never>, | ||
| ): Component<Props, RequirementsOf<Eff>> { | ||
| const Rec = (props: Props): ReactNode => { | ||
| const executor = useExecutor(); | ||
| const suspender = useSuspender(); | ||
| const cache = useRenderCache(); | ||
| return driveRec(body(props), { executor, suspender, cache }); | ||
| }; | ||
| Rec.displayName = body.name || 'EffractComponent'; | ||
| return Rec as Component<Props, RequirementsOf<Eff>>; | ||
| } | ||
| /** | ||
| * Define a resolve-up-front component: a pure Effect (no hooks) rendered to a | ||
| * `ReactNode`. Services resolve synchronously from the runtime; async work | ||
| * suspends through React Suspense. This is the RSC-friendly mode. | ||
| * | ||
| * ```tsx | ||
| * const Header = view(Effect.gen(function* () { | ||
| * const user = yield* CurrentUser; | ||
| * return <h1>Welcome, {user.name}</h1>; | ||
| * })); | ||
| * ``` | ||
| */ | ||
| export function view<A extends ReactNode, E, R>( | ||
| render: Effect.Effect<A, E, R>, | ||
| ): Component<Record<never, never>, R>; | ||
| export function view<A extends ReactNode, E, R, Props>( | ||
| render: (props: Props) => Effect.Effect<A, E, R>, | ||
| ): Component<Props, R>; | ||
| export function view<A extends ReactNode, E, R, Props>( | ||
| render: Effect.Effect<A, E, R> | ((props: Props) => Effect.Effect<A, E, R>), | ||
| ): Component<Props, R> { | ||
| const View = (props: Props): ReactNode => { | ||
| const executor = useExecutor(); | ||
| const suspender = useSuspender(); | ||
| const cache = useRenderCache(); | ||
| const effect = (typeof render === 'function' ? render(props) : render) as AnyEffect; | ||
| return resolveEffect(effect, { executor, suspender, cache }, { index: 0 }) as ReactNode; | ||
| }; | ||
| View.displayName = 'EffractView'; | ||
| return View as Component<Props, R>; | ||
| } |
No website
QualityPackage does not have a website.
No website
QualityPackage does not have a website.
64287
13.51%1328
9.48%40
25%