From 32afd70ae43488610eb01248916b183f1170a15f Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 13 Oct 2026 15:51:06 +0500 Subject: [PATCH 1/6] feat(browser): persist the capture window and report queue by default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../src/launch-options.invalid.test.ts | 1 + packages/browser/src/launch.test.ts | 45 ++++++++++++++++++- packages/browser/src/launch.ts | 16 ++++--- .../src/user-identifier.launch.test.ts | 1 + 4 files changed, 56 insertions(+), 7 deletions(-) diff --git a/packages/browser/src/launch-options.invalid.test.ts b/packages/browser/src/launch-options.invalid.test.ts index 29d132c1..14b9c2fc 100644 --- a/packages/browser/src/launch-options.invalid.test.ts +++ b/packages/browser/src/launch-options.invalid.test.ts @@ -43,6 +43,7 @@ const start = async (over: Record) => { systemProbe: probe, systemMetricsSampler: () => [], captureNetwork: false, + persist: false, replay: false, onError, captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 18d69680..e4d0d196 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -375,7 +375,10 @@ describe('launch', () => { }); it('does not register bundleStore in in-memory mode', async () => { - const client = await launchTracked('tok', baseOptions({ captureStore: memStore() })); + const client = await launchTracked( + 'tok', + baseOptions({ persist: false, captureStore: memStore() }), + ); expect(() => client.getService(BundleStoreToken)).toThrow(); }); @@ -1313,6 +1316,44 @@ describe('launch', () => { await new Promise((r) => setTimeout(r, 0)); // let async hydration settle }); + describe('persist default', () => { + const databaseNames = async (idb: IDBFactory): Promise => + (await idb.databases()).map((d) => d.name ?? ''); + + it('persists by default when IndexedDB is available: the capture database is created', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + await launchTracked('tok', baseOptions({ locks: fakeWebLocks() })); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + it('treats an injected indexedDB as availability too', async () => { + const idb = new IDBFactory(); + await launchTracked('tok', baseOptions({ indexedDB: idb, locks: fakeWebLocks() })); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + it('stays in memory when persist is false, even with IndexedDB available', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + await launchTracked('tok', baseOptions({ persist: false, locks: fakeWebLocks() })); + await new Promise((r) => setTimeout(r, 20)); + const names = await databaseNames(idb); + expect(names).not.toContain(captureDatabaseName('tok')); + expect(names).not.toContain(coexistenceDatabaseName('tok')); + }); + + it('stays in memory when there is no IndexedDB at all', async () => { + vi.stubGlobal('indexedDB', undefined); + const client = await launchTracked('tok', baseOptions()); + expect(client.isLaunched()).toBe(true); + }); + }); + it('threads onError into the persistent capture store', async () => { vi.stubGlobal('indexedDB', new IDBFactory()); const client = await launchTracked('tok', baseOptions({ persist: true, onError: vi.fn() })); @@ -1923,7 +1964,7 @@ describe('launch — capture recovery (multi-instance)', () => { }); it('builds no marker store without persist', async () => { - const client = await launchTracked('tok', baseOptions({})); // in-memory store + const client = await launchTracked('tok', baseOptions({ persist: false })); // in-memory store expect(() => client.getService(ReportMarkerStoreToken)).toThrow(); }); diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 17dcd9aa..8a8cea60 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -360,9 +360,10 @@ export interface BugseeLaunchOptions { /** Max captured data kept in the rolling buffer, in megabytes. Default 10. */ maxDataSize?: number; /** - * Persist the bundle queue to IndexedDB so a crash report survives a reload/kill mid-upload and is - * re-uploaded on the next launch (guaranteed crash delivery). Off by default (in-memory only). An - * explicit `bundleStore` overrides this. + * Keep the rolling capture window and the report queue in IndexedDB, so the recent past survives a + * reload or a browser restart and a report interrupted mid-upload is re-uploaded on the next launch. + * On by default wherever IndexedDB is available (otherwise in-memory); `false` keeps everything in + * memory. An explicit `bundleStore` or `captureStore` overrides the matching part. */ persist?: boolean; /** @@ -580,11 +581,16 @@ async function buildLaunch( // for liveness, and recovers only DEAD siblings (never one a live tab holds). `durableCapture` builds the // per-instance capture/marker VIEWS the launch wraps; `recoverEnabled` additionally records + recovers // markers. A fresh instanceId per launch ⇒ self's namespaces are empty, so a prior crash is a dead sibling. - const durableCapture = options.persist === true && options.captureStore === undefined; + // Durable by default wherever IndexedDB exists, so the rolling window survives a reload or a browser + // restart; `persist: false` opts out, and a runtime without IndexedDB degrades to in-memory. + const persist = + options.persist ?? + (options.indexedDB !== undefined || (globalThis as { indexedDB?: unknown }).indexedDB != null); + const durableCapture = persist && options.captureStore === undefined; const recoverEnabled = (options.recover ?? true) && durableCapture; const coexistence = createCoexistence({ appToken, - persist: options.persist === true, + persist, captureRecovery: durableCapture, ...(options.bundleStore !== undefined ? { bundleOverride: options.bundleStore } : {}), ...(options.onError !== undefined ? { onError: options.onError } : {}), diff --git a/packages/browser/src/user-identifier.launch.test.ts b/packages/browser/src/user-identifier.launch.test.ts index 940b73c6..0b8f3cb1 100644 --- a/packages/browser/src/user-identifier.launch.test.ts +++ b/packages/browser/src/user-identifier.launch.test.ts @@ -79,6 +79,7 @@ const start = async (storage: Storage, over: Partial = {}, systemProbe: probe, systemMetricsSampler: () => [], captureNetwork: false, + persist: false, replay: false, captureStore: createMemoryCaptureStore({ maxRecordingTimeMs: Number.POSITIVE_INFINITY }), deviceIdEnv: { From 93e6f940fb12e58155ed793c2b970c824c3bc9e6 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 13 Oct 2026 15:51:46 +0500 Subject: [PATCH 2/6] docs(browser): document persistence by default 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/README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/browser/README.md b/packages/browser/README.md index f47d2623..e9678754 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -19,6 +19,12 @@ What it contains: - Repository: https://github.com/bugsee/bugsee-javascript/tree/main/packages/browser - Bugsee: https://bugsee.com +## Persistence + +By default the rolling capture window and the queue of reports waiting to upload are kept in IndexedDB, so the recent past survives a page reload or a browser restart, and a report interrupted mid-upload is sent on the next visit. Data is stored in per-app databases named `bugsee-`, `bugsee-capture-` and `bugsee-markers-`. + +Pass `persist: false` to keep everything in memory instead. Where IndexedDB is unavailable (some private modes, restricted contexts) the SDK falls back to memory on its own. + ## License MIT. See the LICENSE file. From cc4853ecd34cb87d310158eecd99dbc30f305c2b Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 13 Oct 2026 16:43:35 +0500 Subject: [PATCH 3/6] fix(browser): persist is fail-closed on invalid values and the probe cannot throw 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/README.md | 2 +- packages/browser/src/launch.test.ts | 65 ++++++++++++++++--- packages/browser/src/launch.ts | 24 +++++-- packages/bugsee/src/option-directions.test.ts | 4 -- 4 files changed, 77 insertions(+), 18 deletions(-) diff --git a/packages/browser/README.md b/packages/browser/README.md index e9678754..e447ba4a 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -23,7 +23,7 @@ What it contains: By default the rolling capture window and the queue of reports waiting to upload are kept in IndexedDB, so the recent past survives a page reload or a browser restart, and a report interrupted mid-upload is sent on the next visit. Data is stored in per-app databases named `bugsee-`, `bugsee-capture-` and `bugsee-markers-`. -Pass `persist: false` to keep everything in memory instead. Where IndexedDB is unavailable (some private modes, restricted contexts) the SDK falls back to memory on its own. +Pass `persist: false` to keep everything in memory instead. The default only checks that the page exposes an IndexedDB object. If the browser refuses to store data (over quota, some private modes), writes fail best-effort and are reported to `onError`; the SDK does not switch to memory at that point. Any `persist` value other than `true` or `false` counts as off and is reported. ## License diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index e4d0d196..63639ae1 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -179,6 +179,7 @@ const baseOptions = (over: Partial = {}): BugseeLaunchOptio systemProbe: probe, systemMetricsSampler: () => [{ name: 'ram_js_heap_used', value: 42 }], captureNetwork: false, + persist: false, // tests opt in; the shipped default is on wherever IndexedDB exists deviceIdEnv: { localStorage: testDeviceIdLocalStorage(), randomUUID: () => TEST_DEVICE_ID, @@ -1323,7 +1324,7 @@ describe('launch', () => { it('persists by default when IndexedDB is available: the capture database is created', async () => { const idb = new IDBFactory(); vi.stubGlobal('indexedDB', idb); - await launchTracked('tok', baseOptions({ locks: fakeWebLocks() })); + await launchTracked('tok', baseOptions({ persist: undefined, locks: fakeWebLocks() })); await vi.waitFor(async () => expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), ); @@ -1331,7 +1332,10 @@ describe('launch', () => { it('treats an injected indexedDB as availability too', async () => { const idb = new IDBFactory(); - await launchTracked('tok', baseOptions({ indexedDB: idb, locks: fakeWebLocks() })); + await launchTracked( + 'tok', + baseOptions({ persist: undefined, indexedDB: idb, locks: fakeWebLocks() }), + ); await vi.waitFor(async () => expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), ); @@ -1340,17 +1344,60 @@ describe('launch', () => { it('stays in memory when persist is false, even with IndexedDB available', async () => { const idb = new IDBFactory(); vi.stubGlobal('indexedDB', idb); - await launchTracked('tok', baseOptions({ persist: false, locks: fakeWebLocks() })); - await new Promise((r) => setTimeout(r, 20)); - const names = await databaseNames(idb); - expect(names).not.toContain(captureDatabaseName('tok')); - expect(names).not.toContain(coexistenceDatabaseName('tok')); + const client = await launchTracked('tok', baseOptions({ persist: false })); + expect(() => client.getService(BundleStoreToken)).toThrow(); + expect(() => client.getService(ReportMarkerStoreToken)).toThrow(); }); it('stays in memory when there is no IndexedDB at all', async () => { vi.stubGlobal('indexedDB', undefined); - const client = await launchTracked('tok', baseOptions()); - expect(client.isLaunched()).toBe(true); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(() => client.getService(BundleStoreToken)).toThrow(); + expect(() => client.getService(ReportMarkerStoreToken)).toThrow(); + }); + + it('treats a throwing indexedDB getter as absent instead of failing launch', async () => { + vi.stubGlobal('indexedDB', undefined); + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + get: () => { + throw new Error('denied'); + }, + }); + try { + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.isLaunched()).toBe(true); + expect(() => client.getService(BundleStoreToken)).toThrow(); + } finally { + Object.defineProperty(globalThis, 'indexedDB', { + configurable: true, + writable: true, + value: undefined, + }); + } + }); + + // Persisting writes captured data at rest, so a non-boolean never turns it on, and it is reported. + it.each([ + 0, + '', + 'false', + 'true', + 1, + null, + ])('persist=%j (invalid) is OFF even with IndexedDB available, and reports', async (bad) => { + vi.stubGlobal('indexedDB', new IDBFactory()); + const onError = vi.fn(); + const client = await launchTracked( + 'tok', + baseOptions({ persist: bad as never, onError, locks: fakeWebLocks() }), + ); + expect(() => client.getService(BundleStoreToken)).toThrow(); + expect( + onError.mock.calls.filter((c) => + String((c[0] as Error).message).startsWith('persist must be a boolean'), + ), + ).toHaveLength(1); }); }); diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 8a8cea60..775f8a4c 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -123,6 +123,17 @@ const DEFAULT_MAX_DATA_SIZE_MB = 10; // (10 MB default vs node's 50 — its canonical identifier lives in @bugsee/protocol for wire parity). /** Canonical identifier of the (browser-local) `replay` switch: no cross-SDK identity, so it lives here. */ const REPLAY_OPTION_KEY = 'com.bugsee.option.replay.enabled'; +/** Canonical identifier of the (browser-local) `persist` switch: no cross-SDK identity, so it lives here. */ +const PERSIST_OPTION_KEY = 'com.bugsee.option.browser.persist'; + +/** Whether the page exposes an IndexedDB object; a throwing getter (sandboxed frames) counts as absent. */ +const indexedDbPresent = (): boolean => { + try { + return (globalThis as { indexedDB?: unknown }).indexedDB != null; + } catch { + return false; + } +}; const BROWSER_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ ...COMMON_OPTION_DEFINITIONS, @@ -158,6 +169,9 @@ const BROWSER_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ default: true, ...FAIL_CLOSED_BOOLEAN_OR_BAG_OPTION, }, + // Persistence collects data at rest, so only boolean `true`/`false` count: anything else is OFF and reported + // (the launch substitutes the IndexedDB default only when the option is absent). + { friendly: 'persist', key: PERSIST_OPTION_KEY, default: false, ...FAIL_CLOSED_BOOLEAN_OPTION }, // The report dialog (`showReportDialog`) is browser/DOM-only. Defaults are the mobile SDKs': nothing // required, labels and the severity picker hidden, new bugs start at `high`. { @@ -581,11 +595,13 @@ async function buildLaunch( // for liveness, and recovers only DEAD siblings (never one a live tab holds). `durableCapture` builds the // per-instance capture/marker VIEWS the launch wraps; `recoverEnabled` additionally records + recovers // markers. A fresh instanceId per launch ⇒ self's namespaces are empty, so a prior crash is a dead sibling. - // Durable by default wherever IndexedDB exists, so the rolling window survives a reload or a browser - // restart; `persist: false` opts out, and a runtime without IndexedDB degrades to in-memory. + // Durable by default wherever an IndexedDB object exists, so the rolling window survives a reload or a + // browser restart. `persist: false` opts out; a non-boolean value is OFF and reported. Presence is all + // that is probed: if the store later cannot commit, writes fail into `onError` (best effort). const persist = - options.persist ?? - (options.indexedDB !== undefined || (globalThis as { indexedDB?: unknown }).indexedDB != null); + options.persist === undefined + ? options.indexedDB !== undefined || indexedDbPresent() + : resolved.options.get(PERSIST_OPTION_KEY, false); const durableCapture = persist && options.captureStore === undefined; const recoverEnabled = (options.recover ?? true) && durableCapture; const coexistence = createCoexistence({ diff --git a/packages/bugsee/src/option-directions.test.ts b/packages/bugsee/src/option-directions.test.ts index 0a3c6911..23c861ae 100644 --- a/packages/bugsee/src/option-directions.test.ts +++ b/packages/bugsee/src/option-directions.test.ts @@ -96,7 +96,6 @@ interface RawEntry { check?: [pkg: string, pattern: RegExp]; } const RAW_STRICT: Record = { - 'browser:persist': { safe: 'off', why: 'read `=== true`: OFF unless boolean true' }, 'browser:recover': { safe: 'n/a', why: 'durability of already-persisted data' }, 'browser:holdBlackout': { safe: 'on', @@ -107,7 +106,6 @@ const RAW_STRICT: Record = { safe: 'on', why: 'INTERNAL hold, forwarded to the browser read above', }, - 'electron:persist': { safe: 'off', why: 'browser option, read `=== true`' }, 'electron:recover': { safe: 'n/a', why: 'durability behaviour' }, 'node:recover': { safe: 'n/a', why: 'durability of already-persisted data' }, 'node:exitOnUncaught': { safe: 'n/a', why: 'process policy' }, @@ -124,7 +122,6 @@ const RAW_STRICT: Record = { check: ['cloudflare', /options\.partitionCaptureByTenant === undefined\s*\?\s*true/], }, 'webview:reportTrigger': { safe: 'n/a', why: 'enables native report gestures (a UI trigger)' }, - 'bugsee:persist': { safe: 'off', why: 'browser option, read `=== true`' }, 'bugsee:recover': { safe: 'n/a', why: 'durability behaviour' }, 'bugsee:holdBlackout': { safe: 'on', why: 'INTERNAL hold, forwarded to the browser read' }, 'bugsee:exitOnUncaught': { safe: 'n/a', why: 'process policy' }, @@ -135,7 +132,6 @@ const RAW_STRICT: Record = { 'nuxt:exitOnUncaught': { safe: 'n/a', why: 'process policy' }, 'nuxt:recover': { safe: 'n/a', why: 'durability behaviour' }, 'nuxt:otelConsume': { safe: 'off', why: 'read `=== true`: OFF unless boolean true' }, - 'nuxt:persist': { safe: 'off', why: 'browser option, read `=== true`' }, 'nuxt:holdBlackout': { safe: 'on', why: 'INTERNAL hold, forwarded to the browser read' }, 'nuxt:partitionCaptureByTenant': { safe: 'on', From 3d64c55f96f9995352aa9e8ade2cd7db513143ff Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Tue, 13 Oct 2026 16:55:17 +0500 Subject: [PATCH 4/6] test(browser): wait for the recovery event instead of a fixed 50 ms 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 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 63639ae1..a5eb4e15 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -2574,7 +2574,12 @@ describe('launch — RelaunchedAfterCrash needs evidence', () => { lifecycleEventsListener: (type) => seen.push(type), }), ); - // let the dead-sibling recovery finish (its marker is swept when it is done) + // let the dead-sibling recovery finish (its marker is swept when it is done). The event can lag on a slow + // machine, so wait for it for a bounded time; the cases that expect silence simply run out the clock. + const deadline = Date.now() + 1500; + while (!seen.includes(LifecycleEvents.RelaunchedAfterCrash) && Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 25)); + } await new Promise((r) => setTimeout(r, 50)); return seen; }; From d2fe46837b41a843122dd2473fd0657aff050c5e Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:39:28 +0500 Subject: [PATCH 5/6] fix(browser): persist by default only where Web Locks exist; guard the navigator.locks read MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reload survival and the sweep of a previous launch's prefix both run through the liveness lock (recoverIfDead is a no-op without it), so persisting by default on an IndexedDB-only page (non-secure http, Safari < 15.4, some WebViews) stranded up to maxDataSize per reload in the origin's shared quota and never recovered it. The default now also requires options.locks or navigator.locks; an explicit persist:true still opts into the documented no-locks degrade. README corrected. createCoexistence read navigator.locks unguarded, so a throwing getter (sandboxed frame) failed launch even with persistence off; it now degrades like absent locks. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- .../browser-utils/src/coexistence.test.ts | 14 +++++ packages/browser-utils/src/coexistence.ts | 11 +++- packages/browser/README.md | 4 +- packages/browser/src/launch.test.ts | 56 +++++++++++++++++++ packages/browser/src/launch.ts | 25 +++++++-- 5 files changed, 103 insertions(+), 7 deletions(-) diff --git a/packages/browser-utils/src/coexistence.test.ts b/packages/browser-utils/src/coexistence.test.ts index b6c456c0..b815040c 100644 --- a/packages/browser-utils/src/coexistence.test.ts +++ b/packages/browser-utils/src/coexistence.test.ts @@ -406,6 +406,20 @@ describe('createCoexistence — bundle queue', () => { expect(pipeline.enqueue).not.toHaveBeenCalled(); // no ids discovered → nothing recovered }); + it('a throwing navigator.locks getter (sandboxed frame) degrades like absent locks instead of throwing', () => { + vi.stubGlobal('navigator', { + get locks(): never { + throw new Error('denied'); + }, + }); + const onError = vi.fn(); + expect(() => + createCoexistence({ appToken: TOK, persist: true, indexedDB: new IDBFactory(), onError }), + ).not.toThrow(); + expect(onError).toHaveBeenCalledTimes(1); // the one-time unavailable warning + expect((onError.mock.calls[0]?.[0] as Error).message).toContain('navigator.locks unavailable'); + }); + it('degrades when Web Locks is unavailable: still persists locally, recovers no sibling', async () => { vi.stubGlobal('navigator', undefined); // no navigator.locks const idb = new IDBFactory(); diff --git a/packages/browser-utils/src/coexistence.ts b/packages/browser-utils/src/coexistence.ts index 767d7d9e..30e39361 100644 --- a/packages/browser-utils/src/coexistence.ts +++ b/packages/browser-utils/src/coexistence.ts @@ -106,6 +106,15 @@ export interface Coexistence { release(): void; } +/** The page's `navigator.locks`, or undefined when absent; a throwing getter (sandboxed frames) counts as absent. */ +function pageLocks(): LockManagerLike | undefined { + try { + return (globalThis as { navigator?: { locks?: LockManagerLike } }).navigator?.locks; + } catch { + return undefined; + } +} + /** Build the multi-instance-safe coexistence root for a launch (bundle queue + optional capture/markers). */ export function createCoexistence(options: CoexistenceOptions): Coexistence { const { appToken, persist, captureRecovery, bundleOverride, onError } = options; @@ -153,7 +162,7 @@ export function createCoexistence(options: CoexistenceOptions): Coexistence { // Hold the instance lock for this realm's lifetime iff there is per-instance durable data a sibling could // recover. The same lock covers bundles + chunks + markers (all keyed by this instanceId). const liveness = createWebLockLiveness( - options.locks ?? (globalThis as { navigator?: { locks?: LockManagerLike } }).navigator?.locks, + options.locks ?? pageLocks(), onError !== undefined ? (message) => onError(new Error(message)) : undefined, ); const coexisting = bundleShared !== undefined || captureShared !== undefined; diff --git a/packages/browser/README.md b/packages/browser/README.md index e447ba4a..2c3c3f06 100644 --- a/packages/browser/README.md +++ b/packages/browser/README.md @@ -21,9 +21,9 @@ What it contains: ## Persistence -By default the rolling capture window and the queue of reports waiting to upload are kept in IndexedDB, so the recent past survives a page reload or a browser restart, and a report interrupted mid-upload is sent on the next visit. Data is stored in per-app databases named `bugsee-`, `bugsee-capture-` and `bugsee-markers-`. +By default the rolling capture window and the queue of reports waiting to upload are kept in IndexedDB, so, where the browser supports it, the recent past survives a page reload or a browser restart, and a report interrupted mid-upload is sent on the next visit. Data is stored in per-app databases named `bugsee-`, `bugsee-capture-` and `bugsee-markers-`. -Pass `persist: false` to keep everything in memory instead. The default only checks that the page exposes an IndexedDB object. If the browser refuses to store data (over quota, some private modes), writes fail best-effort and are reported to `onError`; the SDK does not switch to memory at that point. Any `persist` value other than `true` or `false` counts as off and is reported. +Pass `persist: false` to keep everything in memory instead. The default applies only when the page exposes both an IndexedDB object and Web Locks (`navigator.locks`, which needs a secure `https://` context; Safari before 15.4 and some embedded WebViews lack it): without locks a previous visit's data can never be recovered or cleaned up, so the SDK stays in memory instead. Pass `persist: true` to store anyway. If the browser refuses to store data (over quota, some private modes), writes fail best-effort and are reported to `onError`; the SDK does not switch to memory at that point. Any `persist` value other than `true` or `false` counts as off and is reported. ## License diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index a5eb4e15..3938e8a1 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -1341,6 +1341,62 @@ describe('launch', () => { ); }); + // Reload survival and prefix GC both need Web Locks (recoverIfDead is a no-op without them), so the + // unattended default must not strand ~10 MB per launch in the origin's quota. + it('stays in memory by default when IndexedDB exists but Web Locks do not', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + vi.stubGlobal('navigator', {}); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(() => client.getService(BundleStoreToken)).toThrow(); + expect(() => client.getService(ReportMarkerStoreToken)).toThrow(); + expect(await databaseNames(idb)).not.toContain(captureDatabaseName('tok')); + }); + + it('persists by default when IndexedDB and the page navigator.locks are both present', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + vi.stubGlobal('navigator', { locks: fakeWebLocks() }); + await launchTracked('tok', baseOptions({ persist: undefined })); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + it('treats a throwing navigator getter as no Web Locks instead of failing launch', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { + get locks(): never { + throw new Error('denied'); + }, + }); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.isLaunched()).toBe(true); + expect(() => client.getService(BundleStoreToken)).toThrow(); + }); + + it('explicit persist:true still opts into the documented no-locks degrade', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('indexedDB', idb); + vi.stubGlobal('navigator', {}); + await launchTracked('tok', baseOptions({ persist: true })); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + + it('an injected locks manager counts as Web Locks availability for the default', async () => { + const idb = new IDBFactory(); + vi.stubGlobal('navigator', {}); + await launchTracked( + 'tok', + baseOptions({ persist: undefined, indexedDB: idb, locks: fakeWebLocks() }), + ); + await vi.waitFor(async () => + expect(await databaseNames(idb)).toContain(captureDatabaseName('tok')), + ); + }); + it('stays in memory when persist is false, even with IndexedDB available', async () => { const idb = new IDBFactory(); vi.stubGlobal('indexedDB', idb); diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 775f8a4c..7cbfaade 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -135,6 +135,19 @@ const indexedDbPresent = (): boolean => { } }; +/** + * Whether the page exposes Web Locks (`navigator.locks`, a secure-context API); a throwing getter counts as + * absent. Without it a previous launch's durable data is never recovered or swept (liveness cannot tell a + * dead sibling from a live one), so the unattended persist default requires it. + */ +const navigatorLocksPresent = (): boolean => { + try { + return (globalThis as { navigator?: { locks?: unknown } }).navigator?.locks != null; + } catch { + return false; + } +}; + const BROWSER_OPTION_DEFINITIONS: readonly OptionDefinition[] = [ ...COMMON_OPTION_DEFINITIONS, { friendly: 'maxDataSize', key: BugseeOption.MaxDataSize, default: DEFAULT_MAX_DATA_SIZE_MB }, @@ -595,12 +608,16 @@ async function buildLaunch( // for liveness, and recovers only DEAD siblings (never one a live tab holds). `durableCapture` builds the // per-instance capture/marker VIEWS the launch wraps; `recoverEnabled` additionally records + recovers // markers. A fresh instanceId per launch ⇒ self's namespaces are empty, so a prior crash is a dead sibling. - // Durable by default wherever an IndexedDB object exists, so the rolling window survives a reload or a - // browser restart. `persist: false` opts out; a non-boolean value is OFF and reported. Presence is all - // that is probed: if the store later cannot commit, writes fail into `onError` (best effort). + // Durable by default wherever an IndexedDB object AND Web Locks exist, so the rolling window survives a + // reload or a browser restart. Locks are required because recovery and the sweep of a previous launch's + // prefix both run through the liveness lock: without them the old data is never read or deleted and each + // reload would strand up to `maxDataSize` in the origin's shared quota. An explicit `persist: true` still + // opts into that no-locks degrade. `persist: false` opts out; a non-boolean value is OFF and reported. + // Presence is all that is probed: if the store later cannot commit, writes fail into `onError`. const persist = options.persist === undefined - ? options.indexedDB !== undefined || indexedDbPresent() + ? (options.indexedDB !== undefined || indexedDbPresent()) && + (options.locks !== undefined || navigatorLocksPresent()) : resolved.options.get(PERSIST_OPTION_KEY, false); const durableCapture = persist && options.captureStore === undefined; const recoverEnabled = (options.recover ?? true) && durableCapture; From 595979be899d1f625d385ed9b1b2fb49b3a6b292 Mon Sep 17 00:00:00 2001 From: Alexey Karimov Date: Thu, 15 Oct 2026 15:47:56 +0500 Subject: [PATCH 6/6] fix(browser): report the effective persist option; document the Web Locks requirement (CodeRabbit) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The capability-based default decided persistence but resolved.canonical still held the static false, so sdk.options / getLaunchOptions() said persistence was off while durable stores were active. The canonical record now carries the value in effect. The option's doc comment states the IndexedDB + Web Locks requirement. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --- packages/browser/src/launch.test.ts | 39 +++++++++++++++++++++++++++++ packages/browser/src/launch.ts | 9 +++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/browser/src/launch.test.ts b/packages/browser/src/launch.test.ts index 3938e8a1..d40d191f 100644 --- a/packages/browser/src/launch.test.ts +++ b/packages/browser/src/launch.test.ts @@ -1397,6 +1397,45 @@ describe('launch', () => { ); }); + // The reported effective options must say what is actually in effect, not the static `false` default. + describe('the effective persist option follows the capability-based default', () => { + const PERSIST = 'com.bugsee.option.browser.persist'; + it('reports true when the default turns persistence on', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { locks: fakeWebLocks() }); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.getLaunchOptions()[PERSIST]).toBe(true); + }); + + it('reports false when Web Locks are missing and the default keeps memory', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', {}); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.getLaunchOptions()[PERSIST]).toBe(false); + }); + + it('reports false when IndexedDB is missing and the default keeps memory', async () => { + vi.stubGlobal('indexedDB', undefined); + vi.stubGlobal('navigator', { locks: fakeWebLocks() }); + const client = await launchTracked('tok', baseOptions({ persist: undefined })); + expect(client.getLaunchOptions()[PERSIST]).toBe(false); + }); + + it('leaves an explicit true alone, even without Web Locks', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', {}); + const client = await launchTracked('tok', baseOptions({ persist: true })); + expect(client.getLaunchOptions()[PERSIST]).toBe(true); + }); + + it('leaves an explicit false alone, even where the default would persist', async () => { + vi.stubGlobal('indexedDB', new IDBFactory()); + vi.stubGlobal('navigator', { locks: fakeWebLocks() }); + const client = await launchTracked('tok', baseOptions({ persist: false })); + expect(client.getLaunchOptions()[PERSIST]).toBe(false); + }); + }); + it('stays in memory when persist is false, even with IndexedDB available', async () => { const idb = new IDBFactory(); vi.stubGlobal('indexedDB', idb); diff --git a/packages/browser/src/launch.ts b/packages/browser/src/launch.ts index 7cbfaade..c0f236f1 100644 --- a/packages/browser/src/launch.ts +++ b/packages/browser/src/launch.ts @@ -389,8 +389,10 @@ export interface BugseeLaunchOptions { /** * Keep the rolling capture window and the report queue in IndexedDB, so the recent past survives a * reload or a browser restart and a report interrupted mid-upload is re-uploaded on the next launch. - * On by default wherever IndexedDB is available (otherwise in-memory); `false` keeps everything in - * memory. An explicit `bundleStore` or `captureStore` overrides the matching part. + * On by default wherever the page has BOTH IndexedDB and Web Locks (`navigator.locks`, or an injected + * `locks`): without locks a previous launch's data is never recovered or cleaned up, so the default stays + * in memory and nothing survives a reload. `true` persists anyway (best effort, no cross-tab recovery); + * `false` keeps everything in memory. An explicit `bundleStore` or `captureStore` overrides the matching part. */ persist?: boolean; /** @@ -619,6 +621,9 @@ async function buildLaunch( ? (options.indexedDB !== undefined || indexedDbPresent()) && (options.locks !== undefined || navigatorLocksPresent()) : resolved.options.get(PERSIST_OPTION_KEY, false); + // The canonical record is what the client and `sdk.options` report as in effect: say what the capability + // check decided instead of the static `false` default (for an explicit or invalid value this is a no-op). + resolved.canonical[PERSIST_OPTION_KEY] = persist; const durableCapture = persist && options.captureStore === undefined; const recoverEnabled = (options.recover ?? true) && durableCapture; const coexistence = createCoexistence({