Skip to content

Commit 7013da4

Browse files
committed
Merge remote-tracking branch 'origin/fix/phone-background-quiet' into feat/72-int
# Conflicts: # CHANGELOG.md
2 parents a21a0c5 + 90cbe19 commit 7013da4

11 files changed

Lines changed: 437 additions & 11 deletions

‎CHANGELOG.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@
55
per release, newest first. HTML comments like this one are stripped before
66
rendering, so maintainer notes stay out of the user-facing window. -->
77

8+
## Unreleased
9+
10+
### 🔧 Fixes
11+
- **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.
12+
- The browser's harmless "ResizeObserver loop" warning no longer pops up as "Something went wrong". It is still kept in **Copy diagnostics**.
13+
814
## 1.58.0 — Film with your phone, share it as a video 🎬
915

1016
### 📱 Film with your phone

‎src/components/ContextLostOverlay.svelte‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
// It usually is not lost: the scene graph lives in JavaScript, so a save still works
1010
// and the browser often hands the context back on its own. This says both of those
1111
// things and gives the two actions worth having.
12-
import { contextLost } from '../stores/sceneStore';
12+
import { contextLossShown } from '../stores/sceneStore';
1313
import { save } from '$lib/fileHandler.svelte';
1414
1515
// .tpscene rather than GLTF: it is the whole scene bundle and needs no selection,
@@ -19,7 +19,7 @@
1919
const reload = () => location.reload();
2020
</script>
2121

22-
{#if $contextLost}
22+
{#if $contextLossShown}
2323
<div class="gl-lost" role="alertdialog" aria-modal="true" aria-labelledby="gl-lost-title">
2424
<div class="gl-lost-card">
2525
<h2 id="gl-lost-title">The 3D view has stopped</h2>

‎src/components/Scene.svelte‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99
import { peers, username, userdata, specatorMode, avatarConfig, viewportMenu, objectContextMenu, viewportMenuOpener, addMenu, addMenuOpener, showToast, multiSelectMode } from '../stores/appStore';
1010
import { get } from 'svelte/store';
1111
import { vrPostEnabled } from '$lib/viewportOverrides';
12-
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';
12+
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';
13+
import { startContextLossGate } from '$lib/contextLoss.js';
1314
import {
1415
selectObject,
1516
deselectObject,
@@ -803,6 +804,9 @@
803804
});
804805
$contextLost = false;
805806
};
807+
// a phone backgrounding the tab loses and restores the context routinely: the overlay
808+
// (and the error report) wait for a loss that outlasts the grace rule in contextLoss.js
809+
startContextLossGate(contextLost, contextLossShown);
806810
element.addEventListener('webglcontextlost', onContextLost);
807811
element.addEventListener('webglcontextrestored', onContextRestored);
808812
let downPosition = null;

‎src/lib/contextLoss.js‎

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// WHEN A LOST 3D VIEW IS WORTH SAYING ANYTHING ABOUT (a LEAF: imports nothing).
2+
//
3+
// A phone that minimises the browser, folds, or switches apps routinely takes the WebGL
4+
// context away and hands it back a moment after the page is visible again. Showing "The 3D
5+
// view has stopped" and filing an error report for that is noise: the person never saw the
6+
// view stop. So the raw fact (`contextLost`, which the shader guard still reads at once) and
7+
// the LOUD fact (`contextLossShown`, which drives the overlay and the error report) are two
8+
// stores, and this rule decides the second from the first.
9+
//
10+
// THE RULE: a loss becomes loud only after the page has been VISIBLE for GRACE ms with the
11+
// context still gone — counted from the later of the loss and the moment the page became
12+
// visible. A loss while hidden that comes back within GRACE of the page showing again, or a
13+
// visible loss restored within GRACE, says nothing. Hiding the page again stops the clock.
14+
15+
/** how long a lost context may stay lost on a visible page before the overlay shows */
16+
export const CONTEXT_LOSS_GRACE_MS = 3000;
17+
18+
/**
19+
* @typedef {{ lost: boolean, loud: boolean, lostAt: number, visibleSince: number | null }} LossState
20+
* @typedef {'lost' | 'restored' | 'visible' | 'hidden' | 'tick'} LossEvent
21+
*/
22+
23+
/** @param {number} now @param {boolean} visible @returns {LossState} */
24+
export function lossInit(now, visible) {
25+
return { lost: false, loud: false, lostAt: 0, visibleSince: visible ? now : null };
26+
}
27+
28+
/** When the pending loss turns loud, or null (nothing pending) @param {LossState} s */
29+
export function lossDeadline(s) {
30+
if (!s.lost || s.loud || s.visibleSince === null) return null;
31+
return Math.max(s.lostAt, s.visibleSince) + CONTEXT_LOSS_GRACE_MS;
32+
}
33+
34+
/**
35+
* One step of the rule. Pure.
36+
* @param {LossState} s @param {LossEvent} event @param {number} now @returns {LossState}
37+
*/
38+
export function lossStep(s, event, now) {
39+
let next = s;
40+
if (event === 'lost') next = s.lost ? s : { ...s, lost: true, loud: false, lostAt: now };
41+
else if (event === 'restored') next = { ...s, lost: false, loud: false };
42+
else if (event === 'hidden') next = { ...s, visibleSince: null };
43+
else if (event === 'visible') next = s.visibleSince === null ? { ...s, visibleSince: now } : s;
44+
const due = lossDeadline(next);
45+
if (due !== null && now >= due) next = { ...next, loud: true };
46+
return next;
47+
}
48+
49+
let started = false;
50+
51+
/**
52+
* Drive `shown` from `lost` and the page's visibility. Idempotent.
53+
* @param {{ subscribe: (fn: (v: boolean) => void) => () => void }} lost
54+
* @param {{ set: (v: boolean) => void }} shown
55+
* @param {{ doc?: Document, now?: () => number }} [opts]
56+
*/
57+
export function startContextLossGate(lost, shown, opts = {}) {
58+
if (started) return;
59+
const doc = opts.doc ?? (typeof document !== 'undefined' ? document : null);
60+
if (!doc) return;
61+
started = true;
62+
const now = opts.now ?? (() => Date.now());
63+
const isVisible = () => doc.visibilityState !== 'hidden';
64+
let state = lossInit(now(), isVisible());
65+
/** @type {ReturnType<typeof setTimeout> | null} */
66+
let timer = null;
67+
/** @param {LossEvent} event */
68+
const step = (event) => {
69+
state = lossStep(state, event, now());
70+
shown.set(state.loud);
71+
if (timer) clearTimeout(timer);
72+
timer = null;
73+
const due = lossDeadline(state);
74+
if (due !== null) timer = setTimeout(() => step('tick'), Math.max(0, due - now()) + 20);
75+
};
76+
doc.addEventListener('visibilitychange', () => step(isVisible() ? 'visible' : 'hidden'));
77+
// a loss that lands a moment BEFORE the hide event is fine: 'hidden' stops its clock
78+
lost.subscribe((v) => step(v ? 'lost' : 'restored'));
79+
}
80+
81+
/** test seam: forget the started flag */
82+
export function resetContextLossGateForTest() {
83+
started = false;
84+
}

