diff --git a/packages/astro/src/client.test.ts b/packages/astro/src/client.test.ts index 185927ae..481a008d 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; }); @@ -70,26 +73,59 @@ 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({}); - const doc = {} as Document; + 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 }); - const windowDoc = {} 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 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 = { 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) - expect(listen).toHaveBeenLastCalledWith({ target: windowDoc }); + 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 +134,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 +149,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({}); @@ -128,12 +179,125 @@ 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('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("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('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 sink = vi.fn(); + const live = { id: 'live' }; + launch.mockResolvedValueOnce(live); + relaunchUmbrella.mockResolvedValue({ id: 2 }); + await registerClient('tok', { document: doc, carrier } as never); + liveClient.mockImplementation((host: unknown) => (host === carrier ? live : undefined)); + launch.mockResolvedValueOnce(live); + // 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 }); + 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 () => { + 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 }); + 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 2fbb974e..386b4ee9 100644 --- a/packages/astro/src/client.ts +++ b/packages/astro/src/client.ts @@ -10,66 +10,118 @@ 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. */ 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; + } +} + +type Remembered = { + document?: Document; + window?: unknown; + onError?: (e: unknown) => void; + carrier?: object; }; -// The hydration options of the last registration (see `withHydrationWindow`). -let lastHydration: ReturnType = {}; +// 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 = {}; +// 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, definedOnly: boolean): Remembered { + const picked: Remembered = {}; + for (const key of ['document', 'window', 'onError', 'carrier'] as const) { + 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`: + * + * - 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. */ -async function withHydrationWindow( - options: - | { - onError?: (error: unknown) => void; - carrier?: object; - document?: Document; - window?: unknown; - } - | undefined, - 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; +function withHydrationWindow( + options: HydrationInput, + plan: () => Plan, + start: () => Promise, +): Promise { + const previous = inFlight; + const run = (async () => { + await previous.then(undefined, () => undefined); + return await openWindow(options, plan(), start); + })(); + inFlight = run; + return run; +} + +async function openWindow( + options: HydrationInput, + { base, repeat }: Plan, + start: () => Promise, +): Promise { + const next = { ...base, ...pick(options, repeat) }; + const target = launchDocument(next.document, next.window); + // 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 = { + ...(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 // 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 (!repeat) { + remembered = next; + } + return client; } finally { flushQueuedAstroHydrationErrors(hydration); // success: report what queued; failure: end the window, nothing to report to } @@ -83,7 +135,15 @@ export async function registerClient( appToken: string, options: AstroClientOptions = {}, ): Promise { - return await withHydrationWindow(options, () => 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), + ); } /** @@ -91,7 +151,11 @@ 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, + () => ({ base: remembered, repeat: false }), + () => umbrellaRelaunch(options), + ); } // API parity with the umbrella entries: the same VALUE surface, thin re-exports of the same functions.