Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions src/components/ContextLostOverlay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -19,7 +19,7 @@
const reload = () => location.reload();
</script>

{#if $contextLost}
{#if $contextLossShown}
<div class="gl-lost" role="alertdialog" aria-modal="true" aria-labelledby="gl-lost-title">
<div class="gl-lost-card">
<h2 id="gl-lost-title">The 3D view has stopped</h2>
Expand Down
6 changes: 5 additions & 1 deletion src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
84 changes: 84 additions & 0 deletions src/lib/contextLoss.js
Original file line number Diff line number Diff line change
@@ -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<typeof setTimeout> | 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;
}
17 changes: 14 additions & 3 deletions src/lib/diagnostics.js
Original file line number Diff line number Diff line change
@@ -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.
//
Expand Down Expand Up @@ -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
Expand All @@ -281,15 +292,15 @@ 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) => {
const reason = /** @type {any} */ (event).reason;
// 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) {
Expand Down
4 changes: 3 additions & 1 deletion src/lib/errorReports.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
});
Expand Down
7 changes: 7 additions & 0 deletions src/stores/sceneStore.js
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,13 @@ export const globalRenderer = writable(null);
* @type {import('svelte/store').Writable<boolean>}
*/
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<boolean>}
*/
export const contextLossShown = writable(false);
/** @type {import('svelte/store').Writable<any>} */
export const orbitControls = writable(null);
/**
Expand Down
Loading
Loading