‎src/lib/diagnostics.js‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { writable } from 'svelte/store';
22
import { APP_VERSION, COMMIT_SHA, IS_DEV } from './version.js';
3-
import { describeReason } from './errorReportCore.js';
3+
import { describeReason, ignoredMessage } from './errorReportCore.js';
44

55
// 27-B (hardening audit H4) — THE ONE PLACE A FAILURE LEAVES A TRACE.
66
//
@@ -263,6 +263,17 @@ export async function copyDiagnostics() {
263263
}
264264
}
265265

266+
/**
267+
* Does an uncaught error earn the "Something went wrong" toast? The browser's own benign
268+
* warnings (ResizeObserver loop, cross-origin "Script error.", an interrupted play()) do not:
269+
* the SAME list the error reports skip (errorReportCore's IGNORE), so the two never disagree.
270+
* The line still goes into the ring, so Copy diagnostics keeps it.
271+
* @param {string} message
272+
*/
273+
export function toastsUncaught(message) {
274+
return !ignoredMessage(message);
275+
}
276+
266277
/**
267278
* Install the global capture. Idempotent, and safe to call before anything else in
268279
* App.svelte's onMount — it is the first thing that runs there precisely so that a
@@ -281,15 +292,15 @@ export function startDiagnostics() {
281292
const message = event.error?.message ?? event.message ?? 'unknown error';
282293
const where = event.filename ? ` (${event.filename}:${event.lineno}:${event.colno})` : '';
283294
log('error', 'window', message + where, event.error?.stack);
284-
lastUncaught.set({ message, at: Date.now() });
295+
if (toastsUncaught(message)) lastUncaught.set({ message, at: Date.now() });
285296
});
286297

287298
window.addEventListener('unhandledrejection', (event) => {
288299
const reason = /** @type {any} */ (event).reason;
289300
// 54i b: a non-Error reason is named by constructor + a safe JSON (errorReportCore, a leaf)
290301
const message = describeReason(reason).message || 'unknown rejection';
291302
log('error', 'promise', message, reason?.stack);
292-
lastUncaught.set({ message, at: Date.now() });
303+
if (toastsUncaught(message)) lastUncaught.set({ message, at: Date.now() });
293304
});
294305

295306
if (!IS_DEV) {

‎src/lib/errorReports.js‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,9 @@ export function startErrorReports() {
256256
};
257257
if (document.visibilityState === 'visible') markAlive();
258258
let wasLost = false;
259-
scene.contextLost.subscribe((lost) => {
259+
// a loss the page got back quietly (a phone backgrounding the tab) is not a report:
260+
// read the LOUD half, which the grace rule in contextLoss.js drives
261+
scene.contextLossShown.subscribe((lost) => {
260262
if (lost && !wasLost) reportError({ kind: 'webgl', message: 'WebGL context lost' });
261263
wasLost = !!lost;
262264
});

‎src/stores/sceneStore.js‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,13 @@ export const globalRenderer = writable(null);
8383
* @type {import('svelte/store').Writable<boolean>}
8484
*/
8585
export const contextLost = writable(false);
86+
/**
87+
* The LOUD half of `contextLost`: true only once a loss has outlasted the grace rule in
88+
* `lib/contextLoss.js` (a phone backgrounding the tab and handing the context back on return
89+
* says nothing). The overlay and the error report read this; the shader guard reads the raw one.
90+
* @type {import('svelte/store').Writable<boolean>}
91+
*/
92+
export const contextLossShown = writable(false);
8693
/** @type {import('svelte/store').Writable<any>} */
8794
export const orbitControls = writable(null);
8895
/**

0 commit comments

Comments
 (0)