From d5a06740239f31583e27695281bc8a86d397e273 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:36:04 +0500 Subject: [PATCH 1/2] feat(browser): shared PageLifecycleTracker for the events source and connection/orientation traces MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One tracker owns the window/document lifecycle listeners (pagehide, visibilitychange, online, offline, orientationchange); the events source and the connection/orientation trackers subscribe to it. Browser and webview launches create one and share it. The browser launch now passes the document, so foreground/background events are captured there too. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .changeset/page-lifecycle-tracker.md | 8 + packages/browser/src/index.ts | 5 + packages/browser/src/launch.test.ts | 24 +++ packages/browser/src/launch.ts | 16 +- packages/browser/src/system-events.test.ts | 33 ++++ packages/browser/src/system-events.ts | 96 +++++----- packages/browser/src/trace-producers.ts | 5 + .../src/trackers/connection-tracker.test.ts | 21 +++ .../src/trackers/connection-tracker.ts | 19 +- .../src/trackers/orientation-tracker.test.ts | 15 ++ .../src/trackers/orientation-tracker.ts | 14 +- .../trackers/page-lifecycle-tracker.test.ts | 169 ++++++++++++++++++ .../src/trackers/page-lifecycle-tracker.ts | 91 ++++++++++ packages/webview/src/launch.test.ts | 3 +- packages/webview/src/launch.ts | 13 +- 15 files changed, 479 insertions(+), 53 deletions(-) create mode 100644 .changeset/page-lifecycle-tracker.md create mode 100644 packages/browser/src/trackers/page-lifecycle-tracker.test.ts create mode 100644 packages/browser/src/trackers/page-lifecycle-tracker.ts diff --git a/.changeset/page-lifecycle-tracker.md b/.changeset/page-lifecycle-tracker.md new file mode 100644 index 00000000..0c32e044 --- /dev/null +++ b/.changeset/page-lifecycle-tracker.md @@ -0,0 +1,8 @@ +--- +'@bugsee/browser': minor +'@bugsee/webview': minor +--- + +The browser and webview launches now bind the window and document lifecycle listeners once, in a shared `PageLifecycleTracker` (exported from `@bugsee/browser`). The system-events source, the connection trace and the orientation trace subscribe to it instead of each attaching their own `online`/`offline`/`orientationchange` listeners. + +`createBrowserSystemEventsSource` and `createBrowserSystemTraceProducers` accept an optional `lifecycle` to share one; without it they behave as before. The browser launch now also passes the document, so `process_foreground` / `process_background` events are captured there too. diff --git a/packages/browser/src/index.ts b/packages/browser/src/index.ts index 7c340289..aff50338 100644 --- a/packages/browser/src/index.ts +++ b/packages/browser/src/index.ts @@ -90,6 +90,11 @@ export { createBrowserSystemTraceProducers, defaultBrowserTraceEnv, } from './trace-producers'; +export { + type PageLifecycleEnv, + type PageLifecycleEvents, + PageLifecycleTracker, +} from './trackers/page-lifecycle-tracker'; export { createUiBreadcrumbProvider, createUiBreadcrumbSource, diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index d5e4f4c7..98fbb910 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -454,6 +454,30 @@ describe('launch', () => { expect(events?.map((e) => (e.data as { name: string }).name)).toContain('process_started'); }); + it('ONE shared lifecycle tracker serves traces and events: a single listener per type; visibility reaches events.system via the injected document', async () => { + const store = memStore(); + const w = fakeWindow(); + const doc = fakeWindow(); + const docWin = { ...doc.win, visibilityState: 'hidden' }; + await launchTracked( + 'tok', + baseOptions({ + window: w.win, + document: docWin as unknown as Document, + captureStore: store, + systemMetricsSampler: undefined, + }), + ); + // traces (connection/orientation) AND the events source are both live, yet each type is bound once + expect(w.count('online')).toBe(1); + expect(w.count('orientationchange')).toBe(1); + doc.emit('visibilitychange', {}); + const names = ((await drain(store, 'events.system')) ?? []).map( + (e) => (e.data as { name: string }).name, + ); + expect(names).toContain('process_background'); + }); + // Re-pointed from "captures a document interaction (click) as an events.user entry". What that test // was really protecting: launch() actually WIRES the DOM input source into the client, so a real // document interaction lands in the bundle. That still matters — but the stream it lands in was the diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index c42165c0..1da1ca41 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -93,6 +93,7 @@ import { import { parseStack } from './stack'; import { createBrowserSystemEventsSource } from './system-events'; import { createBrowserSystemTraceProducers, defaultBrowserTraceEnv } from './trace-producers'; +import { PageLifecycleTracker } from './trackers/page-lifecycle-tracker'; import { createUiBreadcrumbProvider, createUiBreadcrumbSource } from './ui-breadcrumb-source'; import { createUserIdentifierStore } from './user-identifier-store'; import { createBrowserViewportSource } from './viewport-source'; @@ -841,6 +842,12 @@ async function buildLaunch( const maxBodyBytes = resolved.options.get(BugseeOption.CaptureNetworkBodySizeLimit, 20480); const network = installNetworkCapture({ carrier, captureBodies, maxBodyBytes }); client.addCaptureProvider(network.provider); + // ONE lifecycle tracker over the injected window+document, shared by the traces' connection/orientation + // producers and the events source: the listeners are bound in a single place. + const lifecycle = new PageLifecycleTracker( + { window: win, ...(domDocument !== undefined ? { document: domDocument } : {}) }, + options.onError, + ); client.addCaptureProvider( createSystemTracesProvider({ // A `systemMetricsSampler` REPLACES the sensor traces (heap, connection, orientation), as on Node; battery still runs. @@ -852,6 +859,7 @@ async function buildLaunch( ...defaultBrowserTraceEnv(), ...(options.systemMetricsSampler !== undefined ? { sensors: false } : {}), window: win, + lifecycle, ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }, options.onError, @@ -862,7 +870,13 @@ async function buildLaunch( }), ); client.addCaptureProvider( - createSystemEventsProvider(createBrowserSystemEventsSource({ window: win })), + createSystemEventsProvider( + createBrowserSystemEventsSource({ + window: win, + lifecycle, + ...(options.onError !== undefined ? { onError: options.onError } : {}), + }), + ), ); // Input capture: one carrier-shared DOM source (capture-phase, passive, observe-only) → the dedicated // `input` stream (`input.json`). NOT `events.user` — that stream belongs to the application's own diff --git a/packages/browser/src/system-events.test.ts b/packages/browser/src/system-events.test.ts index a4e9882a..36d0254f 100644 --- a/packages/browser/src/system-events.test.ts +++ b/packages/browser/src/system-events.test.ts @@ -1,6 +1,7 @@ import type { SystemEvent } from '@bugsee/capture'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { type BrowserSystemEventsEnv, createBrowserSystemEventsSource } from './system-events'; +import { PageLifecycleTracker } from './trackers/page-lifecycle-tracker'; afterEach(() => { vi.unstubAllGlobals(); @@ -144,4 +145,36 @@ describe('createBrowserSystemEventsSource', () => { expect(doc.count('visibilitychange')).toBe(1); off(); }); + + it('subscribes to an injected shared lifecycle tracker instead of attaching its own listeners', () => { + const { env, win, doc } = fakeEnv(); + const lifecycle = new PageLifecycleTracker({ window: win, document: env.document as never }); + const a = activate({ ...env, lifecycle }); + const b = activate({ ...env, lifecycle }); + // two sources over ONE tracker → one listener per type + expect(win.count('online')).toBe(1); + expect(doc.count('visibilitychange')).toBe(1); + win.emit('offline', {}); + expect(a.events.at(-1)).toEqual({ name: 'offline' }); + expect(b.events.at(-1)).toEqual({ name: 'offline' }); + a.off(); + expect(win.count('offline')).toBe(1); // still held by the other source + b.off(); + expect(win.count('offline')).toBe(0); + }); + + it('reports a lifecycle failure to the injected onError, not into event dispatch', () => { + const { env, win, doc } = fakeEnv(); + const onError = vi.fn(); + Object.defineProperty(env.document, 'visibilityState', { + get(): never { + throw new Error('getter'); + }, + }); + const { off } = activate({ ...env, onError }); + expect(() => doc.emit('visibilitychange', {})).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + expect(win.count('pagehide')).toBe(1); + off(); + }); }); diff --git a/packages/browser/src/system-events.ts b/packages/browser/src/system-events.ts index 3a84dec0..d685c508 100644 --- a/packages/browser/src/system-events.ts +++ b/packages/browser/src/system-events.ts @@ -1,6 +1,7 @@ import type { SystemEvent } from '@bugsee/capture'; import { type Interceptor, InterceptorBase } from '@bugsee/core'; import type { WindowEvents } from './detection-providers'; +import { PageLifecycleTracker } from './trackers/page-lifecycle-tracker'; // Browser system-event SOURCE for @bugsee/capture's systemEventsProvider (the node lifecycle-source // analog). A listenable InterceptorBase: on activate it emits a 'process_started' marker and subscribes @@ -12,10 +13,8 @@ import type { WindowEvents } from './detection-providers'; // orientationchange → orientation_changed ({type, angle} from screen.orientation) // Each signal degrades gracefully where its global is absent (e.g. no document/screen in a worker). -/** A DOM event target (window or document) limited to the add/remove listener surface. */ -type EventTarget = WindowEvents; /** The document surface the source needs: visibilitychange + the current visibility state. */ -interface DocumentLike extends EventTarget { +interface DocumentLike extends WindowEvents { readonly visibilityState: DocumentVisibilityState; } /** The screen surface: the current orientation (absent on some browsers). */ @@ -25,70 +24,73 @@ interface ScreenLike { /** Injected globals (each defaults to the real global; an absent one is skipped). */ export interface BrowserSystemEventsEnv { - window?: EventTarget; + window?: WindowEvents; document?: DocumentLike; screen?: ScreenLike; + /** The shared lifecycle tracker; when absent one is created over `window` + `document`. */ + lifecycle?: PageLifecycleTracker; + onError?: (error: unknown) => void; } class BrowserSystemEventsSource extends InterceptorBase<{ event: SystemEvent }> { readonly name = 'browser-system-events'; - readonly #window: EventTarget | undefined; - readonly #document: DocumentLike | undefined; + readonly #lifecycle: PageLifecycleTracker; readonly #screen: ScreenLike | undefined; + #unsubscribe: Array<() => void> = []; - readonly #onPageHide = (event: Event): void => { - this.emit('event', { - name: 'process_exiting', - params: { persisted: (event as PageTransitionEvent).persisted }, - }); - }; - readonly #onVisibility = (): void => { - const visible = this.#document?.visibilityState === 'visible'; - this.emit('event', { name: visible ? 'process_foreground' : 'process_background' }); - }; - readonly #onOnline = (): void => this.emit('event', { name: 'online' }); - readonly #onOffline = (): void => this.emit('event', { name: 'offline' }); - readonly #onOrientation = (): void => { - const orientation = this.#screen?.orientation; - this.emit('event', { - name: 'orientation_changed', - ...(orientation !== undefined - ? { params: { type: orientation.type, angle: orientation.angle } } - : {}), - }); - }; - - constructor(env: BrowserSystemEventsEnv) { + constructor(lifecycle: PageLifecycleTracker, screenLike: ScreenLike | undefined) { super(); - this.#window = env.window; - this.#document = env.document; - this.#screen = env.screen; + this.#lifecycle = lifecycle; + this.#screen = screenLike; } protected onActivate(): void { + const l = this.#lifecycle; this.emit('event', { name: 'process_started' }); - this.#window?.addEventListener('pagehide', this.#onPageHide); - this.#window?.addEventListener('online', this.#onOnline); - this.#window?.addEventListener('offline', this.#onOffline); - this.#window?.addEventListener('orientationchange', this.#onOrientation); - this.#document?.addEventListener('visibilitychange', this.#onVisibility); + this.#unsubscribe = [ + l.on('pagehide', ({ persisted }) => + this.emit('event', { name: 'process_exiting', params: { persisted } }), + ), + l.on('visibility', ({ visible }) => + this.emit('event', { name: visible ? 'process_foreground' : 'process_background' }), + ), + l.on('online', () => this.emit('event', { name: 'online' })), + l.on('offline', () => this.emit('event', { name: 'offline' })), + l.on('orientationchange', () => { + const orientation = this.#screen?.orientation; + this.emit('event', { + name: 'orientation_changed', + ...(orientation !== undefined + ? { params: { type: orientation.type, angle: orientation.angle } } + : {}), + }); + }), + ]; } protected override onDeactivate(): void { - this.#window?.removeEventListener('pagehide', this.#onPageHide); - this.#window?.removeEventListener('online', this.#onOnline); - this.#window?.removeEventListener('offline', this.#onOffline); - this.#window?.removeEventListener('orientationchange', this.#onOrientation); - this.#document?.removeEventListener('visibilitychange', this.#onVisibility); + const unsubscribe = this.#unsubscribe; + this.#unsubscribe = []; + for (const off of unsubscribe) off(); } } export function createBrowserSystemEventsSource( env: BrowserSystemEventsEnv = {}, ): Interceptor<{ event: SystemEvent }> { - return new BrowserSystemEventsSource({ - window: env.window ?? (typeof window !== 'undefined' ? window : undefined), - document: env.document ?? (typeof document !== 'undefined' ? document : undefined), - screen: env.screen ?? (typeof screen !== 'undefined' ? screen : undefined), - }); + const win = env.window ?? (typeof window !== 'undefined' ? window : undefined); + const doc = env.document ?? (typeof document !== 'undefined' ? document : undefined); + const lifecycle = + env.lifecycle ?? + new PageLifecycleTracker( + { + ...(win !== undefined ? { window: win } : {}), + ...(doc !== undefined ? { document: doc } : {}), + }, + env.onError, + ); + return new BrowserSystemEventsSource( + lifecycle, + env.screen ?? (typeof screen !== 'undefined' ? screen : undefined), + ); } diff --git a/packages/browser/src/trace-producers.ts b/packages/browser/src/trace-producers.ts index 155a5d57..d6c3fae7 100644 --- a/packages/browser/src/trace-producers.ts +++ b/packages/browser/src/trace-producers.ts @@ -18,6 +18,7 @@ import { type OrientationState, OrientationTracker, } from './trackers/orientation-tracker'; +import type { PageLifecycleTracker } from './trackers/page-lifecycle-tracker'; // Browser `traces.system` producers (mirrors the mobile tracker/producer split). Each producer subscribes to // one shared tracker in start() and turns its state into the Android-vocabulary wire values; the trackers own @@ -195,6 +196,8 @@ export interface BrowserTraceEnv extends BatteryEnv { sensors?: boolean; /** Drives the heap poll; default the global timers. */ scheduler?: Scheduler; + /** Shared page-lifecycle tracker for connection/orientation; when absent each listens on `window`. */ + lifecycle?: Pick; } /** The real globals, read lazily (workers / Node tests may have none of them). */ @@ -249,6 +252,7 @@ export function createBrowserSystemTraceProducers( new ConnectionProducer( new ConnectionTracker( { + ...(env.lifecycle !== undefined ? { lifecycle: env.lifecycle } : {}), ...(env.window !== undefined ? { window: env.window } : {}), ...(nav !== undefined ? { navigator: nav } : {}), }, @@ -258,6 +262,7 @@ export function createBrowserSystemTraceProducers( new OrientationProducer( new OrientationTracker( { + ...(env.lifecycle !== undefined ? { lifecycle: env.lifecycle } : {}), ...(env.window !== undefined ? { window: env.window } : {}), ...(env.screen !== undefined ? { screen: env.screen } : {}), }, diff --git a/packages/browser/src/trackers/connection-tracker.test.ts b/packages/browser/src/trackers/connection-tracker.test.ts index fa6f4731..06fcc5da 100644 --- a/packages/browser/src/trackers/connection-tracker.test.ts +++ b/packages/browser/src/trackers/connection-tracker.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { ConnectionTracker } from './connection-tracker'; +import { PageLifecycleTracker } from './page-lifecycle-tracker'; function mkTarget() { const l = new Map void>>(); @@ -186,4 +187,24 @@ describe('ConnectionTracker', () => { win.fire('online'); expect(seen).not.toHaveBeenCalled(); }); + + it('with a shared lifecycle it takes online/offline from it and attaches NO window listener of its own', () => { + const win = mkTarget(); + const connection = mkTarget(); + const lifecycle = new PageLifecycleTracker({ window: win as never }); + const t = new ConnectionTracker({ + lifecycle, + window: win, + navigator: { onLine: true, connection }, + }); + const seen: unknown[] = []; + const off = t.on('state', (s) => seen.push(s)); + expect(win.count()).toBe(4); // the lifecycle's own four window listeners; the tracker added none + expect(connection.count()).toBe(1); + win.fire('online'); + win.fire('offline'); + expect(seen).toHaveLength(2); + off(); + expect(win.count() + connection.count()).toBe(0); + }); }); diff --git a/packages/browser/src/trackers/connection-tracker.ts b/packages/browser/src/trackers/connection-tracker.ts index 30a51a95..60a781bc 100644 --- a/packages/browser/src/trackers/connection-tracker.ts +++ b/packages/browser/src/trackers/connection-tracker.ts @@ -1,4 +1,5 @@ import { InterceptorBase } from '@bugsee/core'; +import type { PageLifecycleTracker } from './page-lifecycle-tracker'; // Browser connectivity TRACKER (mirrors the mobile tracker/producer split): snapshots `navigator.onLine` // plus NetworkInformation whenever the browser says either changed — `online`/`offline` on the window and @@ -23,6 +24,8 @@ interface TargetLike { removeEventListener(type: string, listener: () => void): void; } export interface ConnectionEnv { + /** Shared lifecycle tracker for `online`/`offline`; when absent they are listened for on `window`. */ + lifecycle?: Pick; window?: TargetLike; navigator?: { /** NetworkInformation; it is an EventTarget where it exists, but the fields are what matter. */ @@ -36,6 +39,7 @@ export class ConnectionTracker extends InterceptorBase<{ state: ConnectionState readonly #env: ConnectionEnv; readonly #onError: ((error: unknown) => void) | undefined; #attached: Array<{ target: Partial; type: string }> = []; + #subscriptions: Array<() => void> = []; readonly #onChange = (): void => { try { @@ -67,8 +71,16 @@ export class ConnectionTracker extends InterceptorBase<{ state: ConnectionState // before the throw is still removed on deactivation. protected onActivate(): void { try { - this.#listen(this.#env.window, 'online'); - this.#listen(this.#env.window, 'offline'); + const lifecycle = this.#env.lifecycle; + if (lifecycle !== undefined) { + this.#subscriptions.push( + lifecycle.on('online', this.#onChange), + lifecycle.on('offline', this.#onChange), + ); + } else { + this.#listen(this.#env.window, 'online'); + this.#listen(this.#env.window, 'offline'); + } this.#listen(this.#env.navigator?.connection, 'change'); } catch (error) { this.#onError?.(error); @@ -76,6 +88,9 @@ export class ConnectionTracker extends InterceptorBase<{ state: ConnectionState } protected override onDeactivate(): void { + const subscriptions = this.#subscriptions; + this.#subscriptions = []; + for (const off of subscriptions) off(); const attached = this.#attached; this.#attached = []; for (const { target, type } of attached) { diff --git a/packages/browser/src/trackers/orientation-tracker.test.ts b/packages/browser/src/trackers/orientation-tracker.test.ts index c51df67a..dbd3b099 100644 --- a/packages/browser/src/trackers/orientation-tracker.test.ts +++ b/packages/browser/src/trackers/orientation-tracker.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { OrientationTracker } from './orientation-tracker'; +import { PageLifecycleTracker } from './page-lifecycle-tracker'; function mkTarget() { const l = new Map void>>(); @@ -160,4 +161,18 @@ describe('OrientationTracker', () => { t.on('state', () => {})(); expect(removes).toHaveBeenCalledTimes(2); }); + + it('with a shared lifecycle it takes orientationchange from it and releases the subscription on stop', () => { + const win = mkTarget(); + const orientation = Object.assign(mkTarget(), { type: 'portrait-primary', angle: 0 }); + const lifecycle = new PageLifecycleTracker({ window: win as never }); + const t = new OrientationTracker({ lifecycle, window: win, screen: { orientation } }); + const seen: unknown[] = []; + const off = t.on('state', (s) => seen.push(s)); + expect(win.count()).toBe(4); // lifecycle's own four window listeners; the tracker added none + win.fire('orientationchange'); + expect(seen).toEqual([{ type: 'portrait-primary', angle: 0 }]); + off(); + expect(win.count() + orientation.count()).toBe(0); + }); }); diff --git a/packages/browser/src/trackers/orientation-tracker.ts b/packages/browser/src/trackers/orientation-tracker.ts index 9c7721d6..ab433384 100644 --- a/packages/browser/src/trackers/orientation-tracker.ts +++ b/packages/browser/src/trackers/orientation-tracker.ts @@ -1,4 +1,5 @@ import { InterceptorBase } from '@bugsee/core'; +import type { PageLifecycleTracker } from './page-lifecycle-tracker'; // Browser screen-orientation TRACKER (mirrors the mobile tracker/producer split): reports the current // `screen.orientation` {type, angle} on every change. Both `screen.orientation` `change` and the window's @@ -14,6 +15,8 @@ interface TargetLike { removeEventListener(type: string, listener: () => void): void; } export interface OrientationEnv { + /** Shared lifecycle tracker for `orientationchange`; when absent it is listened for on `window`. */ + lifecycle?: Pick; window?: TargetLike; screen?: { orientation?: { type: string; angle: number } & Partial; @@ -25,6 +28,7 @@ export class OrientationTracker extends InterceptorBase<{ state: OrientationStat readonly #env: OrientationEnv; readonly #onError: ((error: unknown) => void) | undefined; #attached: Array<{ target: Partial; type: string }> = []; + #subscriptions: Array<() => void> = []; readonly #onChange = (): void => { try { @@ -57,13 +61,21 @@ export class OrientationTracker extends InterceptorBase<{ state: OrientationStat protected onActivate(): void { try { this.#listen(this.#env.screen?.orientation, 'change'); - this.#listen(this.#env.window, 'orientationchange'); + const lifecycle = this.#env.lifecycle; + if (lifecycle !== undefined) { + this.#subscriptions.push(lifecycle.on('orientationchange', this.#onChange)); + } else { + this.#listen(this.#env.window, 'orientationchange'); + } } catch (error) { this.#onError?.(error); } } protected override onDeactivate(): void { + const subscriptions = this.#subscriptions; + this.#subscriptions = []; + for (const off of subscriptions) off(); const attached = this.#attached; this.#attached = []; for (const { target, type } of attached) { diff --git a/packages/browser/src/trackers/page-lifecycle-tracker.test.ts b/packages/browser/src/trackers/page-lifecycle-tracker.test.ts new file mode 100644 index 00000000..e9dbbc6d --- /dev/null +++ b/packages/browser/src/trackers/page-lifecycle-tracker.test.ts @@ -0,0 +1,169 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PageLifecycleTracker } from './page-lifecycle-tracker'; + +function mkTarget() { + const l = new Map void>>(); + return { + addEventListener: (t: string, f: (e: unknown) => void) => { + const s = l.get(t) ?? new Set<(e: unknown) => void>(); + s.add(f); + l.set(t, s); + }, + removeEventListener: (t: string, f: (e: unknown) => void) => void l.get(t)?.delete(f), + fire: (t: string, e: unknown = {}) => + l.get(t)?.forEach((f) => { + f(e); + }), + count: (t?: string) => + t === undefined ? [...l.values()].reduce((n, s) => n + s.size, 0) : (l.get(t)?.size ?? 0), + }; +} + +function mkEnv() { + const win = mkTarget(); + const doc = Object.assign(mkTarget(), { visibilityState: 'visible' as string }); + return { win, doc }; +} + +describe('PageLifecycleTracker', () => { + it('attaches nothing until the first subscriber', () => { + const { win, doc } = mkEnv(); + new PageLifecycleTracker({ window: win, document: doc }); + expect(win.count() + doc.count()).toBe(0); + }); + + it('attaches pagehide/online/offline/orientationchange on window and visibilitychange on document', () => { + const { win, doc } = mkEnv(); + const t = new PageLifecycleTracker({ window: win, document: doc }); + t.on('online', () => {}); + for (const type of ['pagehide', 'online', 'offline', 'orientationchange']) { + expect(win.count(type)).toBe(1); + } + expect(doc.count('visibilitychange')).toBe(1); + }); + + it('releases everything on the last unsubscribe and re-attaches on the next subscriber', () => { + const { win, doc } = mkEnv(); + const t = new PageLifecycleTracker({ window: win, document: doc }); + const offA = t.on('online', () => {}); + const offB = t.on('offline', () => {}); + offA(); + expect(win.count() + doc.count()).toBe(5); + offB(); + expect(win.count() + doc.count()).toBe(0); + t.on('online', () => {}); + expect(win.count() + doc.count()).toBe(5); + }); + + it('removes each listener exactly once per activation cycle (no stale entries replayed on a later stop)', () => { + const { win, doc } = mkEnv(); + const removes = vi.fn(); + const realRemove = win.removeEventListener; + win.removeEventListener = (t, f) => { + removes(t); + realRemove(t, f); + }; + const t = new PageLifecycleTracker({ window: win, document: doc }); + t.on('online', () => {})(); + t.on('online', () => {})(); + expect(removes).toHaveBeenCalledTimes(8); + }); + + it('emits typed events', () => { + const { win, doc } = mkEnv(); + const t = new PageLifecycleTracker({ window: win, document: doc }); + const seen: unknown[] = []; + t.on('pagehide', (e) => seen.push(['pagehide', e])); + t.on('visibility', (e) => seen.push(['visibility', e])); + t.on('online', () => seen.push(['online'])); + t.on('offline', () => seen.push(['offline'])); + t.on('orientationchange', () => seen.push(['orientationchange'])); + win.fire('pagehide', { persisted: true }); + win.fire('pagehide', {}); + doc.visibilityState = 'hidden'; + doc.fire('visibilitychange'); + doc.visibilityState = 'visible'; + doc.fire('visibilitychange'); + win.fire('online'); + win.fire('offline'); + win.fire('orientationchange'); + expect(seen).toEqual([ + ['pagehide', { persisted: true }], + ['pagehide', { persisted: false }], + ['visibility', { visible: false }], + ['visibility', { visible: true }], + ['online'], + ['offline'], + ['orientationchange'], + ]); + }); + + it('works with no document and with no window', () => { + const { win, doc } = mkEnv(); + const onlyWin = new PageLifecycleTracker({ window: win }); + onlyWin.on('online', () => {})(); + const onlyDoc = new PageLifecycleTracker({ document: doc }); + onlyDoc.on('visibility', () => {}); + expect(doc.count('visibilitychange')).toBe(1); + expect(() => new PageLifecycleTracker({}).on('online', () => {})()).not.toThrow(); + }); + + it('a missing visibilityState counts as not visible', () => { + const win = mkTarget(); + const doc = mkTarget(); + const t = new PageLifecycleTracker({ window: win, document: doc }); + const seen: unknown[] = []; + t.on('visibility', (e) => seen.push(e)); + doc.fire('visibilitychange'); + expect(seen).toEqual([{ visible: false }]); + }); + + it('a throwing getter in the event path is reported, never thrown into dispatch', () => { + const win = mkTarget(); + const onError = vi.fn(); + const doc = mkTarget(); + Object.defineProperty(doc, 'visibilityState', { + get(): never { + throw new Error('getter'); + }, + }); + const t = new PageLifecycleTracker({ window: win, document: doc }, onError); + t.on('visibility', () => {}); + expect(() => doc.fire('visibilitychange')).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + }); + + it('a throwing addEventListener is reported and what attached is still released', () => { + const win = mkTarget(); + const onError = vi.fn(); + const doc = mkTarget(); + doc.addEventListener = () => { + throw new Error('detached'); + }; + const t = new PageLifecycleTracker({ window: win, document: doc }, onError); + const off = t.on('online', () => {}); + expect(onError).toHaveBeenCalledTimes(1); + off(); + expect(win.count()).toBe(0); + }); + + it('a throwing removeEventListener does not leak the others', () => { + const win = mkTarget(); + const doc = mkTarget(); + doc.removeEventListener = () => { + throw new Error('detached'); + }; + const t = new PageLifecycleTracker({ window: win, document: doc }); + const off = t.on('online', () => {}); + expect(() => off()).not.toThrow(); + expect(win.count()).toBe(0); + }); + + it('tolerates targets without addEventListener', () => { + const t = new PageLifecycleTracker({ + window: {} as never, + document: { visibilityState: 'visible' } as never, + }); + expect(() => t.on('online', () => {})()).not.toThrow(); + }); +}); diff --git a/packages/browser/src/trackers/page-lifecycle-tracker.ts b/packages/browser/src/trackers/page-lifecycle-tracker.ts new file mode 100644 index 00000000..8210d5e4 --- /dev/null +++ b/packages/browser/src/trackers/page-lifecycle-tracker.ts @@ -0,0 +1,91 @@ +import { InterceptorBase } from '@bugsee/core'; + +// Shared page-lifecycle TRACKER (the web analog of the mobile Application tracker that Android's TraceAppState +// and EventApplicationLifecycle both subscribe to): the ONE owner of the window/document lifecycle listeners. +// The system-events source and the connection/orientation trackers subscribe to it instead of each attaching +// their own, so the injected window+document are bound in exactly one place. Listeners attach on the first +// subscriber and are removed on the last. + +export interface PageLifecycleEvents { + /** `pagehide`; `persisted` is true when the page enters the bfcache rather than unloading. */ + pagehide: { persisted: boolean }; + /** `visibilitychange`, reduced to whether the document is now visible. */ + visibility: { visible: boolean }; + online: undefined; + offline: undefined; + orientationchange: undefined; +} +interface TargetLike { + addEventListener(type: string, listener: (event: unknown) => void): void; + removeEventListener(type: string, listener: (event: unknown) => void): void; +} +export interface PageLifecycleEnv { + window?: TargetLike; + document?: TargetLike & { readonly visibilityState?: string }; +} + +export class PageLifecycleTracker extends InterceptorBase { + readonly name = 'browser-page-lifecycle'; + readonly #env: PageLifecycleEnv; + readonly #onError: ((error: unknown) => void) | undefined; + #attached: Array<{ target: Partial; type: string; listener: (e: unknown) => void }> = + []; + + constructor(env: PageLifecycleEnv, onError?: (error: unknown) => void) { + super(onError); + this.#env = env; + this.#onError = onError; + } + + // Guarded: the tracker is already marked active before this runs, so a throwing target must be reported + // here, not leak out of `on()`. Whatever attached before the throw is still removed on deactivation. + protected onActivate(): void { + const { window: win, document: doc } = this.#env; + try { + this.#listen(win, 'pagehide', (e) => + this.#guard(() => + this.emit('pagehide', { persisted: (e as { persisted?: boolean }).persisted === true }), + ), + ); + for (const type of ['online', 'offline', 'orientationchange'] as const) { + this.#listen(win, type, () => this.#guard(() => this.emit(type, undefined))); + } + this.#listen(doc, 'visibilitychange', () => + this.#guard(() => this.emit('visibility', { visible: doc?.visibilityState === 'visible' })), + ); + } catch (error) { + this.#onError?.(error); + } + } + + protected override onDeactivate(): void { + const attached = this.#attached; + this.#attached = []; + for (const { target, type, listener } of attached) { + try { + target.removeEventListener?.(type, listener); + } catch { + /* best-effort: one detached target must not leak the others */ + } + } + } + + #listen( + target: Partial | undefined, + type: string, + listener: (event: unknown) => void, + ): void { + if (typeof target?.addEventListener !== 'function') return; + target.addEventListener(type, listener); + this.#attached.push({ target, type, listener }); + } + + /** Never throw into the browser's event dispatch. */ + #guard(fn: () => void): void { + try { + fn(); + } catch (error) { + this.#onError?.(error); + } + } +} diff --git a/packages/webview/src/launch.test.ts b/packages/webview/src/launch.test.ts index 9d428c65..67bb08ac 100644 --- a/packages/webview/src/launch.test.ts +++ b/packages/webview/src/launch.test.ts @@ -961,7 +961,8 @@ describe('launch (webview)', () => { expect(rows.get('ram_js_heap_used')).toBe(10); expect(rows.get('connection')).toMatchObject({ type: 'cellular' }); expect(rows.get('orientation')).toBe(3); - expect(w.count('online', 'offline', 'orientationchange')).toBe(6); + // ONE shared lifecycle tracker: the traces and the events source add 3 listeners between them, not 6. + expect(w.count('online', 'offline', 'orientationchange')).toBe(3); await client.stop(); expect(w.count('online', 'offline', 'orientationchange')).toBe(0); } finally { diff --git a/packages/webview/src/launch.ts b/packages/webview/src/launch.ts index 45bc2dd5..6d4b9d10 100644 --- a/packages/webview/src/launch.ts +++ b/packages/webview/src/launch.ts @@ -8,6 +8,7 @@ import { createUnhandledRejectionProvider, createWindowErrorProvider, defaultBrowserTraceEnv, + PageLifecycleTracker, parseStack, type WindowEvents, } from '@bugsee/browser'; @@ -670,6 +671,14 @@ export function launchWithSeams( client.addCaptureProvider(network.provider); // HTTP server errors (5xx) → `error` reports (mechanism http-error). Off unless `detectHttpErrors`. client.addDetectionProvider(createHttpErrorDetectionProvider(network.interceptor)); + // ONE lifecycle tracker over the host page's window+document, shared by the traces and the events source. + const lifecycle = new PageLifecycleTracker( + { + ...(win !== undefined ? { window: win } : {}), + ...(domDocument !== undefined ? { document: domDocument } : {}), + }, + options.onError, + ); client.addCaptureProvider( createSystemTracesProvider({ // A `systemMetricsSampler` REPLACES the sensor traces (heap, connection, orientation), as on Node; battery still runs. @@ -681,6 +690,7 @@ export function launchWithSeams( ...defaultBrowserTraceEnv(), ...(options.systemMetricsSampler !== undefined ? { sensors: false } : {}), ...(win !== undefined ? { window: win } : {}), + lifecycle, ...(options.scheduler !== undefined ? { scheduler: options.scheduler } : {}), }, options.onError, @@ -695,7 +705,8 @@ export function launchWithSeams( createSystemEventsProvider( createBrowserSystemEventsSource({ window: win, - ...(domDocument !== undefined ? { document: domDocument } : {}), + lifecycle, + ...(options.onError !== undefined ? { onError: options.onError } : {}), }), ), ); From db5381bca2442e3dd449e152650e8727c768e33d Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 22:39:46 +0500 Subject: [PATCH 2/2] test(browser): cover consumer-throw reporting, events-source reactivation and release on stop 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/browser/src/launch.test.ts | 5 ++++- packages/browser/src/system-events.test.ts | 18 ++++++++++++++++++ packages/browser/src/system-events.ts | 6 +++--- .../trackers/page-lifecycle-tracker.test.ts | 14 ++++++++++++++ 4 files changed, 39 insertions(+), 4 deletions(-) diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 98fbb910..10b032ce 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -459,7 +459,7 @@ describe('launch', () => { const w = fakeWindow(); const doc = fakeWindow(); const docWin = { ...doc.win, visibilityState: 'hidden' }; - await launchTracked( + const client = await launchTracked( 'tok', baseOptions({ window: w.win, @@ -476,6 +476,9 @@ describe('launch', () => { (e) => (e.data as { name: string }).name, ); expect(names).toContain('process_background'); + await client.stop(); + expect(w.count('online') + w.count('orientationchange')).toBe(0); + expect(doc.count('visibilitychange')).toBe(0); }); // Re-pointed from "captures a document interaction (click) as an events.user entry". What that test diff --git a/packages/browser/src/system-events.test.ts b/packages/browser/src/system-events.test.ts index 36d0254f..99a944c6 100644 --- a/packages/browser/src/system-events.test.ts +++ b/packages/browser/src/system-events.test.ts @@ -177,4 +177,22 @@ describe('createBrowserSystemEventsSource', () => { expect(win.count('pagehide')).toBe(1); off(); }); + + it('over a shared tracker, re-subscribing after a full release re-emits process_started and resubscribes', () => { + const { env, win } = fakeEnv(); + const lifecycle = new PageLifecycleTracker({ window: win, document: env.document as never }); + const source = createBrowserSystemEventsSource({ ...env, lifecycle }); + const events: SystemEvent[] = []; + const first = source.onAny((_s, e) => events.push(e)); + first(); + expect(win.count('online')).toBe(0); + const second = source.onAny((_s, e) => events.push(e)); + win.emit('online', {}); + expect(events).toEqual([ + { name: 'process_started' }, + { name: 'process_started' }, + { name: 'online' }, + ]); + second(); + }); }); diff --git a/packages/browser/src/system-events.ts b/packages/browser/src/system-events.ts index d685c508..925d975c 100644 --- a/packages/browser/src/system-events.ts +++ b/packages/browser/src/system-events.ts @@ -28,17 +28,17 @@ export interface BrowserSystemEventsEnv { document?: DocumentLike; screen?: ScreenLike; /** The shared lifecycle tracker; when absent one is created over `window` + `document`. */ - lifecycle?: PageLifecycleTracker; + lifecycle?: Pick; onError?: (error: unknown) => void; } class BrowserSystemEventsSource extends InterceptorBase<{ event: SystemEvent }> { readonly name = 'browser-system-events'; - readonly #lifecycle: PageLifecycleTracker; + readonly #lifecycle: Pick; readonly #screen: ScreenLike | undefined; #unsubscribe: Array<() => void> = []; - constructor(lifecycle: PageLifecycleTracker, screenLike: ScreenLike | undefined) { + constructor(lifecycle: Pick, screenLike: ScreenLike | undefined) { super(); this.#lifecycle = lifecycle; this.#screen = screenLike; diff --git a/packages/browser/src/trackers/page-lifecycle-tracker.test.ts b/packages/browser/src/trackers/page-lifecycle-tracker.test.ts index e9dbbc6d..98e23354 100644 --- a/packages/browser/src/trackers/page-lifecycle-tracker.test.ts +++ b/packages/browser/src/trackers/page-lifecycle-tracker.test.ts @@ -166,4 +166,18 @@ describe('PageLifecycleTracker', () => { }); expect(() => t.on('online', () => {})()).not.toThrow(); }); + + it('a throwing consumer is reported and the other consumers still receive the event', () => { + const { win, doc } = mkEnv(); + const onError = vi.fn(); + const t = new PageLifecycleTracker({ window: win, document: doc }, onError); + const seen: string[] = []; + t.on('online', () => { + throw new Error('consumer'); + }); + t.on('online', () => seen.push('second')); + expect(() => win.fire('online')).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); + expect(seen).toEqual(['second']); + }); });