raf-effect-atom 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,14 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.2.2](https://github.com/KATT/raf-effect-atom/compare/raf-effect-atom%400.2.1...raf-effect-atom%400.2.2) (2026-10-23)
4
+
5
+ ### Features
6
+
7
+ * **raf-effect-atom:** stop a loader's wait when its request is aborted ([b1f2f16](https://github.com/KATT/raf-effect-atom/commit/b1f2f16fcce9cd5dd1ba18fa653b40c805eebd93)), references [#41](https://github.com/KATT/raf-effect-atom/issues/41) [#40](https://github.com/KATT/raf-effect-atom/issues/40) [#40](https://github.com/KATT/raf-effect-atom/issues/40) [#41](https://github.com/KATT/raf-effect-atom/issues/41)
8
+
9
+ ### Bug Fixes
10
+
11
+ * **raf-effect-atom:** refuse to hydrate a shared registry during server rendering ([2f793a6](https://github.com/KATT/raf-effect-atom/commit/2f793a63627482bdf2f0e2891829f5191077cfc7))
12
+
13
+ ### Documentation
14
+
15
+ * **raf-effect-atom:** date clientAtomsMiddleware to 0.2.1, the release it shipped in ([c2a4288](https://github.com/KATT/raf-effect-atom/commit/c2a4288f4c5b63575e019712edc9fdc49a052bd2))
16
+ * record that the packages' module state is keyed by what one request owns ([22b23f9](https://github.com/KATT/raf-effect-atom/commit/22b23f947544f8c4459ef172e8b21499403059c4))
17
+
18
+ ## [0.2.1](https://github.com/KATT/raf-effect-atom/compare/raf-effect-atom%400.2.0...raf-effect-atom%400.2.1) (2026-10-23)
19
+
20
+ ### Features
21
+
22
+ - **raf-effect-atom:** run React Router loaders in the browser after the first page ([bee00a9](https://github.com/KATT/raf-effect-atom/commit/bee00a981fd24fcdc9d746053dcd73dbc7ebc853)), references [#41](https://github.com/KATT/raf-effect-atom/issues/41) [#41](https://github.com/KATT/raf-effect-atom/issues/41)
23
+
3
24
  ## [0.2.0](https://github.com/KATT/raf-effect-atom/compare/raf-effect-atom%400.1.0...raf-effect-atom%400.2.0) (2026-10-23)
4
25
 
5
26
  ### ⚠ BREAKING CHANGES
6
27
 
7
- * **raf-effect-atom:** `dehydrate()` from `raf-effect-atom/next` returns a
28
+ - **raf-effect-atom:** `dehydrate()` from `raf-effect-atom/next` returns a
8
29
 
9
30
  ### Features
10
31
 
11
- * **raf-effect-atom:** seed each request registry, async seeds included ([62b6180](https://github.com/KATT/raf-effect-atom/commit/62b618098df87e711ddb4c1d633e191ba3353438))
32
+ - **raf-effect-atom:** seed each request registry, async seeds included ([62b6180](https://github.com/KATT/raf-effect-atom/commit/62b618098df87e711ddb4c1d633e191ba3353438))
12
33
 
13
34
  ## 0.1.0 (2026-10-23)
14
35
 
package/README.md CHANGED
@@ -53,7 +53,7 @@ Each framework has a subpath that gives every server request its own registry an
53
53
 
54
54
  - `raf-effect-atom/tanstack-router`, for TanStack Router and TanStack Start: `setupRouterAtoms({ router, registry })` in `getRouter`, with `Loader.make(registry)` on the router context.
55
55
  - `raf-effect-atom/next`, for the App Router: `prefetch`, `ensure` and `fetch` in server components, which pass the handles to client components as props, and `<HydrateAtoms state={await dehydrate()}>` around them. `makeRequestAtoms({ seed })` makes the same functions over a registry seeded from the request, such as with its cookie.
56
- - `raf-effect-atom/react-router`, for Framework Mode: `atomsMiddleware(atomsContext)` as the root middleware, loaders that return `atoms.dehydrate()` with their handles, and `HydrationBoundary` in the route component.
56
+ - `raf-effect-atom/react-router`, for Framework Mode: `atomsMiddleware(atomsContext)` as the root middleware and `clientAtomsMiddleware(atomsContext)` as its client middleware, loaders that return `atoms.dehydrate()` with their handles and double as client loaders, and `HydrationBoundary` in the route component.
57
57
 
58
58
  In development, `useAtomResult` reports a read that starts an atom nothing started, a request waterfall. `<WaterfallDetection mode="throw">` at the root makes it throw to the nearest error boundary. `useWaterfall(source, ...args)` starts an atom whose input exists only in render, marks it as a known waterfall, and returns its handle.
59
59
 
package/dist/Hooks.mjs CHANGED
@@ -1,16 +1,13 @@
1
1
  "use client";
2
2
  import { stream } from "./internal/pending.mjs";
3
3
  import { hydrated } from "./Hydration.mjs";
4
- import { isDevelopment } from "./internal/development.mjs";
5
4
  import { keyOf, release } from "./internal/leases.mjs";
6
5
  import { atomOf } from "./internal/handles.mjs";
7
- import { isRequestRegistry } from "./RequestRegistry.mjs";
8
- import { isServer } from "./internal/registry.mjs";
6
+ import { requireRequestRegistry } from "./internal/registry.mjs";
9
7
  import { ModeContext, check } from "./internal/waterfalls.mjs";
10
8
  import * as Atom from "effect/reactivity/Atom";
11
9
  import * as Predicate from "effect/Predicate";
12
10
  import * as Cause from "effect/Cause";
13
- import * as Effect from "effect/Effect";
14
11
  import * as Option from "effect/Option";
15
12
  import * as AsyncResult from "effect/reactivity/AsyncResult";
16
13
  import { RegistryContext, useAtomSuspense } from "@effect/atom-react";
@@ -111,7 +108,7 @@ const sourceOf = (atom) => {
111
108
  const useAtomResult = (source, handle) => {
112
109
  const registry = React.useContext(RegistryContext);
113
110
  const atom = atomOf(source, handle.args);
114
- if (isServer() && !isRequestRegistry(registry) && Effect.runSync(isDevelopment)) throw new Error("raf-effect-atom: useAtomResult rendered on the server without a request registry, so results would be shared between requests. Provide RequestRegistry.make() through RegistryContext, as the framework integrations do.");
111
+ requireRequestRegistry(registry, "useAtomResult");
115
112
  const target = sourceOf(atom);
116
113
  const started = hydrated(target);
117
114
  const read = target === atom ? hydrated(atom) : atom;
package/dist/Loader.d.mts CHANGED
@@ -5,7 +5,7 @@ import * as Effect from "effect/Effect";
5
5
  import * as AsyncResult from "effect/reactivity/AsyncResult";
6
6
  import * as AtomRegistry from "effect/reactivity/AtomRegistry";
7
7
  declare namespace Loader_d_exports {
8
- export { LeaseTTL, Loader, SettledResult, ensure, fetch, make, prefetch, result };
8
+ export { LeaseTTL, Loader, MakeOptions, SettledResult, ensure, fetch, make, prefetch, result };
9
9
  }
10
10
  /**
11
11
  * A settled `AsyncResult`: what `ensure` and `fetch` return.
@@ -166,6 +166,23 @@ export interface Loader {
166
166
  */
167
167
  readonly prefetch: <S extends Source>(source: S, ...args: ArgsOf<S>) => Handle<S>;
168
168
  }
169
+ /**
170
+ * Options for `make`.
171
+ *
172
+ * @stability unstable
173
+ * @category models
174
+ * @since 0.3.0
175
+ */
176
+ export interface MakeOptions {
177
+ /** How long a lease lasts in the browser. See {@link LeaseTTL}. */
178
+ readonly leaseTTL?: Duration.Input | undefined;
179
+ /**
180
+ * The request's or the navigation's abort signal. Aborting it interrupts
181
+ * the `ensure` and `fetch` calls still waiting, which reject with its
182
+ * reason. The atoms they started keep loading.
183
+ */
184
+ readonly signal?: AbortSignal | undefined;
185
+ }
169
186
  /**
170
187
  * Creates the Promise verbs over one registry.
171
188
  *
@@ -174,15 +191,22 @@ export interface Loader {
174
191
  * Use in a framework integration, in loaders that are async functions, or
175
192
  * in an event handler that starts an atom before the state change that
176
193
  * renders its reader, for state that can't be in the URL, such as a
177
- * search-as-you-type query. In Effect code, use
178
- * the verbs directly with the `AtomRegistry` provided.
194
+ * search-as-you-type query. In Effect code, use the verbs directly with the
195
+ * `AtomRegistry` provided.
196
+ *
197
+ * **Details**
198
+ *
199
+ * With a `signal`, an aborted request or navigation stops waiting: `ensure`
200
+ * and `fetch` reject with the signal's reason, and the atoms they started
201
+ * keep loading. The React Router and Next.js integrations pass the
202
+ * request's.
179
203
  *
180
204
  * **Example**
181
205
  *
182
206
  * ```ts
183
207
  * import { Loader, RequestRegistry } from "raf-effect-atom"
184
208
  *
185
- * const loader = Loader.make(RequestRegistry.make())
209
+ * const loader = Loader.make(RequestRegistry.make(), { signal: request.signal })
186
210
  * const comments = loader.prefetch(commentPage, 1, 0)
187
211
  * const issueHandle = await loader.ensure(issue, 1)
188
212
  * ```
@@ -191,7 +215,5 @@ export interface Loader {
191
215
  * @category constructors
192
216
  * @since 0.1.0
193
217
  */
194
- export declare const make: (registry: AtomRegistry.AtomRegistry, options?: {
195
- readonly leaseTTL?: Duration.Input | undefined;
196
- }) => Loader;
218
+ export declare const make: (registry: AtomRegistry.AtomRegistry, options?: MakeOptions) => Loader;
197
219
  export { Loader_d_exports };
package/dist/Loader.mjs CHANGED
@@ -250,6 +250,9 @@ const result = (handle) => {
250
250
  if (settledResult === void 0) throw new Error("raf-effect-atom: Loader.result got a handle that no ensure or fetch returned here. Only the code that ran the verb has its result: a handle that crossed the network is a copy. Read it with useAtomResult instead.");
251
251
  return settledResult;
252
252
  };
253
+ const runPromise = (effect, signal) => Effect.runPromise(effect, { signal }).catch((error) => {
254
+ throw signal?.aborted ? signal.reason : error;
255
+ });
253
256
  /**
254
257
  * Creates the Promise verbs over one registry.
255
258
  *
@@ -258,15 +261,22 @@ const result = (handle) => {
258
261
  * Use in a framework integration, in loaders that are async functions, or
259
262
  * in an event handler that starts an atom before the state change that
260
263
  * renders its reader, for state that can't be in the URL, such as a
261
- * search-as-you-type query. In Effect code, use
262
- * the verbs directly with the `AtomRegistry` provided.
264
+ * search-as-you-type query. In Effect code, use the verbs directly with the
265
+ * `AtomRegistry` provided.
266
+ *
267
+ * **Details**
268
+ *
269
+ * With a `signal`, an aborted request or navigation stops waiting: `ensure`
270
+ * and `fetch` reject with the signal's reason, and the atoms they started
271
+ * keep loading. The React Router and Next.js integrations pass the
272
+ * request's.
263
273
  *
264
274
  * **Example**
265
275
  *
266
276
  * ```ts
267
277
  * import { Loader, RequestRegistry } from "raf-effect-atom"
268
278
  *
269
- * const loader = Loader.make(RequestRegistry.make())
279
+ * const loader = Loader.make(RequestRegistry.make(), { signal: request.signal })
270
280
  * const comments = loader.prefetch(commentPage, 1, 0)
271
281
  * const issueHandle = await loader.ensure(issue, 1)
272
282
  * ```
@@ -277,13 +287,14 @@ const result = (handle) => {
277
287
  */
278
288
  const make = (registry, options) => {
279
289
  const leaseTTL = options?.leaseTTL === void 0 ? void 0 : Duration.fromInputUnsafe(options.leaseTTL);
290
+ const signal = options?.signal;
280
291
  const provide = (effect) => {
281
292
  const provided = Effect.provideService(effect, AtomRegistry.AtomRegistry, registry);
282
293
  return leaseTTL === void 0 ? provided : Effect.provideService(provided, LeaseTTL, leaseTTL);
283
294
  };
284
295
  return {
285
- ensure: (source, ...args) => Effect.runPromise(provide(ensure(source, ...args))),
286
- fetch: (source, ...args) => Effect.runPromise(provide(fetch(source, ...args))),
296
+ ensure: (source, ...args) => runPromise(provide(ensure(source, ...args)), signal),
297
+ fetch: (source, ...args) => runPromise(provide(fetch(source, ...args)), signal),
287
298
  prefetch: (source, ...args) => {
288
299
  const atom = atomOf(source, args);
289
300
  afterSeed(registry, () => {
package/dist/Next.d.mts CHANGED
@@ -67,7 +67,9 @@ export declare const makeRequestAtoms: (options?: Options) => RequestAtoms;
67
67
  *
68
68
  * Made once per request through React's `cache`, and disposed through
69
69
  * `cacheSignal` when React finishes the render, after the last streamed
70
- * result has been sent.
70
+ * result has been sent. The verbs take the same signal: when React aborts
71
+ * the render, such as for a navigation the browser left, the `ensure` and
72
+ * `fetch` calls still waiting reject with its reason.
71
73
  *
72
74
  * **Gotchas**
73
75
  *
package/dist/Next.mjs CHANGED
@@ -48,7 +48,9 @@ const atoms = make();
48
48
  *
49
49
  * Made once per request through React's `cache`, and disposed through
50
50
  * `cacheSignal` when React finishes the render, after the last streamed
51
- * result has been sent.
51
+ * result has been sent. The verbs take the same signal: when React aborts
52
+ * the render, such as for a navigation the browser left, the `ensure` and
53
+ * `fetch` calls still waiting reject with its reason.
52
54
  *
53
55
  * **Gotchas**
54
56
  *
@@ -35,6 +35,12 @@ export interface HydrationBoundaryProps {
35
35
  * there, so an `effect-atom-boundary` boundary sees their failures before
36
36
  * its children read them and renders its handler on the server.
37
37
  *
38
+ * **Gotchas**
39
+ *
40
+ * In development, it throws during server rendering when the registry isn't
41
+ * a request registry, such as `@effect/atom-react`'s default one, which
42
+ * every request shares: one request's state would reach the others.
43
+ *
38
44
  * **Example**
39
45
  *
40
46
  * ```tsx
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { split } from "./internal/pending.mjs";
3
3
  import { sourceOf } from "./internal/sources.mjs";
4
- import { isServer } from "./internal/registry.mjs";
4
+ import { isServer, requireRequestRegistry } from "./internal/registry.mjs";
5
5
  import { HydrationBoundary, RegistryContext } from "@effect/atom-react";
6
6
  import * as React from "react";
7
7
  import { jsx } from "react/jsx-runtime";
@@ -36,6 +36,12 @@ import { jsx } from "react/jsx-runtime";
36
36
  * there, so an `effect-atom-boundary` boundary sees their failures before
37
37
  * its children read them and renders its handler on the server.
38
38
  *
39
+ * **Gotchas**
40
+ *
41
+ * In development, it throws during server rendering when the registry isn't
42
+ * a request registry, such as `@effect/atom-react`'s default one, which
43
+ * every request shares: one request's state would reach the others.
44
+ *
39
45
  * **Example**
40
46
  *
41
47
  * ```tsx
@@ -55,6 +61,7 @@ const HydrationBoundary$1 = (props) => {
55
61
  const state = props.state;
56
62
  const source = isServer() ? sourceOf(state) : void 0;
57
63
  const registry = source ?? context;
64
+ requireRequestRegistry(registry, "HydrationBoundary");
58
65
  const settled = React.useMemo(() => state === void 0 || source !== void 0 ? void 0 : split(registry, state), [
59
66
  registry,
60
67
  source,
@@ -3,7 +3,9 @@ import { RegistryProvider } from "./RegistryProvider.mjs";
3
3
  import * as EffectHydration from "effect/reactivity/Hydration";
4
4
  import * as AtomRegistry from "effect/reactivity/AtomRegistry";
5
5
  /**
6
- * One request's registry, with the loader verbs over it.
6
+ * One request's registry, with the loader verbs over it. In a client loader,
7
+ * the browser's registry, whose `dehydrate` returns nothing: its atoms are
8
+ * already where the components read them.
7
9
  *
8
10
  * @stability unstable
9
11
  * @category models
@@ -68,11 +70,14 @@ export interface AtomsMiddlewareOptions {
68
70
  * `seed` runs on the new registry before `next`, so every loader's atoms
69
71
  * start with what it sets.
70
72
  *
73
+ * The verbs take the request's `signal`: when the client goes before the
74
+ * response, such as a navigation that leaves while its loaders wait, the
75
+ * `ensure` and `fetch` calls still waiting reject with its reason.
76
+ *
71
77
  * **Gotchas**
72
78
  *
73
79
  * It's server middleware: export it as the root route's `middleware`, not
74
- * `clientMiddleware`. In the browser, the root's `RegistryProvider` holds
75
- * the registry.
80
+ * `clientMiddleware`, which takes `clientAtomsMiddleware`.
76
81
  *
77
82
  * **Example**
78
83
  *
@@ -91,4 +96,49 @@ export interface AtomsMiddlewareOptions {
91
96
  * @since 0.1.0
92
97
  */
93
98
  export declare const atomsMiddleware: (context: AtomsContext, options?: AtomsMiddlewareOptions) => <Result>(args: MiddlewareArgs, next: () => Promise<Result>) => Promise<Result>;
99
+ /**
100
+ * Creates a root client middleware that gives client loaders the browser's
101
+ * registry, under the context `atomsMiddleware` uses on the server.
102
+ *
103
+ * **When to use**
104
+ *
105
+ * Export it as the root route's `clientMiddleware`, and each route's loader
106
+ * function as both its `loader` and its `clientLoader`. The first page's loaders run on
107
+ * the server and stream their atoms in; after that, navigations run the
108
+ * loaders in the browser. `ensure` then renders what the browser's registry
109
+ * holds without a request, and a link's prefetch doesn't fetch a route's
110
+ * `.data` that the click would fetch again.
111
+ *
112
+ * **Details**
113
+ *
114
+ * The registry is the one the mounted `RegistryProvider` provides. Its
115
+ * `dehydrate` returns nothing, so a loader's data has the same shape in
116
+ * the browser and on the server, and `HydrationBoundary` has nothing to do.
117
+ * The verbs take the navigation's `signal`: when another navigation
118
+ * interrupts it, the `ensure` and `fetch` calls still waiting reject with
119
+ * its reason, and the atoms keep loading.
120
+ *
121
+ * **Gotchas**
122
+ *
123
+ * The atoms run in the browser, so their RPC protocol must reach the server
124
+ * from there, as it does for anything a component starts.
125
+ *
126
+ * **Example**
127
+ *
128
+ * ```ts
129
+ * // app/root.tsx
130
+ * export const middleware = [atomsMiddleware(atomsContext)]
131
+ * export const clientMiddleware = [clientAtomsMiddleware(atomsContext)]
132
+ *
133
+ * // app/routes/issue.tsx: one function for both, since React Router
134
+ * // removes `loader` from the browser's bundle.
135
+ * export const loader = load
136
+ * export const clientLoader = load
137
+ * ```
138
+ *
139
+ * @stability unstable
140
+ * @category integrations
141
+ * @since 0.2.1
142
+ */
143
+ export declare const clientAtomsMiddleware: (context: AtomsContext) => <Result>(args: MiddlewareArgs, next: () => Promise<Result>) => Promise<Result>;
94
144
  export { RegistryProvider };
@@ -1,6 +1,7 @@
1
1
  import { dehydrate } from "./Hydration.mjs";
2
2
  import { make, settled } from "./RequestRegistry.mjs";
3
3
  import { make as make$1 } from "./Loader.mjs";
4
+ import { current } from "./internal/registry.mjs";
4
5
  import { RegistryProvider } from "./RegistryProvider.mjs";
5
6
  import * as Predicate from "effect/Predicate";
6
7
  // src/ReactRouter.ts
@@ -10,8 +11,11 @@ import * as Predicate from "effect/Predicate";
10
11
  * A root `middleware` gives each server request its own registry, under a
11
12
  * router context the app creates. Loaders start atoms with its verbs and
12
13
  * return its `dehydrate()` with their data; the route component hydrates it
13
- * with `HydrationBoundary`, inside the root's `RegistryProvider`. Nothing is
14
- * imported from `react-router`.
14
+ * with `HydrationBoundary`, inside the root's `RegistryProvider`. A root
15
+ * `clientMiddleware` puts the browser's registry under the same context, so
16
+ * one function can be both the route's `loader` and its `clientLoader`: after the first
17
+ * page, loaders run in the browser, where `ensure` finds what the registry
18
+ * already holds. Nothing is imported from `react-router`.
15
19
  *
16
20
  * **Example**
17
21
  *
@@ -26,14 +30,18 @@ import * as Predicate from "effect/Predicate";
26
30
  * import { atomsMiddleware } from "raf-effect-atom/react-router"
27
31
  *
28
32
  * export const middleware = [atomsMiddleware(atomsContext)]
33
+ * export const clientMiddleware = [clientAtomsMiddleware(atomsContext)]
29
34
  *
30
35
  * // app/routes/issue.tsx
31
- * export async function loader(args: Route.LoaderArgs) {
36
+ * async function load(args: Route.LoaderArgs) {
32
37
  * const atoms = args.context.get(atomsContext)
33
38
  * const handle = await atoms.ensure(issue, Number(args.params.id))
34
39
  * return { atoms: atoms.dehydrate(), issue: handle }
35
40
  * }
36
41
  *
42
+ * export const loader = load
43
+ * export const clientLoader = load
44
+ *
37
45
  * export default function IssueRoute(props: Route.ComponentProps) {
38
46
  * return (
39
47
  * <HydrationBoundary state={props.loaderData.atoms}>
@@ -61,11 +69,14 @@ import * as Predicate from "effect/Predicate";
61
69
  * `seed` runs on the new registry before `next`, so every loader's atoms
62
70
  * start with what it sets.
63
71
  *
72
+ * The verbs take the request's `signal`: when the client goes before the
73
+ * response, such as a navigation that leaves while its loaders wait, the
74
+ * `ensure` and `fetch` calls still waiting reject with its reason.
75
+ *
64
76
  * **Gotchas**
65
77
  *
66
78
  * It's server middleware: export it as the root route's `middleware`, not
67
- * `clientMiddleware`. In the browser, the root's `RegistryProvider` holds
68
- * the registry.
79
+ * `clientMiddleware`, which takes `clientAtomsMiddleware`.
69
80
  *
70
81
  * **Example**
71
82
  *
@@ -92,7 +103,7 @@ const atomsMiddleware = (context, options) => async (args, next) => {
92
103
  registry.dispose();
93
104
  throw error;
94
105
  }
95
- const loader = make$1(registry);
106
+ const loader = make$1(registry, { signal: args.request.signal });
96
107
  args.context.set(context, {
97
108
  ...loader,
98
109
  dehydrate: () => dehydrate(registry),
@@ -135,4 +146,58 @@ const disposeAfter = (body, registry) => {
135
146
  }
136
147
  });
137
148
  };
138
- export { RegistryProvider, atomsMiddleware };
149
+ /**
150
+ * Creates a root client middleware that gives client loaders the browser's
151
+ * registry, under the context `atomsMiddleware` uses on the server.
152
+ *
153
+ * **When to use**
154
+ *
155
+ * Export it as the root route's `clientMiddleware`, and each route's loader
156
+ * function as both its `loader` and its `clientLoader`. The first page's loaders run on
157
+ * the server and stream their atoms in; after that, navigations run the
158
+ * loaders in the browser. `ensure` then renders what the browser's registry
159
+ * holds without a request, and a link's prefetch doesn't fetch a route's
160
+ * `.data` that the click would fetch again.
161
+ *
162
+ * **Details**
163
+ *
164
+ * The registry is the one the mounted `RegistryProvider` provides. Its
165
+ * `dehydrate` returns nothing, so a loader's data has the same shape in
166
+ * the browser and on the server, and `HydrationBoundary` has nothing to do.
167
+ * The verbs take the navigation's `signal`: when another navigation
168
+ * interrupts it, the `ensure` and `fetch` calls still waiting reject with
169
+ * its reason, and the atoms keep loading.
170
+ *
171
+ * **Gotchas**
172
+ *
173
+ * The atoms run in the browser, so their RPC protocol must reach the server
174
+ * from there, as it does for anything a component starts.
175
+ *
176
+ * **Example**
177
+ *
178
+ * ```ts
179
+ * // app/root.tsx
180
+ * export const middleware = [atomsMiddleware(atomsContext)]
181
+ * export const clientMiddleware = [clientAtomsMiddleware(atomsContext)]
182
+ *
183
+ * // app/routes/issue.tsx: one function for both, since React Router
184
+ * // removes `loader` from the browser's bundle.
185
+ * export const loader = load
186
+ * export const clientLoader = load
187
+ * ```
188
+ *
189
+ * @stability unstable
190
+ * @category integrations
191
+ * @since 0.2.1
192
+ */
193
+ const clientAtomsMiddleware = (context) => (args, next) => {
194
+ const registry = current();
195
+ if (registry === void 0) throw new Error("clientAtomsMiddleware found no mounted RegistryProvider: render raf-effect-atom/react-router's RegistryProvider around the app in the root layout.");
196
+ args.context.set(context, {
197
+ ...make$1(registry, { signal: args.request.signal }),
198
+ dehydrate: () => [],
199
+ registry
200
+ });
201
+ return next();
202
+ };
203
+ export { RegistryProvider, atomsMiddleware, clientAtomsMiddleware };
@@ -18,6 +18,9 @@ import * as React from "react";
18
18
  * It's read once, when the registry is made. The server's request registry
19
19
  * lives for one request, so it has none.
20
20
  *
21
+ * In the browser, React Router's `clientAtomsMiddleware` gives client
22
+ * loaders the registry of the mounted provider.
23
+ *
21
24
  * **Example**
22
25
  *
23
26
  * ```tsx
@@ -1,5 +1,5 @@
1
1
  "use client";
2
- import { make } from "./internal/registry.mjs";
2
+ import { make, mount } from "./internal/registry.mjs";
3
3
  import { RegistryContext } from "@effect/atom-react";
4
4
  import * as React from "react";
5
5
  import { jsx } from "react/jsx-runtime";
@@ -29,6 +29,9 @@ import { jsx } from "react/jsx-runtime";
29
29
  * It's read once, when the registry is made. The server's request registry
30
30
  * lives for one request, so it has none.
31
31
  *
32
+ * In the browser, React Router's `clientAtomsMiddleware` gives client
33
+ * loaders the registry of the mounted provider.
34
+ *
32
35
  * **Example**
33
36
  *
34
37
  * ```tsx
@@ -47,6 +50,7 @@ import { jsx } from "react/jsx-runtime";
47
50
  */
48
51
  const RegistryProvider = (props) => {
49
52
  const [registry] = React.useState(() => make({ defaultIdleTTL: props.defaultIdleTTL }));
53
+ React.useEffect(() => mount(registry), [registry]);
50
54
  return /* @__PURE__ */ jsx(RegistryContext.Provider, {
51
55
  value: registry,
52
56
  children: props.children
@@ -3,13 +3,16 @@ import { make as make$1, settled } from "../RequestRegistry.mjs";
3
3
  import { make as make$2 } from "../Loader.mjs";
4
4
  import * as React from "react";
5
5
  // src/internal/next.ts
6
+ const renderSignal = () => {
7
+ return React.cacheSignal();
8
+ };
6
9
  const make = (options) => {
7
10
  const getRegistry = React.cache(() => {
8
11
  const registry = make$1(options);
9
- React.cacheSignal()?.addEventListener("abort", () => registry.dispose(), { once: true });
12
+ renderSignal()?.addEventListener("abort", () => registry.dispose(), { once: true });
10
13
  return registry;
11
14
  });
12
- const getLoader = React.cache(() => make$2(getRegistry()));
15
+ const getLoader = React.cache(() => make$2(getRegistry(), { signal: renderSignal() ?? void 0 }));
13
16
  return {
14
17
  dehydrate: async () => {
15
18
  const registry = getRegistry();
@@ -1,8 +1,18 @@
1
- import { make as make$1 } from "../RequestRegistry.mjs";
1
+ import { isDevelopment } from "./development.mjs";
2
+ import { isRequestRegistry, make as make$1 } from "../RequestRegistry.mjs";
3
+ import * as Effect from "effect/Effect";
2
4
  import * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
5
  import { scheduleTask } from "@effect/atom-react";
4
6
  // src/internal/registry.ts
5
7
  const isServer = () => !("window" in globalThis);
8
+ /**
9
+ * In development, throws when `rendered` renders on the server without a
10
+ * request registry: in one every request shares, such as
11
+ * `@effect/atom-react`'s default `RegistryContext`.
12
+ */
13
+ const requireRequestRegistry = (registry, rendered) => {
14
+ if (isServer() && !isRequestRegistry(registry) && Effect.runSync(isDevelopment)) throw new Error(`raf-effect-atom: ${rendered} rendered on the server without a request registry, so results would be shared between requests. Provide RequestRegistry.make() through RegistryContext, as the framework integrations do.`);
15
+ };
6
16
  /** `@effect/atom-react`'s default registry keeps an unused atom this long. */
7
17
  const defaultIdleTTL = 400;
8
18
  /**
@@ -13,4 +23,18 @@ const make = (options) => isServer() ? make$1({ seed: options?.seed }) : AtomReg
13
23
  defaultIdleTTL: options?.defaultIdleTTL ?? defaultIdleTTL,
14
24
  scheduleTask
15
25
  });
16
- export { isServer, make };
26
+ let mounted;
27
+ /**
28
+ * Records the browser registry a `RegistryProvider` provides while it's
29
+ * mounted, for code outside React, such as React Router's client
30
+ * middleware. Returns the cleanup that forgets it.
31
+ */
32
+ const mount = (registry) => {
33
+ mounted = registry;
34
+ return () => {
35
+ if (mounted === registry) mounted = void 0;
36
+ };
37
+ };
38
+ /** The browser registry of the mounted `RegistryProvider`, if one is. */
39
+ const current = () => mounted;
40
+ export { current, isServer, make, mount, requireRequestRegistry };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "raf-effect-atom",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Render-as-you-fetch for Effect Atom: start atoms from route loaders and stream them into the browser, typed failures intact",
5
5
  "keywords": [
6
6
  "effect",
@@ -58,7 +58,7 @@
58
58
  "@types/react-dom": "19.3.0",
59
59
  "@vitest/coverage-v8": "5.0.5",
60
60
  "effect": "4.0.6",
61
- "effect-atom-boundary": "0.1.0",
61
+ "effect-atom-boundary": "0.1.1",
62
62
  "happy-dom": "21.0.4",
63
63
  "react": "19.3.1",
64
64
  "react-dom": "19.3.1",