From b8b811c119129899b1b54f409be67c5760a1edcc Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 3 Nov 2026 18:27:50 +0500 Subject: [PATCH 1/7] fix(astro): a relaunch keeps an explicit document over a later window, as launch does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hydration target was recomputed from the latest options, so registerClient({ document }) then relaunch({ window }) watched the window's document while the launch used the explicit one. Keep the raw document/window and resolve with launch's precedence. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/astro/src/client.test.ts | 39 ++++++++++++--- packages/astro/src/client.ts | 82 ++++++++++++++++--------------- 2 files changed, 76 insertions(+), 45 deletions(-) diff --git a/packages/astro/src/client.test.ts b/packages/astro/src/client.test.ts index 185927ae..0bbf7ddd 100644 --- a/packages/astro/src/client.test.ts +++ b/packages/astro/src/client.test.ts @@ -72,12 +72,13 @@ describe('registerClient', () => { it("hands the page document the launch uses (an explicit one, else the bound window's) to the hydration listener as its target", async () => { launch.mockResolvedValue({}); - const doc = {} as Document; + const doc = { id: 'explicit' } as unknown as Document; await registerClient('tok', { document: doc } as never); expect(listen).toHaveBeenLastCalledWith({ target: doc }); - const windowDoc = {} as Document; + // launch prefers an explicit `document`, so a later window alone does not displace it + const windowDoc = { id: 'window' } as unknown as Document; await registerClient('tok', { window: { document: windowDoc } } as never); - expect(listen).toHaveBeenLastCalledWith({ target: windowDoc }); + expect(listen).toHaveBeenLastCalledWith({ target: doc }); const hostile = { get document(): never { throw new Error('sandboxed'); @@ -85,11 +86,22 @@ describe('registerClient', () => { }; await registerClient('tok', { window: hostile } as never); // nothing selectable this time: the earlier registration's document is KEPT (core's relaunch keeps `document` and `window` too) - expect(listen).toHaveBeenLastCalledWith({ target: windowDoc }); + expect(listen).toHaveBeenLastCalledWith({ target: doc }); + }); + + it('a sandboxed window whose document getter throws leaves the listener on its default target', async () => { + launch.mockResolvedValue({}); + const hostile = { + get document(): never { + throw new Error('sandboxed'); + }, + }; + await registerClient('tok', { window: hostile } as never); + expect(listen).toHaveBeenLastCalledWith({}); }); it('a plain relaunch() keeps the earlier document, carrier and onError (core keeps them), and an explicit one wins', async () => { - const doc = {} as Document; + const doc = { id: 'explicit' } as unknown as Document; const carrier = {}; const onError = vi.fn(); launch.mockResolvedValue({}); @@ -98,7 +110,7 @@ describe('registerClient', () => { await relaunch(); expect(listen).toHaveBeenLastCalledWith({ target: doc, carrier, onError }); expect(flush).toHaveBeenLastCalledWith({ target: doc, carrier, onError }); - const other = {} as Document; + const other = { id: 'other' } as unknown as Document; await relaunch({ document: other } as never); expect(listen).toHaveBeenLastCalledWith({ target: other, carrier, onError }); }); @@ -113,6 +125,21 @@ describe('registerClient', () => { expect(flush).toHaveBeenLastCalledWith({ target: windowDoc }); }); + it("a relaunch({ window }) after an explicit document keeps that document, because launch prefers `document` over the window's", async () => { + const doc = { id: 'explicit' } as unknown as Document; + const windowDoc = { id: 'window' } as unknown as Document; + launch.mockResolvedValue({}); + relaunchUmbrella.mockResolvedValue({}); + await registerClient('tok', { document: doc } as never); + await relaunch({ window: { document: windowDoc } } as never); + expect(listen).toHaveBeenLastCalledWith({ target: doc }); + expect(flush).toHaveBeenLastCalledWith({ target: doc }); + // and an explicit document on the relaunch still wins over the kept one + const other = { id: 'other' } as unknown as Document; + await relaunch({ document: other } as never); + expect(listen).toHaveBeenLastCalledWith({ target: other }); + }); + it('hands a custom carrier to the hydration listener, so it looks where the client lives', async () => { const carrier = {}; launch.mockResolvedValue({}); diff --git a/packages/astro/src/client.ts b/packages/astro/src/client.ts index 2fbb974e..cac279e5 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -15,56 +15,60 @@ import { flushQueuedAstroHydrationErrors, listenForAstroHydrationErrors } from ' /** Options for the Astro client (browser) composition — the batteries-included browser umbrella options. */ export interface AstroClientOptions extends BugseeLaunchOptionsWithPerformance {} -// The hydration listener's options: where to report a failing sink, and which carrier the client lives on (a custom one is not on `globalThis`). -const hydrationOptions = ( - options: - | { - onError?: (error: unknown) => void; - carrier?: object; - document?: Document; - window?: unknown; - } - | undefined, -) => { - // The page document the launch itself uses: an explicit `document`, else the bound window's. Omitted when neither yields one, which - // keeps the listener's default (`globalThis.document`). A hostile `window.document` getter (a sandboxed frame) counts as none. - let target: Document | undefined = options?.document; - if (target === undefined) { - try { - target = (options?.window as { document?: Document } | undefined)?.document; - } catch { - target = undefined; +type HydrationInput = + | { + onError?: (error: unknown) => void; + carrier?: object; + document?: Document; + window?: unknown; } + | undefined; + +// The page document the launch itself uses: an explicit `document`, else the bound window's. Undefined when neither yields one, which +// keeps the listener's default (`globalThis.document`). A hostile `window.document` getter (a sandboxed frame) counts as none. +function launchDocument(document: Document | undefined, win: unknown): Document | undefined { + if (document !== undefined) { + return document; } - return { - ...(options?.onError !== undefined ? { onError: options.onError } : {}), - ...(options?.carrier !== undefined ? { carrier: options.carrier } : {}), - ...(target !== undefined ? { target } : {}), - }; -}; + try { + return (win as { document?: Document } | undefined)?.document; + } catch { + return undefined; + } +} -// The hydration options of the last registration (see `withHydrationWindow`). -let lastHydration: ReturnType = {}; +// What the last registration handed in. `document` and `window` stay RAW: which one the launch uses is decided by precedence, so a +// relaunch that adds only a window must not displace a kept explicit document. +let last: { + document?: Document; + window?: unknown; + onError?: (e: unknown) => void; + carrier?: object; +} = {}; /** * Open the hydration listener's launch window, run `start`, and close the window in a `finally`: an error dispatched while the client is * (re)starting is queued, and the flush runs whether the start succeeded or failed. + * + * Mirrors core's `relaunchWith`, which keeps `document`, `window`, `carrier` and `onError` from the previous launch and lets an explicit + * one win: a plain `relaunch()` must watch the same document, on the same carrier, with the same sink. */ async function withHydrationWindow( - options: - | { - onError?: (error: unknown) => void; - carrier?: object; - document?: Document; - window?: unknown; - } - | undefined, + options: HydrationInput, start: () => Promise, ): Promise { - // Like core's `relaunchWith`, which keeps `document`, `window`, `carrier` and `onError` from the previous launch and lets an explicit one - // win: a plain `relaunch()` must watch the same document, on the same carrier, with the same sink. - const hydration = { ...lastHydration, ...hydrationOptions(options) }; - lastHydration = hydration; + const next = { ...last }; + if (options?.document !== undefined) next.document = options.document; + if (options?.window !== undefined) next.window = options.window; + if (options?.onError !== undefined) next.onError = options.onError; + if (options?.carrier !== undefined) next.carrier = options.carrier; + last = next; + const target = launchDocument(next.document, next.window); + const hydration = { + ...(next.onError !== undefined ? { onError: next.onError } : {}), + ...(next.carrier !== undefined ? { carrier: next.carrier } : {}), + ...(target !== undefined ? { target } : {}), + }; // Astro swallows an island's hydration failure into an `astro:hydration-error` event; nothing else would see it. Listen BEFORE the // launch resolves (an error dispatched meanwhile is queued) and flush once the client exists. listenForAstroHydrationErrors(hydration); From 506709c104a8c48b7f11a73f0ac6ff9be6af0cca Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 3 Nov 2026 18:29:56 +0500 Subject: [PATCH 2/7] fix(astro): remember hydration options like core's lastLaunch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A fresh registration no longer merges with earlier calls (core's plain launch carries nothing over), a relaunch builds on the remembered options, the state moves only after a successful start, a repeat registration (the same live client) changes nothing, and an explicit undefined overwrites a kept value as core's spread does. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/astro/src/client.test.ts | 62 ++++++++++++++++++++++++------- packages/astro/src/client.ts | 52 +++++++++++++++++--------- 2 files changed, 82 insertions(+), 32 deletions(-) diff --git a/packages/astro/src/client.test.ts b/packages/astro/src/client.test.ts index 0bbf7ddd..97ba6640 100644 --- a/packages/astro/src/client.test.ts +++ b/packages/astro/src/client.test.ts @@ -70,25 +70,57 @@ describe('registerClient', () => { expect(order).toEqual(['listen', 'flush']); }); - it("hands the page document the launch uses (an explicit one, else the bound window's) to the hydration listener as its target", async () => { - launch.mockResolvedValue({}); + it("a fresh registration takes its document from its OWN options: an explicit one, else the bound window's", async () => { const doc = { id: 'explicit' } as unknown as Document; + const windowDoc = { id: 'window' } as unknown as Document; + launch.mockResolvedValueOnce({ id: 1 }); await registerClient('tok', { document: doc } as never); expect(listen).toHaveBeenLastCalledWith({ target: doc }); - // launch prefers an explicit `document`, so a later window alone does not displace it - const windowDoc = { id: 'window' } as unknown as Document; - await registerClient('tok', { window: { document: windowDoc } } as never); + // a client that was stopped (or never came up) gives a NEW client: core launches afresh with only these options, so the old document, + // carrier and sink are not carried into it + const carrier = {}; + launch.mockResolvedValueOnce({ id: 2 }); + await registerClient('tok', { window: { document: windowDoc }, carrier } as never); + expect(listen).toHaveBeenLastCalledWith({ target: windowDoc, carrier }); + }); + + it('a repeat registration (core ignores it and returns the live client) leaves the remembered options alone', async () => { + const doc = { id: 'explicit' } as unknown as Document; + const client = { id: 1 }; + launch.mockResolvedValue(client); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + await registerClient('tok', { document: doc } as never); + await registerClient('tok', { window: { document: { id: 'window' } } } as never); + await relaunch(); expect(listen).toHaveBeenLastCalledWith({ target: doc }); - const hostile = { - get document(): never { - throw new Error('sandboxed'); - }, - }; - await registerClient('tok', { window: hostile } as never); - // nothing selectable this time: the earlier registration's document is KEPT (core's relaunch keeps `document` and `window` too) + }); + + it('a failed launch or relaunch does not become the remembered state (core records last-launch options only on success)', async () => { + const doc = { id: 'explicit' } as unknown as Document; + launch.mockResolvedValueOnce({ id: 1 }); + relaunchUmbrella.mockRejectedValueOnce(new Error('boom')); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + await registerClient('tok', { document: doc } as never); + await expect(relaunch({ document: { id: 'failed' } } as never)).rejects.toThrow('boom'); + await relaunch(); + expect(listen).toHaveBeenLastCalledWith({ target: doc }); + launch.mockRejectedValueOnce(new Error('nope')); + await expect(registerClient('tok', { document: { id: 'failed2' } } as never)).rejects.toThrow( + 'nope', + ); + await relaunch(); expect(listen).toHaveBeenLastCalledWith({ target: doc }); }); + it('an explicit undefined on a relaunch overwrites the kept value, as core spreads it', async () => { + const windowDoc = { id: 'window' } as unknown as Document; + launch.mockResolvedValue({ id: 1 }); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + await registerClient('tok', { document: { id: 'explicit' } } as never); + await relaunch({ document: undefined, window: { document: windowDoc } } as never); + expect(listen).toHaveBeenLastCalledWith({ target: windowDoc }); + }); + it('a sandboxed window whose document getter throws leaves the listener on its default target', async () => { launch.mockResolvedValue({}); const hostile = { @@ -155,12 +187,14 @@ describe('registerClient', () => { expect(flush).toHaveBeenCalledTimes(1); }); - it('passes the onError sink to the hydration listener, and keeps it for a later call that gives none (core keeps onError)', async () => { + it('passes the onError sink to the hydration listener, and a relaunch that gives none keeps it (core keeps onError)', async () => { const onError = vi.fn(); + launch.mockResolvedValue({ id: 1 }); + relaunchUmbrella.mockResolvedValue({ id: 2 }); await registerClient('tok', { onError }); expect(listen).toHaveBeenCalledTimes(1); expect(listen).toHaveBeenCalledWith({ onError }); - await registerClient('tok'); + await relaunch(); expect(listen).toHaveBeenLastCalledWith({ onError }); }); }); diff --git a/packages/astro/src/client.ts b/packages/astro/src/client.ts index cac279e5..371edee6 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -37,32 +37,43 @@ function launchDocument(document: Document | undefined, win: unknown): Document } } -// What the last registration handed in. `document` and `window` stay RAW: which one the launch uses is decided by precedence, so a -// relaunch that adds only a window must not displace a kept explicit document. -let last: { +type Remembered = { document?: Document; window?: unknown; onError?: (e: unknown) => void; carrier?: object; -} = {}; +}; + +// What the last SUCCESSFUL launch handed in, like core's `lastLaunch`. `document` and `window` stay RAW: which one the launch uses is +// decided by precedence, so a relaunch that adds only a window must not displace a kept explicit document. +let remembered: Remembered = {}; +let lastClient: unknown; + +function pick(options: HydrationInput): Remembered { + const picked: Remembered = {}; + for (const key of ['document', 'window', 'onError', 'carrier'] as const) { + if (options !== undefined && key in options) { + (picked as Record)[key] = options[key]; + } + } + return picked; +} /** * Open the hydration listener's launch window, run `start`, and close the window in a `finally`: an error dispatched while the client is * (re)starting is queued, and the flush runs whether the start succeeded or failed. * - * Mirrors core's `relaunchWith`, which keeps `document`, `window`, `carrier` and `onError` from the previous launch and lets an explicit - * one win: a plain `relaunch()` must watch the same document, on the same carrier, with the same sink. + * `base` is what the launch builds on: a relaunch starts from the remembered options (core's `relaunchWith` keeps `document`, `window`, + * `carrier` and `onError`; an explicit key wins, even an explicit `undefined`), a fresh launch from nothing (core's plain launch carries + * nothing over). The remembered state moves only once the start succeeded, and a repeat registration (core ignores it and returns the live + * client, the same object) changes nothing. */ -async function withHydrationWindow( +async function withHydrationWindow( options: HydrationInput, - start: () => Promise, -): Promise { - const next = { ...last }; - if (options?.document !== undefined) next.document = options.document; - if (options?.window !== undefined) next.window = options.window; - if (options?.onError !== undefined) next.onError = options.onError; - if (options?.carrier !== undefined) next.carrier = options.carrier; - last = next; + base: Remembered, + start: () => Promise, +): Promise { + const next = { ...base, ...pick(options) }; const target = launchDocument(next.document, next.window); const hydration = { ...(next.onError !== undefined ? { onError: next.onError } : {}), @@ -73,7 +84,12 @@ async function withHydrationWindow( // launch resolves (an error dispatched meanwhile is queued) and flush once the client exists. listenForAstroHydrationErrors(hydration); try { - return await start(); + const client = await start(); + if (client !== lastClient) { + remembered = next; + lastClient = client; + } + return client; } finally { flushQueuedAstroHydrationErrors(hydration); // success: report what queued; failure: end the window, nothing to report to } @@ -87,7 +103,7 @@ export async function registerClient( appToken: string, options: AstroClientOptions = {}, ): Promise { - return await withHydrationWindow(options, () => launch(appToken, options)); + return await withHydrationWindow(options, {}, () => launch(appToken, options)); } /** @@ -95,7 +111,7 @@ export async function registerClient( * drain before the new one is set, and an island that fails to hydrate meanwhile must not be lost. */ export async function relaunch(options?: Parameters[0]): Promise { - return await withHydrationWindow(options, () => umbrellaRelaunch(options)); + return await withHydrationWindow(options, remembered, () => umbrellaRelaunch(options)); } // API parity with the umbrella entries: the same VALUE surface, thin re-exports of the same functions. From 717f186522d15f5a911a0290c0c0810a473d0a89 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 3 Nov 2026 18:32:05 +0500 Subject: [PATCH 3/7] fix(astro): a relaunch waits for the launch in flight; the listener is told the carrier and sink explicitly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A relaunch now reads the remembered options only after a pending launch has settled, as core's relaunchWith does, and the carrier/onError are always passed so an explicit undefined replaces what the hydration listener kept. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/astro/src/client.test.ts | 33 +++++++++++++++++++++++++ packages/astro/src/client.ts | 40 +++++++++++++++++++++++++++---- 2 files changed, 68 insertions(+), 5 deletions(-) diff --git a/packages/astro/src/client.test.ts b/packages/astro/src/client.test.ts index 97ba6640..ba1d0b56 100644 --- a/packages/astro/src/client.test.ts +++ b/packages/astro/src/client.test.ts @@ -187,6 +187,39 @@ describe('registerClient', () => { expect(flush).toHaveBeenCalledTimes(1); }); + it('always states the carrier and sink, so an explicit undefined replaces what the listener kept (core relaunches on the global carrier)', async () => { + launch.mockResolvedValue({ id: 1 }); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + await registerClient('tok', { carrier: {}, onError: vi.fn() } as never); + await relaunch({ carrier: undefined, onError: undefined } as never); + const passed = listen.mock.lastCall?.[0] as Record; + expect(Object.hasOwn(passed, 'carrier') && passed.carrier === undefined).toBe(true); + expect(Object.hasOwn(passed, 'onError') && passed.onError === undefined).toBe(true); + }); + + it('a relaunch started while a registration is still launching waits for it and builds on its options (core awaits the pending launch)', async () => { + const doc = { id: 'explicit' } as unknown as Document; + let finish: (client: unknown) => void = () => undefined; + launch.mockReturnValue(new Promise((resolve) => (finish = resolve))); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + const registering = registerClient('tok', { document: doc } as never); + const relaunching = relaunch(); + finish({ id: 1 }); + await Promise.all([registering, relaunching]); + expect(listen).toHaveBeenLastCalledWith({ target: doc }); + await relaunch(); + expect(listen).toHaveBeenLastCalledWith({ target: doc }); + }); + + it('a relaunch still goes ahead when the launch it waited for failed', async () => { + launch.mockRejectedValue(new Error('nope')); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + const registering = registerClient('tok'); + const relaunching = relaunch(); + await expect(registering).rejects.toThrow('nope'); + await expect(relaunching).resolves.toEqual({ id: 2 }); + }); + it('passes the onError sink to the hydration listener, and a relaunch that gives none keeps it (core keeps onError)', async () => { const onError = vi.fn(); launch.mockResolvedValue({ id: 1 }); diff --git a/packages/astro/src/client.ts b/packages/astro/src/client.ts index 371edee6..bd18d396 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -48,6 +48,8 @@ type Remembered = { // decided by precedence, so a relaunch that adds only a window must not displace a kept explicit document. let remembered: Remembered = {}; let lastClient: unknown; +// The launch still in flight, which a relaunch waits for before reading `remembered` (core's `relaunchWith` awaits the pending launch). +let inFlight: Promise = Promise.resolve(); function pick(options: HydrationInput): Remembered { const picked: Remembered = {}; @@ -68,16 +70,35 @@ function pick(options: HydrationInput): Remembered { * nothing over). The remembered state moves only once the start succeeded, and a repeat registration (core ignores it and returns the live * client, the same object) changes nothing. */ -async function withHydrationWindow( +function withHydrationWindow( + options: HydrationInput, + base: () => Remembered, + start: () => Promise, + afterPending = false, +): Promise { + const previous = inFlight; + const run = (async () => { + if (afterPending) { + await previous.then(undefined, () => undefined); + } + return await openWindow(options, base(), start); + })(); + inFlight = run; + return run; +} + +async function openWindow( options: HydrationInput, base: Remembered, start: () => Promise, ): Promise { const next = { ...base, ...pick(options) }; const target = launchDocument(next.document, next.window); + // `onError` and `carrier` are ALWAYS stated: the listener merges what it is given over what it kept, so an absent key would bring back a + // carrier or sink that this launch no longer has. const hydration = { - ...(next.onError !== undefined ? { onError: next.onError } : {}), - ...(next.carrier !== undefined ? { carrier: next.carrier } : {}), + onError: next.onError, + carrier: next.carrier, ...(target !== undefined ? { target } : {}), }; // Astro swallows an island's hydration failure into an `astro:hydration-error` event; nothing else would see it. Listen BEFORE the @@ -103,7 +124,11 @@ export async function registerClient( appToken: string, options: AstroClientOptions = {}, ): Promise { - return await withHydrationWindow(options, {}, () => launch(appToken, options)); + return await withHydrationWindow( + options, + () => ({}), + () => launch(appToken, options), + ); } /** @@ -111,7 +136,12 @@ export async function registerClient( * drain before the new one is set, and an island that fails to hydrate meanwhile must not be lost. */ export async function relaunch(options?: Parameters[0]): Promise { - return await withHydrationWindow(options, remembered, () => umbrellaRelaunch(options)); + return await withHydrationWindow( + options, + () => remembered, + () => umbrellaRelaunch(options), + true, + ); } // API parity with the umbrella entries: the same VALUE surface, thin re-exports of the same functions. From 6613d5ba76aabd977007ef21f0d0069af0b8ab37 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 3 Nov 2026 18:32:16 +0500 Subject: [PATCH 4/7] docs(astro): note the accepted limits of the remembered hydration state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/astro/src/client.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/astro/src/client.ts b/packages/astro/src/client.ts index bd18d396..c3557126 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -48,6 +48,8 @@ type Remembered = { // decided by precedence, so a relaunch that adds only a window must not displace a kept explicit document. let remembered: Remembered = {}; let lastClient: unknown; +// Known limits (accepted): a repeat is recognised by the client Astro itself last saw, so a live client launched through the umbrella directly +// is not known as one; and the hydration listener of a document the page has moved away from stays attached (as before this tracking). // The launch still in flight, which a relaunch waits for before reading `remembered` (core's `relaunchWith` awaits the pending launch). let inFlight: Promise = Promise.resolve(); From 0da3d487179cdce21b08d7a83110c691ab533879 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 3 Nov 2026 18:40:33 +0500 Subject: [PATCH 5/7] fix(astro): only own option properties count, as in core's spread MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/astro/src/client.test.ts | 10 ++++++++++ packages/astro/src/client.ts | 2 +- 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/astro/src/client.test.ts b/packages/astro/src/client.test.ts index ba1d0b56..a48de1a3 100644 --- a/packages/astro/src/client.test.ts +++ b/packages/astro/src/client.test.ts @@ -220,6 +220,16 @@ describe('registerClient', () => { await expect(relaunching).resolves.toEqual({ id: 2 }); }); + it('only OWN option properties count, as in core\'s spread: an inherited document is not the launch\'s document', async () => { + const own = { id: 'own' } as unknown as Document; + const inherited = { id: 'inherited' } as unknown as Document; + launch.mockResolvedValue({ id: 1 }); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + await registerClient('tok', { document: own } as never); + await relaunch(Object.create({ document: inherited }) as never); + expect(listen).toHaveBeenLastCalledWith({ target: own }); + }); + it('passes the onError sink to the hydration listener, and a relaunch that gives none keeps it (core keeps onError)', async () => { const onError = vi.fn(); launch.mockResolvedValue({ id: 1 }); diff --git a/packages/astro/src/client.ts b/packages/astro/src/client.ts index c3557126..46ac455f 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -56,7 +56,7 @@ let inFlight: Promise = Promise.resolve(); function pick(options: HydrationInput): Remembered { const picked: Remembered = {}; for (const key of ['document', 'window', 'onError', 'carrier'] as const) { - if (options !== undefined && key in options) { + if (options !== undefined && Object.hasOwn(options, key)) { (picked as Record)[key] = options[key]; } } From f0b6823ce9b1dfc71a7dbcaf1d878dd6863edc89 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 3 Nov 2026 18:42:32 +0500 Subject: [PATCH 6/7] fix(astro): a registration while a client is live leaves the hydration listener alone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Core ignores a repeat launch and returns the live client, so there is nothing to listen for or remember; opening a window from nothing overwrote the live listener's carrier and sink with undefined. Asks the carrier the launch would use (also covers a client launched outside Astro). 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/astro/src/client.test.ts | 37 +++++++++++++++++++++++++++++-- packages/astro/src/client.ts | 10 +++++++-- 2 files changed, 43 insertions(+), 4 deletions(-) diff --git a/packages/astro/src/client.test.ts b/packages/astro/src/client.test.ts index a48de1a3..4576bfb8 100644 --- a/packages/astro/src/client.test.ts +++ b/packages/astro/src/client.test.ts @@ -1,6 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const { launch, relaunchUmbrella, listen, flush, order } = vi.hoisted(() => ({ +const { launch, relaunchUmbrella, listen, flush, order, liveClient } = vi.hoisted(() => ({ + liveClient: vi.fn(), launch: vi.fn(), relaunchUmbrella: vi.fn(), listen: vi.fn(), @@ -8,6 +9,7 @@ const { launch, relaunchUmbrella, listen, flush, order } = vi.hoisted(() => ({ order: [] as string[], })); vi.mock('@bugsee/bugsee', () => ({ launch, relaunch: relaunchUmbrella })); +vi.mock('@bugsee/core', () => ({ getCarrierClient: liveClient })); vi.mock('./hydration', () => ({ listenForAstroHydrationErrors: listen, flushQueuedAstroHydrationErrors: flush, @@ -28,6 +30,7 @@ describe('registerClient', () => { relaunchUmbrella.mockReset(); listen.mockReset(); flush.mockReset(); + liveClient.mockReset(); order.length = 0; }); @@ -220,7 +223,7 @@ describe('registerClient', () => { await expect(relaunching).resolves.toEqual({ id: 2 }); }); - it('only OWN option properties count, as in core\'s spread: an inherited document is not the launch\'s document', async () => { + it("only OWN option properties count, as in core's spread: an inherited document is not the launch's document", async () => { const own = { id: 'own' } as unknown as Document; const inherited = { id: 'inherited' } as unknown as Document; launch.mockResolvedValue({ id: 1 }); @@ -230,6 +233,36 @@ describe('registerClient', () => { expect(listen).toHaveBeenLastCalledWith({ target: own }); }); + it('a registration while a client is already live (core ignores it) leaves the listener and the remembered options alone, wherever that client was launched', async () => { + const doc = { id: 'explicit' } as unknown as Document; + const carrier = {}; + const onError = vi.fn(); + const live = { id: 'live' }; + launch.mockResolvedValueOnce(live); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + await registerClient('tok', { document: doc, carrier, onError } as never); + listen.mockClear(); + flush.mockClear(); + liveClient.mockImplementation((host: unknown) => (host === carrier ? live : undefined)); + launch.mockResolvedValueOnce(live); + await registerClient('tok', { carrier } as never); // no sink, no document: core discards them + expect(launch).toHaveBeenLastCalledWith('tok', { carrier }); + expect(listen).not.toHaveBeenCalled(); + expect(flush).not.toHaveBeenCalled(); + // nothing was remembered from the ignored call either + await relaunch(); + expect(listen).toHaveBeenLastCalledWith({ target: doc, carrier, onError }); + }); + + it('asks the carrier the launch would use: the option, else the global', async () => { + const carrier = {}; + launch.mockResolvedValue({ id: 1 }); + await registerClient('tok', { carrier } as never); + expect(liveClient).toHaveBeenLastCalledWith(carrier); + await registerClient('tok'); + expect(liveClient).toHaveBeenLastCalledWith(globalThis); + }); + it('passes the onError sink to the hydration listener, and a relaunch that gives none keeps it (core keeps onError)', async () => { const onError = vi.fn(); launch.mockResolvedValue({ id: 1 }); diff --git a/packages/astro/src/client.ts b/packages/astro/src/client.ts index 46ac455f..45e6c438 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -10,6 +10,7 @@ import { launch, relaunch as umbrellaRelaunch, } from '@bugsee/bugsee'; +import { getCarrierClient } from '@bugsee/core'; import { flushQueuedAstroHydrationErrors, listenForAstroHydrationErrors } from './hydration'; /** Options for the Astro client (browser) composition — the batteries-included browser umbrella options. */ @@ -48,8 +49,8 @@ type Remembered = { // decided by precedence, so a relaunch that adds only a window must not displace a kept explicit document. let remembered: Remembered = {}; let lastClient: unknown; -// Known limits (accepted): a repeat is recognised by the client Astro itself last saw, so a live client launched through the umbrella directly -// is not known as one; and the hydration listener of a document the page has moved away from stays attached (as before this tracking). +// Known limit (accepted): the hydration listener of a document the page has moved away from stays attached (as before this tracking). +// `lastClient` still catches a repeat whose client has not reached the carrier yet (a launch in flight). // The launch still in flight, which a relaunch waits for before reading `remembered` (core's `relaunchWith` awaits the pending launch). let inFlight: Promise = Promise.resolve(); @@ -126,6 +127,11 @@ export async function registerClient( appToken: string, options: AstroClientOptions = {}, ): Promise { + // A client is already live on the carrier the launch would look at: core ignores this call and returns it, so there is nothing to + // listen for, remember or replace. Opening a window from nothing would overwrite the live listener's carrier and sink. + if (getCarrierClient(options.carrier ?? globalThis) !== undefined) { + return await launch(appToken, options); + } return await withHydrationWindow( options, () => ({}), From f8f1a89d943b460783cf50953ecbc30ac3fac113 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 3 Nov 2026 18:54:33 +0500 Subject: [PATCH 7/7] fix(astro): serialise registrations behind the launch in flight; a repeat attaches only its defined options MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All launches wait for the one in flight (the carrier is set only at its end). A registration while a client is live is a repeat: it listens with the remembered options plus only the options it defined (core discarded the rest) and remembers nothing. Fresh launches and relaunches still state carrier/onError explicitly. Replaces the lastClient heuristic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/astro/src/client.test.ts | 71 ++++++++++++++++++++---------- packages/astro/src/client.ts | 72 +++++++++++++++++-------------- 2 files changed, 88 insertions(+), 55 deletions(-) diff --git a/packages/astro/src/client.test.ts b/packages/astro/src/client.test.ts index 4576bfb8..481a008d 100644 --- a/packages/astro/src/client.test.ts +++ b/packages/astro/src/client.test.ts @@ -87,17 +87,6 @@ describe('registerClient', () => { expect(listen).toHaveBeenLastCalledWith({ target: windowDoc, carrier }); }); - it('a repeat registration (core ignores it and returns the live client) leaves the remembered options alone', async () => { - const doc = { id: 'explicit' } as unknown as Document; - const client = { id: 1 }; - launch.mockResolvedValue(client); - relaunchUmbrella.mockResolvedValue({ id: 2 }); - await registerClient('tok', { document: doc } as never); - await registerClient('tok', { window: { document: { id: 'window' } } } as never); - await relaunch(); - expect(listen).toHaveBeenLastCalledWith({ target: doc }); - }); - it('a failed launch or relaunch does not become the remembered state (core records last-launch options only on success)', async () => { const doc = { id: 'explicit' } as unknown as Document; launch.mockResolvedValueOnce({ id: 1 }); @@ -233,25 +222,63 @@ describe('registerClient', () => { expect(listen).toHaveBeenLastCalledWith({ target: own }); }); - it('a registration while a client is already live (core ignores it) leaves the listener and the remembered options alone, wherever that client was launched', async () => { + it('a repeat registration (a client is live) merges only its DEFINED options onto the listener and remembers nothing, wherever the client was launched', async () => { const doc = { id: 'explicit' } as unknown as Document; const carrier = {}; - const onError = vi.fn(); + const sink = vi.fn(); const live = { id: 'live' }; launch.mockResolvedValueOnce(live); relaunchUmbrella.mockResolvedValue({ id: 2 }); - await registerClient('tok', { document: doc, carrier, onError } as never); - listen.mockClear(); - flush.mockClear(); + await registerClient('tok', { document: doc, carrier } as never); liveClient.mockImplementation((host: unknown) => (host === carrier ? live : undefined)); launch.mockResolvedValueOnce(live); - await registerClient('tok', { carrier } as never); // no sink, no document: core discards them - expect(launch).toHaveBeenLastCalledWith('tok', { carrier }); - expect(listen).not.toHaveBeenCalled(); - expect(flush).not.toHaveBeenCalled(); - // nothing was remembered from the ignored call either + // the realistic late call: only a sink (functions cannot ride the injected script), a stated undefined is core-discarded + await registerClient('tok', { carrier, onError: sink, document: undefined } as never); + const passed = listen.mock.lastCall?.[0] as Record; + expect(passed).toEqual({ target: doc, carrier, onError: sink }); + expect(Object.hasOwn(passed, 'document')).toBe(false); + expect(flush).toHaveBeenLastCalledWith(passed); // the listen is settled at once: the client exists + expect(launch).toHaveBeenLastCalledWith('tok', { carrier, onError: sink, document: undefined }); + // nothing was remembered from the repeat: a plain relaunch is back on the first launch's options await relaunch(); - expect(listen).toHaveBeenLastCalledWith({ target: doc, carrier, onError }); + expect(listen).toHaveBeenLastCalledWith({ target: doc, carrier }); + expect( + Object.hasOwn(listen.mock.lastCall?.[0] as object, 'onError') && + (listen.mock.lastCall?.[0] as { onError?: unknown }).onError, + ).toBe(undefined); + }); + + it('a repeat registration with no options keeps the listener on the remembered carrier and sink (it never states undefined)', async () => { + const carrier = {}; + const sink = vi.fn(); + const live = { id: 'live' }; + launch.mockResolvedValue(live); + await registerClient('tok', { carrier, onError: sink } as never); + liveClient.mockReturnValue(live); + await registerClient('tok', { carrier } as never); + expect(listen).toHaveBeenCalledTimes(2); + const passed = listen.mock.lastCall?.[0] as Record; + expect(passed).toEqual({ carrier, onError: sink }); + expect(Object.keys(passed).sort()).toEqual(['carrier', 'onError']); + }); + + it('registrations overlapping a launch in flight wait for it, then see the live client (the carrier is set only at the end of a launch)', async () => { + const carrier = {}; + const sink = vi.fn(); + const live = { id: 'live' }; + let finish: (client: unknown) => void = () => undefined; + launch.mockReturnValueOnce(new Promise((resolve) => (finish = resolve))); + launch.mockResolvedValueOnce(live); + const first = registerClient('tok', { carrier, onError: sink } as never); + const second = registerClient('tok', { carrier } as never); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(launch).toHaveBeenCalledTimes(1); // the second has not started: it waits + liveClient.mockReturnValue(live); // the first launch ends: the carrier now holds the client + finish(live); + await Promise.all([first, second]); + expect(launch).toHaveBeenCalledTimes(2); + const passed = listen.mock.lastCall?.[0] as Record; + expect(passed).toEqual({ carrier, onError: sink }); // not wiped by the second call }); it('asks the carrier the launch would use: the option, else the global', async () => { diff --git a/packages/astro/src/client.ts b/packages/astro/src/client.ts index 45e6c438..386b4ee9 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -48,43 +48,48 @@ type Remembered = { // What the last SUCCESSFUL launch handed in, like core's `lastLaunch`. `document` and `window` stay RAW: which one the launch uses is // decided by precedence, so a relaunch that adds only a window must not displace a kept explicit document. let remembered: Remembered = {}; -let lastClient: unknown; -// Known limit (accepted): the hydration listener of a document the page has moved away from stays attached (as before this tracking). -// `lastClient` still catches a repeat whose client has not reached the carrier yet (a launch in flight). -// The launch still in flight, which a relaunch waits for before reading `remembered` (core's `relaunchWith` awaits the pending launch). +// The launch still in flight. Every call waits for it first, as core's `relaunchWith` does: only then is it known whether a client is live +// (the carrier is set at the END of a launch) and what the last launch remembered. let inFlight: Promise = Promise.resolve(); +// Known limit (accepted): the hydration listener of a document the page has moved away from stays attached (as before this tracking). -function pick(options: HydrationInput): Remembered { +function pick(options: HydrationInput, definedOnly: boolean): Remembered { const picked: Remembered = {}; for (const key of ['document', 'window', 'onError', 'carrier'] as const) { - if (options !== undefined && Object.hasOwn(options, key)) { + if ( + options !== undefined && + Object.hasOwn(options, key) && + !(definedOnly && options[key] === undefined) + ) { (picked as Record)[key] = options[key]; } } return picked; } +type Plan = { base: Remembered; repeat: boolean }; + /** * Open the hydration listener's launch window, run `start`, and close the window in a `finally`: an error dispatched while the client is - * (re)starting is queued, and the flush runs whether the start succeeded or failed. + * (re)starting is queued, and the flush runs whether the start succeeded or failed. Waits for the launch in flight first, then asks `plan`: * - * `base` is what the launch builds on: a relaunch starts from the remembered options (core's `relaunchWith` keeps `document`, `window`, - * `carrier` and `onError`; an explicit key wins, even an explicit `undefined`), a fresh launch from nothing (core's plain launch carries - * nothing over). The remembered state moves only once the start succeeded, and a repeat registration (core ignores it and returns the live - * client, the same object) changes nothing. + * - a relaunch builds on the remembered options (core's `relaunchWith` keeps `document`, `window`, `carrier` and `onError`; an explicit key + * wins, even an explicit `undefined`); + * - a fresh launch builds on nothing (core's plain launch carries nothing over); + * - a REPEAT (a client is live, so core ignores the call and returns it) builds on the remembered options and takes only the DEFINED keys + * it was given: its stated `undefined` was discarded by core, and the listener must not lose what it has. Nothing is remembered from it. + * + * The remembered state moves only once a non-repeat start succeeded. */ function withHydrationWindow( options: HydrationInput, - base: () => Remembered, + plan: () => Plan, start: () => Promise, - afterPending = false, ): Promise { const previous = inFlight; const run = (async () => { - if (afterPending) { - await previous.then(undefined, () => undefined); - } - return await openWindow(options, base(), start); + await previous.then(undefined, () => undefined); + return await openWindow(options, plan(), start); })(); inFlight = run; return run; @@ -92,16 +97,20 @@ function withHydrationWindow( async function openWindow( options: HydrationInput, - base: Remembered, + { base, repeat }: Plan, start: () => Promise, ): Promise { - const next = { ...base, ...pick(options) }; + const next = { ...base, ...pick(options, repeat) }; const target = launchDocument(next.document, next.window); - // `onError` and `carrier` are ALWAYS stated: the listener merges what it is given over what it kept, so an absent key would bring back a - // carrier or sink that this launch no longer has. + // A launch states `onError` and `carrier` ALWAYS: the listener merges what it is given over what it kept, so an absent key would bring + // back a carrier or sink that this launch no longer has. A repeat states only what it has. const hydration = { - onError: next.onError, - carrier: next.carrier, + ...(repeat + ? { + ...(next.onError !== undefined ? { onError: next.onError } : {}), + ...(next.carrier !== undefined ? { carrier: next.carrier } : {}), + } + : { onError: next.onError, carrier: next.carrier }), ...(target !== undefined ? { target } : {}), }; // Astro swallows an island's hydration failure into an `astro:hydration-error` event; nothing else would see it. Listen BEFORE the @@ -109,9 +118,8 @@ async function openWindow( listenForAstroHydrationErrors(hydration); try { const client = await start(); - if (client !== lastClient) { + if (!repeat) { remembered = next; - lastClient = client; } return client; } finally { @@ -127,14 +135,13 @@ export async function registerClient( appToken: string, options: AstroClientOptions = {}, ): Promise { - // A client is already live on the carrier the launch would look at: core ignores this call and returns it, so there is nothing to - // listen for, remember or replace. Opening a window from nothing would overwrite the live listener's carrier and sink. - if (getCarrierClient(options.carrier ?? globalThis) !== undefined) { - return await launch(appToken, options); - } return await withHydrationWindow( options, - () => ({}), + // a client already live on the carrier the launch would look at: core ignores this call + () => + getCarrierClient(options.carrier ?? globalThis) !== undefined + ? { base: remembered, repeat: true } + : { base: {}, repeat: false }, () => launch(appToken, options), ); } @@ -146,9 +153,8 @@ export async function registerClient( export async function relaunch(options?: Parameters[0]): Promise { return await withHydrationWindow( options, - () => remembered, + () => ({ base: remembered, repeat: false }), () => umbrellaRelaunch(options), - true, ); }