@pylonsync/react 0.21.0 → 0.22.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/dist/hooks.d.ts CHANGED
@@ -19,6 +19,15 @@ export interface QueryOptions {
19
19
  export interface UseQueryReturn<T> {
20
20
  data: T[];
21
21
  loading: boolean;
22
+ /**
23
+ * True once the engine has completed a pull against the server in this
24
+ * session (since the last replica reset). While false, `data` may be a
25
+ * stale or partial copy loaded from the local cache, or the server is
26
+ * unreachable. `loading` is false as soon as cached rows exist, so use
27
+ * `synced` when an incomplete list would mislead (counts, "no results",
28
+ * billing state).
29
+ */
30
+ synced: boolean;
22
31
  error: Error | null;
23
32
  /** Re-fetch from the server. Rarely needed — data is live. */
24
33
  refetch: () => void;
@@ -26,6 +35,9 @@ export interface UseQueryReturn<T> {
26
35
  export interface UseQueryOneReturn<T> {
27
36
  data: T | null;
28
37
  loading: boolean;
38
+ /** See `UseQueryReturn.synced`. A `null` row with `synced: false` may
39
+ * still exist on the server. */
40
+ synced: boolean;
29
41
  error: Error | null;
30
42
  refetch: () => void;
31
43
  }
package/dist/index.d.ts CHANGED
@@ -22,6 +22,7 @@ export type { AgentRunRow, AgentMessageRow, UseAgentRunReturn, } from "./useAgen
22
22
  export { useRoom, useRoomMessages } from "./useRoom";
23
23
  export type { RoomPeer, RoomSnapshot, UseRoomOptions, UseRoomReturn, } from "./useRoom";
24
24
  export type { RoomMessage } from "@pylonsync/sync";
25
+ export { MutationRejectedError } from "@pylonsync/sync";
25
26
  export { useShard, connectShard } from "./useShard";
26
27
  export type { UseShardOptions, UseShardReturn, ShardClient, } from "./useShard";
27
28
  export { SHARD_PROTOCOL_VERSION, ShardCodec, ShardFrameKind, parseShardFrame, decodeShardPayload, encodeShardInput, EntityTable, ReplicationError, connectShardGame, ShardClock, EntityInterpolator, Predictor, } from "./shardWire";
@@ -156,8 +157,8 @@ export declare function callFn<T = unknown>(name: string, args?: Record<string,
156
157
  * to survive the window closing).
157
158
  *
158
159
  * The call returns as soon as the request is recorded. A live
159
- * generation stops at its next turn boundary — between tool calls
160
- * within seconds, or after the current model response finishes.
160
+ * generation stops within a few seconds: a model response in progress
161
+ * is aborted, and a running tool sees `ctx.signal` abort.
161
162
  * `run.cancelRequested` is true in that window; drive the stop
162
163
  * button's pending state from it and treat `run.status === "cancelled"`
163
164
  * as the confirmation.
package/dist/useRoom.d.ts CHANGED
@@ -77,10 +77,28 @@ interface SharedRoom {
77
77
  /** Connection-status unsubscribe — fires whenever the engine's
78
78
  * transport state changes so we can flip between push and polling. */
79
79
  connectionStatusUnsubscribe: (() => void) | null;
80
+ /** A `POST /api/rooms/join` is in flight. */
81
+ joining: boolean;
82
+ /** Keepalive timer (`POST /api/rooms/heartbeat`), set once joined. */
83
+ keepalive: ReturnType<typeof setInterval> | null;
84
+ /** Pending rejoin after `NOT_IN_ROOM` / a failed join. */
85
+ rejoinTimer: ReturnType<typeof setTimeout> | null;
86
+ /** Rejoins since the last good snapshot or join. */
87
+ rejoinAttempts: number;
88
+ /** The caller left with `leave()`: no keepalive, no rejoin. */
89
+ left: boolean;
80
90
  }
81
91
  declare function releaseRoom(room: SharedRoom): void;
82
92
  export declare const __roomRegistryInternals: {
83
93
  reset(): void;
94
+ /** Shorten the keepalive / rejoin timings for a test. `reset()` restores
95
+ * the defaults. */
96
+ setTimings(t: {
97
+ keepaliveMs?: number;
98
+ rejoinBaseMs?: number;
99
+ }): void;
100
+ /** Diagnostic: the caller's manual leave() for tests. */
101
+ markLeft(room: SharedRoom): void;
84
102
  acquire(baseUrl: string, roomId: string, userId: string, token: string | undefined, initialPresence: Record<string, any>, heartbeatInterval: number, engine?: SyncEngine | null): SharedRoom;
85
103
  release: typeof releaseRoom;
86
104
  size(): number;
@@ -73,6 +73,15 @@ export declare function useRouteSeed<T = unknown>(): T | null;
73
73
  * ```
74
74
  */
75
75
  export declare function useRouteData<T>(loader: () => Promise<T> | T, deps: readonly unknown[]): T | null;
76
+ interface LoaderEntry {
77
+ source: string;
78
+ deps: readonly unknown[];
79
+ promise: Promise<unknown>;
80
+ }
81
+ /** Find or create the cached promise for this loader call. A synchronous
82
+ * loader result becomes an already-fulfilled thenable, which `use()` reads
83
+ * without suspending. */
84
+ declare function loaderEntry(source: string, deps: readonly unknown[], loader: () => unknown): LoaderEntry;
76
85
  /**
77
86
  * Client-side redirect — replaces the current history entry with `href`.
78
87
  * Drop-in for Next's `redirect` when called from a client component
@@ -118,3 +127,10 @@ export interface PylonRouter {
118
127
  * ```
119
128
  */
120
129
  export declare function useRouter(): PylonRouter;
130
+ /** Test hooks for the useRouteData promise cache. */
131
+ export declare const __routeDataCacheInternals: {
132
+ lookup: typeof loaderEntry;
133
+ size: () => number;
134
+ clear: () => void;
135
+ };
136
+ export {};
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "publishConfig": {
4
4
  "access": "public"
5
5
  },
6
- "version": "0.21.0",
6
+ "version": "0.22.1",
7
7
  "type": "module",
8
8
  "main": "./src/index.ts",
9
9
  "types": "./dist/index.d.ts",
@@ -14,9 +14,9 @@
14
14
  "prepack": "bun run build"
15
15
  },
16
16
  "dependencies": {
17
- "@pylonsync/realtime": "0.21.0",
18
- "@pylonsync/sdk": "0.21.0",
19
- "@pylonsync/sync": "0.21.0"
17
+ "@pylonsync/realtime": "0.22.1",
18
+ "@pylonsync/sdk": "0.22.1",
19
+ "@pylonsync/sync": "0.22.1"
20
20
  },
21
21
  "peerDependencies": {
22
22
  "react": ">=19.0.0"
@@ -25,6 +25,7 @@
25
25
  "@happy-dom/global-registrator": "^20.10.0",
26
26
  "@testing-library/react": "^16.3.0",
27
27
  "@types/react": "^19.0.0",
28
+ "@types/react-dom": "^19.0.0",
28
29
  "react": "^19.0.0",
29
30
  "react-dom": "^19.0.0"
30
31
  },
package/src/hooks.ts CHANGED
@@ -31,6 +31,15 @@ export interface QueryOptions {
31
31
  export interface UseQueryReturn<T> {
32
32
  data: T[];
33
33
  loading: boolean;
34
+ /**
35
+ * True once the engine has completed a pull against the server in this
36
+ * session (since the last replica reset). While false, `data` may be a
37
+ * stale or partial copy loaded from the local cache, or the server is
38
+ * unreachable. `loading` is false as soon as cached rows exist, so use
39
+ * `synced` when an incomplete list would mislead (counts, "no results",
40
+ * billing state).
41
+ */
42
+ synced: boolean;
34
43
  error: Error | null;
35
44
  /** Re-fetch from the server. Rarely needed — data is live. */
36
45
  refetch: () => void;
@@ -39,6 +48,9 @@ export interface UseQueryReturn<T> {
39
48
  export interface UseQueryOneReturn<T> {
40
49
  data: T | null;
41
50
  loading: boolean;
51
+ /** See `UseQueryReturn.synced`. A `null` row with `synced: false` may
52
+ * still exist on the server. */
53
+ synced: boolean;
42
54
  error: Error | null;
43
55
  refetch: () => void;
44
56
  }
@@ -159,6 +171,12 @@ export function useQuery<T = Row>(
159
171
  // instead of flashing "nothing here" over data that is on its way back.
160
172
  const loading = refetching || (!settled && data.length === 0);
161
173
 
174
+ const synced = useSyncExternalStore(
175
+ subscribe,
176
+ useCallback(() => sync.isSynced(), [sync]),
177
+ FALSE_SNAPSHOT,
178
+ );
179
+
162
180
  // Register interest so the reconcile safety net sweeps this entity
163
181
  // even when the local replica has zero rows for it. Without this, a
164
182
  // server row in a never-cached entity (created on another surface, a
@@ -184,7 +202,7 @@ export function useQuery<T = Row>(
184
202
  .finally(() => setRefetching(false));
185
203
  }, [sync]);
186
204
 
187
- return { data, loading, error, refetch };
205
+ return { data, loading, synced, error, refetch };
188
206
  }
189
207
 
190
208
  /**
@@ -248,6 +266,12 @@ export function useQueryOne<T = Row>(
248
266
  // server-side from flashing "not found" while it is still in flight.
249
267
  const loading = refetching || (!settled && data === null);
250
268
 
269
+ const synced = useSyncExternalStore(
270
+ subscribe,
271
+ useCallback(() => sync.isSynced(), [sync]),
272
+ FALSE_SNAPSHOT,
273
+ );
274
+
251
275
  // Register interest so reconcile sweeps this entity even with zero
252
276
  // local rows. See SyncEngine.observeEntity.
253
277
  useEffect(() => {
@@ -265,7 +289,7 @@ export function useQueryOne<T = Row>(
265
289
  .finally(() => setRefetching(false));
266
290
  }, [sync]);
267
291
 
268
- return { data, loading, error, refetch };
292
+ return { data, loading, synced, error, refetch };
269
293
  }
270
294
 
271
295
  // ---------------------------------------------------------------------------
package/src/index.ts CHANGED
@@ -134,6 +134,9 @@ export type {
134
134
  UseRoomReturn,
135
135
  } from "./useRoom";
136
136
  export type { RoomMessage } from "@pylonsync/sync";
137
+ // Thrown by db.insert / update / delete (and useEntity's) when the server
138
+ // rejects a write.
139
+ export { MutationRejectedError } from "@pylonsync/sync";
137
140
 
138
141
  // Shard hook for real-time sims (games, MMO, live docs, etc.)
139
142
  export { useShard, connectShard } from "./useShard";
@@ -625,8 +628,8 @@ export async function callFn<T = unknown>(
625
628
  * to survive the window closing).
626
629
  *
627
630
  * The call returns as soon as the request is recorded. A live
628
- * generation stops at its next turn boundary — between tool calls
629
- * within seconds, or after the current model response finishes.
631
+ * generation stops within a few seconds: a model response in progress
632
+ * is aborted, and a running tool sees `ctx.signal` abort.
630
633
  * `run.cancelRequested` is true in that window; drive the stop
631
634
  * button's pending state from it and treat `run.status === "cancelled"`
632
635
  * as the confirmation.
@@ -1048,8 +1051,22 @@ export async function uploadFile(
1048
1051
  body,
1049
1052
  headers: { "Content-Type": contentType },
1050
1053
  });
1054
+ // Read the response body to the end, even an empty 204. Chrome cancels a
1055
+ // fetch whose body is never read once the Response is dropped, and
1056
+ // DevTools then lists the upload as failed (net::ERR_ABORTED) although
1057
+ // the server stored the bytes.
1058
+ const putBody = await put.text().catch(() => "");
1051
1059
  if (!put.ok) {
1052
- throw new PylonHttpError(`Upload of ${filename} failed: ${put.status}`, put.status);
1060
+ let message = `Upload of ${filename} failed: ${put.status}`;
1061
+ let code: string | undefined;
1062
+ try {
1063
+ const err = JSON.parse(putBody) as { error?: { code?: string; message?: string } };
1064
+ if (err.error?.message) message = err.error.message;
1065
+ code = err.error?.code;
1066
+ } catch {
1067
+ // Not JSON (storage provider XML, proxy page): keep the status text.
1068
+ }
1069
+ throw new PylonHttpError(message, put.status, code);
1053
1070
  }
1054
1071
 
1055
1072
  return pylonFetch<UploadedFile>(transport, "/api/files/confirm", {
@@ -65,6 +65,43 @@ describe("uploadFile", () => {
65
65
  expect(seen[1].headers.Authorization).toBeUndefined();
66
66
  });
67
67
 
68
+ // Chrome cancels a fetch whose response body is never read once the
69
+ // Response is dropped: DevTools listed every upload's PUT as
70
+ // net::ERR_ABORTED even though the server answered 204 and stored the
71
+ // bytes. Reproduced in headless Chrome over CDP (loadingFailed,
72
+ // canceled: true); reading the body gives loadingFinished.
73
+ test("reads the PUT response body to the end (local and presigned)", async () => {
74
+ const puts: Response[] = [];
75
+ const inner = globalThis.fetch;
76
+ globalThis.fetch = (async (input: any, init: any = {}) => {
77
+ const res = await inner(input, init);
78
+ if ((init.method ?? "GET") === "PUT") {
79
+ const r = new Response(null, { status: 204 });
80
+ puts.push(r);
81
+ return r;
82
+ }
83
+ return res;
84
+ }) as typeof fetch;
85
+ await uploadFile(new Blob(["hi"]), { token: "tok" });
86
+ uploadUrl = "https://bucket.s3.example/f_1?X-Amz-Signature=abc";
87
+ await uploadFile(new Blob(["hi"]), { token: "tok" });
88
+ expect(puts).toHaveLength(2);
89
+ expect(puts.every((r) => r.bodyUsed)).toBe(true);
90
+ });
91
+
92
+ test("a refused PUT surfaces the server's error message", async () => {
93
+ globalThis.fetch = (async (input: any) => {
94
+ const url = String(input?.url ?? input);
95
+ if (url.endsWith("/api/files/init")) return new Response(JSON.stringify({ uploadUrl, assetId: "f_1" }), { status: 200 });
96
+ return new Response(JSON.stringify({ error: { code: "PAYLOAD_TOO_LARGE", message: "Body exceeds upload max" } }), { status: 413 });
97
+ }) as typeof fetch;
98
+ await expect(uploadFile(new Uint8Array([1]), { token: "tok" })).rejects.toMatchObject({
99
+ status: 413,
100
+ code: "PAYLOAD_TOO_LARGE",
101
+ message: "Body exceeds upload max",
102
+ });
103
+ });
104
+
68
105
  test("a refused PUT throws before confirm", async () => {
69
106
  globalThis.fetch = (async (input: any, init: any = {}) => {
70
107
  const url = String(input?.url ?? input);
@@ -0,0 +1,70 @@
1
+ // `useQuery` / `useQueryOne` expose `synced`: false until the engine has
2
+ // completed a server pull in this session. Without it an app could not tell
3
+ // a warm but incomplete local replica from a complete one — `loading` is
4
+ // false as soon as cached rows exist (saas template showed stale partial
5
+ // data briefly).
6
+
7
+ import { afterEach, describe, expect, test } from "bun:test";
8
+ import { act, cleanup, render, screen, waitFor } from "@testing-library/react";
9
+ import type { SyncEngine } from "@pylonsync/sync";
10
+ import { createTestEnv, type TestEnv } from "@pylonsync/sync/src/test-harness";
11
+
12
+ import { useQuery, useQueryOne } from "./hooks";
13
+
14
+ const asEngine = (e: TestEnv["engine"]): SyncEngine => e as unknown as SyncEngine;
15
+
16
+ let env: TestEnv | null = null;
17
+ afterEach(async () => {
18
+ cleanup();
19
+ if (env) {
20
+ await env.dispose();
21
+ env = null;
22
+ }
23
+ });
24
+
25
+ function Probe({ engine }: { engine: SyncEngine }) {
26
+ const list = useQuery<{ id: string }>(engine, "Todo");
27
+ const one = useQueryOne<{ id: string }>(engine, "Todo", "t1");
28
+ return (
29
+ <div>
30
+ <span data-testid="list">{`${list.data.length}:${list.loading}:${list.synced}`}</span>
31
+ <span data-testid="one">{`${one.data ? "row" : "none"}:${one.synced}`}</span>
32
+ </div>
33
+ );
34
+ }
35
+
36
+ describe("useQuery synced", () => {
37
+ test("cached rows render with synced false until the pull confirms them", async () => {
38
+ let release!: () => void;
39
+ const gate = new Promise<void>((r) => (release = r));
40
+ env = createTestEnv({ transport: "poll", beforePull: () => gate });
41
+ env.signIn({ userId: "u1" });
42
+ env.server.seed("Todo", [{ id: "t1" }, { id: "t2" }]);
43
+ // A row the local replica already holds (what a warm cache provides).
44
+ env.engine.store.applyChange({
45
+ seq: 0,
46
+ entity: "Todo",
47
+ row_id: "t1",
48
+ kind: "insert",
49
+ data: { id: "t1" },
50
+ timestamp: "",
51
+ });
52
+
53
+ render(<Probe engine={asEngine(env.engine)} />);
54
+ expect(screen.getByTestId("list").textContent).toBe("1:false:false");
55
+ expect(screen.getByTestId("one").textContent).toBe("row:false");
56
+
57
+ let started!: Promise<void>;
58
+ act(() => {
59
+ started = env!.engine.start();
60
+ });
61
+ release();
62
+ await act(async () => {
63
+ await started;
64
+ });
65
+ await waitFor(() =>
66
+ expect(screen.getByTestId("list").textContent).toBe("2:false:true"),
67
+ );
68
+ expect(screen.getByTestId("one").textContent).toBe("row:true");
69
+ });
70
+ });
@@ -31,21 +31,34 @@ interface RecordedRequest {
31
31
 
32
32
  let recorded: RecordedRequest[];
33
33
  let originalFetch: typeof globalThis.fetch;
34
+ /** Per-test override: return a body (or a promise of one) for a request,
35
+ * or undefined for the default room response. */
36
+ let respond: ((url: string, method: string) => unknown) | null = null;
34
37
 
35
38
  function installFetchStub(): void {
36
39
  recorded = [];
40
+ respond = null;
37
41
  originalFetch = globalThis.fetch;
38
42
  globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
39
43
  const url = typeof input === "string" ? input : input.toString();
40
44
  const method = (init?.method ?? "GET").toUpperCase();
41
45
  recorded.push({ url, method });
42
- return new Response(JSON.stringify({ snapshot: { peers: [] }, members: [] }), {
46
+ const custom = respond ? await respond(url, method) : undefined;
47
+ const body = custom ?? { snapshot: { peers: [] }, members: [] };
48
+ return new Response(JSON.stringify(body), {
43
49
  status: 200,
44
50
  headers: { "content-type": "application/json" },
45
51
  });
46
52
  }) as typeof fetch;
47
53
  }
48
54
 
55
+ function isJoin(r: RecordedRequest): boolean {
56
+ return r.method === "POST" && r.url.endsWith("/api/rooms/join");
57
+ }
58
+ function isKeepalive(r: RecordedRequest): boolean {
59
+ return r.method === "POST" && r.url.endsWith("/api/rooms/heartbeat");
60
+ }
61
+
49
62
  function restoreFetch(): void {
50
63
  globalThis.fetch = originalFetch;
51
64
  }
@@ -69,6 +82,7 @@ interface FakeEngine {
69
82
  | Array<{ user_id: string; joined_at: string; data?: any }>
70
83
  | null;
71
84
  getRoomError: (roomId: string) => { code: string; message?: string } | null;
85
+ resubscribeRoom: (roomId: string) => void;
72
86
  isWebSocketConnected: () => boolean;
73
87
  connectionStatus: () => string;
74
88
  // The store.subscribe surface — useRoom subscribes to connection-status
@@ -83,6 +97,7 @@ interface FakeEngine {
83
97
  _rooms: Map<string, FakeRoomEntry>;
84
98
  _subscribeCalls: string[];
85
99
  _unsubscribeCalls: string[];
100
+ _resubscribeCalls: string[];
86
101
  setWsConnected(open: boolean): void;
87
102
  pushSnapshot(roomId: string, members: any[]): void;
88
103
  pushUpdate(
@@ -100,7 +115,13 @@ function makeFakeEngine(): FakeEngine {
100
115
  _rooms: new Map(),
101
116
  _subscribeCalls: [],
102
117
  _unsubscribeCalls: [],
118
+ _resubscribeCalls: [],
103
119
 
120
+ resubscribeRoom: (roomId) => {
121
+ engine._resubscribeCalls.push(roomId);
122
+ const entry = engine._rooms.get(roomId);
123
+ if (entry) entry.error = null;
124
+ },
104
125
  isWebSocketConnected: () => engine._wsOpen,
105
126
  connectionStatus: () => (engine._wsOpen ? "connected" : "reconnecting"),
106
127
  store: {
@@ -208,6 +229,8 @@ describe("useRoom WS push path", () => {
208
229
  25, // would tick every 25ms IF polling were active
209
230
  engine as any,
210
231
  );
232
+ // The room subscribes only after the HTTP join lands.
233
+ await flush();
211
234
  expect(engine._subscribeCalls).toEqual([ROOM]);
212
235
  expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
213
236
  expect(__roomRegistryInternals.isPolling(room)).toBe(false);
@@ -251,7 +274,7 @@ describe("useRoom WS push path", () => {
251
274
  expect(room.peers.map((p) => p.user_id)).toEqual(["bob"]);
252
275
  });
253
276
 
254
- test("server pushes NOT_IN_ROOM error → surfaces via room.error, isConnected false, no retry", async () => {
277
+ test("NOT_IN_ROOM → rejoin over HTTP, then resubscribe; the snapshot restores presence", async () => {
255
278
  const engine = makeFakeEngine();
256
279
  const room = __roomRegistryInternals.acquire(
257
280
  BASE,
@@ -262,20 +285,132 @@ describe("useRoom WS push path", () => {
262
285
  1_000,
263
286
  engine as any,
264
287
  );
265
- // Let the HTTP join settle first so its success doesn't clobber
266
- // the WS-pushed error state.
267
288
  await flush();
289
+ expect(recorded.filter(isJoin)).toHaveLength(1);
290
+
291
+ // E.g. the socket reconnected and the server had dropped us when the
292
+ // old socket closed; the replayed subscribe is refused.
268
293
  engine.pushError(ROOM, "NOT_IN_ROOM", "not a member");
269
294
  await flush();
270
- expect(typeof room.error).toBe("string");
271
- expect(room.error ?? "").toContain("not a member");
272
- expect(room.isConnected).toBe(false);
273
- // Snapshot-timeout fallback should NOT have engaged (error path
274
- // cancels the backwards-compat timer).
295
+ expect(recorded.filter(isJoin)).toHaveLength(2);
296
+ expect(engine._resubscribeCalls).toEqual([ROOM]);
297
+ expect(room.error).toBeNull();
298
+
299
+ engine.pushSnapshot(ROOM, [{ user_id: "alice", joined_at: "t1" }]);
300
+ await flush();
301
+ expect(room.isConnected).toBe(true);
302
+ expect(room.peers.map((p) => p.user_id)).toEqual(["alice"]);
303
+ // Snapshot-timeout fallback never engaged.
275
304
  await flush(80);
276
305
  expect(__roomRegistryInternals.isPolling(room)).toBe(false);
277
306
  });
278
307
 
308
+ test("NOT_IN_ROOM that keeps coming back surfaces the error after bounded retries", async () => {
309
+ __roomRegistryInternals.setTimings({ rejoinBaseMs: 1 });
310
+ const engine = makeFakeEngine();
311
+ const room = __roomRegistryInternals.acquire(
312
+ BASE,
313
+ ROOM,
314
+ USER,
315
+ TOKEN,
316
+ {},
317
+ 1_000,
318
+ engine as any,
319
+ );
320
+ await flush();
321
+ for (let i = 0; i < 8; i++) {
322
+ engine.pushError(ROOM, "NOT_IN_ROOM", "not a member");
323
+ await flush(40);
324
+ }
325
+ // 1 initial join + 5 rejoins, then the error reaches the hook.
326
+ expect(recorded.filter(isJoin)).toHaveLength(6);
327
+ expect(room.error ?? "").toContain("not a member");
328
+ expect(room.isConnected).toBe(false);
329
+ });
330
+
331
+ test("first mount: room-subscribe waits for the HTTP join (no NOT_IN_ROOM race)", async () => {
332
+ let releaseJoin!: () => void;
333
+ const joinGate = new Promise<void>((r) => (releaseJoin = r));
334
+ respond = async (url, method) => {
335
+ if (method === "POST" && url.endsWith("/api/rooms/join")) {
336
+ await joinGate;
337
+ }
338
+ return undefined;
339
+ };
340
+ const engine = makeFakeEngine();
341
+ const room = __roomRegistryInternals.acquire(
342
+ BASE,
343
+ ROOM,
344
+ USER,
345
+ TOKEN,
346
+ {},
347
+ 1_000,
348
+ engine as any,
349
+ );
350
+ await flush(20);
351
+ expect(engine._subscribeCalls).toEqual([]);
352
+ releaseJoin();
353
+ await flush();
354
+ expect(engine._subscribeCalls).toEqual([ROOM]);
355
+ expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
356
+ });
357
+
358
+ test("keepalive heartbeats while joined; alive:false rejoins", async () => {
359
+ __roomRegistryInternals.setTimings({ keepaliveMs: 20 });
360
+ let alive = true;
361
+ respond = (url, method) =>
362
+ method === "POST" && url.endsWith("/api/rooms/heartbeat")
363
+ ? { alive, ...(alive ? {} : { reason: "not_in_room" }) }
364
+ : undefined;
365
+ const engine = makeFakeEngine();
366
+ const room = __roomRegistryInternals.acquire(
367
+ BASE,
368
+ ROOM,
369
+ USER,
370
+ TOKEN,
371
+ {},
372
+ 1_000,
373
+ engine as any,
374
+ );
375
+ await flush(70);
376
+ expect(recorded.filter(isKeepalive).length).toBeGreaterThanOrEqual(2);
377
+ expect(recorded.filter(isJoin)).toHaveLength(1);
378
+
379
+ // The idle sweep dropped us: the next heartbeat reports it and the
380
+ // registry rejoins and resubscribes.
381
+ alive = false;
382
+ await flush(40);
383
+ alive = true;
384
+ expect(recorded.filter(isJoin).length).toBeGreaterThanOrEqual(2);
385
+ expect(engine._resubscribeCalls.length).toBeGreaterThanOrEqual(1);
386
+
387
+ // Manual leave stops the keepalive and any rejoin.
388
+ __roomRegistryInternals.markLeft(room);
389
+ const before = recorded.filter(isKeepalive).length;
390
+ await flush(60);
391
+ expect(recorded.filter(isKeepalive).length).toBe(before);
392
+ });
393
+
394
+ test("teardown stops the keepalive", async () => {
395
+ __roomRegistryInternals.setTimings({ keepaliveMs: 20 });
396
+ const engine = makeFakeEngine();
397
+ const room = __roomRegistryInternals.acquire(
398
+ BASE,
399
+ ROOM,
400
+ USER,
401
+ TOKEN,
402
+ {},
403
+ 1_000,
404
+ engine as any,
405
+ );
406
+ await flush(30);
407
+ __roomRegistryInternals.release(room);
408
+ await flush(10);
409
+ const before = recorded.filter(isKeepalive).length;
410
+ await flush(60);
411
+ expect(recorded.filter(isKeepalive).length).toBe(before);
412
+ });
413
+
279
414
  test("WS down → polling fallback fires; reconnect promotes back to push", async () => {
280
415
  const engine = makeFakeEngine();
281
416
  engine.setWsConnected(false);
@@ -317,6 +452,7 @@ describe("useRoom WS push path", () => {
317
452
  25,
318
453
  engine as any,
319
454
  );
455
+ await flush();
320
456
  expect(__roomRegistryInternals.isWsAttached(room)).toBe(true);
321
457
  expect(__roomRegistryInternals.isPolling(room)).toBe(false);
322
458
 
@@ -349,6 +485,7 @@ describe("useRoom WS push path", () => {
349
485
  engine as any,
350
486
  );
351
487
  expect(r1).toBe(r2);
488
+ await flush();
352
489
  // Registry dedup → only one acquire path → only one subscribe.
353
490
  expect(engine._subscribeCalls).toEqual([ROOM]);
354
491
  __roomRegistryInternals.release(r1);
package/src/useRoom.ts CHANGED
@@ -108,6 +108,23 @@ export interface UseRoomReturn {
108
108
  * setup recovers without the user noticing. */
109
109
  const PUSH_SNAPSHOT_TIMEOUT_MS = 2_000;
110
110
 
111
+ /** How often a joined room sends `POST /api/rooms/heartbeat`. The server
112
+ * drops a member after 120 s without activity; 45 s leaves room for two
113
+ * lost heartbeats. */
114
+ const ROOM_KEEPALIVE_MS = 45_000;
115
+
116
+ /** Backoff base for rejoining after `NOT_IN_ROOM` or a failed join. The
117
+ * first rejoin runs at once; later ones wait 1 s, 2 s, 4 s, ... up to
118
+ * 30 s. After `MAX_REJOIN_ATTEMPTS` the error is surfaced to the hook. */
119
+ const REJOIN_BASE_MS = 1_000;
120
+ const MAX_REJOIN_ATTEMPTS = 5;
121
+
122
+ /** Test overrides for the timings above. */
123
+ const timing = {
124
+ keepaliveMs: ROOM_KEEPALIVE_MS,
125
+ rejoinBaseMs: REJOIN_BASE_MS,
126
+ };
127
+
111
128
  interface SharedRoom {
112
129
  /** Number of live React hooks holding this room. */
113
130
  refs: number;
@@ -148,6 +165,16 @@ interface SharedRoom {
148
165
  /** Connection-status unsubscribe — fires whenever the engine's
149
166
  * transport state changes so we can flip between push and polling. */
150
167
  connectionStatusUnsubscribe: (() => void) | null;
168
+ /** A `POST /api/rooms/join` is in flight. */
169
+ joining: boolean;
170
+ /** Keepalive timer (`POST /api/rooms/heartbeat`), set once joined. */
171
+ keepalive: ReturnType<typeof setInterval> | null;
172
+ /** Pending rejoin after `NOT_IN_ROOM` / a failed join. */
173
+ rejoinTimer: ReturnType<typeof setTimeout> | null;
174
+ /** Rejoins since the last good snapshot or join. */
175
+ rejoinAttempts: number;
176
+ /** The caller left with `leave()`: no keepalive, no rejoin. */
177
+ left: boolean;
151
178
  }
152
179
 
153
180
  const rooms = new Map<string, SharedRoom>();
@@ -225,6 +252,22 @@ function startWsPush(room: SharedRoom): void {
225
252
  clearTimeout(room.pushSnapshotTimer);
226
253
  room.pushSnapshotTimer = null;
227
254
  }
255
+ // The server no longer counts us as a member: our socket closed
256
+ // (reconnect), the idle sweep dropped us, or the subscribe beat the
257
+ // join. Rejoin over HTTP, then resubscribe. Surface the error only
258
+ // when rejoining keeps failing.
259
+ if (
260
+ err.code === 'NOT_IN_ROOM' &&
261
+ !room.left &&
262
+ room.rejoinAttempts < MAX_REJOIN_ATTEMPTS
263
+ ) {
264
+ if (room.isConnected) {
265
+ room.isConnected = false;
266
+ notify(room);
267
+ }
268
+ scheduleRejoin(room);
269
+ return;
270
+ }
228
271
  room.error =
229
272
  err.code === 'NOT_IN_ROOM'
230
273
  ? 'You are not a member of this room.'
@@ -238,6 +281,7 @@ function startWsPush(room: SharedRoom): void {
238
281
  clearTimeout(room.pushSnapshotTimer);
239
282
  room.pushSnapshotTimer = null;
240
283
  }
284
+ room.rejoinAttempts = 0;
241
285
  room.peers = members
242
286
  .filter((m) => m.user_id !== room.userId)
243
287
  .map(memberToPeer);
@@ -302,6 +346,13 @@ function evaluateDelivery(room: SharedRoom): void {
302
346
  return;
303
347
  }
304
348
  if (engine.isWebSocketConnected()) {
349
+ // The server answers `room-subscribe` with NOT_IN_ROOM unless the
350
+ // HTTP join already landed. Subscribe only after it has; the join's
351
+ // success path calls back in here.
352
+ if (!room.joined) {
353
+ stopHeartbeat(room);
354
+ return;
355
+ }
305
356
  startWsPush(room);
306
357
  } else {
307
358
  stopWsPush(room);
@@ -314,6 +365,8 @@ function joinRoom(room: SharedRoom): void {
314
365
  // and notifies all subscribers. Late subscribers either pick up the
315
366
  // already-resolved snapshot from `room.peers` or the next push /
316
367
  // heartbeat.
368
+ if (room.joining || room.left) return;
369
+ room.joining = true;
317
370
  pylonFetch<{ snapshot?: { peers?: RoomPeer[] } }>(
318
371
  transportFor(room),
319
372
  '/api/rooms/join',
@@ -326,6 +379,7 @@ function joinRoom(room: SharedRoom): void {
326
379
  // The room may have been torn down before join landed (mount →
327
380
  // unmount → no remount). In that case the entry is gone from the
328
381
  // registry; bail and let the leave path (if it ran) do its thing.
382
+ room.joining = false;
329
383
  if (!rooms.has(roomKeyFor(room))) return;
330
384
  room.joined = true;
331
385
  room.isConnected = true;
@@ -334,14 +388,80 @@ function joinRoom(room: SharedRoom): void {
334
388
  room.peers = body.snapshot.peers.filter((p) => p.user_id !== room.userId);
335
389
  }
336
390
  notify(room);
391
+ startKeepalive(room);
392
+ if (room.wsUnsubscribe && room.engine) {
393
+ // Rejoin after NOT_IN_ROOM: the engine still holds the room's
394
+ // subscription, so ask it to resend `room-subscribe`.
395
+ room.engine.resubscribeRoom(room.roomId);
396
+ } else {
397
+ evaluateDelivery(room);
398
+ }
337
399
  })
338
400
  .catch((e: any) => {
401
+ room.joining = false;
339
402
  if (!rooms.has(roomKeyFor(room))) return;
403
+ if (room.rejoinAttempts < MAX_REJOIN_ATTEMPTS && !room.left) {
404
+ scheduleRejoin(room);
405
+ return;
406
+ }
340
407
  room.error = e?.message ?? 'Failed to join room';
341
408
  notify(room);
342
409
  });
343
410
  }
344
411
 
412
+ /** Rejoin with backoff: at once for the first attempt, then 1 s, 2 s, 4 s,
413
+ * ... capped at 30 s. One pending rejoin at a time. */
414
+ function scheduleRejoin(room: SharedRoom): void {
415
+ if (room.rejoinTimer || room.joining || room.left) return;
416
+ const attempt = room.rejoinAttempts;
417
+ room.rejoinAttempts += 1;
418
+ const delay =
419
+ attempt === 0 ? 0 : Math.min(30_000, timing.rejoinBaseMs * 2 ** (attempt - 1));
420
+ room.rejoinTimer = setTimeout(() => {
421
+ room.rejoinTimer = null;
422
+ if (!rooms.has(roomKeyFor(room))) return;
423
+ joinRoom(room);
424
+ }, delay);
425
+ }
426
+
427
+ /** Keep the server-side membership alive. The server drops a member after
428
+ * 120 s without activity; a heartbeat refreshes it without emitting an
429
+ * event. `alive: false` means the server already dropped us: rejoin. */
430
+ function startKeepalive(room: SharedRoom): void {
431
+ if (room.keepalive || room.left) return;
432
+ room.keepalive = setInterval(() => {
433
+ if (!rooms.has(roomKeyFor(room)) || room.left) return;
434
+ pylonFetch<{ alive?: boolean }>(transportFor(room), '/api/rooms/heartbeat', {
435
+ method: 'POST',
436
+ json: { room: room.roomId, user_id: room.userId },
437
+ })
438
+ .then((body) => {
439
+ if (body?.alive === false && rooms.has(roomKeyFor(room))) {
440
+ if (room.isConnected) {
441
+ room.isConnected = false;
442
+ notify(room);
443
+ }
444
+ scheduleRejoin(room);
445
+ }
446
+ })
447
+ .catch(() => {
448
+ // Offline or an older server without the endpoint: the next
449
+ // heartbeat retries, and a reconnect's NOT_IN_ROOM rejoins.
450
+ });
451
+ }, timing.keepaliveMs);
452
+ }
453
+
454
+ function stopKeepalive(room: SharedRoom): void {
455
+ if (room.keepalive) {
456
+ clearInterval(room.keepalive);
457
+ room.keepalive = null;
458
+ }
459
+ if (room.rejoinTimer) {
460
+ clearTimeout(room.rejoinTimer);
461
+ room.rejoinTimer = null;
462
+ }
463
+ }
464
+
345
465
  function leaveRoom(room: SharedRoom): void {
346
466
  // Only ship leave if join actually landed — matches the original
347
467
  // StrictMode race-fix. Server leave is idempotent now, but skipping
@@ -395,6 +515,11 @@ function acquireRoom(
395
515
  wsUnsubscribe: null,
396
516
  pushSnapshotTimer: null,
397
517
  connectionStatusUnsubscribe: null,
518
+ joining: false,
519
+ keepalive: null,
520
+ rejoinTimer: null,
521
+ rejoinAttempts: 0,
522
+ left: false,
398
523
  };
399
524
  rooms.set(key, room);
400
525
  joinRoom(room);
@@ -429,6 +554,7 @@ function releaseRoom(room: SharedRoom): void {
429
554
  }
430
555
  stopHeartbeat(room);
431
556
  stopWsPush(room);
557
+ stopKeepalive(room);
432
558
  if (room.connectionStatusUnsubscribe) {
433
559
  room.connectionStatusUnsubscribe();
434
560
  room.connectionStatusUnsubscribe = null;
@@ -479,12 +605,26 @@ export const __roomRegistryInternals = {
479
605
  if (room.pendingTeardown) clearTimeout(room.pendingTeardown);
480
606
  stopHeartbeat(room);
481
607
  stopWsPush(room);
608
+ stopKeepalive(room);
482
609
  if (room.connectionStatusUnsubscribe) {
483
610
  room.connectionStatusUnsubscribe();
484
611
  room.connectionStatusUnsubscribe = null;
485
612
  }
486
613
  }
487
614
  rooms.clear();
615
+ timing.keepaliveMs = ROOM_KEEPALIVE_MS;
616
+ timing.rejoinBaseMs = REJOIN_BASE_MS;
617
+ },
618
+ /** Shorten the keepalive / rejoin timings for a test. `reset()` restores
619
+ * the defaults. */
620
+ setTimings(t: { keepaliveMs?: number; rejoinBaseMs?: number }): void {
621
+ if (t.keepaliveMs !== undefined) timing.keepaliveMs = t.keepaliveMs;
622
+ if (t.rejoinBaseMs !== undefined) timing.rejoinBaseMs = t.rejoinBaseMs;
623
+ },
624
+ /** Diagnostic: the caller's manual leave() for tests. */
625
+ markLeft(room: SharedRoom): void {
626
+ room.left = true;
627
+ stopKeepalive(room);
488
628
  },
489
629
  acquire(
490
630
  baseUrl: string,
@@ -669,7 +809,13 @@ export function useRoom(
669
809
  // Manual leave — bypasses the shared lifecycle and tells the server
670
810
  // we're gone immediately. The next effect cleanup will still try to
671
811
  // release the refcount; the join-guard means a duplicate leave is
672
- // skipped if needed, and the server is idempotent anyway.
812
+ // skipped if needed, and the server is idempotent anyway. Stop the
813
+ // keepalive and any rejoin so the room stays left.
814
+ const room = roomRef.current;
815
+ if (room) {
816
+ room.left = true;
817
+ stopKeepalive(room);
818
+ }
673
819
  pylonFetch(
674
820
  { baseUrl, token },
675
821
  '/api/rooms/leave',
@@ -0,0 +1,206 @@
1
+ // useRouteData must hand React's `use()` the same promise on every render
2
+ // attempt. It called `use(Promise.resolve(loader()))`, which minted a new
3
+ // promise per render: React logged "A component was suspended by an
4
+ // uncached promise" on listing pages and the loader ran again on every
5
+ // retry.
6
+ //
7
+ // Mounted with react-dom/client directly, outside the act() environment:
8
+ // under act, React 19 does not retry a suspended render when its promise
9
+ // resolves in this test runtime, so every Suspense test would hang.
10
+
11
+ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
12
+ import { Component, Suspense, type ReactNode } from "react";
13
+ import { createRoot, type Root } from "react-dom/client";
14
+
15
+ import { __routeDataCacheInternals, useRouteData } from "./useRouter";
16
+
17
+ let root: Root | null = null;
18
+ let host: HTMLElement | null = null;
19
+ let prevActEnv: unknown;
20
+
21
+ beforeEach(() => {
22
+ prevActEnv = (globalThis as { IS_REACT_ACT_ENVIRONMENT?: unknown }).IS_REACT_ACT_ENVIRONMENT;
23
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: unknown }).IS_REACT_ACT_ENVIRONMENT = false;
24
+ host = document.createElement("div");
25
+ document.body.appendChild(host);
26
+ root = createRoot(host);
27
+ });
28
+
29
+ afterEach(() => {
30
+ root?.unmount();
31
+ host?.remove();
32
+ root = null;
33
+ host = null;
34
+ (globalThis as { IS_REACT_ACT_ENVIRONMENT?: unknown }).IS_REACT_ACT_ENVIRONMENT = prevActEnv;
35
+ });
36
+
37
+ function mount(node: ReactNode) {
38
+ root!.render(<Suspense fallback={<p>loading</p>}>{node}</Suspense>);
39
+ }
40
+
41
+ async function until(text: string): Promise<void> {
42
+ for (let i = 0; i < 100; i++) {
43
+ if (host!.textContent === text) return;
44
+ await new Promise((r) => setTimeout(r, 5));
45
+ }
46
+ throw new Error(`expected "${text}", saw "${host!.textContent}"`);
47
+ }
48
+
49
+ function captureConsoleError(): { messages: string[]; restore: () => void } {
50
+ const original = console.error;
51
+ const messages: string[] = [];
52
+ console.error = (...args: unknown[]) => {
53
+ messages.push(args.map(String).join(" "));
54
+ };
55
+ return { messages, restore: () => (console.error = original) };
56
+ }
57
+
58
+ describe("useRouteData", () => {
59
+ test("suspends on one cached promise and runs the loader once", async () => {
60
+ let calls = 0;
61
+ const load = (slug: string) => {
62
+ calls += 1;
63
+ return new Promise<string>((r) => setTimeout(() => r(`item:${slug}`), 5));
64
+ };
65
+ function Item({ slug }: { slug: string }) {
66
+ const v = useRouteData(() => load(slug), [slug]);
67
+ return <p>{v}</p>;
68
+ }
69
+ const logs = captureConsoleError();
70
+ try {
71
+ mount(<Item slug="a" />);
72
+ await until("item:a");
73
+ } finally {
74
+ logs.restore();
75
+ }
76
+ expect(calls).toBe(1);
77
+ expect(logs.messages.some((m) => m.includes("uncached promise"))).toBe(false);
78
+ });
79
+
80
+ test("a synchronous loader value renders without a fallback", async () => {
81
+ let calls = 0;
82
+ function Item() {
83
+ const v = useRouteData(() => {
84
+ calls += 1;
85
+ return "ready";
86
+ }, ["sync"]);
87
+ return <p>{v}</p>;
88
+ }
89
+ mount(<Item />);
90
+ await until("ready");
91
+ expect(calls).toBe(1);
92
+ });
93
+
94
+ test("new deps load again; two loaders with the same deps keep their own results", async () => {
95
+ const seen: string[] = [];
96
+ function Pair({ id }: { id: string }) {
97
+ const a = useRouteData(async () => {
98
+ seen.push(`a:${id}`);
99
+ return `A-${id}`;
100
+ }, [id]);
101
+ const b = useRouteData(async () => {
102
+ seen.push(`b:${id}`);
103
+ return `B-${id}`;
104
+ }, [id]);
105
+ return <p>{`${a} ${b}`}</p>;
106
+ }
107
+ mount(<Pair id="1" />);
108
+ await until("A-1 B-1");
109
+ mount(<Pair id="2" />);
110
+ await until("A-2 B-2");
111
+ expect(seen.filter((s) => s === "a:1")).toHaveLength(1);
112
+ expect(seen.filter((s) => s === "a:2")).toHaveLength(1);
113
+ expect(seen.filter((s) => s === "b:2")).toHaveLength(1);
114
+ });
115
+ });
116
+
117
+ describe("useRouteData promise cache", () => {
118
+ test("a server render shares a promise only within one request", () => {
119
+ __routeDataCacheInternals.clear();
120
+ const g = globalThis as { window?: unknown };
121
+ const saved = g.window;
122
+ delete g.window;
123
+ try {
124
+ let calls = 0;
125
+ const loader = () => {
126
+ calls += 1;
127
+ return Promise.resolve(`user-${calls}`);
128
+ };
129
+ const serverData = () => ({
130
+ get: () => {}, list: () => {}, queryGraph: () => {}, paginate: () => {},
131
+ });
132
+ const requestA = serverData();
133
+ const requestB = serverData();
134
+ const a1 = __routeDataCacheInternals.lookup("src", [requestA, "slug"], loader);
135
+ const a2 = __routeDataCacheInternals.lookup("src", [requestA, "slug"], loader);
136
+ const b = __routeDataCacheInternals.lookup("src", [requestB, "slug"], loader);
137
+ expect(a1).toBe(a2);
138
+ expect(b).not.toBe(a1);
139
+ expect(calls).toBe(2);
140
+ // A module-level object (an API client) is not a request scope.
141
+ const api = { me: () => {} };
142
+ const m1 = __routeDataCacheInternals.lookup("src", [api, "me"], loader);
143
+ const m2 = __routeDataCacheInternals.lookup("src", [api, "me"], loader);
144
+ expect(m1).not.toBe(m2);
145
+ // No object at all: never cached on the server.
146
+ const p1 = __routeDataCacheInternals.lookup("src", ["slug"], loader);
147
+ const p2 = __routeDataCacheInternals.lookup("src", ["slug"], loader);
148
+ expect(p1).not.toBe(p2);
149
+ expect(__routeDataCacheInternals.size()).toBe(0);
150
+ } finally {
151
+ g.window = saved;
152
+ }
153
+ });
154
+
155
+ test("a rejected load stays long enough for the error boundary, then leaves", async () => {
156
+ __routeDataCacheInternals.clear();
157
+ let calls = 0;
158
+ const loader = () => {
159
+ calls += 1;
160
+ return Promise.reject(new Error("boom"));
161
+ };
162
+ const first = __routeDataCacheInternals.lookup("src-err", ["x"], loader);
163
+ await first.promise.catch(() => {});
164
+ // React's re-render after the rejection sees the same promise.
165
+ expect(__routeDataCacheInternals.lookup("src-err", ["x"], loader)).toBe(first);
166
+ expect(calls).toBe(1);
167
+ await new Promise((r) => setTimeout(r, 1_100));
168
+ __routeDataCacheInternals.lookup("src-err", ["x"], loader).promise.catch(() => {});
169
+ expect(calls).toBe(2);
170
+ __routeDataCacheInternals.clear();
171
+ });
172
+
173
+ test("a failing loader renders the error boundary without a retry loop", async () => {
174
+ let calls = 0;
175
+ class Boundary extends Component<{ children: ReactNode }, { error: string | null }> {
176
+ state = { error: null as string | null };
177
+ static getDerivedStateFromError(e: Error) {
178
+ return { error: e.message };
179
+ }
180
+ render() {
181
+ return this.state.error ? <p>{`error:${this.state.error}`}</p> : this.props.children;
182
+ }
183
+ }
184
+ function Item() {
185
+ const v = useRouteData(() => {
186
+ calls += 1;
187
+ return new Promise<string>((_, reject) => setTimeout(() => reject(new Error("boom")), 5));
188
+ }, ["failing"]);
189
+ return <p>{v}</p>;
190
+ }
191
+ const logs = captureConsoleError();
192
+ try {
193
+ root!.render(
194
+ <Boundary>
195
+ <Suspense fallback={<p>loading</p>}>
196
+ <Item />
197
+ </Suspense>
198
+ </Boundary>,
199
+ );
200
+ await until("error:boom");
201
+ } finally {
202
+ logs.restore();
203
+ }
204
+ expect(calls).toBe(1);
205
+ });
206
+ });
package/src/useRouter.ts CHANGED
@@ -205,15 +205,140 @@ export function useRouteData<T>(
205
205
  // loader is intentionally excluded; `deps` are the reload trigger.
206
206
  // eslint-disable-next-line react-hooks/exhaustive-deps
207
207
  }, deps);
208
- if (!optimistic) {
208
+ // Hooks run in both modes so their order never changes.
209
+ const committed = useRef<LoaderEntry | null>(null);
210
+ const source = optimistic ? "" : loader.toString();
211
+ const entry = optimistic
212
+ ? null
213
+ : committed.current && sameLoader(committed.current, source, deps)
214
+ ? committed.current
215
+ : loaderEntry(source, deps, loader);
216
+ useEffect(() => {
217
+ if (!entry) return;
218
+ // Once mounted, the ref keeps the promise; the shared cache only has to
219
+ // bridge renders that suspended before the first commit.
220
+ committed.current = entry;
221
+ releaseLoaderEntry(entry);
222
+ });
223
+ if (entry) {
209
224
  // SSR + hard load + non-seeded nav: suspend so the server streams the real
210
225
  // content and the client hydrates it synchronously from the pre-fulfilled
211
- // serverData cache (matches the server HTML — no mismatch).
212
- return use(Promise.resolve(loader())) as T;
226
+ // serverData cache (matches the server HTML — no mismatch). `use()` must
227
+ // get the SAME promise on every attempt: a render that suspends before its
228
+ // first commit keeps no state, so the promise lives in a shared cache
229
+ // keyed by the loader's source and `deps`.
230
+ return use(entry.promise) as T;
213
231
  }
214
232
  return data ?? seed;
215
233
  }
216
234
 
235
+ interface LoaderEntry {
236
+ source: string;
237
+ deps: readonly unknown[];
238
+ promise: Promise<unknown>;
239
+ }
240
+
241
+ /** Promises for loaders whose component has not committed yet. Bounded so
242
+ * renders that suspend and are then abandoned cannot grow it forever. */
243
+ const pendingLoaders: LoaderEntry[] = [];
244
+ const PENDING_LOADERS_MAX = 64;
245
+
246
+ function sameLoader(e: LoaderEntry, source: string, deps: readonly unknown[]): boolean {
247
+ if (e.source !== source || e.deps.length !== deps.length) return false;
248
+ for (let i = 0; i < deps.length; i++) {
249
+ if (!Object.is(e.deps[i], deps[i])) return false;
250
+ }
251
+ return true;
252
+ }
253
+
254
+ /** Server renders: entries keyed by the page's `serverData` found in
255
+ * `deps`. The SSR runtime builds a new `serverData` for every request,
256
+ * so a render's retries share one promise and nothing is shared between
257
+ * requests. Loaders whose deps hold no `serverData` are not cached on
258
+ * the server (a module-level object in deps would be shared by every
259
+ * request). */
260
+ const serverLoaders = new WeakMap<object, LoaderEntry[]>();
261
+
262
+ /** True for the SSR `serverData` object (see `ServerData` in ./ssr). */
263
+ function isServerData(d: unknown): d is object {
264
+ if (d === null || typeof d !== "object") return false;
265
+ const o = d as Record<string, unknown>;
266
+ return (
267
+ typeof o.get === "function" &&
268
+ typeof o.list === "function" &&
269
+ typeof o.queryGraph === "function" &&
270
+ typeof o.paginate === "function"
271
+ );
272
+ }
273
+
274
+ /** How long a rejected load stays cached. React re-renders the suspended
275
+ * component once the promise rejects; that render must see the same
276
+ * rejected promise so the error reaches the error boundary. A retry
277
+ * after this window runs the loader again. */
278
+ const REJECTED_ENTRY_TTL_MS = 1_000;
279
+
280
+ function makeEntry(source: string, deps: readonly unknown[], loader: () => unknown): LoaderEntry {
281
+ const value = loader();
282
+ let promise: Promise<unknown>;
283
+ if (value != null && typeof (value as { then?: unknown }).then === "function") {
284
+ promise = value as Promise<unknown>;
285
+ } else {
286
+ promise = Promise.resolve(value);
287
+ // React reads `status` / `value` on a thenable to skip suspending.
288
+ Object.assign(promise, { status: "fulfilled", value });
289
+ }
290
+ return { source, deps: [...deps], promise };
291
+ }
292
+
293
+ /** Find or create the cached promise for this loader call. A synchronous
294
+ * loader result becomes an already-fulfilled thenable, which `use()` reads
295
+ * without suspending. */
296
+ function loaderEntry(
297
+ source: string,
298
+ deps: readonly unknown[],
299
+ loader: () => unknown,
300
+ ): LoaderEntry {
301
+ if (typeof window === "undefined") {
302
+ // The module is shared by every request; the key must include the
303
+ // request (see `serverLoaders`).
304
+ const scope = deps.find(isServerData);
305
+ if (!scope) return makeEntry(source, deps, loader);
306
+ let list = serverLoaders.get(scope);
307
+ if (!list) {
308
+ list = [];
309
+ serverLoaders.set(scope, list);
310
+ }
311
+ const hit = list.find((e) => sameLoader(e, source, deps));
312
+ if (hit) return hit;
313
+ const entry = makeEntry(source, deps, loader);
314
+ list.push(entry);
315
+ const scoped = list;
316
+ entry.promise.then(undefined, () => {
317
+ setTimeout(() => {
318
+ const i = scoped.indexOf(entry);
319
+ if (i !== -1) scoped.splice(i, 1);
320
+ }, REJECTED_ENTRY_TTL_MS);
321
+ });
322
+ return entry;
323
+ }
324
+ const hit = pendingLoaders.find((e) => sameLoader(e, source, deps));
325
+ if (hit) return hit;
326
+ const entry = makeEntry(source, deps, loader);
327
+ pendingLoaders.push(entry);
328
+ // A failed load must not stay cached forever: an error-boundary retry
329
+ // has to run the loader again.
330
+ entry.promise.then(undefined, () => {
331
+ setTimeout(() => releaseLoaderEntry(entry), REJECTED_ENTRY_TTL_MS);
332
+ });
333
+ if (pendingLoaders.length > PENDING_LOADERS_MAX) pendingLoaders.shift();
334
+ return entry;
335
+ }
336
+
337
+ function releaseLoaderEntry(entry: LoaderEntry): void {
338
+ const i = pendingLoaders.indexOf(entry);
339
+ if (i !== -1) pendingLoaders.splice(i, 1);
340
+ }
341
+
217
342
  /**
218
343
  * Client-side redirect — replaces the current history entry with `href`.
219
344
  * Drop-in for Next's `redirect` when called from a client component
@@ -300,3 +425,12 @@ export function useRouter(): PylonRouter {
300
425
  [],
301
426
  );
302
427
  }
428
+
429
+ /** Test hooks for the useRouteData promise cache. */
430
+ export const __routeDataCacheInternals = {
431
+ lookup: loaderEntry,
432
+ size: (): number => pendingLoaders.length,
433
+ clear: (): void => {
434
+ pendingLoaders.length = 0;
435
+ },
436
+ };