From 4cc7c4608b702ab896af26e43a1c4727dfb98839 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 4 Nov 2026 07:22:28 +0200 Subject: [PATCH 1/2] fix: a phone backgrounding the tab says nothing when it comes back - the 'Something went wrong' toast skips what error reports already ignore (ResizeObserver loop, Script error, interrupted play(), aborts); the line stays in the diagnostics ring - a lost WebGL context shows the overlay and files a report only once it has stayed lost for 3 s on a visible page (contextLoss.js, contextLossShown); a loss while hidden that comes back on return is quiet - unit tests for both rules; e2e background-quiet; dispose and error-reports wait out the grace Co-Authored-By: Claude Opus 5.5 --- src/components/ContextLostOverlay.svelte | 4 +- src/components/Scene.svelte | 6 +- src/lib/contextLoss.js | 84 ++++++++++ src/lib/diagnostics.js | 17 +- src/lib/errorReports.js | 4 +- src/stores/sceneStore.js | 7 + tests/e2e/background-quiet.test.cjs | 190 +++++++++++++++++++++++ tests/e2e/dispose.test.cjs | 6 + tests/e2e/error-reports.test.cjs | 8 +- tests/unit/backgroundQuiet.test.js | 116 ++++++++++++++ 10 files changed, 431 insertions(+), 11 deletions(-) create mode 100644 src/lib/contextLoss.js create mode 100644 tests/e2e/background-quiet.test.cjs create mode 100644 tests/unit/backgroundQuiet.test.js diff --git a/src/components/ContextLostOverlay.svelte b/src/components/ContextLostOverlay.svelte index 6109e9ea7..d3f6fd26c 100644 --- a/src/components/ContextLostOverlay.svelte +++ b/src/components/ContextLostOverlay.svelte @@ -9,7 +9,7 @@ // It usually is not lost: the scene graph lives in JavaScript, so a save still works // and the browser often hands the context back on its own. This says both of those // things and gives the two actions worth having. - import { contextLost } from '../stores/sceneStore'; + import { contextLossShown } from '../stores/sceneStore'; import { save } from '$lib/fileHandler.svelte'; // .tpscene rather than GLTF: it is the whole scene bundle and needs no selection, @@ -19,7 +19,7 @@ const reload = () => location.reload(); -{#if $contextLost} +{#if $contextLossShown}

The 3D view has stopped

diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index f826a007b..8b0fbd029 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -9,7 +9,8 @@ import { peers, username, userdata, specatorMode, avatarConfig, viewportMenu, objectContextMenu, viewportMenuOpener, addMenu, addMenuOpener, showToast, multiSelectMode } from '../stores/appStore'; import { get } from 'svelte/store'; import { vrPostEnabled } from '$lib/viewportOverrides'; - import { isLocked, editorMode, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, sessionCompositesOverRoom, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode, contextLost, readVrOverride } from '../stores/sceneStore'; + import { isLocked, editorMode, editorCam, isVRMode, globalScene, objectsGroup, showGrid, TControls, selectedObject, selectedObjects, lockedObjects, marqueeRect, worldRig, vrOverride, specators, globalCamera, globalRenderer, orbitControls, passthroughActive, sessionCompositesOverRoom, vrObjectsPanelOpen, vrPaletteOpen, vrPropsPanelOpen, vrPrefabsPanelOpen, vrChatPanelOpen, vrEditMenuOpen, vrSnapMenuOpen, vrSettingsPanelOpen, vrApprovePanelOpen, vrToolMode, viewMode, contextLost, contextLossShown, readVrOverride } from '../stores/sceneStore'; + import { startContextLossGate } from '$lib/contextLoss.js'; import { selectObject, deselectObject, @@ -799,6 +800,9 @@ }); $contextLost = false; }; + // a phone backgrounding the tab loses and restores the context routinely: the overlay + // (and the error report) wait for a loss that outlasts the grace rule in contextLoss.js + startContextLossGate(contextLost, contextLossShown); element.addEventListener('webglcontextlost', onContextLost); element.addEventListener('webglcontextrestored', onContextRestored); let downPosition = null; diff --git a/src/lib/contextLoss.js b/src/lib/contextLoss.js new file mode 100644 index 000000000..abddc592f --- /dev/null +++ b/src/lib/contextLoss.js @@ -0,0 +1,84 @@ +// WHEN A LOST 3D VIEW IS WORTH SAYING ANYTHING ABOUT (a LEAF: imports nothing). +// +// A phone that minimises the browser, folds, or switches apps routinely takes the WebGL +// context away and hands it back a moment after the page is visible again. Showing "The 3D +// view has stopped" and filing an error report for that is noise: the person never saw the +// view stop. So the raw fact (`contextLost`, which the shader guard still reads at once) and +// the LOUD fact (`contextLossShown`, which drives the overlay and the error report) are two +// stores, and this rule decides the second from the first. +// +// THE RULE: a loss becomes loud only after the page has been VISIBLE for GRACE ms with the +// context still gone — counted from the later of the loss and the moment the page became +// visible. A loss while hidden that comes back within GRACE of the page showing again, or a +// visible loss restored within GRACE, says nothing. Hiding the page again stops the clock. + +/** how long a lost context may stay lost on a visible page before the overlay shows */ +export const CONTEXT_LOSS_GRACE_MS = 3000; + +/** + * @typedef {{ lost: boolean, loud: boolean, lostAt: number, visibleSince: number | null }} LossState + * @typedef {'lost' | 'restored' | 'visible' | 'hidden' | 'tick'} LossEvent + */ + +/** @param {number} now @param {boolean} visible @returns {LossState} */ +export function lossInit(now, visible) { + return { lost: false, loud: false, lostAt: 0, visibleSince: visible ? now : null }; +} + +/** When the pending loss turns loud, or null (nothing pending) @param {LossState} s */ +export function lossDeadline(s) { + if (!s.lost || s.loud || s.visibleSince === null) return null; + return Math.max(s.lostAt, s.visibleSince) + CONTEXT_LOSS_GRACE_MS; +} + +/** + * One step of the rule. Pure. + * @param {LossState} s @param {LossEvent} event @param {number} now @returns {LossState} + */ +export function lossStep(s, event, now) { + let next = s; + if (event === 'lost') next = s.lost ? s : { ...s, lost: true, loud: false, lostAt: now }; + else if (event === 'restored') next = { ...s, lost: false, loud: false }; + else if (event === 'hidden') next = { ...s, visibleSince: null }; + else if (event === 'visible') next = s.visibleSince === null ? { ...s, visibleSince: now } : s; + const due = lossDeadline(next); + if (due !== null && now >= due) next = { ...next, loud: true }; + return next; +} + +let started = false; + +/** + * Drive `shown` from `lost` and the page's visibility. Idempotent. + * @param {{ subscribe: (fn: (v: boolean) => void) => () => void }} lost + * @param {{ set: (v: boolean) => void }} shown + * @param {{ doc?: Document, now?: () => number }} [opts] + */ +export function startContextLossGate(lost, shown, opts = {}) { + if (started) return; + const doc = opts.doc ?? (typeof document !== 'undefined' ? document : null); + if (!doc) return; + started = true; + const now = opts.now ?? (() => Date.now()); + const isVisible = () => doc.visibilityState !== 'hidden'; + let state = lossInit(now(), isVisible()); + /** @type {ReturnType | null} */ + let timer = null; + /** @param {LossEvent} event */ + const step = (event) => { + state = lossStep(state, event, now()); + shown.set(state.loud); + if (timer) clearTimeout(timer); + timer = null; + const due = lossDeadline(state); + if (due !== null) timer = setTimeout(() => step('tick'), Math.max(0, due - now()) + 20); + }; + doc.addEventListener('visibilitychange', () => step(isVisible() ? 'visible' : 'hidden')); + // a loss that lands a moment BEFORE the hide event is fine: 'hidden' stops its clock + lost.subscribe((v) => step(v ? 'lost' : 'restored')); +} + +/** test seam: forget the started flag */ +export function resetContextLossGateForTest() { + started = false; +} diff --git a/src/lib/diagnostics.js b/src/lib/diagnostics.js index 684dede56..ba0caa798 100644 --- a/src/lib/diagnostics.js +++ b/src/lib/diagnostics.js @@ -1,6 +1,6 @@ import { writable } from 'svelte/store'; import { APP_VERSION, COMMIT_SHA, IS_DEV } from './version.js'; -import { describeReason } from './errorReportCore.js'; +import { describeReason, ignoredMessage } from './errorReportCore.js'; // 27-B (hardening audit H4) — THE ONE PLACE A FAILURE LEAVES A TRACE. // @@ -263,6 +263,17 @@ export async function copyDiagnostics() { } } +/** + * Does an uncaught error earn the "Something went wrong" toast? The browser's own benign + * warnings (ResizeObserver loop, cross-origin "Script error.", an interrupted play()) do not: + * the SAME list the error reports skip (errorReportCore's IGNORE), so the two never disagree. + * The line still goes into the ring, so Copy diagnostics keeps it. + * @param {string} message + */ +export function toastsUncaught(message) { + return !ignoredMessage(message); +} + /** * Install the global capture. Idempotent, and safe to call before anything else in * App.svelte's onMount — it is the first thing that runs there precisely so that a @@ -281,7 +292,7 @@ export function startDiagnostics() { const message = event.error?.message ?? event.message ?? 'unknown error'; const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : ''; log('error', 'window', message + where, event.error?.stack); - lastUncaught.set({ message, at: Date.now() }); + if (toastsUncaught(message)) lastUncaught.set({ message, at: Date.now() }); }); window.addEventListener('unhandledrejection', (event) => { @@ -289,7 +300,7 @@ export function startDiagnostics() { // 54i b: a non-Error reason is named by constructor + a safe JSON (errorReportCore, a leaf) const message = describeReason(reason).message || 'unknown rejection'; log('error', 'promise', message, reason?.stack); - lastUncaught.set({ message, at: Date.now() }); + if (toastsUncaught(message)) lastUncaught.set({ message, at: Date.now() }); }); if (!IS_DEV) { diff --git a/src/lib/errorReports.js b/src/lib/errorReports.js index b523e5891..3fe25bbb4 100644 --- a/src/lib/errorReports.js +++ b/src/lib/errorReports.js @@ -256,7 +256,9 @@ export function startErrorReports() { }; if (document.visibilityState === 'visible') markAlive(); let wasLost = false; - scene.contextLost.subscribe((lost) => { + // a loss the page got back quietly (a phone backgrounding the tab) is not a report: + // read the LOUD half, which the grace rule in contextLoss.js drives + scene.contextLossShown.subscribe((lost) => { if (lost && !wasLost) reportError({ kind: 'webgl', message: 'WebGL context lost' }); wasLost = !!lost; }); diff --git a/src/stores/sceneStore.js b/src/stores/sceneStore.js index a384d2bc7..ec9233daa 100644 --- a/src/stores/sceneStore.js +++ b/src/stores/sceneStore.js @@ -83,6 +83,13 @@ export const globalRenderer = writable(null); * @type {import('svelte/store').Writable} */ export const contextLost = writable(false); +/** + * The LOUD half of `contextLost`: true only once a loss has outlasted the grace rule in + * `lib/contextLoss.js` (a phone backgrounding the tab and handing the context back on return + * says nothing). The overlay and the error report read this; the shader guard reads the raw one. + * @type {import('svelte/store').Writable} + */ +export const contextLossShown = writable(false); /** @type {import('svelte/store').Writable} */ export const orbitControls = writable(null); /** diff --git a/tests/e2e/background-quiet.test.cjs b/tests/e2e/background-quiet.test.cjs new file mode 100644 index 000000000..f808855e6 --- /dev/null +++ b/tests/e2e/background-quiet.test.cjs @@ -0,0 +1,190 @@ +// fix-bgquiet — A PHONE THAT MINIMISES THE BROWSER SAYS NOTHING WHEN IT COMES BACK. +// +// The report (1.57.0, a foldable phone, Android Chrome, an empty editor scene): a "Something went +// wrong: ResizeObserver loop completed with undelivered notifications" toast, then — minimise and +// reopen — the WebGL context lost and restored a second later and the signaling socket dropped. +// +// What this suite pins: +// 1. hidden page: the context is lost, the signaling socket drops; the page shows again and the +// context comes back inside the 3 s grace → NO overlay, NO toast, NO error report, and the +// Connect dot returns on the same peer id +// 2. a loss on a VISIBLE page held 4 s → the overlay shows (and a `webgl` report goes out), +// and inside the grace it does not; restoring takes it down +// 3. a REAL ResizeObserver loop (a callback resizing its own target — headless reports it) +// reaches the diagnostics ring but raises no toast; a real error still toasts (control) +// +// Counterfactual: on v1.57.0 step 1 shows the overlay and sends a `webgl` report, and step 3 toasts. +// The report endpoint is a Playwright route on the debug-hook override `errorReports:url`. +// +// Run: APP_URL=https://theprototype.app:5967/ npm run e2e -- background-quiet +const h = require('./helpers.cjs'); + +const MOCK = 'https://errors.mock.test/community/api/errors'; +// the reported device, unfolded (CSS px); DPR 1 keeps a software renderer quick +const PHONE = { + viewport: { width: 691, height: 634 }, + isMobile: true, + hasTouch: true, + userAgent: 'Mozilla/5.0 (Linux; Android 15; Mobile) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Mobile Safari/537.36' +}; + +/** @param {any} page @param {boolean} hidden */ +const setHidden = (page, hidden) => + page.evaluate((hid) => { + Object.defineProperty(document, 'visibilityState', { configurable: true, get: () => (hid ? 'hidden' : 'visible') }); + Object.defineProperty(document, 'hidden', { configurable: true, get: () => hid }); + document.dispatchEvent(new Event('visibilitychange')); + }, hidden); + +/** @param {any} page */ +const dot = (page) => + page.evaluate(() => { + // the phone shell's chip or the desktop pill — whichever this layout drew + const el = document.querySelector('.cx-status-dot[data-tone]'); + let pc = null; + window.__stores.peers.subscribe((v) => (pc = v))(); + return { tone: el?.getAttribute('data-tone') ?? null, open: !!pc?.peer?.open, id: pc?.peer?.id ?? '' }; + }); + +/** start recording: every toast text, and whether the loud context-loss store ever rose @param {any} page */ +const record = (page) => + page.evaluate(() => { + const w = /** @type {any} */ (window); + w.__bq = { toasts: [], shown: false }; + w.__bqUnsub?.forEach((/** @type {() => void} */ u) => u()); + w.__bqUnsub = [ + w.__stores.toastStore.subscribe((/** @type {any[]} */ list) => { + for (const t of list) { + const text = typeof t === 'string' ? t : (t && (t.text || t.message)) || ''; + if (text && !w.__bq.toasts.includes(text)) w.__bq.toasts.push(text); + } + }), + w.__stores.contextLossShown.subscribe((/** @type {boolean} */ v) => { + if (v) w.__bq.shown = true; + }) + ]; + }); +/** @param {any} page */ +const recorded = (page) => page.evaluate(() => /** @type {any} */ (window).__bq); +// what this suite is about; an unrelated toast (the auto-quality governor on a software renderer) is not +const ABOUT = /went wrong|3D view|graphics|context|peer server|reconnect|disconnect|signal|offline/i; +/** @param {string[]} toasts */ +const ours = (toasts) => toasts.filter((t) => ABOUT.test(t)); + +h.run(async () => { + const browser = await h.launch(); + /** @type {any[]} */ + let reports = []; + const P = await h.setupPage(browser, 'phone', { context: PHONE, storage: { 'errorReports:url': MOCK } }); + const page = P.page; + await P.ctx.route(/errors\.mock\.test/, async (/** @type {any} */ route) => { + try { + reports.push(JSON.parse(route.request().postData() || 'null')); + } catch { + reports.push(null); + } + await route.fulfill({ status: 204, headers: { 'access-control-allow-origin': '*' }, body: '' }); + }); + await page.waitForFunction(() => !!window.__stores?.contextLossShown && !!window.__stores?.diagnostics, null, { timeout: 30000 }); + + // ---- 0. premises ---------------------------------------------------------------------- + const canLose = await page.evaluate(() => { + let r = null; + window.__stores.globalRenderer.subscribe((v) => (r = v))(); + window.__loseCtx = r?.getContext?.()?.getExtension?.('WEBGL_lose_context') ?? null; + return !!window.__loseCtx; + }); + h.check(canLose, 'premise: WEBGL_lose_context is available, so a REAL context loss can be driven'); + h.check((await page.evaluate(() => window.__stores.errorReports.errorReportsUrl())) === MOCK, 'premise: error reports go to the mock endpoint'); + await h.eventually(() => dot(page), (d) => d.open && !!d.id, 'premise: the signaling link is open', 30000); + const before = await dot(page); + h.check(!!before.tone, `premise: the Connect dot is drawn (${before.tone})`); + h.check(!(await page.locator('.gl-lost').isVisible().catch(() => false)), 'premise: no overlay while the context is healthy'); + + // ---- 1. minimise, lose, reopen, restore inside the grace ------------------------------- + if (canLose) { + reports = []; + await record(page); + await setHidden(page, true); + await page.evaluate(() => { + let pc = null; + window.__stores.peers.subscribe((v) => (pc = v))(); + pc.peer.disconnect(); // a phone tab switch drops the signaling socket for real + window.__loseCtx.loseContext(); + }); + await page.waitForTimeout(4000); // longer than the grace: a hidden page's clock does not run + h.check(!(await page.locator('.gl-lost').isVisible().catch(() => false)), '1a hidden for 4 s with the context lost: no overlay'); + await setHidden(page, false); + await page.waitForTimeout(1000); + await page.evaluate(() => window.__loseCtx.restoreContext()); + await h.eventually(() => page.evaluate(() => { + let v = true; + window.__stores.contextLost.subscribe((x) => (v = x))(); + return v; + }), (v) => v === false, '1b the context comes back', 10000); + await page.waitForTimeout(3500); // past where a visible-clock overlay would have risen + const r1 = await recorded(page); + h.check(r1.shown === false && !(await page.locator('.gl-lost').isVisible().catch(() => false)), `1c no overlay at any moment (loud store rose: ${r1.shown})`); + h.check(!ours(r1.toasts).length, `1d no toast about any of it (${JSON.stringify(r1.toasts)})`); + h.check(!reports.some((r) => r?.kind === 'webgl'), `1e no webgl error report (${JSON.stringify(reports.map((r) => r?.kind))})`); + await h.eventually(() => dot(page), (d) => d.open && d.tone === before.tone, `1f the Connect dot returns (${before.tone})`, 30000); + const back = await dot(page); + h.check(back.id === before.id, `1g …on the same peer id (${before.id} → ${back?.id})`); + h.check(!ours((await recorded(page)).toasts).length, '1h and the reconnect said nothing out loud either'); + } + + // ---- 2. a loss on a visible page held 4 s --------------------------------------------- + if (canLose) { + reports = []; + await record(page); + await page.evaluate(() => window.__loseCtx.loseContext()); + await page.waitForTimeout(1500); + h.check(!(await page.locator('.gl-lost').isVisible().catch(() => false)), '2a inside the grace the overlay stays down'); + await page.waitForTimeout(2500); + h.check(await page.locator('.gl-lost').isVisible().catch(() => false), '2b held 4 s on a visible page: the overlay shows'); + await h.eventually(async () => reports.filter((r) => r?.kind === 'webgl').length, (n) => n === 1, '2c …and ONE webgl report goes out', 6000); + await page.evaluate(() => window.__loseCtx.restoreContext()); + await h.eventually(() => page.locator('.gl-lost').isVisible().catch(() => false), (v) => v === false, '2d restoring takes it down', 20000); + } + + // ---- 3. a real ResizeObserver loop: logged, not toasted -------------------------------- + { + reports = []; + await record(page); + const looped = await page.evaluate( + () => + new Promise((resolve) => { + let seen = false; + const onErr = (/** @type {ErrorEvent} */ e) => { + if (/ResizeObserver loop/.test(e.message)) seen = true; + }; + window.addEventListener('error', onErr); + const el = document.createElement('div'); + el.style.cssText = 'position:fixed;left:0;top:0;width:10px;height:10px;opacity:0;pointer-events:none'; + document.body.appendChild(el); + let n = 0; + const ro = new ResizeObserver(() => { + if (n++ < 20) el.style.height = 10 + n + 'px'; // the classic shape: resize your own target + }); + ro.observe(el); + setTimeout(() => { + ro.disconnect(); + el.remove(); + window.removeEventListener('error', onErr); + resolve(seen); + }, 1500); + }) + ); + h.check(looped, 'positive control: the browser reports the ResizeObserver loop here'); + const inRing = await page.evaluate(() => window.__stores.diagnostics.lines().some((/** @type {any} */ l) => /ResizeObserver loop/.test(JSON.stringify(l)))); + h.check(inRing, '3a the loop line is in the diagnostics ring (Copy diagnostics keeps it)'); + await page.waitForTimeout(500); + const r3 = await recorded(page); + h.check(!r3.toasts.some((t) => /Something went wrong/.test(t)), `3b …and no "Something went wrong" toast (${JSON.stringify(r3.toasts)})`); + await page.evaluate(() => setTimeout(() => window.dispatchEvent(new ErrorEvent('error', { message: 'e2e real failure after a resize' })), 0)); + await h.eventually(() => recorded(page), (r) => r.toasts.some((/** @type {string} */ t) => /Something went wrong: e2e real failure/.test(t)), '3c control: a real error still toasts', 5000); + } + + await h.closePeer(P); + await h.finish(browser); +}); diff --git a/tests/e2e/dispose.test.cjs b/tests/e2e/dispose.test.cjs index 3f6107b69..2008dd3c6 100644 --- a/tests/e2e/dispose.test.cjs +++ b/tests/e2e/dispose.test.cjs @@ -143,7 +143,13 @@ h.run(async () => { 'premise: the overlay is hidden while the context is healthy' ); await A.page.evaluate(() => window.__loseCtx.loseContext()); + // fix-bgquiet: the overlay waits out a 3 s grace (a phone hands a context straight back) await A.page.waitForTimeout(800); + h.check( + !(await A.page.locator('.gl-lost').isVisible().catch(() => false)), + 'inside the grace the overlay stays down' + ); + await A.page.waitForTimeout(3200); const overlay = await A.page.locator('.gl-lost').isVisible().catch(() => false); h.check(overlay, 'a lost context raises the overlay instead of looking like a freeze'); diff --git a/tests/e2e/error-reports.test.cjs b/tests/e2e/error-reports.test.cjs index c33fde9d7..ac3661132 100644 --- a/tests/e2e/error-reports.test.cjs +++ b/tests/e2e/error-reports.test.cjs @@ -132,10 +132,10 @@ h.run(async () => { const rej = requests[1]?.body ?? {}; h.check(rej.kind === 'rejection' && rej.msg === 'e2e rejected after ms', `B13 an unhandled rejection: kind rejection, long numbers folded (${rej.kind}: ${rej.msg})`); - await page.evaluate(() => { - window.__stores.contextLost.set(true); - window.__stores.contextLost.set(false); - }); + // fix-bgquiet: a loss is reported only once it outlasts the 3 s grace on a visible page + await page.evaluate(() => window.__stores.contextLost.set(true)); + await page.waitForTimeout(3600); + await page.evaluate(() => window.__stores.contextLost.set(false)); await waitRequests(page, 3); h.check(requests[2]?.body?.kind === 'webgl', `B14 the main renderer losing its WebGL context: kind webgl (${requests[2]?.body?.kind})`); diff --git a/tests/unit/backgroundQuiet.test.js b/tests/unit/backgroundQuiet.test.js new file mode 100644 index 000000000..533a90599 --- /dev/null +++ b/tests/unit/backgroundQuiet.test.js @@ -0,0 +1,116 @@ +// A phone backgrounding the tab: two pieces of noise the owner saw on 1.57.0 (OPPO Find N6, +// Android Chrome) — a "Something went wrong: ResizeObserver loop…" toast, and the 3D-view +// overlay + error report for a WebGL context the browser handed straight back on return. +import { describe, it, expect, beforeEach } from 'vitest'; +import { toastsUncaught } from '$lib/diagnostics.js'; +import { ignoredMessage } from '$lib/errorReportCore.js'; +import { + CONTEXT_LOSS_GRACE_MS as G, + lossInit, + lossStep, + lossDeadline, + startContextLossGate, + resetContextLossGateForTest +} from '$lib/contextLoss.js'; + +describe('the error toast skips what error reports skip', () => { + const noise = [ + 'ResizeObserver loop completed with undelivered notifications.', + 'ResizeObserver loop limit exceeded', + 'Script error.', + 'The play() request was interrupted by a call to pause().', + 'The operation was aborted.', + 'AbortError: signal is aborted without reason' + ]; + it.each(noise)('no toast for %s', (m) => { + expect(ignoredMessage(m)).toBe(true); + expect(toastsUncaught(m)).toBe(false); + }); + it('a real error still toasts', () => { + expect(toastsUncaught("Cannot read properties of undefined (reading 'position')")).toBe(true); + }); + it('one list: every message agrees between the two', () => { + for (const m of [...noise, 'x is not defined', 'boom', '']) expect(toastsUncaught(m)).toBe(!ignoredMessage(m)); + }); +}); + +/** run a script of [event, at] through the rule */ +const run = (/** @type {boolean} */ visible, /** @type {[any, number][]} */ steps) => { + let s = lossInit(0, visible); + for (const [e, t] of steps) s = lossStep(s, e, t); + return s; +}; + +describe('a lost 3D view is quiet when it comes back', () => { + it('lost while hidden, restored right after the page shows: quiet', () => { + const s = run(true, [['hidden', 1000], ['lost', 1100], ['tick', 30000], ['visible', 60000], ['restored', 60500]]); + expect(s.loud).toBe(false); + expect(s.lost).toBe(false); + }); + it('lost while hidden, nothing pending until the page shows', () => { + const s = run(true, [['hidden', 1000], ['lost', 1100]]); + expect(lossDeadline(s)).toBe(null); + expect(lossStep(s, 'tick', 999999).loud).toBe(false); + }); + it('lost while hidden, still lost GRACE after the page shows: loud', () => { + const s = run(true, [['hidden', 1000], ['lost', 1100], ['visible', 60000]]); + expect(lossDeadline(s)).toBe(60000 + G); + expect(lossStep(s, 'tick', 60000 + G - 1).loud).toBe(false); + expect(lossStep(s, 'tick', 60000 + G).loud).toBe(true); + }); + it('lost while visible, restored inside GRACE: quiet', () => { + const s = run(true, [['lost', 5000], ['tick', 5000 + G - 1], ['restored', 5000 + G - 1]]); + expect(s.loud).toBe(false); + }); + it('lost while visible and held: loud at GRACE, and the restore clears it', () => { + let s = run(true, [['lost', 5000], ['tick', 5000 + G]]); + expect(s.loud).toBe(true); + s = lossStep(s, 'restored', 9000); + expect(s.loud).toBe(false); + }); + it('hiding stops the clock, and showing again restarts it from zero', () => { + const s = run(true, [['lost', 5000], ['hidden', 6000], ['tick', 20000], ['visible', 30000], ['tick', 30000 + G - 1]]); + expect(s.loud).toBe(false); + expect(lossStep(s, 'tick', 30000 + G).loud).toBe(true); + }); + it('a second lost event does not restart a running clock', () => { + const s = run(true, [['lost', 5000], ['lost', 7000]]); + expect(lossDeadline(s)).toBe(5000 + G); + }); +}); + +describe('the gate drives the loud store', () => { + beforeEach(() => resetContextLossGateForTest()); + const fakeDoc = () => { + /** @type {Record void>} */ + const on = {}; + return { + visibilityState: 'visible', + addEventListener: (/** @type {string} */ t, /** @type {() => void} */ f) => (on[t] = f), + fire(/** @type {'visible' | 'hidden'} */ v) { + this.visibilityState = v; + on.visibilitychange?.(); + } + }; + }; + const stores = () => { + /** @type {(v: boolean) => void} */ + let sub = () => {}; + const shown = { v: false, set(/** @type {boolean} */ x) { this.v = x; } }; + return { lost: { subscribe: (/** @type {any} */ f) => ((sub = f), f(false), () => {}), set: (/** @type {boolean} */ v) => sub(v) }, shown }; + }; + it('hide, lose, show, restore: the loud store never rises', () => { + let t = 0; + const doc = fakeDoc(); + const { lost, shown } = stores(); + /** @type {boolean[]} */ + const seen = []; + const watch = { set: (/** @type {boolean} */ v) => (shown.set(v), seen.push(v)) }; + startContextLossGate(lost, watch, { doc: /** @type {any} */ (doc), now: () => t }); + t = 100; doc.fire('hidden'); + t = 200; lost.set(true); + t = 9000; doc.fire('visible'); + t = 9500; lost.set(false); + expect(seen.every((v) => v === false)).toBe(true); + }); +}); From 90cbe1966edf39517ecc36ea4b90f1eaa26cecc0 Mon Sep 17 00:00:00 2001 From: AlexZ005 Date: Wed, 4 Nov 2026 07:25:58 +0200 Subject: [PATCH 2/2] =?UTF-8?q?docs(changelog):=20Unreleased=20=E2=80=94?= =?UTF-8?q?=20quiet=20phone=20background/return?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b956ed093..3324fbbd0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ per release, newest first. HTML comments like this one are stripped before rendering, so maintainer notes stay out of the user-facing window. --> +## Unreleased + +### 🔧 Fixes +- **Switching away from the browser on a phone and coming back says nothing.** The 3D view no longer flashes "The 3D view has stopped" when the browser hands its graphics back within a few seconds of the page showing again; it only appears when the view stays stopped. +- The browser's harmless "ResizeObserver loop" warning no longer pops up as "Something went wrong". It is still kept in **Copy diagnostics**. + ## 1.57.0 — Shared games start fast 🚀 ### ▶ A play view made only for playing