|
| 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 | +} |
0 commit comments