raf-effect-atom 0.2.1 → 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,10 +1,25 @@
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
+
3
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)
4
19
 
5
20
  ### Features
6
21
 
7
- * **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)
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)
8
23
 
9
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)
10
25
 
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,
@@ -70,6 +70,10 @@ export interface AtomsMiddlewareOptions {
70
70
  * `seed` runs on the new registry before `next`, so every loader's atoms
71
71
  * start with what it sets.
72
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
+ *
73
77
  * **Gotchas**
74
78
  *
75
79
  * It's server middleware: export it as the root route's `middleware`, not
@@ -110,6 +114,9 @@ export declare const atomsMiddleware: (context: AtomsContext, options?: AtomsMid
110
114
  * The registry is the one the mounted `RegistryProvider` provides. Its
111
115
  * `dehydrate` returns nothing, so a loader's data has the same shape in
112
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.
113
120
  *
114
121
  * **Gotchas**
115
122
  *
@@ -131,7 +138,7 @@ export declare const atomsMiddleware: (context: AtomsContext, options?: AtomsMid
131
138
  *
132
139
  * @stability unstable
133
140
  * @category integrations
134
- * @since 0.2.0
141
+ * @since 0.2.1
135
142
  */
136
143
  export declare const clientAtomsMiddleware: (context: AtomsContext) => <Result>(args: MiddlewareArgs, next: () => Promise<Result>) => Promise<Result>;
137
144
  export { RegistryProvider };
@@ -69,6 +69,10 @@ import * as Predicate from "effect/Predicate";
69
69
  * `seed` runs on the new registry before `next`, so every loader's atoms
70
70
  * start with what it sets.
71
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
+ *
72
76
  * **Gotchas**
73
77
  *
74
78
  * It's server middleware: export it as the root route's `middleware`, not
@@ -99,7 +103,7 @@ const atomsMiddleware = (context, options) => async (args, next) => {
99
103
  registry.dispose();
100
104
  throw error;
101
105
  }
102
- const loader = make$1(registry);
106
+ const loader = make$1(registry, { signal: args.request.signal });
103
107
  args.context.set(context, {
104
108
  ...loader,
105
109
  dehydrate: () => dehydrate(registry),
@@ -142,19 +146,6 @@ const disposeAfter = (body, registry) => {
142
146
  }
143
147
  });
144
148
  };
145
- const browserAtoms = /* @__PURE__ */ new WeakMap();
146
- const browserAtomsOf = (registry) => {
147
- let atoms = browserAtoms.get(registry);
148
- if (atoms === void 0) {
149
- atoms = {
150
- ...make$1(registry),
151
- dehydrate: () => [],
152
- registry
153
- };
154
- browserAtoms.set(registry, atoms);
155
- }
156
- return atoms;
157
- };
158
149
  /**
159
150
  * Creates a root client middleware that gives client loaders the browser's
160
151
  * registry, under the context `atomsMiddleware` uses on the server.
@@ -173,6 +164,9 @@ const browserAtomsOf = (registry) => {
173
164
  * The registry is the one the mounted `RegistryProvider` provides. Its
174
165
  * `dehydrate` returns nothing, so a loader's data has the same shape in
175
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.
176
170
  *
177
171
  * **Gotchas**
178
172
  *
@@ -194,12 +188,16 @@ const browserAtomsOf = (registry) => {
194
188
  *
195
189
  * @stability unstable
196
190
  * @category integrations
197
- * @since 0.2.0
191
+ * @since 0.2.1
198
192
  */
199
193
  const clientAtomsMiddleware = (context) => (args, next) => {
200
194
  const registry = current();
201
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.");
202
- args.context.set(context, browserAtomsOf(registry));
196
+ args.context.set(context, {
197
+ ...make$1(registry, { signal: args.request.signal }),
198
+ dehydrate: () => [],
199
+ registry
200
+ });
203
201
  return next();
204
202
  };
205
203
  export { RegistryProvider, atomsMiddleware, clientAtomsMiddleware };
@@ -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
  /**
@@ -27,4 +37,4 @@ const mount = (registry) => {
27
37
  };
28
38
  /** The browser registry of the mounted `RegistryProvider`, if one is. */
29
39
  const current = () => mounted;
30
- export { current, isServer, make, mount };
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.1",
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",