raf-effect-atom 0.2.0 → 0.2.1

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,20 @@
1
1
  # Changelog
2
2
 
3
+ ## [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
+
5
+ ### Features
6
+
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)
8
+
3
9
  ## [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
10
 
5
11
  ### ⚠ BREAKING CHANGES
6
12
 
7
- * **raf-effect-atom:** `dehydrate()` from `raf-effect-atom/next` returns a
13
+ - **raf-effect-atom:** `dehydrate()` from `raf-effect-atom/next` returns a
8
14
 
9
15
  ### Features
10
16
 
11
- * **raf-effect-atom:** seed each request registry, async seeds included ([62b6180](https://github.com/KATT/raf-effect-atom/commit/62b618098df87e711ddb4c1d633e191ba3353438))
17
+ - **raf-effect-atom:** seed each request registry, async seeds included ([62b6180](https://github.com/KATT/raf-effect-atom/commit/62b618098df87e711ddb4c1d633e191ba3353438))
12
18
 
13
19
  ## 0.1.0 (2026-10-23)
14
20
 
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
 
@@ -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
@@ -71,8 +73,7 @@ export interface AtomsMiddlewareOptions {
71
73
  * **Gotchas**
72
74
  *
73
75
  * 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.
76
+ * `clientMiddleware`, which takes `clientAtomsMiddleware`.
76
77
  *
77
78
  * **Example**
78
79
  *
@@ -91,4 +92,46 @@ export interface AtomsMiddlewareOptions {
91
92
  * @since 0.1.0
92
93
  */
93
94
  export declare const atomsMiddleware: (context: AtomsContext, options?: AtomsMiddlewareOptions) => <Result>(args: MiddlewareArgs, next: () => Promise<Result>) => Promise<Result>;
95
+ /**
96
+ * Creates a root client middleware that gives client loaders the browser's
97
+ * registry, under the context `atomsMiddleware` uses on the server.
98
+ *
99
+ * **When to use**
100
+ *
101
+ * Export it as the root route's `clientMiddleware`, and each route's loader
102
+ * function as both its `loader` and its `clientLoader`. The first page's loaders run on
103
+ * the server and stream their atoms in; after that, navigations run the
104
+ * loaders in the browser. `ensure` then renders what the browser's registry
105
+ * holds without a request, and a link's prefetch doesn't fetch a route's
106
+ * `.data` that the click would fetch again.
107
+ *
108
+ * **Details**
109
+ *
110
+ * The registry is the one the mounted `RegistryProvider` provides. Its
111
+ * `dehydrate` returns nothing, so a loader's data has the same shape in
112
+ * the browser and on the server, and `HydrationBoundary` has nothing to do.
113
+ *
114
+ * **Gotchas**
115
+ *
116
+ * The atoms run in the browser, so their RPC protocol must reach the server
117
+ * from there, as it does for anything a component starts.
118
+ *
119
+ * **Example**
120
+ *
121
+ * ```ts
122
+ * // app/root.tsx
123
+ * export const middleware = [atomsMiddleware(atomsContext)]
124
+ * export const clientMiddleware = [clientAtomsMiddleware(atomsContext)]
125
+ *
126
+ * // app/routes/issue.tsx: one function for both, since React Router
127
+ * // removes `loader` from the browser's bundle.
128
+ * export const loader = load
129
+ * export const clientLoader = load
130
+ * ```
131
+ *
132
+ * @stability unstable
133
+ * @category integrations
134
+ * @since 0.2.0
135
+ */
136
+ export declare const clientAtomsMiddleware: (context: AtomsContext) => <Result>(args: MiddlewareArgs, next: () => Promise<Result>) => Promise<Result>;
94
137
  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}>
@@ -64,8 +72,7 @@ import * as Predicate from "effect/Predicate";
64
72
  * **Gotchas**
65
73
  *
66
74
  * 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.
75
+ * `clientMiddleware`, which takes `clientAtomsMiddleware`.
69
76
  *
70
77
  * **Example**
71
78
  *
@@ -135,4 +142,64 @@ const disposeAfter = (body, registry) => {
135
142
  }
136
143
  });
137
144
  };
138
- export { RegistryProvider, atomsMiddleware };
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
+ /**
159
+ * Creates a root client middleware that gives client loaders the browser's
160
+ * registry, under the context `atomsMiddleware` uses on the server.
161
+ *
162
+ * **When to use**
163
+ *
164
+ * Export it as the root route's `clientMiddleware`, and each route's loader
165
+ * function as both its `loader` and its `clientLoader`. The first page's loaders run on
166
+ * the server and stream their atoms in; after that, navigations run the
167
+ * loaders in the browser. `ensure` then renders what the browser's registry
168
+ * holds without a request, and a link's prefetch doesn't fetch a route's
169
+ * `.data` that the click would fetch again.
170
+ *
171
+ * **Details**
172
+ *
173
+ * The registry is the one the mounted `RegistryProvider` provides. Its
174
+ * `dehydrate` returns nothing, so a loader's data has the same shape in
175
+ * the browser and on the server, and `HydrationBoundary` has nothing to do.
176
+ *
177
+ * **Gotchas**
178
+ *
179
+ * The atoms run in the browser, so their RPC protocol must reach the server
180
+ * from there, as it does for anything a component starts.
181
+ *
182
+ * **Example**
183
+ *
184
+ * ```ts
185
+ * // app/root.tsx
186
+ * export const middleware = [atomsMiddleware(atomsContext)]
187
+ * export const clientMiddleware = [clientAtomsMiddleware(atomsContext)]
188
+ *
189
+ * // app/routes/issue.tsx: one function for both, since React Router
190
+ * // removes `loader` from the browser's bundle.
191
+ * export const loader = load
192
+ * export const clientLoader = load
193
+ * ```
194
+ *
195
+ * @stability unstable
196
+ * @category integrations
197
+ * @since 0.2.0
198
+ */
199
+ const clientAtomsMiddleware = (context) => (args, next) => {
200
+ const registry = current();
201
+ 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));
203
+ return next();
204
+ };
205
+ 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
@@ -13,4 +13,18 @@ const make = (options) => isServer() ? make$1({ seed: options?.seed }) : AtomReg
13
13
  defaultIdleTTL: options?.defaultIdleTTL ?? defaultIdleTTL,
14
14
  scheduleTask
15
15
  });
16
- export { isServer, make };
16
+ let mounted;
17
+ /**
18
+ * Records the browser registry a `RegistryProvider` provides while it's
19
+ * mounted, for code outside React, such as React Router's client
20
+ * middleware. Returns the cleanup that forgets it.
21
+ */
22
+ const mount = (registry) => {
23
+ mounted = registry;
24
+ return () => {
25
+ if (mounted === registry) mounted = void 0;
26
+ };
27
+ };
28
+ /** The browser registry of the mounted `RegistryProvider`, if one is. */
29
+ const current = () => mounted;
30
+ export { current, isServer, make, mount };
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.1",
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",