raf-effect-atom 0.3.1 → 0.3.3

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,25 +1,37 @@
1
1
  # Changelog
2
2
 
3
+ ## [0.3.3](https://github.com/KATT/raf-effect-atom/compare/raf-effect-atom%400.3.2...raf-effect-atom%400.3.3) (2026-10-24)
4
+
5
+ ### Bug Fixes
6
+
7
+ * **raf-effect-atom:** name the fixes when useAtomResult throws for a browser-only handle on the server ([40b94d0](https://github.com/KATT/raf-effect-atom/commit/40b94d07947b65bb06d278cf47c855f4b8afdc78))
8
+
9
+ ## [0.3.2](https://github.com/KATT/raf-effect-atom/compare/raf-effect-atom%400.3.1...raf-effect-atom%400.3.2) (2026-10-24)
10
+
11
+ ### Bug Fixes
12
+
13
+ - **raf-effect-atom:** take no lease on an atom a mounted component reads ([899d0b7](https://github.com/KATT/raf-effect-atom/commit/899d0b7c70f6836c41c4416b0c2063c1ccf3d8b2))
14
+
3
15
  ## [0.3.1](https://github.com/KATT/raf-effect-atom/compare/raf-effect-atom%400.3.0...raf-effect-atom%400.3.1) (2026-10-24)
4
16
 
5
17
  ### Features
6
18
 
7
- * **raf-effect-atom:** add useHandleValue, a handle read that doesn't suspend ([4b083f7](https://github.com/KATT/raf-effect-atom/commit/4b083f7dee91834cda71691f44a1498061f3cfe1))
19
+ - **raf-effect-atom:** add useHandleValue, a handle read that doesn't suspend ([4b083f7](https://github.com/KATT/raf-effect-atom/commit/4b083f7dee91834cda71691f44a1498061f3cfe1))
8
20
 
9
21
  ### Code Refactoring
10
22
 
11
- * name files in kebab-case, PascalCase only for Effect modules ([d76b06a](https://github.com/KATT/raf-effect-atom/commit/d76b06aef3d04d6be6c0b0e086a756234ab3f253))
23
+ - name files in kebab-case, PascalCase only for Effect modules ([d76b06a](https://github.com/KATT/raf-effect-atom/commit/d76b06aef3d04d6be6c0b0e086a756234ab3f253))
12
24
 
13
25
  ### Documentation
14
26
 
15
- * **raf-effect-atom:** pass a read's result to a boundary's hook in the README and JSDoc ([fd82e5c](https://github.com/KATT/raf-effect-atom/commit/fd82e5c1f92b3f99dc166140383837b11b695ca7))
16
- * **raf-effect-atom:** pass the atom to a boundary's hook in the README and JSDoc ([772d349](https://github.com/KATT/raf-effect-atom/commit/772d3494954f64a5c0fc92ebfd146b5f14e16e51))
27
+ - **raf-effect-atom:** pass a read's result to a boundary's hook in the README and JSDoc ([fd82e5c](https://github.com/KATT/raf-effect-atom/commit/fd82e5c1f92b3f99dc166140383837b11b695ca7))
28
+ - **raf-effect-atom:** pass the atom to a boundary's hook in the README and JSDoc ([772d349](https://github.com/KATT/raf-effect-atom/commit/772d3494954f64a5c0fc92ebfd146b5f14e16e51))
17
29
 
18
30
  ### Tests
19
31
 
20
- * **raf-effect-atom:** pass a read's result to a boundary's hook ([df11247](https://github.com/KATT/raf-effect-atom/commit/df11247edab69b060c0677059ae24a4d8835c28e))
21
- * **raf-effect-atom:** read through a boundary's hook with the atom ([de19b85](https://github.com/KATT/raf-effect-atom/commit/de19b8502ef41a8efbd9b50da6e00843cb38f2fd))
22
- * **raf-effect-atom:** start an Atom.swr over a keyed readable that picks a seed or a live query ([697e785](https://github.com/KATT/raf-effect-atom/commit/697e78550bff0dce02903b963677828786770338))
32
+ - **raf-effect-atom:** pass a read's result to a boundary's hook ([df11247](https://github.com/KATT/raf-effect-atom/commit/df11247edab69b060c0677059ae24a4d8835c28e))
33
+ - **raf-effect-atom:** read through a boundary's hook with the atom ([de19b85](https://github.com/KATT/raf-effect-atom/commit/de19b8502ef41a8efbd9b50da6e00843cb38f2fd))
34
+ - **raf-effect-atom:** start an Atom.swr over a keyed readable that picks a seed or a live query ([697e785](https://github.com/KATT/raf-effect-atom/commit/697e78550bff0dce02903b963677828786770338))
23
35
 
24
36
  ## [0.3.0](https://github.com/KATT/raf-effect-atom/compare/raf-effect-atom%400.2.6...raf-effect-atom%400.3.0) (2026-10-23)
25
37
 
package/dist/Loader.d.mts CHANGED
@@ -21,9 +21,11 @@ export type SettledResult<A, E> = AsyncResult.Failure<A, E> | AsyncResult.Succes
21
21
  *
22
22
  * **Details**
23
23
  *
24
- * A lease ends when `useAtomResult` mounts the atom, or when this time has
25
- * passed, after which the registry's idle TTL applies as usual. On a request
26
- * registry, leases last until the registry is disposed.
24
+ * A lease ends when `useAtomResult` or `useHandleValue` mounts the atom, or
25
+ * when this time has passed, after which the registry's idle TTL applies as
26
+ * usual. While a component reads the atom, a verb takes no lease on it, so the
27
+ * atom goes idle when the last reader unmounts. On a request registry, leases
28
+ * last until the registry is disposed.
27
29
  *
28
30
  * @stability unstable
29
31
  * @category references
@@ -41,7 +43,8 @@ export declare const LeaseTTL: Context.Reference<Duration.Duration>;
41
43
  * **Details**
42
44
  *
43
45
  * - Starts the atom if it isn't in the registry, and leaves it alone if it
44
- * is. Takes a lease either way. The component that reads the handle
46
+ * is. Takes a lease either way, unless a mounted component reads the
47
+ * atom. The component that reads the handle
45
48
  * suspends until the result settles. On a request registry whose async
46
49
  * seed is still running, it waits for the seed first.
47
50
  * - A derived atom that isn't serializable starts the first serializable
package/dist/Loader.mjs CHANGED
@@ -60,9 +60,11 @@ var Loader_exports = /* @__PURE__ */ __exportAll({
60
60
  *
61
61
  * **Details**
62
62
  *
63
- * A lease ends when `useAtomResult` mounts the atom, or when this time has
64
- * passed, after which the registry's idle TTL applies as usual. On a request
65
- * registry, leases last until the registry is disposed.
63
+ * A lease ends when `useAtomResult` or `useHandleValue` mounts the atom, or
64
+ * when this time has passed, after which the registry's idle TTL applies as
65
+ * usual. While a component reads the atom, a verb takes no lease on it, so the
66
+ * atom goes idle when the last reader unmounts. On a request registry, leases
67
+ * last until the registry is disposed.
66
68
  *
67
69
  * @stability unstable
68
70
  * @category references
@@ -122,7 +124,8 @@ const rejectDefects = (result) => {
122
124
  * **Details**
123
125
  *
124
126
  * - Starts the atom if it isn't in the registry, and leaves it alone if it
125
- * is. Takes a lease either way. The component that reads the handle
127
+ * is. Takes a lease either way, unless a mounted component reads the
128
+ * atom. The component that reads the handle
126
129
  * suspends until the result settles. On a request registry whose async
127
130
  * seed is still running, it waits for the seed first.
128
131
  * - A derived atom that isn't serializable starts the first serializable
@@ -1,39 +1,60 @@
1
1
  import * as Atom from "effect/reactivity/Atom";
2
2
  // src/internal/leases.ts
3
- const leases = /* @__PURE__ */ new WeakMap();
3
+ const registries = /* @__PURE__ */ new WeakMap();
4
+ const leasesOf = (registry) => {
5
+ let state = registries.get(registry);
6
+ if (state === void 0) {
7
+ state = {
8
+ leases: /* @__PURE__ */ new Map(),
9
+ readers: /* @__PURE__ */ new Map()
10
+ };
11
+ registries.set(registry, state);
12
+ }
13
+ return state;
14
+ };
4
15
  /** The registry's key for an atom: its serialization key, or the atom. */
5
16
  const keyOf = (atom) => Atom.isSerializable(atom) ? atom[Atom.SerializableTypeId].key : atom;
6
17
  /**
7
18
  * Mounts `atom` until `release`, or until `ttl` milliseconds pass. Taking a
8
- * lease the atom already has restarts its timer.
19
+ * lease the atom already has restarts its timer. While a component holds the
20
+ * atom (see `hold`), it takes none: the component keeps the atom mounted, and
21
+ * a lease would keep it mounted after the component unmounts.
9
22
  */
10
23
  const take = (registry, atom, ttl) => {
11
- let byKey = leases.get(registry);
12
- if (byKey === void 0) {
13
- byKey = /* @__PURE__ */ new Map();
14
- leases.set(registry, byKey);
15
- }
24
+ const state = leasesOf(registry);
16
25
  const key = keyOf(atom);
17
- let lease = byKey.get(key);
26
+ if (state.readers.has(key)) return;
27
+ let lease = state.leases.get(key);
18
28
  if (lease === void 0) {
19
29
  const unmount = registry.mount(atom);
20
- const owner = byKey;
21
30
  const created = {
22
31
  release: () => {
23
32
  clearTimeout(created.timer);
24
- owner.delete(key);
33
+ state.leases.delete(key);
25
34
  unmount();
26
35
  },
27
36
  timer: void 0
28
37
  };
29
- byKey.set(key, created);
38
+ state.leases.set(key, created);
30
39
  lease = created;
31
40
  }
32
41
  clearTimeout(lease.timer);
33
42
  lease.timer = ttl === void 0 ? void 0 : setTimeout(lease.release, ttl);
34
43
  };
35
- /** Releases `atom`'s lease, if it has one. */
36
- const release = (registry, atom) => {
37
- leases.get(registry)?.get(keyOf(atom))?.release();
44
+ /**
45
+ * Marks `atom` as held by a component subscribed to it, until the returned
46
+ * function runs. Ends the atom's lease, and `take` takes none while any
47
+ * component holds it.
48
+ */
49
+ const hold = (registry, atom) => {
50
+ const state = leasesOf(registry);
51
+ const key = keyOf(atom);
52
+ state.readers.set(key, (state.readers.get(key) ?? 0) + 1);
53
+ state.leases.get(key)?.release();
54
+ return () => {
55
+ const count = (state.readers.get(key) ?? 1) - 1;
56
+ if (count === 0) state.readers.delete(key);
57
+ else state.readers.set(key, count);
58
+ };
38
59
  };
39
- export { keyOf, release, take };
60
+ export { hold, keyOf, take };
@@ -34,7 +34,8 @@ type Readable<S> = S extends ((...args: infer Args) => Atom.Atom<AsyncResult.Asy
34
34
  * instead of started, so the browser doesn't request it a second time.
35
35
  * - The atom is read through `hydrated`, so a hydrated result is kept.
36
36
  * - Once the component mounts, the loader's lease ends and the component
37
- * keeps the atom mounted.
37
+ * keeps the atom mounted. A loader that starts the atom again while it's
38
+ * mounted takes no lease, so the atom goes idle when the component unmounts.
38
39
  * - A derived atom is read through the first serializable atom its
39
40
  * `initialValueTarget` chain points at: that one is awaited, hydrated and
40
41
  * released, so the derived atom doesn't request it again.
@@ -62,6 +63,12 @@ type Readable<S> = S extends ((...args: infer Args) => Atom.Atom<AsyncResult.Asy
62
63
  * `RequestRegistry.make`), or a module-level registry would share results
63
64
  * between requests: in development, the hook throws without one.
64
65
  *
66
+ * On the server, the hook throws for a handle `Loader.prefetchInBrowser`
67
+ * returned, so React renders the nearest Suspense boundary's fallback and
68
+ * reports the error. With no boundary between the read and the route, the
69
+ * server can't render the page. `useHandleValue` returns `Initial` there
70
+ * instead.
71
+ *
65
72
  * `Atom.transform`'s `initialValueTarget` option points at the end of the
66
73
  * target's own chain, which for an AtomRpc query with `reactivityKeys` is an
67
74
  * inner atom with no serialization key. A derived atom read here sets
@@ -109,7 +116,9 @@ export declare const useAtomResult: <S extends Source>(source: S, handle: NoInfe
109
116
  * - A failure is returned as it is, `waiting` while the atom refreshes, and
110
117
  * so is a defect.
111
118
  * - Once the component has subscribed to the atom, the loader's lease ends
112
- * and the component keeps the atom mounted.
119
+ * and the component keeps the atom mounted. A loader that starts the atom
120
+ * again while it's mounted takes no lease, so the atom goes idle when the
121
+ * component unmounts.
113
122
  * - A handle `Loader.prefetchInBrowser` returned on the server reads as
114
123
  * `Initial` there, without starting the atom; the browser's read starts
115
124
  * it.
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
  import { get, stream } from "./internal/pending.mjs";
3
3
  import { hydrated } from "./Hydration.mjs";
4
- import { keyOf, release } from "./internal/leases.mjs";
4
+ import { hold, keyOf } from "./internal/leases.mjs";
5
5
  import { atomOf, sourceOf } from "./internal/handles.mjs";
6
6
  import { isRequestRegistry } from "./RequestRegistry.mjs";
7
7
  import { requireRequestRegistry } from "./internal/registry.mjs";
@@ -58,7 +58,8 @@ const targetsOf = (atom, handle, hook) => {
58
58
  * instead of started, so the browser doesn't request it a second time.
59
59
  * - The atom is read through `hydrated`, so a hydrated result is kept.
60
60
  * - Once the component mounts, the loader's lease ends and the component
61
- * keeps the atom mounted.
61
+ * keeps the atom mounted. A loader that starts the atom again while it's
62
+ * mounted takes no lease, so the atom goes idle when the component unmounts.
62
63
  * - A derived atom is read through the first serializable atom its
63
64
  * `initialValueTarget` chain points at: that one is awaited, hydrated and
64
65
  * released, so the derived atom doesn't request it again.
@@ -86,6 +87,12 @@ const targetsOf = (atom, handle, hook) => {
86
87
  * `RequestRegistry.make`), or a module-level registry would share results
87
88
  * between requests: in development, the hook throws without one.
88
89
  *
90
+ * On the server, the hook throws for a handle `Loader.prefetchInBrowser`
91
+ * returned, so React renders the nearest Suspense boundary's fallback and
92
+ * reports the error. With no boundary between the read and the route, the
93
+ * server can't render the page. `useHandleValue` returns `Initial` there
94
+ * instead.
95
+ *
89
96
  * `Atom.transform`'s `initialValueTarget` option points at the end of the
90
97
  * target's own chain, which for an AtomRpc query with `reactivityKeys` is an
91
98
  * inner atom with no serialization key. A derived atom read here sets
@@ -114,7 +121,7 @@ const useAtomResult = (source, handle) => {
114
121
  const registry = React.useContext(RegistryContext);
115
122
  const atom = atomOf(source, handle.args);
116
123
  requireRequestRegistry(registry, "useAtomResult");
117
- if (handle.inBrowser === true && isRequestRegistry(registry)) throw new Error(`raf-effect-atom: ${String(atom)} starts in the browser (Loader.prefetchInBrowser), so the server renders the nearest Suspense boundary's fallback, and the browser renders the boundary.`);
124
+ if (handle.inBrowser === true && isRequestRegistry(registry)) throw new Error(`raf-effect-atom: ${String(atom)} starts in the browser (Loader.prefetchInBrowser), so useAtomResult throws on the server: React renders the nearest Suspense boundary's fallback, and the browser renders the boundary. Read it under a Suspense boundary inside the route, or with useHandleValue, which returns Initial on the server.`);
118
125
  const targets = targetsOf(atom, handle, "useAtomResult");
119
126
  const { read, started } = targets;
120
127
  const streamed = Predicate.isString(targets.key) ? stream(registry, targets.key) : void 0;
@@ -126,9 +133,7 @@ const useAtomResult = (source, handle) => {
126
133
  includeFailure: true,
127
134
  suspendOnWaiting: AsyncResult.isFailure(current) && current.waiting
128
135
  });
129
- React.useEffect(() => {
130
- release(registry, started);
131
- }, [registry, started]);
136
+ React.useEffect(() => hold(registry, started), [registry, started]);
132
137
  if (AsyncResult.isFailure(result) && Option.isNone(Cause.findErrorOption(result.cause))) throw Cause.squash(result.cause);
133
138
  return result;
134
139
  };
@@ -156,7 +161,9 @@ const loading = Atom.make(AsyncResult.initial(true));
156
161
  * - A failure is returned as it is, `waiting` while the atom refreshes, and
157
162
  * so is a defect.
158
163
  * - Once the component has subscribed to the atom, the loader's lease ends
159
- * and the component keeps the atom mounted.
164
+ * and the component keeps the atom mounted. A loader that starts the atom
165
+ * again while it's mounted takes no lease, so the atom goes idle when the
166
+ * component unmounts.
160
167
  * - A handle `Loader.prefetchInBrowser` returned on the server reads as
161
168
  * `Initial` there, without starting the atom; the browser's read starts
162
169
  * it.
@@ -214,7 +221,7 @@ const useHandleValue = (source, handle) => {
214
221
  }
215
222
  const result = useAtomValue(waiting ? loading : targets.read);
216
223
  React.useEffect(() => {
217
- if (!waiting) release(registry, started);
224
+ if (!waiting) return hold(registry, started);
218
225
  }, [
219
226
  registry,
220
227
  started,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "raf-effect-atom",
3
- "version": "0.3.1",
3
+ "version": "0.3.3",
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",