@pylonsync/react 0.21.0 → 0.22.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.
- package/dist/hooks.d.ts +12 -0
- package/dist/index.d.ts +3 -2
- package/dist/useRoom.d.ts +18 -0
- package/dist/useRouter.d.ts +16 -0
- package/package.json +5 -4
- package/src/hooks.ts +26 -2
- package/src/index.ts +20 -3
- package/src/uploadFile.test.ts +37 -0
- package/src/useQuery.synced.test.tsx +70 -0
- package/src/useRoom.push.test.ts +146 -9
- package/src/useRoom.ts +147 -1
- package/src/useRouteData.test.tsx +206 -0
- package/src/useRouter.ts +137 -3
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
|
|
160
|
-
*
|
|
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;
|
package/dist/useRouter.d.ts
CHANGED
|
@@ -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.
|
|
6
|
+
"version": "0.22.0",
|
|
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.
|
|
18
|
-
"@pylonsync/sdk": "0.
|
|
19
|
-
"@pylonsync/sync": "0.
|
|
17
|
+
"@pylonsync/realtime": "0.22.0",
|
|
18
|
+
"@pylonsync/sdk": "0.22.0",
|
|
19
|
+
"@pylonsync/sync": "0.22.0"
|
|
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
|
|
629
|
-
*
|
|
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
|
-
|
|
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", {
|
package/src/uploadFile.test.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/useRoom.push.test.ts
CHANGED
|
@@ -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
|
-
|
|
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("
|
|
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(
|
|
271
|
-
expect(
|
|
272
|
-
expect(room.
|
|
273
|
-
|
|
274
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|