codehost 0.11.1 → 0.13.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/CHANGELOG.md CHANGED
@@ -1,3 +1,17 @@
1
+ # [0.13.0](https://github.com/snomiao/codehost/compare/v0.12.0...v0.13.0) (2026-06-09)
2
+
3
+
4
+ ### Features
5
+
6
+ * **web:** host-scoped folder deep links — /host/<hostname>/<path> ([f6955ab](https://github.com/snomiao/codehost/commit/f6955ab37517f312ee780795efc503cb74ccdc36))
7
+
8
+ # [0.12.0](https://github.com/snomiao/codehost/compare/v0.11.1...v0.12.0) (2026-06-09)
9
+
10
+
11
+ ### Features
12
+
13
+ * **web:** URL-driven reconnect — Forward rehydrates, auto-reconnect on drop ([8e1f186](https://github.com/snomiao/codehost/commit/8e1f186bd6611d5d56a9061fed23ed7316c735ca))
14
+
1
15
  ## [0.11.1](https://github.com/snomiao/codehost/compare/v0.11.0...v0.11.1) (2026-06-09)
2
16
 
3
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codehost",
3
- "version": "0.11.1",
3
+ "version": "0.13.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -23,7 +23,7 @@ interface DevArgs {
23
23
  export const devCommand: CommandModule<{}, DevArgs> = {
24
24
  command: "dev [dir]",
25
25
  describe:
26
- "Serve a single folder over WebRTC; open it at codehost.dev/dev/<path> (or /gh/<owner>/<repo>, /git/<host>/<owner>/<repo> for a git repo)",
26
+ "Serve a single folder over WebRTC; open it at codehost.dev/host/<hostname>/<path> (or /gh/<owner>/<repo>, /git/<host>/<owner>/<repo> for a git repo)",
27
27
  builder: (y) =>
28
28
  y
29
29
  .positional("dir", {
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
- import { parseDeepLink, pickRoomMatch, repoKey, shareableDeepLink, toPosixPath } from "./repo";
2
+ import { parseDeepLink, pickRoomMatch, repoKey, resolveDevTarget, shareableDeepLink, toPosixPath } from "./repo";
3
+ import type { PeerInfo } from "./signaling";
3
4
  import { parseGitRemote } from "../cli/git";
4
5
 
5
6
  describe("toPosixPath", () => {
@@ -109,12 +110,60 @@ describe("parseDeepLink + repoKey round-trip", () => {
109
110
  expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
110
111
  });
111
112
 
113
+ test("/host/<hostname>/<path> -> host-scoped dev target", () => {
114
+ const dl = parseDeepLink("/host/Mac/Users/taku");
115
+ expect(dl?.type === "dev" && dl.target.host).toBe("Mac");
116
+ expect(dl?.type === "dev" && dl.target.path).toBe("/Users/taku");
117
+ });
118
+
119
+ test("/host/<hostname>/<Windows drive path> round-trips", () => {
120
+ const path = shareableDeepLink({ folder: "/C:/ws", host: "EC2AMAZ-PH8C4K1" })!;
121
+ expect(path).toBe("/host/EC2AMAZ-PH8C4K1/C:/ws");
122
+ const dl = parseDeepLink(path);
123
+ expect(dl?.type === "dev" && dl.target.host).toBe("EC2AMAZ-PH8C4K1");
124
+ expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
125
+ });
126
+
127
+ test("legacy /dev/<path> still parses host-agnostic (no host)", () => {
128
+ const dl = parseDeepLink("/dev/C:/ws");
129
+ expect(dl?.type === "dev" && dl.target.host).toBeUndefined();
130
+ expect(dl?.type === "dev" && dl.target.path).toBe("/C:/ws");
131
+ });
132
+
112
133
  test("non-deep-link -> null", () => {
113
134
  expect(parseDeepLink("/")).toBeNull();
114
135
  expect(parseDeepLink("/settings")).toBeNull();
115
136
  });
116
137
  });
117
138
 
139
+ describe("resolveDevTarget host scoping", () => {
140
+ const mk = (peerId: string, host: string, cwd: string): PeerInfo => ({
141
+ peerId,
142
+ role: "server",
143
+ meta: { name: host, host, cwd },
144
+ });
145
+ // Same served path on two different machines — the ambiguity host scoping fixes.
146
+ const servers = [mk("pA", "boxA", "/C:/ws"), mk("pB", "boxB", "/C:/ws")];
147
+
148
+ test("host-scoped target picks the matching host", () => {
149
+ expect(resolveDevTarget(servers, { host: "boxB", path: "/C:/ws" })?.peerId).toBe("pB");
150
+ expect(resolveDevTarget(servers, { host: "boxA", path: "/C:/ws" })?.peerId).toBe("pA");
151
+ });
152
+
153
+ test("host-scoped target with no matching host -> null (won't cross machines)", () => {
154
+ expect(resolveDevTarget(servers, { host: "boxC", path: "/C:/ws" })).toBeNull();
155
+ });
156
+
157
+ test("legacy host-agnostic target matches by path alone", () => {
158
+ expect(resolveDevTarget(servers, { path: "/C:/ws" })?.peerId).toBe("pA");
159
+ });
160
+
161
+ test("leading/trailing slash differences still match (e.g. expose cwd)", () => {
162
+ const ex = [mk("pE", "boxE", "localhost:8090")];
163
+ expect(resolveDevTarget(ex, { host: "boxE", path: "/localhost:8090" })?.peerId).toBe("pE");
164
+ });
165
+ });
166
+
118
167
  describe("shareableDeepLink", () => {
119
168
  test("GitHub repo -> /gh sugar", () => {
120
169
  expect(shareableDeepLink({ repo: "github.com/snomiao/codehost", branch: "main" })).toBe(
@@ -17,8 +17,12 @@ export interface RepoTarget {
17
17
  branch?: string;
18
18
  }
19
19
 
20
- /** A direct folder mount address: `/dev/<absolute-fs-path>`. */
20
+ /** A direct folder mount address: host-scoped `/host/<hostname>/<path>`, or the
21
+ * legacy host-agnostic `/dev/<path>` (a bare path collides across machines, so
22
+ * new links carry the host). */
21
23
  export interface DevTarget {
24
+ /** Hostname the workspace lives on; undefined for a legacy host-agnostic link. */
25
+ host?: string;
22
26
  path: string;
23
27
  }
24
28
 
@@ -31,7 +35,8 @@ export type DeepLink =
31
35
  * Parse a deep-link pathname:
32
36
  * /gh/<owner>/<repo>(/tree/<branch>) -> GitHub repo target
33
37
  * /git/<host>/<owner>/<repo>(/tree/<branch>) -> any-host repo target
34
- * /dev/<fs-path> -> direct folder mount
38
+ * /host/<hostname>/<fs-path> -> host-scoped folder mount
39
+ * /dev/<fs-path> -> legacy host-agnostic folder mount
35
40
  * Branch may contain slashes. Anything else -> null (normal app).
36
41
  */
37
42
  export function parseDeepLink(pathname: string): DeepLink {
@@ -50,6 +55,13 @@ export function parseDeepLink(pathname: string): DeepLink {
50
55
  target: { host: git[1].toLowerCase(), owner: git[2], name: git[3], branch: git[4] },
51
56
  };
52
57
  }
58
+ // Host-scoped folder mount: first segment is the hostname, the rest is the
59
+ // served path (which itself may contain slashes and a Windows drive colon).
60
+ const host = clean.match(/^\/host\/([^/]+)\/(.+)$/);
61
+ if (host) {
62
+ return { type: "dev", target: { host: host[1], path: `/${host[2].replace(/^\/+/, "")}` } };
63
+ }
64
+ // Legacy host-agnostic folder mount.
53
65
  const dev = clean.match(/^\/dev\/(.+)$/);
54
66
  if (dev) {
55
67
  return { type: "dev", target: { path: `/${dev[1].replace(/^\/+/, "")}` } };
@@ -96,11 +108,17 @@ export function fillLayout(layout: string, t: RepoTarget): string {
96
108
  * Shareable deep-link pathname for a connected workspace. A git-identified
97
109
  * server renders `/gh/<owner>/<repo>` for GitHub or `/git/<host>/<owner>/<repo>`
98
110
  * for any other host (with `/tree/<branch>` when known); a non-git workspace is
99
- * addressed by its opened folder as a `/dev/<path>` mount. Round-trips through
111
+ * addressed by its opened folder, scoped to its hostname as `/host/<host>/<path>`
112
+ * (or the legacy `/dev/<path>` when no host is known). Round-trips through
100
113
  * parseDeepLink + resolve{Repo,Dev}Target so another room member opening it
101
114
  * lands here. Returns null when there's nothing addressable.
102
115
  */
103
- export function shareableDeepLink(opts: { repo?: string; branch?: string; folder?: string }): string | null {
116
+ export function shareableDeepLink(opts: {
117
+ repo?: string;
118
+ branch?: string;
119
+ folder?: string;
120
+ host?: string;
121
+ }): string | null {
104
122
  if (opts.repo) {
105
123
  const [host, owner, name] = opts.repo.split("/");
106
124
  if (host && owner && name) {
@@ -108,7 +126,10 @@ export function shareableDeepLink(opts: { repo?: string; branch?: string; folder
108
126
  return opts.branch ? `${base}/tree/${opts.branch}` : base;
109
127
  }
110
128
  }
111
- if (opts.folder) return `/dev/${opts.folder.replace(/^\/+/, "")}`;
129
+ if (opts.folder) {
130
+ const path = opts.folder.replace(/^\/+/, "");
131
+ return opts.host ? `/host/${opts.host}/${path}` : `/dev/${path}`;
132
+ }
112
133
  return null;
113
134
  }
114
135
 
@@ -138,10 +159,16 @@ export function resolveRepoTarget(servers: PeerInfo[], target: RepoTarget): Reso
138
159
  return null;
139
160
  }
140
161
 
141
- /** Pick a `dev`/repo server whose served cwd matches a /dev/<path> target. */
162
+ /** Pick a folder-mount server whose served cwd matches the target path, scoped
163
+ * to `target.host` when the link carries one (a bare path is ambiguous across
164
+ * machines). Compares with leading + trailing slashes stripped: `parseDeepLink`
165
+ * forces a leading "/" on the path, but a served cwd may lack one (e.g. an
166
+ * `expose` server's `localhost:<port>`), so a trailing-only trim never matches. */
142
167
  export function resolveDevTarget(servers: PeerInfo[], target: DevTarget): Resolution | null {
143
- const want = trimSlash(target.path);
144
- const hit = servers.find((s) => s.meta && trimSlash(s.meta.cwd) === want);
168
+ const want = stripEnds(target.path);
169
+ const hit = servers.find(
170
+ (s) => s.meta && stripEnds(s.meta.cwd) === want && (!target.host || s.meta.host === target.host),
171
+ );
145
172
  return hit ? { peerId: hit.peerId } : null;
146
173
  }
147
174
 
@@ -172,3 +199,9 @@ function branchOk(meta: PeerMeta, target: RepoTarget): boolean {
172
199
  function trimSlash(p: string): string {
173
200
  return p.replace(/\/+$/, "");
174
201
  }
202
+
203
+ /** Strip leading and trailing slashes — for comparing a `/dev/<path>` target to
204
+ * a served cwd that may or may not carry a leading slash. */
205
+ function stripEnds(p: string): string {
206
+ return p.replace(/^\/+|\/+$/g, "");
207
+ }
@@ -46,7 +46,8 @@ function tokenFromHash(): string {
46
46
  /** Short label for the "looking for…" state from a deep link. */
47
47
  function deepLinkLabel(dl: DeepLink): string | null {
48
48
  if (!dl) return null;
49
- return dl.type === "repo" ? `${dl.target.owner}/${dl.target.name}` : dl.target.path;
49
+ if (dl.type === "repo") return `${dl.target.owner}/${dl.target.name}`;
50
+ return dl.target.host ? `${dl.target.host}:${dl.target.path}` : dl.target.path;
50
51
  }
51
52
 
52
53
  function folderQuery(folder?: string): string {
@@ -177,10 +178,19 @@ export function Discovery() {
177
178
  const activeRoomRef = useRef<string | null>(null);
178
179
  const sendersRef = useRef<Map<string, (to: string, data: unknown) => void>>(new Map());
179
180
  // Whether the live connection pushed a history entry (so Disconnect/Back can
180
- // pop it), and whether we're currently *viewing* a workspace (so popstate only
181
- // tears down from the iframe view, not during a mid-connect URL revert).
181
+ // pop it back to the list).
182
182
  const pushedRef = useRef(false);
183
- const viewingRef = useRef(false);
183
+ // A dial is in flight — a synchronous guard so the several reconnect triggers
184
+ // (popstate, server-list change, retry timer, deep-link auto-connect) never
185
+ // double-dial (connState updates a render too late to gate them).
186
+ const dialingRef = useRef(false);
187
+ // Set just before a failed-dial history.back() so the resulting popstate is
188
+ // treated as a URL revert, not a user navigation — the reconciler skips it once.
189
+ const revertingRef = useRef(false);
190
+ // Latest merged server list + connection state, read by the URL reconciler
191
+ // (invoked from the once-at-mount popstate handler) without a stale closure.
192
+ const allServersRef = useRef<RoomedServer[]>([]);
193
+ const connStateRef = useRef<ConnState>("idle");
184
194
 
185
195
  // Deep-link resolution (/gh/<owner>/<repo>/... or /dev/<path>): parse once,
186
196
  // auto-connect when a matching server appears, remember the opened folder.
@@ -218,11 +228,16 @@ export function Discovery() {
218
228
  const folder = activeFolderRef.current;
219
229
  setTimeout(() => setIframeSrc(`/vs/${peerId}/${folderQuery(folder)}`), 400);
220
230
  });
221
- // Back / Cmd+Left from a workspace returns to the list: the browser restores
222
- // the previous URL and we drop the connection. Only when actually viewing —
223
- // a mid-connect URL revert (failed dial) must leave the list state untouched.
231
+ // Back/Forward (Cmd+Left / Cmd+Right) reconcile the connection to the URL: a
232
+ // workspace deep link (re)connects to its server, the list URL drops the
233
+ // connection. The browser already changed the URL; we follow it. A failed
234
+ // dial's revert-back is skipped once (revertingRef) so it keeps the list.
224
235
  const onPopState = () => {
225
- if (viewingRef.current) teardownConn();
236
+ if (revertingRef.current) {
237
+ revertingRef.current = false;
238
+ return;
239
+ }
240
+ syncToUrl();
226
241
  };
227
242
  window.addEventListener("popstate", onPopState);
228
243
  // A valid token in the URL fragment (#t=<token>) joins the room and turns on
@@ -265,6 +280,23 @@ export function Discovery() {
265
280
  tryAutoConnect();
266
281
  }, [serversByRoom, tokens]);
267
282
 
283
+ // Keep the connection in sync with the URL as servers come and go: reconnect
284
+ // when the workspace named by the address bar (re)appears in a room — covers a
285
+ // daemon restart or a dropped channel while the tab stays open.
286
+ useEffect(() => {
287
+ syncToUrl();
288
+ }, [serversByRoom]);
289
+
290
+ // Safety-net retry: while the URL names a workspace we're not connected to and
291
+ // no dial is in flight, retry every few seconds — covers a dropped channel
292
+ // whose server never left the room (so no list change fires the effect above).
293
+ useEffect(() => {
294
+ if (!parseDeepLink(window.location.pathname)) return;
295
+ if (connState === "connected" || connState === "connecting") return;
296
+ const id = setInterval(() => syncToUrl(), 5000);
297
+ return () => clearInterval(id);
298
+ }, [connState, serversByRoom]);
299
+
268
300
  function joinFromInput(e: React.FormEvent) {
269
301
  e.preventDefault();
270
302
  const t = draft.trim();
@@ -295,59 +327,70 @@ export function Discovery() {
295
327
  sendersRef.current.delete(t);
296
328
  }
297
329
 
298
- async function connectTo(server: PeerInfo, room: string, folder?: string) {
330
+ async function connectTo(server: PeerInfo, room: string, folder?: string, fromHistory = false) {
299
331
  const send = sendersRef.current.get(room);
300
332
  if (!send) return;
333
+ dialingRef.current = true; // synchronous gate against concurrent triggers
334
+ let didPush = false;
335
+ try {
336
+ // Clear any prior connection's broker state first: after an RTC drop the
337
+ // broker still holds the dead channel in `locals`, so re-dialing the same
338
+ // peer would otherwise resolve straight to it. Also covers switching peers.
339
+ if (activePeerRef.current) connBroker.disconnect(activePeerRef.current);
301
340
 
302
- rtcRef.current?.close();
303
- rtcRef.current = null;
304
- setIframeSrc(null);
305
- setActivePeerId(server.peerId);
306
- activePeerRef.current = server.peerId;
307
- activeRoomRef.current = room;
308
- viewingRef.current = false;
309
- setConnState("connecting");
310
-
311
- // Update the address bar the instant Connect is clicked (don't wait for the
312
- // WebRTC handshake) and push a history entry, so Back / Cmd+Left returns to
313
- // the list. Reverted if the connection fails.
314
- const openFolder = folder ?? server.meta?.cwd;
315
- const targetPath = shareablePathFor(server, openFolder);
316
- const pushed = !!targetPath && targetPath !== window.location.pathname;
317
- if (pushed) history.pushState(null, "", targetPath);
318
- pushedRef.current = pushed;
319
- sharePathRef.current = targetPath ?? window.location.pathname;
320
-
321
- // The broker decides whether this tab owns the connection. `establish` is
322
- // only invoked when we're the owner (or get promoted on failover); other
323
- // tabs reuse the owner's channel via a proxy, so they never open WebRTC.
324
- const establish = () =>
325
- new Promise<RTCDataChannel>((resolve, reject) => {
326
- const rtc = new RtcClient({
327
- sendSignal: (data: RtcSignal) => send(server.peerId, data),
328
- onState: (state) => {
329
- if (state === "failed" || state === "disconnected") setConnState("failed");
330
- },
331
- onOpen: (channel) => {
341
+ rtcRef.current?.close();
342
+ rtcRef.current = null;
343
+ setIframeSrc(null);
344
+ setActivePeerId(server.peerId);
345
+ activePeerRef.current = server.peerId;
346
+ activeRoomRef.current = room;
347
+ setConnState("connecting");
348
+
349
+ // Update the address bar the instant Connect is clicked (don't wait for the
350
+ // handshake) and push a history entry, so Back returns to the list and
351
+ // Forward returns here. When `fromHistory`, the browser already set the URL
352
+ // (back/forward/reconnect) don't push again, but a prior entry exists.
353
+ const openFolder = folder ?? server.meta?.cwd;
354
+ if (fromHistory) {
355
+ pushedRef.current = true;
356
+ sharePathRef.current = window.location.pathname;
357
+ } else {
358
+ const targetPath = shareablePathFor(server, openFolder);
359
+ didPush = !!targetPath && targetPath !== window.location.pathname;
360
+ if (didPush) history.pushState(null, "", targetPath);
361
+ pushedRef.current = didPush;
362
+ sharePathRef.current = targetPath ?? window.location.pathname;
363
+ }
364
+
365
+ // The broker decides whether this tab owns the connection. `establish` is
366
+ // only invoked when we're the owner (or get promoted on failover); other
367
+ // tabs reuse the owner's channel via a proxy, so they never open WebRTC.
368
+ const establish = () =>
369
+ new Promise<RTCDataChannel>((resolve, reject) => {
370
+ const rtc = new RtcClient({
371
+ sendSignal: (data: RtcSignal) => send(server.peerId, data),
372
+ onState: (state) => {
373
+ if (state === "failed" || state === "disconnected") setConnState("failed");
374
+ },
375
+ onOpen: (channel) => {
376
+ clearTimeout(timer);
377
+ resolve(channel);
378
+ },
379
+ onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
380
+ });
381
+ rtcRef.current = rtc;
382
+ // Don't hang forever dialing a peer that never answers (e.g. a stale
383
+ // server still listed in the room): fail the attempt after 15s.
384
+ const timer = setTimeout(() => {
385
+ rtc.close();
386
+ reject(new Error("connection timed out"));
387
+ }, 15000);
388
+ rtc.start().catch((err) => {
332
389
  clearTimeout(timer);
333
- resolve(channel);
334
- },
335
- onClose: () => setConnState((s) => (s === "connected" ? "idle" : s)),
336
- });
337
- rtcRef.current = rtc;
338
- // Don't hang forever dialing a peer that never answers (e.g. a stale
339
- // server still listed in the room): fail the attempt after 15s.
340
- const timer = setTimeout(() => {
341
- rtc.close();
342
- reject(new Error("connection timed out"));
343
- }, 15000);
344
- rtc.start().catch((err) => {
345
- clearTimeout(timer);
346
- reject(err);
390
+ reject(err);
391
+ });
347
392
  });
348
- });
349
393
 
350
- try {
351
394
  await connBroker.connect(server.peerId, establish);
352
395
  setConnState("connected");
353
396
  // The daemon no longer sets a default folder (current VS Code serve-web
@@ -357,15 +400,16 @@ export function Discovery() {
357
400
  setIframeSrc(`/vs/${server.peerId}/${folderQuery(openFolder)}`);
358
401
  setResolving(null);
359
402
  recordConnect(server, room, openFolder);
360
- viewingRef.current = true;
361
403
  } catch {
362
404
  setConnState("failed");
363
- // Undo the optimistic history entry / URL change. We never started
364
- // viewing, so the popstate handler leaves the "failed" card in place.
365
- if (pushed) {
366
- pushedRef.current = false;
405
+ // Undo the optimistic history entry we pushed. revertingRef makes the
406
+ // resulting popstate a no-op so the "failed" card stays on the list.
407
+ if (didPush) {
408
+ revertingRef.current = true;
367
409
  history.back();
368
410
  }
411
+ } finally {
412
+ dialingRef.current = false;
369
413
  }
370
414
  }
371
415
 
@@ -375,7 +419,12 @@ export function Discovery() {
375
419
  function shareablePathFor(server: PeerInfo, folder?: string): string | null {
376
420
  return deepLinkRef.current
377
421
  ? window.location.pathname
378
- : shareableDeepLink({ repo: server.meta?.repo, branch: server.meta?.branch, folder });
422
+ : shareableDeepLink({
423
+ repo: server.meta?.repo,
424
+ branch: server.meta?.branch,
425
+ folder,
426
+ host: server.meta?.host,
427
+ });
379
428
  }
380
429
 
381
430
  async function shareLink() {
@@ -449,7 +498,33 @@ export function Discovery() {
449
498
  setConnState("idle");
450
499
  sharePathRef.current = null;
451
500
  pushedRef.current = false;
452
- viewingRef.current = false;
501
+ }
502
+
503
+ // Resolve a workspace deep-link path to a live server across all joined rooms.
504
+ function findServerForDeepLink(dl: DeepLink): (RoomedServer & { folder?: string }) | null {
505
+ if (!dl) return null;
506
+ const peers = allServersRef.current.map((x) => x.server);
507
+ const res = dl.type === "repo" ? resolveRepoTarget(peers, dl.target) : resolveDevTarget(peers, dl.target);
508
+ if (!res) return null;
509
+ const match = allServersRef.current.find((x) => x.server.peerId === res.peerId);
510
+ return match ? { ...match, folder: res.folder } : null;
511
+ }
512
+
513
+ // Reconcile the live connection to the current URL. Drives Back/Forward nav and
514
+ // auto-reconnect: a workspace deep link connects to (or reconnects to) the
515
+ // server it resolves to; the list URL ("/") drops the connection. Reads only
516
+ // refs/window, so it's safe to call from the once-at-mount popstate handler.
517
+ function syncToUrl() {
518
+ const dl = parseDeepLink(window.location.pathname);
519
+ if (!dl) {
520
+ if (activePeerRef.current) teardownConn();
521
+ return;
522
+ }
523
+ if (dialingRef.current) return; // a dial is already in flight
524
+ const target = findServerForDeepLink(dl);
525
+ if (!target) return; // its server isn't present (yet) — wait for it to appear
526
+ if (activePeerRef.current === target.server.peerId && connStateRef.current === "connected") return;
527
+ void connectTo(target.server, target.room, target.folder, true);
453
528
  }
454
529
 
455
530
  function disconnect() {
@@ -468,6 +543,10 @@ export function Discovery() {
468
543
  const allServers: RoomedServer[] = tokens.flatMap((t) =>
469
544
  (serversByRoom[t] ?? []).map((server) => ({ server, room: t })),
470
545
  );
546
+ // Mirror the latest merged servers + connection state into refs so the URL
547
+ // reconciler (called from event handlers/timers) never reads a stale closure.
548
+ allServersRef.current = allServers;
549
+ connStateRef.current = connState;
471
550
  const serverCount = allServers.length;
472
551
  const onlineRooms = tokens.filter((t) => roomOpen[t]).length;
473
552
  const activeServer = allServers.find((x) => x.server.peerId === activePeerId)?.server;