raf-effect-atom 0.0.0-stage → 0.0.0

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.
@@ -0,0 +1,107 @@
1
+ "use client";
2
+ import { get } from "./internal/pending.mjs";
3
+ import { hydrated } from "./Hydration.mjs";
4
+ import { keyOf } from "./internal/leases.mjs";
5
+ import { prefetch } from "./Loader.mjs";
6
+ import { ModeContext, isStarted } from "./internal/waterfalls.mjs";
7
+ import * as Predicate from "effect/Predicate";
8
+ import * as Effect from "effect/Effect";
9
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
10
+ import { RegistryContext } from "@effect/atom-react";
11
+ import * as React from "react";
12
+ import { jsx } from "react/jsx-runtime";
13
+ // src/Waterfalls.tsx
14
+ /**
15
+ * Request waterfalls: the escape hatch for atoms only a render can start,
16
+ * and the development check that finds the others.
17
+ *
18
+ * @stability unstable
19
+ * @since 0.1.0
20
+ */
21
+ /**
22
+ * Sets how `useAtomResult` reports request waterfalls in the tree below.
23
+ *
24
+ * **When to use**
25
+ *
26
+ * Put it in the root layout, around the app. `"throw"` turns every atom a
27
+ * component starts on its own into an error in development, which a person
28
+ * or a coding agent can't miss.
29
+ *
30
+ * **Details**
31
+ *
32
+ * Production builds don't check: the check sits behind
33
+ * `process.env.NODE_ENV !== "production"`, which bundlers replace at build
34
+ * time, so it's left out of the bundle, and this component renders only its
35
+ * children.
36
+ *
37
+ * **Example**
38
+ *
39
+ * ```tsx
40
+ * import { WaterfallDetection } from "raf-effect-atom"
41
+ *
42
+ * function App(props: { readonly children: React.ReactNode }) {
43
+ * return <WaterfallDetection mode="throw">{props.children}</WaterfallDetection>
44
+ * }
45
+ * ```
46
+ *
47
+ * @stability unstable
48
+ * @category components
49
+ * @since 0.1.0
50
+ */
51
+ const WaterfallDetection = (props) => process.env.NODE_ENV === "production" ? props.children : /* @__PURE__ */ jsx(ModeContext, {
52
+ value: props.mode,
53
+ children: props.children
54
+ });
55
+ /**
56
+ * Starts an atom during render and returns it, for an input only a render
57
+ * knows, such as a debounced search query.
58
+ *
59
+ * **When to use**
60
+ *
61
+ * As an escape hatch, when no loader, server component or event handler can
62
+ * start the atom: each call is a request waterfall, a round trip after the
63
+ * render that found the input. Prefer putting the state that picks the data
64
+ * in the URL, where a loader reads it, or a procedure that resolves the input
65
+ * on the server. Searching a codebase for `useWaterfall` lists its
66
+ * waterfalls.
67
+ *
68
+ * **Details**
69
+ *
70
+ * - Starts the atom like `Loader.prefetch`, with a lease that
71
+ * `useAtomResult` takes over when the component mounts, and only if
72
+ * nothing started it yet: re-renders start nothing.
73
+ * - A result the server is still streaming is awaited by `useAtomResult`,
74
+ * not started again.
75
+ * - `useAtomResult` doesn't report an atom started this way.
76
+ *
77
+ * **Gotchas**
78
+ *
79
+ * Pass it the atom a loader would start, inside an error boundary's `wrap`:
80
+ * `useAtomResult(boundary.wrap(useWaterfall(user(id))))`. The wrapper isn't
81
+ * serializable, so the server couldn't send it to the browser.
82
+ *
83
+ * **Example**
84
+ *
85
+ * ```tsx
86
+ * import { useAtomResult, useWaterfall } from "raf-effect-atom"
87
+ *
88
+ * function Author(props: { readonly userId: UserId }) {
89
+ * const result = useAtomResult(useWaterfall(user(props.userId)))
90
+ * // ...
91
+ * }
92
+ * ```
93
+ *
94
+ * @see {@link WaterfallDetection} for the development check it opts out of
95
+ *
96
+ * @stability unstable
97
+ * @category hooks
98
+ * @since 0.1.0
99
+ */
100
+ const useWaterfall = (atom) => {
101
+ const registry = React.useContext(RegistryContext);
102
+ const target = hydrated(atom);
103
+ const key = keyOf(target);
104
+ if (!(Predicate.isString(key) && get(registry, key) !== void 0) && !isStarted(registry, target)) Effect.runSync(Effect.provideService(prefetch(atom), AtomRegistry.AtomRegistry, registry));
105
+ return atom;
106
+ };
107
+ export { WaterfallDetection, useWaterfall };
@@ -0,0 +1,12 @@
1
+ // \0rolldown/runtime.js
2
+ var __defProp = Object.defineProperty;
3
+ var __exportAll = (all, no_symbols) => {
4
+ let target = {};
5
+ for (var name in all) __defProp(target, name, {
6
+ get: all[name],
7
+ enumerable: true
8
+ });
9
+ if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
10
+ return target;
11
+ };
12
+ export { __exportAll };
@@ -0,0 +1,7 @@
1
+ import { Loader_d_exports } from "./Loader.mjs";
2
+ import { HydrationBoundary, HydrationBoundaryProps } from "./ReactHydration.mjs";
3
+ import { Hydration_d_exports } from "./Hydration.mjs";
4
+ import { RequestRegistry_d_exports } from "./RequestRegistry.mjs";
5
+ import { useAtomResult } from "./Hooks.mjs";
6
+ import { WaterfallDetection, WaterfallMode, useWaterfall } from "./Waterfalls.mjs";
7
+ export { Hydration_d_exports as Hydration, HydrationBoundary, HydrationBoundaryProps, Loader_d_exports as Loader, RequestRegistry_d_exports as RequestRegistry, WaterfallDetection, WaterfallMode, useAtomResult, useWaterfall };
package/dist/index.mjs ADDED
@@ -0,0 +1,7 @@
1
+ import { Hydration_exports } from "./Hydration.mjs";
2
+ import { RequestRegistry_exports } from "./RequestRegistry.mjs";
3
+ import { Loader_exports } from "./Loader.mjs";
4
+ import { useAtomResult } from "./Hooks.mjs";
5
+ import { HydrationBoundary } from "./ReactHydration.mjs";
6
+ import { WaterfallDetection, useWaterfall } from "./Waterfalls.mjs";
7
+ export { Hydration_exports as Hydration, HydrationBoundary, Loader_exports as Loader, RequestRegistry_exports as RequestRegistry, WaterfallDetection, useAtomResult, useWaterfall };
@@ -0,0 +1,6 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Config from "effect/Config";
3
+ // src/internal/development.ts
4
+ /** Whether `NODE_ENV` isn't `production`, read through `Config`. */
5
+ const isDevelopment = Config.String("NODE_ENV").pipe(Config.withDefault("development"), Effect.map((env) => env !== "production"), Effect.orElseSucceed(() => true));
6
+ export { isDevelopment };
@@ -0,0 +1,39 @@
1
+ import * as Atom from "effect/reactivity/Atom";
2
+ // src/internal/leases.ts
3
+ const leases = /* @__PURE__ */ new WeakMap();
4
+ /** The registry's key for an atom: its serialization key, or the atom. */
5
+ const keyOf = (atom) => Atom.isSerializable(atom) ? atom[Atom.SerializableTypeId].key : atom;
6
+ /**
7
+ * Mounts `atom` until `release`, or until `ttl` milliseconds pass. Taking a
8
+ * lease the atom already has restarts its timer.
9
+ */
10
+ 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
+ }
16
+ const key = keyOf(atom);
17
+ let lease = byKey.get(key);
18
+ if (lease === void 0) {
19
+ const unmount = registry.mount(atom);
20
+ const owner = byKey;
21
+ const created = {
22
+ release: () => {
23
+ clearTimeout(created.timer);
24
+ owner.delete(key);
25
+ unmount();
26
+ },
27
+ timer: void 0
28
+ };
29
+ byKey.set(key, created);
30
+ lease = created;
31
+ }
32
+ clearTimeout(lease.timer);
33
+ lease.timer = ttl === void 0 ? void 0 : setTimeout(lease.release, ttl);
34
+ };
35
+ /** Releases `atom`'s lease, if it has one. */
36
+ const release = (registry, atom) => {
37
+ leases.get(registry)?.get(keyOf(atom))?.release();
38
+ };
39
+ export { keyOf, release, take };
@@ -0,0 +1,16 @@
1
+ import { dehydrate as dehydrate$1 } from "../Hydration.mjs";
2
+ import { make } from "../RequestRegistry.mjs";
3
+ import { make as make$1 } from "../Loader.mjs";
4
+ import * as React from "react";
5
+ // src/internal/next.ts
6
+ const getRegistry = React.cache(() => {
7
+ const registry = make();
8
+ React.cacheSignal()?.addEventListener("abort", () => registry.dispose(), { once: true });
9
+ return registry;
10
+ });
11
+ const getLoader = React.cache(() => make$1(getRegistry()));
12
+ const prefetch = (atom) => getLoader().prefetch(atom);
13
+ const ensure = (atom) => getLoader().ensure(atom);
14
+ const fetch = (atom) => getLoader().fetch(atom);
15
+ const dehydrate = () => dehydrate$1(getRegistry());
16
+ export { dehydrate, ensure, fetch, getRegistry, prefetch };
@@ -0,0 +1,75 @@
1
+ import * as Hydration from "effect/reactivity/Hydration";
2
+ import * as Predicate from "effect/Predicate";
3
+ // src/internal/pending.ts
4
+ const held = /* @__PURE__ */ new WeakMap();
5
+ const pending = /* @__PURE__ */ new WeakMap();
6
+ /** Each key's latest stream, kept once it settles. */
7
+ const streams = /* @__PURE__ */ new WeakMap();
8
+ /** The promise of a streamed result still on its way, if there is one. */
9
+ const get = (registry, key) => pending.get(registry)?.get(key);
10
+ /**
11
+ * The promise of a key's streamed result, settled or not. A component that
12
+ * suspended on it with `React.use` must call `React.use` again when React
13
+ * retries it, or React reports a conditional `use`. Once settled, it's
14
+ * marked fulfilled the way React marks the thenables it tracks, so `use`
15
+ * returns at once instead of suspending again.
16
+ */
17
+ const stream = (registry, key) => streams.get(registry)?.get(key);
18
+ const hold = (registry, entry, result) => {
19
+ let entries = held.get(registry);
20
+ if (entries === void 0) {
21
+ entries = /* @__PURE__ */ new WeakSet();
22
+ held.set(registry, entries);
23
+ }
24
+ if (entries.has(entry)) return;
25
+ entries.add(entry);
26
+ let byKey = pending.get(registry);
27
+ if (byKey === void 0) {
28
+ byKey = /* @__PURE__ */ new Map();
29
+ pending.set(registry, byKey);
30
+ }
31
+ if (byKey.has(entry.key)) return;
32
+ let latest = streams.get(registry);
33
+ if (latest === void 0) {
34
+ latest = /* @__PURE__ */ new Map();
35
+ streams.set(registry, latest);
36
+ }
37
+ const owner = byKey;
38
+ const done = () => {
39
+ owner.delete(entry.key);
40
+ };
41
+ const fulfilled = () => {
42
+ Object.assign(promise, {
43
+ status: "fulfilled",
44
+ value: void 0
45
+ });
46
+ };
47
+ const promise = Promise.resolve(result).then((value) => {
48
+ done();
49
+ if (!Predicate.isSymbol(value)) {
50
+ const resolved = {
51
+ ...entry,
52
+ resultPromise: void 0,
53
+ value
54
+ };
55
+ Hydration.hydrate(registry, [resolved]);
56
+ }
57
+ fulfilled();
58
+ }, () => {
59
+ done();
60
+ fulfilled();
61
+ });
62
+ owner.set(entry.key, promise);
63
+ latest.set(entry.key, promise);
64
+ };
65
+ /**
66
+ * Holds back each streamed entry of `state` until its promise resolves, and
67
+ * returns the settled ones.
68
+ */
69
+ const split = (registry, state) => {
70
+ const settled = [];
71
+ for (const entry of Hydration.toValues(Array.from(state))) if (entry.resultPromise === void 0) settled.push(entry);
72
+ else hold(registry, entry, entry.resultPromise);
73
+ return settled;
74
+ };
75
+ export { get, split, stream };
@@ -0,0 +1,16 @@
1
+ import { make as make$1 } from "../RequestRegistry.mjs";
2
+ import * as AtomRegistry from "effect/reactivity/AtomRegistry";
3
+ import { scheduleTask } from "@effect/atom-react";
4
+ // src/internal/registry.ts
5
+ const isServer = () => !("window" in globalThis);
6
+ /** `@effect/atom-react`'s default registry keeps an unused atom this long. */
7
+ const defaultIdleTTL = 400;
8
+ /**
9
+ * A request registry on the server, and in the browser a registry like
10
+ * `@effect/atom-react`'s `RegistryProvider` makes.
11
+ */
12
+ const make = (options) => isServer() ? make$1() : AtomRegistry.make({
13
+ defaultIdleTTL: options?.defaultIdleTTL ?? defaultIdleTTL,
14
+ scheduleTask
15
+ });
16
+ export { isServer, make };
@@ -0,0 +1,14 @@
1
+ // src/internal/sources.ts
2
+ /** The registry each `dehydrate` result came from, while it's referenced. */
3
+ const sources = /* @__PURE__ */ new WeakMap();
4
+ const remember = (state, registry) => {
5
+ sources.set(state, registry);
6
+ return state;
7
+ };
8
+ /**
9
+ * The registry `state` was dehydrated from, when it's the same object
10
+ * `dehydrate` returned: during server rendering by a framework that passes
11
+ * loader data to components without serializing it, such as React Router.
12
+ */
13
+ const sourceOf = (state) => state === void 0 ? void 0 : sources.get(state);
14
+ export { remember, sourceOf };
@@ -0,0 +1,5 @@
1
+ import "effect/reactivity/Atom";
2
+ import "effect/reactivity/AtomRegistry";
3
+ import "react";
4
+ /** What a development read does when it starts an atom nothing started. */
5
+ export type Mode = "off" | "throw" | "warn";
@@ -0,0 +1,83 @@
1
+ import { keyOf } from "./leases.mjs";
2
+ import * as Predicate from "effect/Predicate";
3
+ import * as Option from "effect/Option";
4
+ import * as AsyncResult from "effect/reactivity/AsyncResult";
5
+ import * as React from "react";
6
+ // src/internal/waterfalls.ts
7
+ const ModeContext = React.createContext("warn");
8
+ const docs = "https://raf-effect-atom.katt.dev/docs/request-waterfalls";
9
+ /** Whether the registry has `atom`'s node, or a hydrated value for it. */
10
+ const isStarted = (registry, atom) => {
11
+ const key = keyOf(atom);
12
+ return registry.getNodes().has(key) || Predicate.isString(key) && registry.preloadedSerializable.has(key);
13
+ };
14
+ const isLoading = (node) => {
15
+ const value = Option.getOrUndefined(node.valueOption());
16
+ return AsyncResult.isAsyncResult(value) && AsyncResult.isInitial(value);
17
+ };
18
+ /**
19
+ * The nodes `registry.get` created that load on their own: `Initial`, with
20
+ * no `Initial` parent. A derived atom over a source that is loading or
21
+ * loaded isn't one, so reading through `Atom.map` or a wrapper over an atom
22
+ * a loader started reports nothing.
23
+ */
24
+ const startedLoading = (registry, atom, before) => {
25
+ const root = registry.getNodes().get(keyOf(atom));
26
+ const started = [];
27
+ const seen = /* @__PURE__ */ new Set();
28
+ const queue = root === void 0 ? [] : [root];
29
+ for (const node of queue) {
30
+ if (seen.has(node) || before.has(keyOf(node.atom))) continue;
31
+ seen.add(node);
32
+ if (isLoading(node) && !Array.from(node.parents).some(isLoading)) started.push(node);
33
+ queue.push(...node.parents);
34
+ }
35
+ return started;
36
+ };
37
+ const nameOf = (atom) => {
38
+ const key = keyOf(atom);
39
+ return atom.label?.[0] ?? (Predicate.isString(key) ? key : "an atom");
40
+ };
41
+ const message = (read, started) => `raf-effect-atom: rendering ${read === started ? `read ${read}` : `read ${read}, which started ${started}`} before anything else had started it, so its request waits for this render: a request waterfall. Start it before render, in the route's loader or the server component (atoms.prefetch or atoms.ensure). If the state that picks it, such as a page, a filter or an open dialog, isn't in the URL, put it there so the loader can. As escape hatches for state that can't be in the URL: start it in the event handler that changes the state (Loader.make(registry).prefetch), or, if its input only exists in render, read it with useAtomResult(useWaterfall(atom)). ${docs}`;
42
+ const warned = /* @__PURE__ */ new WeakMap();
43
+ /**
44
+ * The nodes a read started in throw mode, with the error. React renders a
45
+ * component that threw once more before it gives up, and that render finds
46
+ * the node, so the error stays with the node.
47
+ */
48
+ const thrown = /* @__PURE__ */ new WeakMap();
49
+ /**
50
+ * Reads `atom`, reporting by `mode` when the read starts an atom no loader,
51
+ * server component, event handler or `useWaterfall` started. Development
52
+ * only: callers skip it in production builds.
53
+ */
54
+ const check = (registry, atom, mode) => {
55
+ if (mode === "off") return;
56
+ if (isStarted(registry, atom)) {
57
+ const node = registry.getNodes().get(keyOf(atom));
58
+ const error = mode === "throw" && node ? thrown.get(node) : void 0;
59
+ if (error !== void 0) throw error;
60
+ return;
61
+ }
62
+ const before = new Set(registry.getNodes().keys());
63
+ registry.get(atom);
64
+ const first = startedLoading(registry, atom, before)[0];
65
+ if (first === void 0) return;
66
+ const text = message(nameOf(atom), nameOf(first.atom));
67
+ if (mode === "throw") {
68
+ const error = new Error(text);
69
+ const node = registry.getNodes().get(keyOf(atom));
70
+ if (node !== void 0) thrown.set(node, error);
71
+ throw error;
72
+ }
73
+ let seen = warned.get(registry);
74
+ if (seen === void 0) {
75
+ seen = /* @__PURE__ */ new Set();
76
+ warned.set(registry, seen);
77
+ }
78
+ if (!seen.has(text)) {
79
+ seen.add(text);
80
+ console.warn(text);
81
+ }
82
+ };
83
+ export { ModeContext, check, isStarted };
package/package.json CHANGED
@@ -1,6 +1,83 @@
1
1
  {
2
2
  "name": "raf-effect-atom",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "0.0.0",
4
+ "description": "Render-as-you-fetch for Effect Atom: start atoms from route loaders and stream them into the browser, typed failures intact",
5
+ "keywords": [
6
+ "effect",
7
+ "effect-atom",
8
+ "react",
9
+ "render-as-you-fetch",
10
+ "ssr",
11
+ "suspense"
12
+ ],
13
+ "license": "MIT",
14
+ "author": {
15
+ "name": "KATT",
16
+ "email": "alexander@n1s.se"
17
+ },
18
+ "repository": {
19
+ "type": "git",
20
+ "url": "git+https://github.com/KATT/raf-effect-atom.git",
21
+ "directory": "packages/raf-effect-atom"
22
+ },
23
+ "homepage": "https://raf-effect-atom.katt.dev",
24
+ "bugs": {
25
+ "url": "https://github.com/KATT/raf-effect-atom/issues"
26
+ },
27
+ "files": [
28
+ "dist/",
29
+ "CHANGELOG.md",
30
+ "LICENSE",
31
+ "README.md"
32
+ ],
33
+ "type": "module",
34
+ "sideEffects": false,
35
+ "exports": {
36
+ ".": "./dist/index.mjs",
37
+ "./next": "./dist/Next.mjs",
38
+ "./react-router": "./dist/ReactRouter.mjs",
39
+ "./tanstack-router": "./dist/TanStackRouter.mjs"
40
+ },
41
+ "peerDependencies": {
42
+ "@effect/atom-react": "^4.0.6",
43
+ "@tanstack/react-router": "^1.170.46",
44
+ "effect": "^4.0.6",
45
+ "react": "^19.2.0"
46
+ },
47
+ "peerDependenciesMeta": {
48
+ "@tanstack/react-router": {
49
+ "optional": true
50
+ }
51
+ },
52
+ "devDependencies": {
53
+ "@effect/atom-react": "4.0.6",
54
+ "@effect/vitest": "4.0.6",
55
+ "@tanstack/react-router": "1.170.46",
56
+ "@testing-library/react": "16.3.3",
57
+ "@types/react": "19.3.0",
58
+ "@types/react-dom": "19.3.0",
59
+ "@vitest/coverage-v8": "5.0.5",
60
+ "effect": "4.0.6",
61
+ "effect-atom-boundary": "0.0.0",
62
+ "happy-dom": "21.0.4",
63
+ "react": "19.3.1",
64
+ "react-dom": "19.3.1",
65
+ "react-error-boundary": "6.1.6",
66
+ "react-router": "8.5.0",
67
+ "react-server-dom-webpack": "19.3.1",
68
+ "tstyche": "7.2.5",
69
+ "typescript": "7.0.2",
70
+ "vite-plus": "1.4.0"
71
+ },
72
+ "publishConfig": {
73
+ "access": "public",
74
+ "provenance": true
75
+ },
76
+ "scripts": {
77
+ "build": "vp pack",
78
+ "test": "vp test && pnpm test-types",
79
+ "test-types": "tstyche",
80
+ "typecheck": "tsc",
81
+ "publish:npm": "pnpm pack --out package.tgz && npm publish package.tgz --access public"
82
+ }
6
83
  }