@doeixd/affe is a reactive UI library built on two complementary foundations: a signal-based reactive graph (Solid.js-compatible) and Effect's typed async/error model. The central thesis is that reactive state and typed effects are better together — atoms give you fine-grained reactivity with zero boilerplate, and Effect gives you a principled, composable way to handle everything async.
Affe convergence work is tracked against the canonical contract in AF_UI_CONTRACT.md. Use that document for the target architecture when implementation details and older exploratory docs differ.
For authored slot-based components, use the slot-contract path in
SLOT_CONTRACT_GOLDEN_PATH.md.
Mental model: atoms are the reactive layer; Effect is the async layer. When they meet — in async atoms, actions, and components — Effect's types flow through unmodified.
Atom— The reactive state unit. Callable for reads (atom()), writable variants exposeset/update/modify. Atoms track their own dependencies; derived atoms recompute lazily when upstreams change.Derived atom— Read-only atom computed from other atoms. Created withAtom.make((get) => ...)orAtom.derived(...). Results are cached until dependencies change.QueryRef— Async read handle fromdefineQuery. Bundlesresult,pending,latest,effect, and invalidation APIs into one ergonomic object.Mutation handle— Async write handle fromAtom.optimistic(...).action(...),Atom.runtime(...).action(...), or callback-styledefineMutation(...). Exposesrun,effect,runEffect,result, andpending; optimistic handles also exposevalue,committed,optimistic,hasOptimistic,rollback, andclear.Action handle— Runtime-bound mutation handle fromAtom.action/Atom.runtime(...).action. The preferred way to express mutations when you have Effect-native code.Result— The seven-state async type (Idle,Loading,Refreshing,Success,Failure,Stale,Defect). Distinguishing initial load, revalidation, failed refresh with data, typed failures, and defects makes UI states explicit rather than derived.Effect(from theeffectpackage) — A typed programEffect<A, E, R>. The.effect(...)methods on query/mutation handles convert reactive state into composable Effect values.BridgeError— Tagged errors emitted when you compose a reactive atom into an Effect pipeline and the atom is stillLoading(ResultLoadingError) or has aDefect(ResultDefectError). Makes the gap between reactive state and Effect's error channel explicit.MutationSupersededError— Emitted when a newer mutation run interrupts an earlier one. Lets Effect pipelines react to cancellation rather than silently dropping results.AtomRef— Object/collection-centric reactive refs with property-level access. Use when you want per-property subscriptions on a shared object without splitting it into many atoms.Optimistic action— Authored optimistic lifecycle fromAtom.optimistic(source).action(...),Atom.runtime(layer).optimistic(source).action(...), orComponent.optimistic(source).action(...). Lets UI read one visible value while preserving committed state, rollback, typedResult, pending, and reconciliation.Store— There is no separate top-level store. UseAtomReffor object/draft-style state orAtom.projection(...)for computed mutable views.
Effect programs carry three type parameters:
A— success valueE— typed error channel (only expected errors belong here)R— required services/context (dependency injection at the type level)
This library preserves all three axes as async state flows from Effect programs into atoms and back. Nothing is silently discarded.
// Effect<User[], HttpError, Api> — three type params preserved
const usersEffect = Effect.gen(function* () {
const api = yield* Api;
return yield* api.listUsers();
});
const rt = Atom.runtime(ApiLive);
const users = rt.atom(usersEffect);
// users() → Result<User[], HttpError> — A and E preserved in Result
// users.effect() → Effect<User[], HttpError | BridgeError> — composable in Effect pipelinesWhy Atom.runtime(layer)? Requirements (R) must be satisfied before an atom can read. The runtime takes a Layer once, satisfies all Rs at creation time, and all atoms/actions created from it share that bound context. This keeps atom definitions portable — you define the effect with R, and bind the layer at the callsite.
const rt = Atom.runtime(ApiLive);
rt.atom(effect); // RReq extends R — type-checked at creation
rt.action(fn); // same requirement safetyWritable vs read-only:
- Writable:
Atom.make(value),Atom.value(value) - Read-only derived:
Atom.make((get) => ...),Atom.derived((get) => ...)
Convenience type aliases:
Atom.ReadonlyAtom<A>(alias ofAtom.Atom<A>)Atom.WritableAtom<A, W = A>(alias ofAtom.Writable<A, W>)Atom.ResultAtom<A, E, R = never>(Atom<Result<A, E>, E, R>)Atom.AsyncAtom<A, E, R = never>compatibility alias forResultAtom
This library is built on Effect's dependency injection system. Services are typed capabilities; layers are how you build and provide them. Understanding how they fit together is the key to wiring up a real application.
A service is a typed interface declared as a Context.Service key. An Effect that requires a service declares it in its R type parameter. A layer (Layer<ROut, E, RIn>) is a recipe that constructs services — it can itself require other services (RIn) and it produces one or more services (ROut).
Layer<ApiService, never, HttpClient>
────────── ───── ──────────
provides error requires
mount(fn, container, layer) and Atom.runtime(layer) are the two places where you hand a composed layer to the framework and satisfy all requirements at once. Everything below that callsite can yield* MyService freely.
These are the services the library owns and provides built-in layers for.
Key-based invalidation and subscription. Used internally by single-flight to decide which loaders to revalidate after a mutation, and exposed via Atom.withReactivity(...) for application-level cache invalidation across module boundaries.
What it provides: invalidate(keys), subscribe(keys, onInvalidate), flush(), lastInvalidated() (test only)
Available layers:
| Layer | Description |
|---|---|
Reactivity.live |
Auto-flushing via microtask scheduler. Use in production. |
Reactivity.test |
Manual flush with lastInvalidated capture. Use in tests. |
// Production
mount(App, document.body, Layer.merge(ApiLive, Reactivity.live));
// Test — flush and inspect what was invalidated
mount(App, document.body, Layer.merge(ApiLive, Reactivity.test));When ReactivityService is present in the layer passed to mount(), it is automatically installed as the global reactivity backend. Single-flight and Atom.withReactivity will use it without any further wiring. Providing it any other way (Effect.provide, Atom.runtime) does not install it. See reactivity.md.
URL state and navigation. Provides a reactive url atom and imperative navigation methods. The router layer is environment-specific — you pick the right one for your deployment context.
What it provides: url atom (ReadonlyAtom<URL>), navigate(to), back(), forward(), preload?(to). preload(to) warms matched route loaders without changing the current URL.
Available layers:
| Layer | Description |
|---|---|
Route.Router.Browser |
Wraps the browser History API. Listens to popstate. Use in client-rendered apps. |
Route.Router.browser({ base }) |
The browser router for an app served under a sub-path (base: "/docs"); routes and url() stay app-relative. |
Route.Router.Hash |
Hash-based routing (#/path). Listens to hashchange. Use when you can't control server routing. |
Route.Router.Server({ url }) |
Static URL from an incoming request. Use during SSR (Route.renderRequest provides it for you). |
Route.Router.Memory(initial?) |
In-memory history stack. Use in tests and Node environments. |
// Browser app
const AppLayer = Layer.mergeAll(ApiLive, Reactivity.live, Route.Router.Browser);
// SSR handler
const ssrLayer = (req: Request) =>
Layer.mergeAll(ApiLive, Reactivity.live, Route.Router.Server({ url: req.url }));
// Tests
const testLayer = Layer.mergeAll(
ApiLive,
Reactivity.test,
Route.Router.Memory("/users/1"),
);Per-route context: params, query string, hash, matched flag, and loader data atoms. This service is provided automatically by the router internals when a component mounts inside a matched route. You don't construct it directly — you read from it via Route.params, Route.query, Route.hash, Route.loaderData, and Route.loaderResult.
What it provides: params, query, hash, prefix, matched, loaderData, loaderResult — all as readonly atoms.
Transport contract for mutation single-flight. When present, Atom.action(...) handles with singleFlight options will route mutation requests through this transport instead of executing the local Effect. This enables server-side mutation execution with client-side cache seeding.
What it provides: execute(request, options) — sends a mutation request envelope and receives a payload response.
Available layers:
| Layer | Description |
|---|---|
Route.FetchSingleFlightTransport(options?) |
Default HTTP fetch-based transport. Sends requests to the configured endpoint. |
const AppLayer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Browser,
Route.FetchSingleFlightTransport({ endpoint: "/_sf" }),
);When SingleFlightTransportService is present in the layer passed to mount(), it is automatically wired into action handles that declare singleFlight options — no manual plumbing needed.
These three services form a cohesive group and are always provided together via RouterRuntime.toLayer(runtime, history). They exist as separate tags so individual pieces of the router can declare narrower requirements.
| Tag | Provides |
|---|---|
RouterRuntime.HistoryTag |
location(), push(to), replace(to), go(delta) |
RouterRuntime.NavigationTag |
navigate(...), submit(...), fetch(...), revalidate(...), cancel(...) |
RouterRuntime.RouterRuntimeTag |
Full runtime instance: snapshot(), subscribe(), initialize(), and all navigation/dispatch methods |
const runtime = RouterRuntime.create(routes);
const layer = RouterRuntime.toLayer(runtime, historyAdapter);
// layer provides all three tagsDesign tokens and theme mode. Optional — only required if you use Style.tokenColor, Style.tokenSpacing, or Style.tokenFontSize. Without it, token lookups fall back to CSS custom property names.
What it provides: tokens, mode atom ("light" | "dark"), resolve(token).
Available layers:
| Layer | Description |
|---|---|
Theme.ThemeLight |
Default light-mode tokens. |
Theme.layer(tokens, { mode? }) |
Layer from a user-declared token object. |
const AppLayer = Layer.mergeAll(ApiLive, Reactivity.live, Theme.ThemeLight);Custom token schemas use the same typed path machinery:
const appTheme = Theme.define({
color: { brand: { tertiary: "#f0f" } },
spacing: { page: { gutter: 24 } },
});
appTheme.path("color", "brand.tertiary"); // typed
const ThemeLive = appTheme.layer({ mode: "dark" });Provided automatically by Route.renderRequest(...) and ServerRoute.dispatch(...) during SSR execution. You read from them inside server-side component setup and server route handlers via the convenience helpers:
// Inside server component setup or server route handler:
const url = yield * Route.serverUrl; // from ServerRequestTag
const req = yield * Route.serverRequest; // from ServerRequestTag
yield * Route.setStatus(404); // writes to ServerResponseTag
yield * Route.setHeader("Cache-Control", "no-store");
yield * Route.serverRedirect("/login");These tags are never part of your app's mount() layer — they are scoped to a single request/response cycle.
mount(fn, container, layer) does several things beyond just providing services to useService():
- Creates a
ManagedRuntimefrom the layer — this is whatuseService(tag)reads from. - If
ReactivityServiceis in the layer, installs it as the global reactivity backend. - If
SingleFlightTransportServiceis in the layer, installs it for action handle dispatch. - Wraps the tree in a reactive ownership scope — when
mountis disposed, all child effects and subscriptions clean up. - Returns a dispose function.
const dispose = mount(
() => <App />,
document.getElementById("root")!,
Layer.mergeAll(ApiLive, Reactivity.live, Route.Router.Browser),
);
// Later:
dispose(); // shuts down runtime, cleans up event listeners, cancels fiberscreateMount(layer) pre-binds the layer so you can call mount(fn, container) without repeating the layer:
const mount = createMount(AppLayer);
mount(() => <App />, document.getElementById("root")!);Atom.runtime(layer) is the atom-side equivalent. Use it when async atoms or actions need services but the atoms are defined outside a component (e.g., at module level).
// Module-level: declare requirements, bind layer once
const rt = Atom.runtime(ApiLive);
export const currentUser = rt.atom(
Effect.service(UserApi).pipe(Effect.flatMap((api) => api.me())),
);
export const saveProfile = rt.action((input: ProfileInput) =>
Effect.service(UserApi).pipe(Effect.flatMap((api) => api.save(input))),
);Global layers let you inject cross-cutting services (logging, tracing, feature flags) into every runtime without changing each callsite:
// In your app bootstrap — runs before any runtime is created
Atom.runtime.addGlobalLayer(LoggingLive);
Atom.runtime.addGlobalLayer(TracingLive);
// All subsequent Atom.runtime(layer) calls automatically merge these in
const rt = Atom.runtime(ApiLive);
// rt now has ApiLive + LoggingLive + TracingLiveThis is the right place for observability infrastructure that spans multiple feature modules.
Component.require(...tags) declares which services the component's setup Effect needs. This propagates into the component's Requirements<T> type, which bubbles up to the parent that mounts or wraps the component.
const UserCard = Component.make(
Component.props<{ id: string }>(),
Component.require(UserApi, AnalyticsService), // ← declares requirements
Component.setup<{ id: string }>()
.bind("user", ({ props }) =>
Component.query(
() => UserApi.pipe(Effect.flatMap((api) => api.getUser(props.id))),
{ name: "user" },
)
)
.doEffect(({ props }) =>
AnalyticsService.pipe(
Effect.flatMap((analytics) =>
analytics.track("user:view", { id: props.id })
),
)
),
(_, { user }) => <Async result={user()} success={(u) => <div>{u.name}</div>} />,
);Component.withLayer(layer) satisfies requirements at the component level — useful for isolating service implementations to a subtree:
// Provide a mock API to just this component tree
const TestableUserCard = UserCard.pipe(Component.withLayer(MockUserApiLive));If the parent already provides the required services (via mount() or a parent Component.withLayer), no additional wiring is needed.
Standalone (no requirements):
Reactivity.live / .test
Theme.ThemeLight
Route.Router.Browser / .Hash / .Server / .Memory
Route.FetchSingleFlightTransport
Router runtime group (composed via RouterRuntime.toLayer):
RouterRuntime.RouterRuntimeTag
RouterRuntime.HistoryTag ─┐
RouterRuntime.NavigationTag ├─ all from toLayer()
RouterRuntime.RouterRuntimeTag ─┘
Request-scoped (provided per-request by render/dispatch, not by mount):
Route.ServerRequestTag
Route.ServerResponseTag
Your services (you define requirements):
ApiLive → may require HttpClient, Config, etc.
HttpClient.live → standalone (or requires Config)
Minimal browser app:
const AppLayer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Browser,
);
mount(() => <App />, document.getElementById("root")!, AppLayer);With single-flight mutations:
const AppLayer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Browser,
Route.FetchSingleFlightTransport({ endpoint: "/_sf" }),
);With theme:
const AppLayer = Layer.mergeAll(
ApiLive,
Reactivity.live,
Route.Router.Browser,
Theme.ThemeLight,
);Test setup (no browser APIs, manual flush):
const TestLayer = Layer.mergeAll(
MockApiLive,
Reactivity.test,
Route.Router.Memory("/"),
);
const harness = withTestLayer(TestLayer); // from "@doeixd/affe/testing"SSR per-request layer:
// renderRequest provides the server router for the request's URL itself.
const handleRequest = (request: Request) =>
Effect.runPromise(Route.renderRequest(app, {
request,
layer: Layer.mergeAll(ApiLive, Reactivity.live),
}));Global observability (applied to all atom runtimes):
// app/bootstrap.ts — runs once at startup
Atom.runtime.addGlobalLayer(OtelTracingLive);
Atom.runtime.addGlobalLayer(StructuredLogLive);Effect-native component primitive with typed props, requirements, and errors. Components are Effect programs: their setup phase is an Effect generator that acquires resources, declares local state, runs queries, and wires actions — all in one composable unit.
The key insight is that Component.make separates setup (an Effect that runs once per mount and returns bindings) from view (a reactive function of props and bindings). Setup is where you acquire services, register cleanup, and express async intent. View is purely reactive.
Component.make(props, require, setup, view)Component.headless(props, require, setup)— setup-only, no view (for logic reuse)Component.from(fn)— create from a plain function componentComponent.isComponent(value)— type guard for component valuesComponent.props<P>()/Component.propsSchema(schema)— declare prop shapeComponent.require(...tags)— declare required Effect services- metadata extractors:
Component.Requirements<T>,Component.Errors<T>,Component.PropsOf<T>,Component.BindingsOf<T>,Component.SlotsOf<T>,Component.SlotContractOf<T> - slot contract helpers:
Component.withSlots(slots)for authoredView.Slotscontracts,Component.withSlotContract(contract)for the direct lower-level spelling. When the contract is aView.Slotsvalue,Component.SlotsOf<T>is projected fromView.Slots.HandlesOf<typeof slots>. - setup/render bridges:
Component.setupEffect(component, props),Component.renderEffect(component, props), andComponent.renderViewEffect(component, props) - setup builder (pipeable authoring layer over setup-as-Effect):
Component.setup<Props>()— start a named binding builderComponent.bind(name, effect, options?)— add an Effect-created binding; advanced resumability adapters may attach a schema-backed state snapshot policy throughoptions.resumeComponent.value(name, fn)— add a pure setup valueComponent.doEffect(effect)— run setup work without adding a bindingComponent.use(fragment)— merge a reusable setup fragment- extractors:
Component.SetupPropsOf<T>,Component.SetupBindingsOf<T>,Component.SetupErrorsOf<T>,Component.SetupRequirementsOf<T>
- setup helpers (yield inside setup Effect):
Component.signal(initial)— local live accessor and setter for setup-owned state; scoped setters throw after setup scope closeComponent.effect(fn)— setup-scoped reactive effect with cleanup support; its reactive owner is disposed with the setup scopeComponent.state(initial)— component-instance-local writable atom; when a setup scope is present, writes after scope close throw instead of mutating stale local state. ItsComponent.StateAtom<A>result retainsAthroughResume.inspectHandle(...).Component.derived(fn)— local derived atomComponent.query(effectOrPortableCode, options?)— local async query, auto-managed lifetime. Queries capture the setup service map so async work can use services supplied by component layers, and query owners are disposed with the setup scope.options.reactivityKeysaccepts canonicalReactivity.Keywitnesses or dynamic string keys and reruns the live query on invalidation. Retry-schedule errors join the query's typedResulterror, and retry/poll schedule service requirements join the setup Effect's requirements. Portable executors preserve normalized keys in resume snapshots;Portable.cacheKey(descriptor, reactivityKeys)derives their stable server/client cache and single-flight identity.Component.action(fn, options?)— local action, auto-managed lifetime. Returned handles are callable and exposerun(...),runEffect(...),effect(...),result, andpending; action handles capture the setup service map so later runs can use services supplied by component layers, and reject runs after setup scope close.Component.optimistic(source).action(spec)— component-local optimistic action over setup-owned state; captures the setup service map likeComponent.action(...)and rejects mutating operations after setup scope close.Component.ref<T>()— DOM or imperative ref;.currentis cleared when the setup scope closesComponent.fromDequeue(dequeue, handler)— wire an Effect Queue into component lifetime throughScope.ScopeComponent.schedule(schedule, run)— run on an Effect Schedule throughScope.ScopeComponent.scheduleEffect(schedule, effect)— Effect variant throughScope.Scope
- transforms (pipeable on a Component):
Component.withLayer(layer)— provide additional services to this component subtreeComponent.withErrorBoundary(handlers)— catch typed setup/render errorsComponent.withLoading(fallback)— show fallback while setup Effect is pendingComponent.withSpan(name)— add Effect tracing spanComponent.memo(eq)— memoize by prop equalityComponent.tapSetup(tap)— observe setup bindings for debuggingComponent.withPreSetup(effect)— run an Effect before setup (e.g. prefetch)Component.withSetupRetry(schedule)— retry failed setup on a ScheduleComponent.withSetupTimeout(duration)— fail setup if it takes too long
Component.mount(component, { props, layer, target })— mount to DOM
const Counter = Component.make(
Component.props<{ readonly start: number }>(),
Component.require<never>(),
({ start }) =>
Effect.gen(function* () {
// Setup runs once — acquire state, queries, actions here
const [count, setCount] = yield* Component.signal(start);
const doubled = yield* Component.derived(() => count() * 2);
return { count, setCount, doubled };
}),
// View is a reactive function — runs whenever its atom reads change
(_props, { doubled }) => doubled(),
);The same setup can be authored as a pipeable named-binding builder:
const CounterSetup = Component.setup<{ readonly start: number }>()
.bind("count", ({ props }) => Component.state(props.start), {
resume: Resume.snapshotState(Schema.Number),
})
.bind("doubled", ({ bindings }) =>
Component.derived(() => bindings.count() * 2),
);
const Counter = Component.make(
Component.props<{ readonly start: number }>(),
Component.require<never>(),
CounterSetup,
(_props, { doubled }) => doubled(),
);Runtime-native view wrapper for exposing structural slot metadata while preserving current JSX/unknown render output.
View.make(slots, node, options?)— create an inspectableView<Slots>View.Slot.make(name, options?),View.Slot.bind(slot, handle),View.Slots.make(...),View.fromSlots(...)— preferred slot contract path for authored viewsView.tree(slots, tree, node?, options?)— create aView<Slots>with optional renderer-neutral typed tree metadata while preserving the runtime nodeView.element(...),View.fragment(...),View.textNode(...),View.hole(...)— build typed tree metadata nodesView.isView(value)/View.node(value)— detect and unwrap view-backed outputView.slot(name, options?)/View.hidden(name, options?)— describe public and internal slotsView.remap(source, target)— describe wrapper/child slot remappingView.validateSlotTargets(view, targets, options?)— diagnose unknown/hidden dynamic attachment targetsView.validateRemaps(view)— diagnose incompatible slot remapsView.validateTree(view, options?)— diagnose typed tree slot references and tree/slot capability mismatchesView.validatePlatform(view, metadata)— diagnose unsupported capabilities, events, attributes, and platform requirementsView.platform(metadata, { onDiagnostic? })— Effect layer for runtime renderer/platform diagnosticsView.PlatformTag— service tag installed byView.platform(...)View.Event.*,View.Attribute.*,View.Requirement.*,Element.Capability.*— branded metadata witnesses for type-preserving platform metadataView.nameOfEvent(...),View.nameOfAttribute(...),View.nameOfRequirement(...),View.nameOfCapability(...),Element.nameOfCapability(...)— normalize strings and witnesses for diagnostics/adaptersElement.extendsCapability(...)/View.extendsCapability(...)— test capability hierarchy compatibility- extraction helpers:
View.SlotCapabilityOf<T>,View.SlotEventsOf<T>,View.PlatformCapabilitiesOf<T>,View.PlatformEventsOf<T> - compatibility helpers:
View.MissingPlatformSupport<Slot, Platform>,View.IsPlatformCompatible<Slot, Platform>
Use View.Slots plus View.fromSlots(...) for authored components. Direct
slotMetadata is still available for dynamic/generated views and low-level
renderer adapters.
When a component render function returns a View, Component.renderEffect(...) registers the view slots and, if View.platform(...) is installed in the current Effect environment, reports platform diagnostics for the active renderer boundary.
const Input = View.Slot.make("input", {
capability: Element.Capability.TextInput,
allowedEvents: [View.Event.Input, View.Event.Focus],
allowedAttributes: [View.Attribute.AriaLabel],
platformRequirements: [View.Requirement.Keyboard],
});
type InputEvents = View.Slot.EventsOf<typeof Input>; // "input" | "focus"
const slots = View.Slots.make({
input: View.Slot.bind(Input, Element.textInput()),
});
const view = View.fromSlots(slots, "input-node");
const WebLite = View.platform(
{
name: "web-lite",
capabilities: [Element.Capability.Container, Element.Capability.TextInput],
events: [View.Event.Input, View.Event.Focus],
attributes: [View.Attribute.AriaLabel],
requirements: [View.Requirement.Keyboard],
},
{
onDiagnostic: (diagnostic) => console.warn(diagnostic.message),
},
);
const rendered = Effect.runSync(
Component.renderEffect(Card, {}).pipe(Effect.provide(WebLite)),
);Typed tree metadata is opt-in and does not change render output:
type Slots = {
readonly root: Element.Container;
readonly input: Element.TextInput;
};
const view = View.tree(
View.Slots.handles(slots),
View.element<Slots>(Element.Capability.Container, {
slot: "root",
children: [
View.element<Slots>(Element.Capability.TextInput, {
slot: "input",
props: {
className: View.className(["field", { invalid }]),
onInput: View.event<InputEvent>(() => undefined),
},
}),
],
}),
jsxNode,
);Component.renderEffect(...) still unwraps to jsxNode; Component.renderViewEffect(...) exposes the tree metadata for diagnostics, SSR/hydration planning, and future renderer adapters.
View.validateTree(view) checks typed tree metadata when present. It reports
unknown slot references, hidden slot references, and tree element capabilities
that do not satisfy the referenced slot capability. As with remaps and platform
checks, capability comparison is hierarchy-aware.
Plain strings are still accepted for compatibility and dynamic/generated metadata. Witnesses are preferred for new code because their literal names can flow through generic helpers and composed metadata objects.
Element capabilities can be hierarchical. Built-ins currently model
TextInput -> Focusable -> Interactive -> Base, with Container and
Draggable also extending Interactive, and Collection extending Base.
Custom capabilities can declare parents with
Element.Capability.make("DatePicker", { extends: [Element.Capability.TextInput] }).
Remap and platform diagnostics treat a child capability as satisfying a parent
requirement.
Compatibility helpers are intentionally conservative: they report missing
support only when both required metadata and platform support are literal enough
to compare. If metadata widens to string, runtime diagnostics remain the source
of truth.
Composable behavior building blocks for headless UI logic. A Behavior is an Effect program that wires event handling, accessibility, and keyboard interaction onto abstract "slots" (Element handles) without knowing anything about rendering. This lets the same behavior — say, Behaviors.disclosure — work whether you render a <button> or a custom element.
The Element.* constructors define what capability a slot needs (is it interactive? focusable? a text input?). Behaviors are then attached by matching slots to those capabilities.
Behavior.make(run)— define a behavior as an Effect programBehavior.forSlots(slots)(run)— define authored behavior over aView.SlotscontractBehavior.compose(a, b, ...)— merge multiple behaviors into oneBehavior.decorator(behavior)— behavior that wraps anotherBehavior.attach(behavior, { select, merge? })— low-level/general attach:selectpicks the behavior's elements from any bindings (including derived values like() => bindings.filtered()), which the contract-keyed forms can't. PreferattachToSlots(authored) /attachBySlotContract(typed remap) /attachBySlots(dynamic string map) when aView.Slotscontract exists; those are typed sugar over this.Behavior.attachToSlots(behavior, slots)— attach authored behavior to the same slot contractBehavior.attachBySlotContract(behavior, elementMap, merge?)— typed remapping through slot contractsBehavior.attachBySlots(behavior, elementMap, merge?)— dynamic/generated string-map wiringBehavior.events(eventMap)/Behavior.withMetadata(behavior, metadata)— declare event requirements for attachment diagnosticsBehavior.binding(name)/Behavior.provides(contract)(behavior)— publish behavior-created binding state (isOpen,selectedKey, etc.) as typed, inspectable metadataBehavior.outEvent(name)/Behavior.eventBus(contract)/Behavior.emits(contract)(behavior)— declare logical behavior out-events and expose typedemit/onchannels for parent subscribersBehavior.validateAttachmentBySlots(behavior, elementMap, view)— validate slot targets and required behavior events againstView.slot(...)metadataBehavior.validateComponentAttachmentBySlots(behavior, elementMap, component, props)— render a component View and validate mapped behavior attachment metadata
const NeedsInput = Behavior.events({
input: [View.Event.Input, View.Event.Focus],
})(
Behavior.make<{ readonly input: Element.TextInput }, {}, never, never>(
({ input }) => input.on("input", () => undefined).pipe(Effect.as({})),
),
);
const Field = View.Slot.make("field", {
capability: Element.Capability.TextInput,
allowedEvents: [View.Event.Input],
});
const FieldSlots = View.Slots.make({
field: View.Slot.bind(Field, Element.textInput()),
});
Behavior.validateAttachmentBySlots(
NeedsInput,
{ input: "field" },
View.fromSlots(FieldSlots, null),
);Element capability constructors:
Element.interactive()/Element.container()/Element.focusable()/Element.textInput()/Element.draggable()Element.collection(items)—forEachandobserveEachfor dynamic collection lifecycleElement.Capability.make(name, { extends })— define a branded capability witness with optional parent capabilities
Component slot integration:
Component.withBehavior(behavior, selectElements, merge?)Component.slotInteractive()/Component.slotContainer()/Component.slotFocusable()/Component.slotTextInput()/Component.slotDraggable()/Component.slotCollection(items?)
Built-in behaviors:
Behaviors.disclosure— open/close toggle with accessibilityBehaviors.selection(options?)— single/multi select with keyboard navigationBehaviors.searchFilter(options)— live search/filter over a collectionBehaviors.keyboardNav(options?)— arrow key navigationBehaviors.pagination(options?)— page-based navigation over a collectionBehaviors.focusTrap(options?)— constrain tab focus within a region; when the runtime elements include afocusablescollection it cycles Tab/Shift+Tab and focuses the active item on activationBehaviors.combobox(options)— combined input + dropdown behavior
Headless factory helpers:
Composables.createCombobox(options)— composable combobox without a Component
Typed style composition that treats CSS as data. Styles are assembled as structured slot objects, not string templates, so they can be composed, overridden, and attached to component slots safely.
Style composition primitives:
Style.slot,Style.compose,Style.when,Style.whenBinding,Style.states,Style.responsiveStyle.whenBinding(binding, predicateOrValue, style)includes a style piece only when a setup/behavior binding matches. Pass either a binding name or aBehavior.binding(...)witness; authoredStyle.make(...)attachment preserves the binding name and rejects components that do not expose the referenced binding.- animated:
Style.animation,Style.keyframes,Style.transition - advanced:
Style.nest,Style.vars,Style.pseudo,Style.extends(slot) - selectors:
Style.child,Style.descendant,Style.sibling,Style.attr,Style.not,Style.is - animation helpers:
Style.animate,Style.enter,Style.exit,Style.enterStagger,Style.layoutAnimation - at-rules:
Style.media,Style.supports,Style.container,Style.containerQuery,Style.containerType - grid/layers/global:
Style.grid,Style.layers,Style.inLayer,Style.global,Style.globalLayer - global style service:
Style.GlobalStyleTag,Style.resolveGlobal,Style.globalLayer(global, { apply? })
Style maps and attachment:
Style.make— create a style map (slot name → style)Style.make(slots, map)— create an authored style map over aView.Slotscontract (Style.forSlotswas removed in DQ-054)Style.attachToSlots(style, slots)— attach authored styles to the same slot contractStyle.attachBySlotContract(style, map)— typed remapping through slot contractsStyle.attach/Style.attachByView— low-level/general attach for components without a publishedView.Slotscontract (attachtargets setupbindings.slots;attachByViewtargets the renderedView). PreferattachToSlots(authored) /attachBySlotContract(typed remap) /attachBySlots(dynamic string map) when a contract exists; those are typed sugar over these general forms.Style.attachBySlots— dynamic/generated string-map wiringStyle.attachBySlotsFor<Bindings>()— compatibility helper for binding-slot mapsStyle.validateComponentAttachment(style, component, props)— render a component View and validate style slot targets against its metadataStyle.validatePlatform(style, metadata)— runtime diagnostics for renderer-supported style propertiesStyle.platform(metadata, { onDiagnostic? })— Effect layer for runtime style platform diagnostics duringStyle.attach(...)Style.PlatformTag— service tag installed byStyle.platform(...)Style.GlobalStyleTag— service tag installed byStyle.globalLayer(...); exposes{ sheet: { pieces, resolved }, apply? }Style.reportPlatformDiagnostics(style, service)— explicit reporting helper for adapters or non-setup attachment pathsStyle.Property.*,Style.Property.make(name)— branded metadata witnesses for type-preserving style property metadataStyle.nameOfProperty(...)— normalize string or branded property metadata for diagnostics/adapters
const BackdropFilter = Style.Property.make("backdropFilter");
const style = Style.make({
root: Style.slot({ color: "red", backdropFilter: "blur(4px)" }),
});
Style.validatePlatform(style, {
name: "web",
properties: [Style.Property.Color, BackdropFilter],
});
const WebStylePlatform = Style.platform(
{
name: "web",
properties: [Style.Property.Color, BackdropFilter],
},
{
onDiagnostic: (diagnostic) => console.warn(diagnostic.message),
},
);
type Supported = Style.Property.NamesOf<
[typeof Style.Property.Color, typeof BackdropFilter]
>; // "color" | "backdropFilter"When WebStylePlatform is provided to a component using Style.attach(...),
unsupported properties are reported through onDiagnostic during setup.
Variants and recipes — type-safe prop-driven style variation:
Style.variants,Style.recipeStyle.VariantProps<T>,Style.RecipeProps<T>— infer prop types from a recipe
Design tokens:
Style.tokenColor,Style.tokenSpacing,Style.tokenFontSizeStyle.override,Style.Provider— runtime token overrides at subtree boundaries
Theme service:
Theme.Themeservice key — inject into Effect layer for system theme accessTheme.ThemeLight— default layerTheme.define(tokens)/Theme.defineTokens(tokens)/Theme.layer(tokens, opts?)— user-declared token schemas with typed pathsTheme.lookupToken(tokens, path)— resolve a token path to a value
Utility helpers (src/style-utils.ts):
StyleUtils.padded,StyleUtils.rounded,StyleUtils.elevated,StyleUtils.borderedStyleUtils.textStyle,StyleUtils.flexRow,StyleUtils.flexColStyleUtils.interactive,StyleUtils.truncated
Styled composables (src/styled-composables.ts):
StyledComposables.createStyledCombobox— styled + behaviors wired together
Example: examples/styled-combobox/App.tsx
Routing uses route nodes as the app-first golden path. Constructors create route identity, and pipe helpers attach orthogonal metadata, loading, and behavior:
const Home = Route.index(HomePage).pipe(Route.id("home"));
const User = Route.page("/users/:userId", UserPage).pipe(
Route.id("users.detail"),
Route.paramsSchema(Schema.Struct({ userId: Schema.String })),
Route.querySchema(
Schema.Struct({
tab: Schema.optional(
Schema.Union([Schema.Literal("profile"), Schema.Literal("settings")]),
),
}),
),
Route.loader((params) => Effect.succeed({ id: params.userId, name: "Ada" })),
Route.title((params, user) => `${params.userId}:${user?.name ?? "loading"}`),
Route.meta((params, user) => ({ description: user?.id ?? params.userId })),
);
export const AppRoutes = Route.define(
Route.layout(AppShell).pipe(
Route.id("app"),
Route.children([
Route.ref(Home),
Route.mount(User, [
Route.page("settings", SettingsPage).pipe(Route.id("users.settings")),
]),
]),
),
);
const href = Route.link(User);
href({ userId: "ada" }, { query: { tab: "profile" }, hash: "activity" });The key design goal is that route metadata accumulates on a first-class route node, with strong inference flowing through the pipe chain. Use Route.componentOf(node) when an API needs the materialized routed component behind a node.
Component-first Component.pipe(Route.path(...)) routes remain available for compatibility and lower-level composition. New app examples should prefer Route.page(...), Route.layout(...), Route.index(...), and Route.define(...).
Component wrappers like Component.withLoading(...), Component.withSpan(...), and Component.withLayer(...) preserve route metadata and extraction behavior, so helpers like Route.link(...) and Route.ParamsOf<T> survive more safe composition chains.
Route-node golden path:
Route.page(path, component)— create a page route nodeRoute.layout(component)— create a layout route nodeRoute.index(component)— create an index route node that matches its parent exactlyRoute.define(root)— materialize a route-node tree and return its rootRoute.ref(node),Route.mount(node, children),Route.children([...])— compose route trees without losing node metadataRoute.componentOf(node)— extract/materialize the routed component represented by a node
Shared route pipes:
Route.paramsSchema,Route.querySchema,Route.hashSchema— replace raw URL inference with decoded schema outputRoute.id— assign a stable route id for linking, loaders, SSR payloads, and diagnosticsRoute.loader,Route.title,Route.meta,Route.guard,Route.transition— accumulate route behavior and metadata on the same route valueRoute.path(pattern)— compatibility component-first route constructor
Route accessors (inside components):
Route.params— typed URL params atomRoute.query— typed query string atomRoute.hash— typed hash atomRoute.prefix— matched prefix atomRoute.loaderData— loader result atom (success value)Route.loaderResult— loader result atom (Resultunion, for explicit state handling)
Pattern utilities:
Route.matchPattern,Route.extractParams,Route.resolvePattern,Route.matches(pattern)
Links:
Route.link(routeNodeOrRoutedComponent)— create a typed link helper for a route node, unified route, or routed componentRoute.Link— generic link component
Query sync:
Route.queryAtom(key, schema, { default })— atom backed by a URL query parameter; writes update the URL, reads come from it
Loader infrastructure:
Route.loader— declare loader data and loader effect on a routeRoute.loaderError,Route.prefetch,Route.reload,Route.actionRoute.runMatchedLoaders(source, url, options?)— run all matched loaders.sourceis a route tree root or an explicitRoute.registry([...]); there is no global route set
Extraction helpers:
Route.RouteNodeParamsOf<T>,Route.RouteNodeQueryOf<T>,Route.RouteNodeHashOf<T>,Route.RouteNodeLoaderDataOf<T>,Route.RouteNodeLoaderErrorOf<T>- Aliases:
Route.ParamsOf<T>,Route.QueryOf<T>,Route.HashOf<T>,Route.LoaderDataOf<T>,Route.LoaderErrorOf<T>
Route tree introspection/validation:
Route.nodes(...),Route.parentOf(...),Route.ancestorsOf(...),Route.depthOf(...),Route.routeChainOf(...),Route.fullPathOf(...),Route.paramNamesOf(...)Route.validateTree(...)— validate the route tree; reports conflicting sibling patterns
Metadata precedence:
- Title: deepest matched route wins
- Meta: merged root → leaf (deeper keys override parent)
- Callback forms for
Route.title/Route.metareceive(params, loaderData, loaderResult) - Route head callbacks stay reactive after setup and recompute on match/params/loader changes
Extra route pipes/utilities:
Route.guard,Route.title,Route.meta,Route.transitionRoute.lazy(importer, { loading? })— demand-load a component, exposepreload(), and update through signals when the module resolvesRoute.Switch({ children, fallback? })— render the most specific routed child that matches the current URL (pass components, not calls, so only the winner is created)Route.collect,Route.collectAll(source),Route.validateLinksRoute.registry([...]),Route.isRouteRegistry,Route.RouteSourceTag,Route.routeSourceLayer(source)— explicit route sources;routeSourceLayeris what makesRouterService.preloadresolvable
SSR/SSG loader helpers:
Route.serializeLoaderData,Route.deserializeLoaderData,Route.streamDeferredLoaderScriptsRoute.collectSitemapEntries(source, baseUrl?),Route.sitemapParamsRoute.LoaderHandoff/Route.LoaderHandoffEntry,Route.loaderHandoffVersion,Route.loaderHandoffGlobalKey,Route.loaderHandoffNotifyKey— the versioned streamed-loader envelope (replaceswindow.__LOADER_DATA__/window.__HYDRATE_ROUTE__)Route.readLoaderHandoff,Route.hydrateLoaderHandoff(source, options?),Route.onLoaderHandoffEntry(handler)— read, apply, and observe that envelope; decoding validates through theSerializationservice, so a bad payload is a typedSchemaError
Head/meta utilities:
Route.mergeRouteMetaChain,Route.resolveRouteHead,Route.applyRouteHeadToDocumentRoute.RouteHeadStore,Route.makeRouteHeadStore,Route.RouteHeadTag,Route.currentRouteHeadStore,Route.clientRouteHeadStore,Route.resolveRouteHeadOf(store)— head state is per-request on the server and per-owner on the client;Route.setRouteHead/Route.removeRouteHead/Route.createRouteHeadIdall take the store
Router layers:
Route.Router.Browser,Route.Router.Hash,Route.Router.Server({ url }),Route.Router.Memory(initial?)
Streaming:
Route.runStreamingNavigation— orchestrate streamed navigation responses
Single-flight solves the double round-trip problem. A normal mutation flow requires two network requests: one to execute the mutation, and a second to reload the route data that changed. With single-flight, a single request carries both the mutation execution and the refreshed loader payloads back to the client. The client seeds its loader cache directly from the response — no second fetch, no loading flash.
Without single-flight:
Client → POST /api/save → Server executes mutation
Client ← { ok: true }
Client → GET /users/123 → Server runs loader
Client ← { user: {...} } ← UI updates
With single-flight:
Client → POST /_sf/save → Server executes mutation + runs affected loaders
Client ← { mutation: {...}, loaders: [{ routeId, result }] }
Client seeds cache, UI updates ← no second request
1. Client: action fires, transport intercepts
When Atom.action has a singleFlight option, every run(input) call checks for a transport:
- First checks for
SingleFlightTransportServicein the Effect runtime (installed viamount()layer). - Falls back to a globally-installed transport (set at bootstrap via
Atom.runtime.addGlobalLayer). - Falls back to a direct
fetchtosingleFlight.endpointif no transport service is present. - If
singleFlight.mode === "force"and no transport exists, the Effect fails rather than silently running locally.
const saveUser = Atom.action(
(input: { readonly id: string; readonly name: string }) =>
api.saveUser(input),
{
reactivityKeys: { users: ["list"], user: ["by-id", "profile"] },
singleFlight: {
endpoint: "/_single-flight/users/save",
// Optional: compute the target URL from the input (for loader matching on the server)
url: (input) => `/users/${input.id}`,
},
},
);The transport sends a request envelope:
{
name?: string; // optional mutation name for server-side routing
args: unknown[]; // the mutation arguments
url: string; // current or computed target URL
}2. Server: mutation runs, loaders are selected and run
Route.singleFlight(fn, options) is the recommended server API. It handles the full server-side flow:
// server/routes/users.ts
const saveUserHandler = Route.singleFlight(
(input: { readonly id: string; readonly name: string }) =>
api.saveUser(input),
{
target: (result) => `/users/${result.id}`, // URL for loader matching
setLoaders: Route.seedLoader(UserRoute), // seed cache directly
},
);Internally, Route.singleFlight is composed from two lower-level pieces — understanding them explains what happens at each step:
Route.actionSingleFlight(fn, options) — the mutation runner:
- Begins capturing reactivity invalidations (via
Reactivity.tracked). - Executes the mutation function.
- Collects any invalidation keys the mutation emitted.
- Calls
Route.runMatchedLoaders(targetUrl, { reactivityKeys: capturedKeys })to select and execute loaders. - Applies direct loader seeding from
options.setLoaders. - Returns a
SingleFlightPayload.
Route.createSingleFlightHandler(run, options) — the request/response adapter:
- Receives the request envelope from the client.
- Provides a
Route.Router.Server({ url })layer to the runner so loaders resolve against the right URL. - Wraps the result in a
SingleFlightResponseenvelope:{ ok: true, payload }or{ ok: false, error }.
3. Server: which loaders run — reactivity key matching
The most important decision actionSingleFlight makes is which matched loaders to actually re-run. It uses reactivity keys as the coordination signal:
- Each loader, when it runs, reads atoms/queries that are registered with reactivity keys. These reads are captured automatically.
- The mutation, when it runs (or via
reactivityKeysoptions onAtom.action), emits invalidation keys. runMatchedLoadersfilters candidates: a loader only runs if its captured keys intersect with the mutation's invalidated keys.
Mutation invalidates: ["user:123", "users:list"]
UserRoute loader depends on: ["user:123"] ✓ runs (intersection)
UserListRoute loader depends on: ["users:list"] ✓ runs (intersection)
StatsRoute loader depends on: ["stats"] ✗ skipped (no intersection)
Fallback: if the mutation emits no invalidation keys (nothing was captured), the system falls back to running all matched loaders for the target URL. This is the safe default — it's better to over-fetch than to silently serve stale data.
You can override loader selection with the revalidate option:
Route.singleFlight(fn, {
revalidate: "none", // skip all loaders (use only setLoaders seeding)
revalidate: "all", // always run all matched loaders
revalidate: "reactivity", // default: reactivity-key-driven (with fallback to all)
});4. Response payload structure
// Success
{
ok: true,
payload: {
mutation: A, // the mutation result
url: string, // the target URL used for loader matching
loaders: Array<{
routeId: string, // route identifier
result: Result<any, any>, // Success | Failure | Loading
}>
}
}
// Failure
{ ok: false, error: E }5. Client: cache seeding, no second request
Route.hydrateSingleFlightPayload(payload) runs on the client after the transport receives the response.
If you already have an explicit unified route tree available, prefer Route.hydrateSingleFlightPayload(payload, root) so hydration can resolve loaders from the route tree directly.
For each entry in payload.loaders:
- Finds the registered route by
routeId. - Extracts URL params from the target URL (using the route's pattern).
- Writes the
Resultdirectly into the loader cache:setLoaderCacheEntry(routeId, params, result).
Components that are already mounted read from this cache synchronously on next render — no loading state, no flash.
hydrateSingleFlightPayload is called automatically by Route.invokeSingleFlight(...). You can call it manually if you're managing the transport yourself, or pass { hydrate: false } to invokeSingleFlight to skip it.
When you already have an explicit route tree, Route.actionSingleFlight(...) and Route.invokeSingleFlight(...) can also be given that route tree through their app option so loader selection and hydration stay tree-first instead of relying on registry lookup.
When a loader runs on the server, it's the default path — it re-fetches data fresh. But if the mutation result already contains the data the loader would return, you can short-circuit by seeding the cache directly and skipping the loader entirely. These are the three seeding APIs, ordered from most to least automatic:
Route.seedLoader(route, select?) — use when the mutation result is (or closely matches) the loader payload. No setLoaders boilerplate:
// mutation result IS the user object
setLoaders: Route.seedLoader(UserRoute);
// mutation result needs a projection
setLoaders: Route.seedLoader(UserRoute, (result) => result.user);Route.setLoaderData(route, data) — explicitly wrap data in a Success result and seed it:
setLoaders: (result) => [
Route.setLoaderData(UserRoute, result.user),
Route.setLoaderData(PermissionsRoute, result.permissions),
];Route.setLoaderResult(route, result) — provide the full Result value. Use when you want to seed a Failure or Loading state explicitly:
setLoaders: (result) =>
result.deleted
? [Route.setLoaderResult(UserRoute, Result.failure(new NotFoundError()))]
: [Route.setLoaderData(UserRoute, result.user)];Route.seedLoaderResult(route, fn) — project the mutation result to a Result (convenience form of setLoaderResult):
setLoaders: Route.seedLoaderResult(UserRoute, (result) =>
result.ok ? Result.success(result.data) : Result.failure(result.error),
);Three server APIs at increasing levels of abstraction:
| API | Combines | Use when |
|---|---|---|
Route.singleFlight(fn, opts) |
actionSingleFlight + createSingleFlightHandler |
Always — the recommended path |
Route.actionSingleFlight(fn, opts) |
mutation runner + reactivity capture + loader selection | You need the runner separate from the HTTP adapter |
Route.createSingleFlightHandler(run, opts) |
HTTP request/response wrapping | You have a custom runner and need to adapt it to the transport protocol |
Route.mutationSingleFlight is the variant for defineMutation-style mutations (same semantics, different input shape).
The transport is the piece that moves the request envelope from client to server and back. It's deliberately separated from the mutation logic — the same Route.singleFlight server handler works regardless of whether the client uses HTTP, an RPC channel, or a direct in-process call.
Route.SingleFlightTransportTag — the service tag. When present in the mount() layer, it's automatically wired into all Atom.action handles that have singleFlight options.
Route.FetchSingleFlightTransport(options?) — the built-in HTTP transport layer:
// Simple: all single-flight requests go to the same endpoint
Route.FetchSingleFlightTransport({ endpoint: "/_sf" });
// Per-action routing via the request name
Route.FetchSingleFlightTransport({
endpoint: (request) => (request.name ? `/_sf/${request.name}` : "/_sf"),
});Custom transport — implement the service interface to use any transport:
const MySingleFlightLayer = Layer.succeed(Route.SingleFlightTransportTag, {
execute: (request, options) =>
myRpcChannel.call(request.name ?? "mutation", request).pipe(
Effect.map((response) => ({ ok: true, payload: response })),
Effect.catch((err) => Effect.succeed({ ok: false, error: err })),
),
});The transport interface only knows about request/response envelopes. It doesn't know about routes, loaders, or reactivity. That separation makes it easy to test, mock, or swap.
The router runtime does not automatically revalidate loaders after a mutation. Single-flight is the mechanism — the payload from the server is the revalidation. After hydrateSingleFlightPayload seeds the loader cache, components that depend on those loaders re-render from the cache.
For mutations that don't use single-flight, you call router.revalidate() explicitly to re-run all matched loaders for the current URL.
// Single-flight: revalidation is implicit — payload seeds the cache
const saved = await saveUser.run(input);
// Regular mutation: must revalidate manually
await saveUserRegular.run(input);
router.revalidate();| Scenario | What to use |
|---|---|
| Mutation result IS the loader data | seedLoader(Route) |
| Mutation returns partial data matching loader shape | seedLoader(Route, select) |
| Multiple loaders to seed from one result | setLoaders: (r) => [setLoaderData(A, r.a), setLoaderData(B, r.b)] |
| Mutation may produce a failure that should be shown as route error | setLoaderResult(Route, Result.failure(...)) |
| Skip all loaders, seed only | revalidate: "none" + setLoaders |
| Always revalidate all matched loaders | revalidate: "all" |
| Let reactivity decide (default) | omit revalidate |
| Transport via HTTP fetch | Route.FetchSingleFlightTransport(...) in layer |
| Transport via custom RPC | Custom Layer.succeed(Route.SingleFlightTransportTag, ...) |
| Force single-flight (fail if no transport) | singleFlight: { mode: "force", ... } |
Full guides (archived deep-dives): docs/archive/SINGLE_FLIGHT.md, docs/archive/SINGLE_FLIGHT_COMPARISON.md, docs/archive/SINGLE_FLIGHT_TRANSPORT.md
Async rendering contract:
- Prefer
Route.loaderResult(returnsResultunion) withAsync,Loading,Errored,MatchTagrather than route-specific loading components. This keeps the async control-flow consistent with the rest of the library.
Examples: examples/router-basic/, examples/router-typed-links/, examples/router-single-flight/, examples/router-single-flight-fetch/
Server-side route handlers with typed request decoding, schema-based params/form/body, and structured response encoding.
Route definition:
ServerRoute.action,ServerRoute.document,ServerRoute.json,ServerRoute.resource,ServerRoute.methodServerRoute.path,ServerRoute.params,ServerRoute.query,ServerRoute.headers,ServerRoute.cookiesServerRoute.form,ServerRoute.body,ServerRoute.responseServerRoute.handle(...)— handler input shape is inferred from accumulated route metadata (params/form/body/query/headers/cookies all flow into the handler type)ServerRoute.define— finalize a server route definition
Document rendering:
ServerRoute.documentRenderer,ServerRoute.generatedPathServerRoute.runDocument(...)— run a document route within a runtimeServerRoute.document(app)accepts unified route roots directly
Graph helpers:
ServerRoute.nodes(...),ServerRoute.validate(...)— validates overlapping document patterns and invalid decode wiringServerRoute.matches(...),ServerRoute.find(...)ServerRoute.byKey(...),ServerRoute.identity(...)— observability helpers
Execution:
ServerRoute.execute(route, request)— Schema-based params/form/body decoding + basic response encodingServerRoute.executeWithServices(...),ServerRoute.executeFromServices(...)— service-native variantsServerRoute.dispatch(routes, request, { layer?, csrf? })— full route dispatch with loader payload output; refuses cross-site state-changing requests with a 403 unlesscsrf: falseor the origin is incsrf.trustedOriginsServerRoute.checkOrigin(request, csrf?)— the same cross-site check, for endpoints you route yourselfServerRoute.dispatchWithRuntime(runtime, request, ...)— runtime-backed dispatchServerRoute.toResponse(...)— convert dispatch results to a generic response shape (status,headers,body/html, redirect, notFound)
Control flow:
ServerRoute.redirect(...),ServerRoute.notFound()Route.ServerResponseTag— shape responses from inside handlers
Structured metadata:
ServerRouteMeta<...>— carries typed metadata aligned with runtime fields; keeps helper typing consistent
The runtime that ties history, navigation state, loaders, and server dispatch together.
RouterRuntime.create(...),RouterRuntime.createMemoryHistory(...)- Runtime methods:
initialize(),snapshot(),subscribe(),navigate(),navigateApp(),submit(),fetch(),revalidate() - Cancellation:
runtime.cancel(...),NavigationTag.cancel(...) - Service tags:
RouterRuntime.HistoryTag,RouterRuntime.NavigationTag,RouterRuntime.RouterRuntimeTag,RouterRuntime.toLayer(...)
Snapshot fields:
- Matched loader results during init/navigation/revalidation
lastActionOutcome,lastFetchOutcome,lastDocumentResult,lastDispatchResult- Unified
phase-based task objects:navigation,revalidation,requestState,dispatchState, fetcherstate RouterRuntimeOutcome,RouterTaskState— normalized outcome shapes for actions/fetches/documents/dispatchesinFlight— lightweight in-flight task ids for navigation/submit/request/dispatch/revalidate/fetchmatchedServerRoute— server-route observability/debugging
Execution model:
submit(...)/fetch(...)can execute typedServerRoutehandlers directly when passed a server route node- Repeated navigation/fetch work enters
cancelledstate before the next task begins - In-flight task registry guards against stale superseded writes
- SSR bridge:
Route.renderRequest(app, { request, layer? }),Route.ServerRequestTag,Route.ServerResponseTag Route.renderRequestStream(app, { request, layer? })— the same render, streamed- Server convenience:
Route.serverRequest,Route.serverUrl,Route.setStatus(...),Route.setHeader(...),Route.appendHeader(...),Route.serverRedirect(...),Route.serverNotFound()
Library-owned reactivity service for key-based invalidation and subscription. This sits above the signal graph — it's a semantic layer that lets async operations declare what data they touch so invalidation can be driven by intent rather than atom identity.
A query that reads users:list can be invalidated by any mutation that also declares users:list as a reactivity key — even if they share no atom reference. This makes cache invalidation composable across module boundaries.
Reactivity.Tag— service tag for reactivity providerReactivity.live— default provider with microtask batching and auto-flushReactivity.test— testing provider with manual flush control andlastInvalidatedtrackingReactivity.tracked(effect, options?)— execute an Effect while tracking which reactivity keys it reads; accumulates accessed keys internally.options.initial— initial set of tracked keysReactivity.invalidating(effect, keys)— execute an Effect that invalidates specified keys on completion
Key witnesses (authored path):
Plain string keys have no compile-time connection between the tracked site and the invalidating site — a typo silently means "never refreshes". Witnesses give both sides one shared, literal-typed value. Strings remain the dynamic/generated escape hatch (matching the slot-contract tiering).
const Users = Reactivity.Key.make("users");
const user = Reactivity.Key.family("user");
// service read tracks the witness
Reactivity.tracked(fetchUsers(), { keys: [Users] });
// service write invalidates the same value
Reactivity.invalidating(api.addUser(name), [Users]);
// parameterized members: user(42) -> "user:42"
Reactivity.tracked(fetchUser(id), { keys: [user(id)] });Reactivity.Key.make(name)— create a branded key witness with a literal-typedname;.child(sub)derives hierarchical children (Users.child(42)->"users:42")Reactivity.Key.family(name)— callable member derivation (todo(42)-> the"todo:42"witness) with.keyas the parent witnessReactivity.Key.is(value)— witness type guardReactivity.KeyNameOf<T>— extract the literal key name from a witness or family type- Witnesses are accepted anywhere
ReactivityKeysInputis accepted (tracked/invalidating,reactivityKeysoptions on atoms/actions/components/route loaders,Route.runMatchedLoadersfilters) and mix freely with strings - Hierarchy matches the record-form convention: a child expands to ancestors + self for both tracking and invalidation, so invalidating the parent reaches child observers and vice versa
Atom helpers:
Atom.invalidateReactivity(keys)— invalidate reactivity keysAtom.trackReactivity(keys)— track which keys are accessed during a readAtom.withReactivity(keys)— register reactivity keys for an atomAtom.reactivityKeys(atom)— retrieve registered keys for an atomAtom.flushReactivity()— force-flush reactivity invalidations
The core reactive state primitive. Atoms are plain objects with read/write methods backed by the signal graph. They are callable — atom() reads the current value and registers a reactive dependency in whatever computation is running. This makes JSX natural: <div>{count()}</div> is just a function call that the reactive runtime intercepts.
Why callable reads? Compared to property access (atom.value), a call is visually explicit — you can see at a glance where reactive tracking happens. Compared to hooks, there's no ordering constraint; atoms can be read conditionally or in loops.
Atom.make(value)— create a writable atom with an initial valueAtom.make((get) => ...)— create a derived (read-only) atom;get(other)reads and tracks dependenciesAtom.value(value)— explicit writable constructor, including function-valued atoms. Use this when you want to store a function as data rather than treat it as a derived getter.Atom.derived((get) => ...)— explicit derived constructor (same asAtom.make(fn)but unambiguous)Atom.readable(read, refresh?)— low-level read-only atom constructorAtom.writable(read, write, refresh?)— low-level writable atom constructorAtom.family(fn)— memoized atom factory keyed by argument tuple. Same args return the same atom instance. Optional{ equals }for custom key equality and{ schema }for schema-validated atom family values. Exposesevict(...args)andclear()for cache cleanup.Atom.runtime(layer)— create an atom runtime bound to an EffectLayer:runtime.atom(effect)— async atom from an Effectruntime.atom((get) => effect)— dependency-aware async atom;get(...)/get.result(...)read other atoms inside the getter
Atom.runtime.addGlobalLayer(layer)— add a global layer applied to all newly-created runtimesAtom.runtimeEffect(layer)— Effect constructor variant of runtime creationAtom.keepAlive(atom)— compatibility helper; identity in this packageatom.pipe(...)— pipeable atom transformations (effect-style composition)
Async policies (pipeable):
Atom.withOptimistic(atom)/Atom.withOptimistic()— optimistic overlays withsetOptimistic,clearOptimistic,isOptimisticPending,withEffect(...)Atom.withRetry(atom, schedule)/Atom.withRetry(schedule)— retry policy for async result atomsAtom.withPolling(atom, schedule)/Atom.withPolling(schedule)— polling policyAtom.withStaleTime(atom, duration)/Atom.withStaleTime(duration)— auto-refresh after stale duration
Actions:
Atom.action(effect, options?)/Atom.action(runtime, effect, options?)— create a linear action handle from an Effect function. Actions serialize concurrent calls (later calls supersede earlier ones). Options:name,reactivityKeys,onSuccess,onError,onTransition.- Handle shape: callable +
run(input)+runEffect(input)+effect(input)+result()+pending() runEffect(input)preserves success output typeAfor Effect compositionreactivityKeysinvalidates the declared keys after a successful run
- Handle shape: callable +
Other constructors:
Atom.effect(fn)— standalone async Effect atom (no runtime required; for simple async without services)Atom.pull(stream, options?)— pull-based stream pagination; callset(void 0)to pull next chunkAtom.projection(derive, initial, options?)— mutable derived projection; mutate draft or return next value with keyed reconciliationAtom.projectionAsync(derive, initial, options?)— async projection returningResult<T, E>; usesoptions.runtimeor ambient mount runtimeAtom.searchParam(name, codec?)— atom bound to a URL search param (browser only)Atom.kvs({ key, defaultValue, ... })— atom backed by key-value storage (localStorage by default)Atom.Stream.*— advanced stream assembly helpers (see Stream Integration below)
const count = Atom.make(0);
const doubled = Atom.make((get) => get(count) * 2);
// Store a function as data — use Atom.value, not Atom.make
const callback = Atom.value((n: number) => n + 1);
// Family: same "alice" argument always returns the same atom
const todoById = Atom.family((id: string) => Atom.make({ id, done: false }));
// Schema-validated family values: member reads return Exit<T, SchemaError>
const ageByUser = Atom.family((id: string) => Atom.value(id.length), {
schema: Schema.Int,
});
// Runtime: bind Effect layer once, derive many atoms safely
const runtime = Atom.runtime(MyLayer);
const userAtom = runtime.atom(
Effect.service(UserApi).pipe(Effect.flatMap((api) => api.me())),
);
const increment = runtime.action((n: number) =>
Effect.sync(() => console.log(n)),
);
// Projection: mutable computed view with draft mutation
const selectedMap = Atom.projection((draft: Record<string, boolean>) => {
draft["a"] = true;
}, {});Atom.map(atom, fn)/Atom.map(fn)— derive a new atom by transforming the value; data-first and data-last formsAtom.withFallback(atom, fallback)/Atom.withFallback(fallback)— replacenull/undefinedwith a fallback
const label = Atom.map(count, (n) => `Count: ${n}`);
const safe = Atom.withFallback(nameAtom, "anonymous");Writable atoms are callable and expose sync instance methods for component ergonomics:
atom()— read current value reactivelyatom.set(value)— sync writeatom.update(fn)— sync update from previous valueatom.modify(fn)— sync read-modify-write, returns a computed value
Effect helpers (all support data-first Atom.set(atom, value) and data-last Atom.set(value) forms):
Atom.get(atom)→Effect<A>— read atom valueAtom.result(atom)→Effect<A, E | BridgeError>— unwrap coreResultatoms into typed Effects. Fails withResultLoadingErrorif still loading,ResultDefectErrorif defected.Atom.set(atom, value)→Effect<void>— write atom valueAtom.update(atom, fn)→Effect<void>— update from previous valueAtom.modify(atom, fn)→Effect<A>— read-modify-write, returning a computed valueAtom.refresh(atom)→Effect<void>— force-invalidate atom and its dependents- Aliases for Effect-first naming:
Atom.getEffect,Atom.resultEffect,Atom.setEffect,Atom.updateEffect,Atom.modifyEffect
count(); // 0 — reactive read
count.update((n) => n + 1); // sync write
const prev = count.modify((n) => [n, n + 1]); // read-modify-write
// Effect helpers for pipelines
Effect.runSync(Atom.get(count));Atom.subscribe(atom, listener, options?)— subscribe to value changes; returns unsubscribe. Calls listener immediately by default ({ immediate: false }to skip).Atom.flush()— flush queued reactive invalidations immediately. Notification mode is always microtask.
Atom.fromStream(stream, initialValue, runtime?)— atom whose value updates from an Effect Stream; starts a fiber on first readAtom.fromQueue(queue, initialValue)— shorthand:fromStream(Stream.fromQueue(queue), initial)Atom.fromSchedule(schedule, initialValue, runtime?)— atom from an EffectScheduleviaStream.fromScheduleAtom.Stream.textInput(stream, options?)— stream recipe for UI text input normalization (trim,minLength)Atom.Stream.searchInput(stream, options?)— search-box recipe (trim/minLength+ optionallowercase+distinct)Atom.Stream.emptyState<T>()— empty stream state for out-of-order assemblyAtom.Stream.applyChunk<T>(state, chunk)— apply a chunk to stream state, handling out-of-order updatesAtom.Stream.hydrateState<T>(value)— stream state initialized with a hydrated server value
const prices = Atom.fromStream(priceStream, 0);
const events = Atom.fromQueue(eventQueue, null);
// Out-of-order stream assembly (SSR + client patches)
const initialState = Atom.Stream.hydrateState(serverInitialList);
const updatedState = Atom.Stream.applyChunk(initialState, newItem);Atom.isAtom(u)—trueifuis anAtom<any>Atom.isWritable(atom)—trueif the atom is aWritable<R, W>
Atom.Atom<A>— read-only atomAtom.Writable<R, W>— readable asR, writable asWAtom.Context— callable read context withget,refresh,set,result,addFinalizerAtom.WriteContext<A>— write context withget,set,refreshSelf,setSelf,result,addFinalizerAtom.AtomRuntime<R, E>— runtime wrapper withmanaged,atom(...),action(...),dispose()Atom.ProjectionOptions<T>/Atom.ProjectionAsyncOptions<T, R>— projection configuration
Schema-validated form fields backed by atoms. The core idea is that a form field has two representations: the raw input (what the user typed, possibly invalid) and the parsed value (the typed output if validation passes). ValidatedAtom holds both as atoms, so you can bind the input to a UI element and read the parsed value only when you need it.
AtomSchema.struct lets you compose multiple fields into a typed form model with unified isValid, touch, and reset behavior — without writing per-field boilerplate.
AtomSchema.make(schema, inputAtom, options?)— wrap an existing writable atom with validation.options.initialis the baseline fordirtycomparison andreset().AtomSchema.makeInitial(schema, initial)— create a standalone validated atom with an initial valueAtomSchema.validated(schema, options?)— pipeable schema wrapper for writable atomsAtomSchema.parsed(schema, options?)— alias ofvalidatedfor parse-oriented namingAtomSchema.struct(fields)— compose many validated fields (including nested structs) into one typed form model. Struct API:input,value,error,touched,dirty,isValid,touch(),reset().AtomSchema.path(root, ...segments)— writable atom focused on a nested object pathAtomSchema.validateEffect(fieldOrStruct)— validate and read typed form values as an EffectAtomSchema.HtmlInput— built-in form codecs:number(Schema.NumberFromString)date(Schema.Date)optionalString— schema +input(value)for empty-string mappingoptionalNumber— schema +input(value)for empty-string mapping
const field = AtomSchema.makeInitial(Schema.Int, 25);
field.isValid(); // true
field.input.set(1.5);
field.isValid(); // false — 1.5 is not an Int
field.reset(); // back to 25| Property | Type | Description |
|---|---|---|
input |
Writable<I, I> |
Raw input atom (writes mark field as touched) |
result |
Atom<Exit<A, SchemaError>> |
Parse result |
error |
Atom<Option<SchemaError>> |
Validation error or None |
value |
Atom<Option<A>> |
Parsed value or None |
isValid |
Atom<boolean> |
true when input passes validation |
touched |
Atom<boolean> |
true after first write |
dirty |
Atom<boolean> |
true when input differs from initial |
reset() |
() => void |
Restore initial value, clear touched |
AtomSchema.ValidatedAtom<A, I>AtomSchema.SchemaError
Structured debug logging for atom reads and writes using Effect's Logger. Wraps atoms transparently — the rest of your code doesn't know logging is in place.
AtomLogger.traced(atom, label)— wrap a read-only atom to log reads viaEffect.logDebugwith{ atom, op, value }annotationsAtomLogger.tracedWritable(atom, label)— wrap a writable atom to log both reads and writesAtomLogger.logGet(atom, label?)→Effect<A>— read atom as an Effect with debug loggingAtomLogger.logSet(atom, value, label?)→Effect<void>— write atom as an Effect with debug loggingAtomLogger.snapshot(atoms)→Effect<Record<string, unknown>>— read all labeled atoms and return a snapshot (useful in tests and bug reports)
const traced = AtomLogger.tracedWritable(count, "count");
const snap = Effect.runSync(
AtomLogger.snapshot([
["count", count],
["name", name],
]),
);Import:
@doeixd/affe/Registry
A centralized read/write/subscribe context for atoms. Useful when you need to manage atom state outside of a reactive computation — in tests, in server environments, or when mounting multiple isolated trees.
In most cases, use mount() for automatic registry management. The registry API is for advanced scenarios where you need explicit control over atom lifetimes.
Registry.make()— create a new registry instanceRegistry.useRegistry()— get ambient owner-scoped registry (stable per owner, auto-disposed on cleanup). Outside any owner, returns a shared detached registry.
| Method | Signature | Description |
|---|---|---|
get(atom) |
<A>(atom: Atom<A>) => A |
Read current value |
set(atom, value) |
<R,W>(atom: Writable<R,W>, value: W) => void |
Write a value |
update(atom, fn) |
<R>(atom: Writable<R,R>, fn: (v: R) => R) => void |
Update from previous |
modify(atom, fn) |
<R,W,A>(atom: Writable<R,W>, fn: (v: R) => [A, W]) => A |
Read-modify-write |
subscribe(atom, fn) |
<A>(atom: Atom<A>, fn: (v: A) => void) => () => void |
Subscribe to changes |
mount(atom) |
<A>(atom: Atom<A>) => () => void |
Keep atom alive (run effects) |
refresh(atom) |
<A>(atom: Atom<A>) => void |
Force-invalidate |
reset() |
() => void |
Dispose mounted owners and clear registry |
dispose() |
() => void |
Clean up all subscriptions |
Registry.Registry
The fetch model (FetchResult) has been deleted (RESULT_UNIFICATION_PLAN
Slices 4-5, 2026-08-12): core Result is the only async model, and the flat
wire DTO the fetch model used to double as lives in src/result-wire.ts —
the single module allowed to construct or interpret it.
toWire(result)/fromWire(wire)— project a coreResultto the flat, JSON-safe DTO and back (Stalecrosses as a failure carryingpreviousSuccess; the round trip reconstructsStale).- Consumers import the schema and helpers through
Serialization(Serialization.ResultWire,resultToWire,resultFromWire,encodeResult/decodeResult,encodeResultRecord/decodeResultRecord).
Per-property reactive access to objects and arrays. Use AtomRef when you have a shared object where different parts of the UI need to subscribe to different properties — it avoids splitting the object into many independent atoms while still enabling fine-grained updates.
AtomRef is a ref abstraction, not an Atom type directly. Use AtomRef.toAtom(ref) to bridge into atom combinators (Atom.map, etc.).
AtomRef.make(initial)— create a ref for an object; returnsAtomRef<A>AtomRef.collection(items)— create a reactive array; returnsCollection<A>AtomRef.toAtom(ref)— convert anAtomRef<A>toWritable<A, A>for atom-graph interop
| Method | Description |
|---|---|
ref() |
Read current value (callable — reactive tracking) |
get() |
Read current value (method form) |
prop(key) |
Get a reactive ref for a single property |
set(value) |
Replace the entire object |
update(fn) |
Update via a function |
modify(fn) |
Read-modify-write and return a computed value |
subscribe(fn) |
Subscribe to changes |
value |
Current snapshot (non-reactive) |
| Method | Description |
|---|---|
push(item) |
Append an item |
insertAt(index, item) |
Insert at position |
remove(ref) |
Remove by item ref identity |
toArray() |
Get current items array |
AtomRef.AtomRef<A>,AtomRef.ReadonlyRef<A>,AtomRef.Collection<A>
SSR state transfer — serialize atom values on the server and restore them on the client. The pattern prevents a flash of loading state: the server serializes atoms as part of the HTML response, and the client restores them before first render.
Hydration.dehydrate(registry, entries)— snapshot atom values to a serializable array.entries:Iterable<[key: string, atom: Atom<any>]>. ReturnsDehydratedAtomValue[].Hydration.hydrate(registry, state, resolvers, options?)— restore atom values from a dehydrated snapshot.resolvers:Record<string, Writable<any, any>>mapping keys to atoms.options.validateemits warnings for unknown server keys and missing resolver keys.options.onUnknownKey/options.onMissingKey: custom validation callbacks.Hydration.toValues(state)— filter dehydrated state to typed value entriesHydration.hydrateEffect(registry, state, resolvers, options?)— Effect constructor variant with optional strict typed failures
// Server
const state = Hydration.dehydrate(registry, [["count", countAtom]]);
// → embed `state` as JSON in HTML
// Client
Hydration.hydrate(registry, state, { count: countAtom });
// → countAtom is now pre-populated before first renderHydration.DehydratedAtom,Hydration.DehydratedAtomValue,Hydration.HydrateOptions,Hydration.HydrationError
Advanced protocols for restoring supported executable relationships and component state without replaying component setup. Ordinary components and closures remain valid; unsupported bindings fail closed so an adapter can activate the whole component.
Resume.event(action)— mark a zero-argument portable component action as a deferred resumable event.Resume.activationEvent(targetKey, Resume.MouseEventProjection, handler)— declare an activation-required event. The capture listener retains only the predefined Schema-backed projection, waits for the closest dormant boundary to commit, and replays through the keyed active listener exactly once. Incompatible event types (for examplekeydownwith the mouse projection) are rejected during collection and again during client installation.Resume.addressable({ id, buildId, props })(component)— make a fully composed component explicitly addressable from schema-encoded props. This must be the terminal component combinator so SSR and client activation refer to the same component.Resume.activationOf(component)— retrieve an addressable component's inferred portable entry for registration in the client resolver.Resume.componentActivation({ id, buildId, props, component })— low-level portable entry constructor for generated or framework-owned integrations.Resume.collect(render, options)— collect SSR event markers, supported committed state bindings, and explicit compiler-extracted text-expression regions into a versioned, schema-validated manifest. New expression collections use manifest v4's discriminated target record.Resume.decodeManifest(serialized, expectedBuildId, options?)— enforce the client payload-size ceiling, validate a v1/v2/v3/v4 manifest, and compare it with the independently supplied client build.Resume.installClient(options)— install the delegated portable-action adapter without running component setup or view. It validates the manifest again, uses capture listeners so non-bubbling events are observable, and rejects a second active installation on the same root. The returned installation exposes its validatedboundariesmap,boundaryState(componentId), the restore-firstresume(componentId)Effect, explicitactivate(componentId), and the low-levelwriteBinding(componentId, binding, schema, value)adapter hook. The schema infers and encodes the domain value, so application code does not cast wire values.writeBindingEncoded(componentId, binding, encodedValue)is the explicit framework-adapter escape hatch. A dormant state write updates the existing snapshot and lazily invalidates only dependent text expressions; query, active, missing, disposed, and encoding failures remain distinct typed errors.Resume.installClientScoped(options)— scoped form ofinstallClient; releasing the caller Scope disposes listeners, transitions, and activated component scopes.Resume.scanComponentBoundaries(root, manifest)— discover paired component comments and validate their manifest identity, uniqueness, completeness, and nesting.Resume.scanExpressionBoundaries(root, manifest)— perform the same validation for legacy-v3 and v4 textaf:exprcomment-pair regions. Client installation indexes their dependency keys without loading expression modules and releases component-owned subscribers before activation.Resume.scanExpressionTargets(root, manifest)— validate all v4 text and element targets, their marker/manifest bijection, target kind, and closest component owner. One element may carry several document-local IDs throughdata-af-expr; markers are removed only after the complete scan succeeds. A v4{ kind: "text" }target implies the existing comment-pair region; attribute and style-property names use conservative schema allowlists. Non-text installation remains explicitly unsupported until the corresponding patch strategies are enabled.PortableExtract.expr(render, options)infers capture and dependency tuple types from their codecs;bindanddepsare checked inputs and cannot widen those inferred domains. Active rendering reads state dependencies inside the reactive accessor. Collection verifies that each dependency codec can decode the exact encoded snapshot the dormant client will receive.Resume.snapshotState(schema)— declare how a namedComponent.statebinding is encoded and validated.Resume.inspectHandle(value)— inspect supported state-handle capabilities without exposing private signal/owner state.Resume.restoreStateBindings(component, manifest, componentId, props?)— validate a complete state/query component snapshot before allocation, then create fresh client-owned handles, Registry, portable behavior attachments, and Scope. The result containsbindings,queries,registry, and an idempotentdisposeEffect. The props argument is inferred and required when the component has required props; components accepting{}or only optional props may omit it.Resume.restoreStateBindingsScoped(component, manifest, componentId, props?)— scoped form with the same inferred props contract; fresh handles are invalidated when the caller Scope closes.
Manifest v2 component records declare region: { kind: "comment-pair" }.
During collection, the matching rendered output is surrounded by
<!--af:component:cN:start--> and <!--af:component:cN:end-->. Paired
sentinels represent element, fragment, text-only, and empty component output
without conflating a slot or element root with component ownership.
Explicitly addressable records also carry an activation portable descriptor.
Activation replaces only the nodes between the validated markers, runs the
component under the caller-owned ManagedRuntime, and does not publish
Active until async setup and the initial view mount have committed. It
disposes nested activated regions before their owner and is closed by the
installation without closing the caller runtime.
Collection diagnostics include phase, severity, and disposition.
Unsupported collection paths currently use
disposition: "fallback-required".
const Counter = Component.make(
Component.props<{}>(),
Component.require<never>(),
Component.setup<{}>().bind("count", () => Component.state(0), {
resume: Resume.snapshotState(Schema.Number),
}),
(_props, bindings) => bindings.count(),
);
const restored = yield * Resume.restoreStateBindings(Counter, manifest, "c0");
Component.renderWithBindings(Counter, {}, restored.bindings);
yield * restored.dispose;const Props = Schema.Struct({ id: Schema.String });
const Card = Component.make(
Component.propsSchema(Props),
Component.require<never>(),
({ id }) => Effect.succeed({ id }),
(_props, bindings) => bindings.id,
);
export const AddressableCard = Card.pipe(
// Put style, behavior, definition, and layer wrappers before this call.
Resume.addressable({
id: "app.card",
buildId: BuildId,
props: Props,
}),
);
export const CardActivation = Resume.activationOf(AddressableCard);
// The client resolver maps "app.card" to CardActivation.
yield * installation.activate("c0");The current restoration slice accepts named schema-backed state/query bindings,
canonical Component.withSlots(...) transforms, and portable behavior
transforms in authored order. The boundary transaction validates first,
allocates everything in one child Scope, and commits ownership only after
render/listener attachment succeeds. Documented restoration failures close
that Scope before optional activation; defects are terminal. No server atom,
Scope, owner, setter, listener, or finalizer is transferred.
Portable.makeResolver(...) keeps loader callbacks lazy, memoizes the returned
Effect once per resolver, and normalizes synchronous throws, typed failures,
and defects (including rejected dynamic imports) to
PortableCodeLoadError.
RPC client factory for flat endpoint maps. Wraps a transport function into a typed client with reactive query, mutation, and action handles.
AtomRpc.Tag()(id, { call, runtime? })— create a typed RPC client:query(tag, payload, options?)— reactive query;options.reactivityKeyssupportedmutation(tag, options?)— Effect mutation;options.reactivityKeysinvalidates declarativelyaction(tag, options?)— linear action handle with.run/.runEffect/.effect/.result/.pending; supportsreactivityKeys,onErrorrefresh(tag, payload)— force-refresh a query
AtomRpc.AtomRpcClient<Defs, R>
HTTP API client factory for grouped endpoints. Same ergonomics as AtomRpc but organized by group/endpoint rather than flat tags.
AtomHttpApi.Tag()(id, { call, runtime? })— create a typed HTTP API client:query(group, endpoint, request, options?)— reactive query;options.reactivityKeyssupportedmutation(group, endpoint, options?)— Effect mutation;options.reactivityKeysinvalidates declarativelyaction(group, endpoint, options?)— linear action handle with.run/.runEffect/.effect/.result/.pending; supportsreactivityKeys,onErrorrefresh(group, endpoint, request)— force-refresh a query
AtomHttpApi.AtomHttpApiClient<Defs, R>
For practical usage patterns and edge cases, see docs/ACTION_EFFECT_USE_RESOURCE.md.
Resultand scoped constructors are also available from@doeixd/affe/advanced.
atomEffect(fn, runtime?)— low-level reactive async computation. Tracks signal dependencies, interrupts previous fiber on re-run. Useful when you need direct control over the reactive graph.defineQuery(fn, options?)— ergonomic keyed query bundle. Returns{ key, result, pending, latest, effect, invalidate, refresh }. The preferred API when you need caching, invalidation, or observability.options.onTransition— emits{ phase: start|success|failure|defect, name? }for observabilityoptions.retrySchedule— retries typed failures with an EffectSchedulebefore settling toFailure; schedule failures remain in the inferred query error typeoptions.pollSchedule— invalidates the query key on a schedule for polling-style refresh; schedule requirements are preservedoptions.observe— emits metrics events{ kind, phase, name?, startedAt, finishedAt?, durationMs? }
scopedQueryEffect(scope, fn, options?)— Effect constructor variant for scope-bound query accessorsImport from:
@doeixd/affe/advancedcreateQueryKey<A>(name?)— create typed invalidation keys for queriesinvalidate(key)— invalidate one or many query keysisPending(result)—Accessor<boolean>;trueonly duringRefreshing(not initialLoading). Useful for showing a subtle "revalidating" spinner without hiding existing data.latest(result)—Accessor<A | undefined>with the last successful value. Useful for keeping old data visible during a refresh.query.effect()— convert query state toEffect<A, E | BridgeError>for typed composition in generator flows
Three async APIs serve different use cases. The right choice depends on whether you need services, invalidation, or just a reactive async value:
| API | Returns | Runtime | Dependencies | Use Case |
|---|---|---|---|---|
Atom.effect(fn) |
Atom<Result> |
No | None | Simple async without services |
Atom.query(fn, runtime?) |
Atom<Result> |
Optional | Effect services | Service-based query with DI |
atomEffect(fn, runtime?) |
Signal<Result> |
Optional | Signal reads | Low-level reactive effects in Computation contexts |
defineQuery(fn, options?) |
QueryRef |
Optional | All of the above | Keyed queries with invalidation, observability, polling |
// No services needed → Atom.effect
const posts = Atom.effect(() =>
Effect.tryPromise(() => fetch("/posts").then((r) => r.json() as Promise<PostList>)),
);
// posts() → Result<PostList, UnknownError>
// Needs an injected service → Atom.query or defineQuery
const user = Atom.query(() =>
Effect.service(UserApi).pipe(Effect.flatMap((api) => api.getUser("123"))),
);
// user() → Result<User, ApiError>
// Needs invalidation, polling, or observability → defineQuery
const data = defineQuery(() => Effect.tryPromise(() => fetch("/data")), {
name: "fetchData",
onTransition: ({ phase }) => console.log("Phase:", phase),
pollSchedule: Schedule.spaced("30 seconds"),
});
// data.result() → Result
// data.invalidate() → trigger refreshuseService(tag)— synchronously access a service from the ambient runtime. Throws if called outside amount(..., layer)tree; includes the missing service key when runtime exists but service is not provided.useServices({ ...tags })— resolve multiple services at once with inferred return typesmount(fn, container, layer)— bootstrap aManagedRuntimefrom aLayerand render. AlluseServicecalls inside the tree resolve from this runtime.createMount(layer)— create a mount function pre-bound to a layerlayerContext(layer, fn, runtime?)— run a function with a Layer-provided contextImport from:
@doeixd/affe/advanced- Component and mount lifetimes are scope-backed: disposing a parent root interrupts descendant Effect fibers transitively
scopedRootEffect(scope, fn)— Effect constructor variant for creating a reactive root tied to an Effect ScopeImport from:
@doeixd/affe/advanced
Atom.optimistic(source).action(spec)— create an optimistic action over a writable atom.spec.update(current, input)computes the visible optimistic value;spec.effect(next, input)runs the typed Effect; optionalreconcile,commit, andreactivityKeyscontrol server confirmation and refresh.Atom.runtime(layer).optimistic(source).action(spec)— runtime-bound optimistic action that satisfies service requirements from the runtime layer.Component.optimistic(source).action(spec)— component-scoped optimistic action for setup-created state.createOptimistic(source)— lower-level optimistic overlay primitive withget,set,clear,isPending. Use when integrating custom mutation machinery.defineMutation(fn, options?)— lower-level callback-style mutation withoptimistic,rollback,onSuccess,onFailure,refreshhooks. Returns{ run, effect, result, pending }. Supportsinvalidatesfor query key invalidation.effect(input)returnsEffect<void, E | BridgeError | MutationSupersededError>options.onTransitionemits{ phase: start|success|failure|defect }options.observeemits metrics events
scopedMutationEffect(scope, fn, options?)— Effect constructor variant for scope-bound mutation handlesImport from:
@doeixd/affe/advanced
Result has seven states because UI needs to distinguish cases that are often collapsed together:
| Variant | Description | Why separate? |
|---|---|---|
Idle |
Not started, and not starting until asked | A manual query before its first run is not "loading": nothing is in flight |
Loading |
Initial load, no value yet | First load needs a full skeleton/spinner, not just a subtle indicator |
Refreshing<A, E> |
Revalidating with previous settled value | Can show stale data + subtle indicator instead of hiding content |
Success<A> |
Settled with a value | Normal case |
Failure<E> |
Settled with a typed error | Expected error you can handle specifically |
Stale<A, E> |
Failed refresh with last successful data | Show the last good data and the typed error together |
Defect |
Unexpected defect or interrupt | Programming error or unhandled exception — usually a generic error boundary |
Failure vs Defect: Failure carries a typed E — you know what went wrong and can handle it specifically (e.g., show a "not found" message). Defect is the untyped escape hatch for things that shouldn't happen — bugs, unexpected exceptions, fiber interruptions. Separating them means your error UI can be typed and specific, not a generic catch-all.
Refreshing vs Loading: Refreshing carries the previous A and E values. This lets you show the last known data while a refresh is in progress, rather than replacing content with a spinner. isPending(result) is true only during Refreshing, not Loading.
Stale vs Failure: Stale carries both the typed error and the last successful data. Use it for failed refreshes where the UI can continue showing the last good value while surfacing the error.
Constructors/helpers: Result.loading, refreshing, success, failure, stale, defect, settled, fromExit, toExit, toOption, getData, getError, rawCause
Guards: Result.isLoading, isRefreshing, isSuccess, isFailure, isStale, isDefect
Combinators: Result.match, map, flatMap, getOrElse, getOrThrow, builder, all
Fluent, partial matcher. Every handler is optional and the render type accumulates across handlers, so a call site that does not care about a variant does not have to name it — and adding a variant later does not break it.
Result.builder(users())
.onLoading(() => <Spinner />)
.onSuccess((list) => <UserList users={list} />)
.onStale((error, list) => <UserList users={list} warning={error} />)
.onFailure((error) => <ErrorView error={error} />)
.render(); // JSX | undefinedHandlers: onIdle(), onLoading(), onRefreshing(previous), onSuccess(value),
onStale(error, data), onFailure(error), onDefect(cause, rawCause), then
render().
Two documented fallbacks keep short builders total:
Refreshingdelegates to the handler of the variant it wraps (so a builder with onlyonSuccessstill renders during a refresh).StaleandDefectdelegate toonFailure—Stalewith its typed error,Defectwith a taggedResultDefectErrorenvelope.onFailure's parameter is thereforeE | ResultDefectError.
render() returns undefined when the variant that occurred has no handler and
no fallback.
Combine a tuple of results into one result of a tuple.
const combined = Result.all([userResult, prefsResult]);
// Result<readonly [User, Prefs], UserError | PrefsError>Short-circuit priority is Defect > Failure > Stale > Loading > Refreshing > Success. Stale and Refreshing keep their combined data when every input
still has data available (Result.getData), so keep-stale-on-failure composes;
otherwise they degrade to Failure and Loading respectively.
These components pattern-match Result or conditional values and render the appropriate slot. They are the reactive equivalent of switch statements over async state.
Each returns an accessor: in JSX (<Async result={query()} ... />) the props are getters, and the component re-renders its branch when they change. A branch is rebuilt only when the selection changes (for Loading, only when loading flips), and the previous branch is disposed. Called as a plain function, read the accessor to get the current branch.
Async({ result, loading?, refreshing?, stale?, success, error?, defect? })— render slots based onResultstate.refreshing?is optional; falls back to the previous settled state.stale?is optional; falls back toerror?or thesuccessslot with stale data.Loading({ when, fallback?, children })— show children while loadingErrored({ result, children })— show children on errorTypedBoundary({ result, catch, children })— show children only when error matches a type guard orSchemaShow({ when, fallback?, children })— conditional renderingFor({ each, children })— list rendering with keyingSwitch/Match({ when, children })— multi-case conditionalMatchTag({ value, cases, fallback? })— type-safe_tagpattern matching. Works with any Effect-style tagged union.Optional({ when, fallback?, children })— render when truthyMatchOption({ value, some, none? })— match EffectOptionDynamic({ component, ...props })— dynamic component selection at runtimeWithLayer({ layer, runtime?, fallback?, children })— build a Layer and provide its services to a subtree (component setup,useService, nested boundaries); showsfallbackwhile an asynchronous layer builds, and releases the layer on unmountFrame({ children })/createFrame(initial?)— animation frame loop
Result<A, E>,Loading,Refreshing<A, E>,Success<A>,Failure<E>,Stale<A, E>,DefectBridgeError(ResultLoadingError | ResultDefectError),MutationSupersededErrorRuntimeLike<R, E>,OptimisticRef<T>,MutationEffectHandle<A, E>,MutationEffectOptions<A, E, R>
Import from:
@doeixd/affe/internals
Solid.js-compatible reactive primitives. These are the foundation that Atom is built on. You rarely need these directly — the Atom API is the intended surface. Use these only for advanced scenarios requiring direct signal graph access, or for interop with code written in Solid.js style.
createSignal<T>(initial, options?)→[Accessor<T>, Setter<T>]createEffect(fn)— run side effect when dependencies changecreateMemo(fn, options?)→Accessor<T>— cached derived valuecreateRoot(fn)— create a new reactive ownership scopecreateContext(defaultValue)/useContext(ctx)— dependency injectiononCleanup(fn)— register cleanup when owner disposesonMount(fn)— run after component mountsuntrack(fn)/sample(fn)— read without tracking dependenciesbatch(fn)— batch multiple writes into one notification passflush()— flush queued updates immediatelymergeProps(...sources)/splitProps(props, keys)— props utilitiesgetOwner()/runWithOwner(owner, fn)— ownership utilities
Why ownership matters: Every reactive computation runs inside an owner. When an owner disposes, all effects and cleanups registered under it run automatically. This prevents memory leaks and dangling subscriptions. The Atom API manages ownership transparently through mount() and Component.make; the raw createRoot is for cases where you need explicit scope control.
Accessor<T>,Setter<T>,SignalOptions<T>,Context<T>
Functions called by babel-plugin-jsx-dom-expressions compiled JSX output. You don't call these directly — the Babel plugin generates calls to them. They are documented here for framework authors and for understanding the compiled output.
template(html)— create a lazy factory that clones a reusable DOM or SSR templateinsert(parent, accessor, marker?, current?)— insert reactive childrencreateComponent(Comp, props)— instantiate a component in a new reactive rootspread(node, accessor, isSVG?, skipChildren?)— reactive prop spreadingattr(node, name, value)/prop(node, name, value)— set attributes/propertiesclassList(node, value, prev?)— reactive class togglingstyle(node, value, prev?)— reactive inline stylesdelegateEvents(events)— set up global event delegationrender(fn, container)— mount a component tree; returns dispose functionrenderWithHMR(fn, container, hot?, key?)— mount with Vite HMR self-accept + previous dispose handlingwithViteHMR(dispose, hot?, key?)— attach any disposer to Vite HMR lifecycle@doeixd/affe/vite—affe(options?), the Vite plugin that compiles.tsx/.jsxwithbabel-plugin-jsx-dom-expressions(options:include,exclude,hydratable,jsx,babelPlugins)
isServer—truewhenwindow/documentare unavailablerenderToString(fn)— render component tree to HTML string using virtual DOMhydrateRoot(fn, container)— attach reactivity to server-rendered DOM; returns dispose functionisHydrating()—trueduring hydration passgetNextHydrateNode()— advance hydration walker (for custom component hydration)getRequestEvent()/setRequestEvent(event)— SSR request context
For JSX runtime transforms, use the package entry: @doeixd/affe/runtime.
Renderer-neutral accessibility pattern contracts. These validate structure, slot capabilities, and declared slot events. They do not claim full WCAG or WAI-ARIA certification by themselves; behavior and renderer adapters still own runtime semantics.
A11y.pattern(name, slots)— create a pattern contract from aView.Slotscontract.A11y.validate(contract, view)— return diagnostics for missing slots, capability mismatches, and missing required events.- Built-in contracts:
Dialog,Tooltip,Popover,Tabs,Slider,Calendar,DragAndDrop. - Built-in slot contracts:
DialogSlots,TooltipSlots,PopoverSlots,TabsSlots,SliderSlots,CalendarSlots,DragAndDropSlots. A11y.catalog— built-in pattern catalog withtierand advisoryroles.
const diagnostics = A11y.validate(A11y.Dialog, view);Schema-driven form fields backed by signals. A form keeps raw field state, touched/dirty flags, typed validation errors, and a submit handle that can use local mutation semantics or single-flight transport.
Form.field(initial)— create a standalone field handle.Form.make(spec, options)— create a form from schema-backed field specs.Form.applyServerErrors(form, errors)— map server errors back onto fields.FieldHandle—value,touched,dirty,error,state,set,touch,reset,setError.FormHandle—fields,values,touched,dirty,valid,errors,validate,reset,submit.FormInvalid<E>— typed validation failure{ _tag: "FormInvalid", errors }.
const form = Form.make(
{
email: { schema: Schema.String, initial: "" },
},
{
onSubmit: (values) => UserService.save(values),
reactivityKeys: ["users"],
singleFlight: { mode: "auto" },
},
);
yield * form.validate();
form.submit.run();FieldHandle.reset() with no argument restores the current baseline.
reset(value) updates the baseline to value, including explicit
undefined.
Small in-process devtools primitives for timeline events, atom snapshots, slot-contract inspection, and MCP-style agent reads.
Devtools.createSession(options?)— create a timeline/snapshot session.session.record(event)— record action/query/invalidation/snapshot events.session.timeline()/session.snapshots()— read immutable snapshots.session.snapshotAtoms(entries)— store an atom snapshot.session.excludeFromHistory(name)— skip noisy named events.session.setKeyframeInterval(n)— configure automatic keyframes.session.mcp.readTimeline()/readSnapshots()/rewindTo(id)/dispatchNamed(name, run)— minimal agent control/read surface.Devtools.snapshotRegistry(registry, atoms)— snapshot atoms through a registry.Devtools.slotContractTree(view)— inspect rendered slot metadata.Devtools.observeToTimeline(session, event)— normalize named action/query observations into timeline events.
const session = Devtools.createSession({ keyframeInterval: 25 });
session.record({ kind: "action", name: "saveUser", phase: "start" });
session.mcp.readTimeline();Schema-backed wire codec service used by route loader payloads and result wire projection.
Serialization.Tag— Effect service tag.Serialization.layer— default Effect Schema / JSON codec layer.encodeSync(schema, value)/decodeSync(schema, input)— pure helpers.- Service methods:
serialize(schema, value)/deserialize(schema, input)onSerializationService. - Result helpers:
resultToWire,resultFromWire,encodeResult,decodeResult,encodeResultRecord,decodeResultRecord. - Wire schemas:
ResultWire,ResultWireRecord.
src/result-wire.ts is the only module that constructs or interprets the
flat result DTO (Initial | Success | Failure with waiting, timestamp,
previousSuccess). Serialization re-exports its schema and functions, so the
historic Serialization.resultToWire / resultFromWire paths keep working.
toWire(result, now?)— project a coreResult.nowdefaults toDate.nowand only feeds the write-onlytimestampfield; tests pin it for byte-deterministic output.fromWire(wire)— rehydrate a coreResult. Deliberately lenient: it accepts every historical wire value, including shapestoWirecan no longer produce.ResultWire,ResultWireRecord,ResultWireValue,SuccessWireValue.
waiting is the boolean shadow of Refreshing; previousSuccess is Stale.data
when settled and Refreshing.previous in flight; timestamp is a serializer
field with no model counterpart. Exit/rawCause are not on the wire — Defect
travels as error: { defect: string }. The mapping is frozen and pinned by
golden-byte fixtures in src/__tests__/serialization.test.ts.
Any new module needing a serializable result must import this one; a hand-rolled mapping is a review-blocking defect.
The public route helpers Route.serializeLoaderData(...),
Route.deserializeLoaderData(...), and Route.streamDeferredLoaderScripts(...)
use this service shape internally while keeping the loader wire DTO flat and
JSON-safe.
Qwik-parity as a configuration, not a fork: auto-capture plus the universal
seroval codec bundled as one preset. Lives in the workspace as a separate
package that builds against public @doeixd/affe subpaths only — it
doubles as the external-style consumer of the published adapter SPI
(@doeixd/affe/adapter-spi, gated by Resume.spiVersion).
Two entries, split on purpose:
@doeixd/affe-permissive(build/server):permissive({ buildId, vite?, stateHandles? })returns{ vitePlugins, serverLayer, clientLayer, spiVersion }— theextract.auto-capable compiler plugin and the async seroval codec. Both layer fields are the same layer: theDQ-012serializer stamp makes mismatched codecs fail closed, so split codecs would be a footgun. Import this from vite configs and server code only.@doeixd/affe-permissive/client(browser-safe):permissiveClient({ stateHandles? })returns the codec layer the client runtime must include, pluscreateHandleRegistry()and the fail-closed SPI gate. Importing the main entry from client code drags the compiler (babel) into the bundle — the chunk-size pins inbrowser-tests/permissive-demo.spec.tsguard this.
createHandleRegistry() closes the cross-process state-handle gap: register
each resumable handle under the same stable key on server and client, and a
serialized handle restores to the client's live handle. Unknown keys fail
closed.
Strict-mode projects that never construct a permissive/seroval layer fetch
zero seroval bytes — pinned by browser-tests/strict-mode-bytes.spec.ts.
End-to-end usage: examples/permissive-demo.
Small brand for already-sanitized HTML payloads.
SafeHtml.make(html)— brand a string as safe.SafeHtml.isSafeHtml(value)— runtime guard.SafeHtml.unwrap(value)— recover the underlying string.
Only call SafeHtml.make(...) after sanitization at the application boundary.
Unified diagnostic helpers that normalize View/Component/Style/Behavior/Route/ServerRoute validators into one shape.
Diagnostics.Diagnostic—{ source, severity, code, message, ... }Diagnostics.normalize(diagnostic, options?)— wrap an existing structured diagnosticDiagnostics.fromMessages(source, messages, options?)— wrap string validators like route/server-route checksDiagnostics.collectView(view, options?)— run slot-target, remap, tree, and optional platform checksDiagnostics.collectComponent(component, props)— render and validate declared-vs-rendered slot contractDiagnostics.collectStylePlatform(style, platform)— normalize style platform diagnosticsDiagnostics.collectBehaviorAttachment(behavior, map, view)— normalize behavior attachment diagnosticsDiagnostics.collectDoctorTargets(moduleExports, options?)— discover diagnostic arrays, route trees, server-route arrays, and{ diagnostics }objects from module-like exportsDiagnostics.doctorFromTargets(targets)— summarize collected doctor targetsDiagnostics.report(diagnostics),Diagnostics.layer(onDiagnostic, options?)— Effect-native reporting with optional dedupeDiagnostics.doctor(diagnostics)/Diagnostics.formatReport(report)— stable summary for tests, dev-mode integrations, and CLI outputDiagnostics.hasErrors(diagnostics)/Diagnostics.format(diagnostic)— test/CLI-friendly helpers
const diagnostics = Diagnostics.collectView(view, {
slotTargets: ["root", "missing"],
platform: webPlatform,
});
if (Diagnostics.hasErrors(diagnostics)) {
throw new Error(diagnostics.map(Diagnostics.format).join("\n"));
}CLI:
affe doctor ./dist/app-routes.js
affe doctor ./dist/app-routes.js --export app --export serverRoutes
affe doctor ./dist/app-routes.js --json
affe doctor ./dist/app-routes.js --fail-on-warningsThe imported module may export route trees, server route arrays, diagnostics
arrays, or objects with a diagnostics array. The command exits non-zero on
errors, and --fail-on-warnings promotes warnings to a failing CI result.
Testing utilities for reactive code without requiring DOM or jsdom.
withTestLayer combines an Effect runtime with a reactive ownership scope into one object. This lets you run atoms, actions, and queries inside tests as if they were mounted inside a real component tree — without any DOM setup.
withTestLayer(layer)— create a test harness:runtime— the underlying EffectManagedRuntime<R>run<A>(fn: () => A)— execute function in test context (atoms resolve, effects fire)tick(ms?)— wait for async workdispose()— dispose runtime and reactive scope
renderWithLayer(layer, ui)— create a harness and run a setup callback immediatelyrender(component, { props, layer? })— Affe component test driver; returns{ view, slots, driver }behaviorDriver(slots)— DOM-free event driver over Element handles:press,input,emit,attr,stylestyleOf/attrOf/expectStyle/expectAttr— direct handle assertions
import { render } from "@doeixd/affe/testing";
const { slots, driver } = await render(ButtonCard, { props: {} });
driver.press("button");
expect(slots.root.getStyle("opacity")).toBe(1);For full testing patterns, see docs/TESTING.md.
Babel JSX plugin integration. This module is imported by babel-plugin-jsx-dom-expressions during compilation. You configure it once and never import it directly.
Configure Babel:
{
"plugins": [
[
"babel-plugin-jsx-dom-expressions",
{
"moduleName": "@doeixd/affe/runtime",
"generate": "dom"
}
]
]
}For users: Handled automatically by the Babel plugin.
For framework authors: See babel-plugin-jsx-dom-expressions documentation for custom runtime implementation requirements.
Low-level Solid.js-compatible reactive primitives. Also re-exported from @doeixd/affe/advanced.
When to use: Only in advanced scenarios where you need direct access to the reactive graph — custom integrations, Solid.js interop, or building your own abstractions on top of the signal system.
For most applications: Use the Atom API instead.
Exports: createSignal, createEffect, createMemo, createRoot, createContext, useContext, onCleanup, onMount, untrack, sample, batch, flush, mergeProps, splitProps, getOwner, runWithOwner.
These are 100% compatible with Solid.js signal/effect patterns.
Named typed logical fact channels backed directly by Effect PubSub.
Event.channel(name)— declare an allocation-free channel contract.Event.schema(schema)— attach a schema for decoded payload inference andEvent.ingest(...)at untyped boundaries.Event.layer(channel, options)— provide one scoped channel service using Effect'sbounded,dropping,sliding, or explicitunboundedstrategy.Event.publish(channel, payload)— publish a typed payload; returns Effect's boolean acceptance result, not subscriber success.Event.ingest(channel, unknown)— decode unknown input then publish.Event.stream(channel)— consume with normal EffectStreamoperators orComponent.subscription(...).
Use direct Effect PubSub for private service-local channels. Event does not
provide a global bus, replay, request/reply, automatic Atom updates, or
automatic Reactivity invalidation.