mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Chore(debug): remove the ?debug=nav overlay
The stall it was built to diagnose is fixed (utils/revive-animations.js); the overlay, its App.vue gate and the event it listened to go. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013GbLTGMZ45MV78qd2tHoco
This commit is contained in:
4 files changed
+9
-266
No files matched your search
@@ -21,8 +21,6 @@
|
||||
<Alert />
|
||||
<DocsAssistant />
|
||||
<PWA v-if="offerPwaInstall" />
|
||||
<!-- TEMPORARY: ?debug=nav diagnostic overlay (widgets/NavDebug.vue). -->
|
||||
<NavDebug v-if="navDebug" />
|
||||
</TooltipProvider>
|
||||
</template>
|
||||
|
||||
@@ -47,16 +45,6 @@ const PWA = defineAsyncComponent(() => import('@/components/widgets/PWA.vue'));
|
||||
const User = defineAsyncComponent(() => import('@/components/User.vue'));
|
||||
const Preferences = defineAsyncComponent(() => import('@/components/widgets/Preferences.vue'));
|
||||
const offerPwaInstall = ref(false);
|
||||
// TEMPORARY diagnostic overlay for the iOS Nav animation stall: ?debug=nav
|
||||
// turns it on (kept in localStorage), ?debug=off turns it off.
|
||||
const NavDebug = defineAsyncComponent(() => import('@/components/widgets/NavDebug.vue'));
|
||||
const navDebug = ref(false);
|
||||
try {
|
||||
const asked = new URLSearchParams(window.location.search).get('debug');
|
||||
if (asked === 'nav') localStorage.setItem('jn-debug', 'nav');
|
||||
if (asked === 'off') localStorage.removeItem('jn-debug');
|
||||
navDebug.value = localStorage.getItem('jn-debug') === 'nav';
|
||||
} catch { /* storage disabled */ }
|
||||
onMounted(() => {
|
||||
if (shouldOfferPwaInstall()) {
|
||||
setTimeout(() => { offerPwaInstall.value = true; }, 30 * 1000);
|
||||
|
||||
@@ -1,226 +0,0 @@
|
||||
<template>
|
||||
<!-- TEMPORARY diagnostic overlay for the iOS "Nav animations stall after a
|
||||
swipe-back" problem. Opt in with ?debug=nav (kept in localStorage; ?debug=off
|
||||
clears it). Shows, live: the rAF frame rate, whether Pulse's ping animation
|
||||
advances and iterates, two reference CSS animations (one in this fixed
|
||||
panel, one injected into the page flow), the Nav's state, and a timeline
|
||||
of navigation / visibility / input events. The buttons try candidate
|
||||
remedies one at a time, so the one that revives the Nav names the cause.
|
||||
"Copy" puts the whole log on the clipboard. Not localized — remove when done. -->
|
||||
<div class="fixed bottom-2 left-2 right-2 z-[99999] rounded-md border bg-card/95 p-2 font-mono text-[11px] leading-tight text-foreground shadow-lg"
|
||||
style="max-height: 46vh; overflow: auto;">
|
||||
<div class="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span>rAF <b>{{ fps }}</b>/s · gap≤<b>{{ maxGap }}</b>ms</span>
|
||||
<span>ping t+<b>{{ pingDelta }}</b>ms/500 · {{ pingState }} · iter {{ pingIterations }}</span>
|
||||
<span>vis {{ visibility }} · focus {{ focused ? 'y' : 'n' }}</span>
|
||||
<span>scrollY {{ scrollY }} · navHidden {{ navHidden }}</span>
|
||||
<span class="inline-flex items-center gap-1">panel
|
||||
<span class="inline-block size-3 animate-spin rounded-sm bg-primary" style="animation-duration: 2s" />
|
||||
</span>
|
||||
<span>ua-vt {{ lastUaTransition }}</span>
|
||||
</div>
|
||||
<div class="mt-1 flex flex-wrap gap-1">
|
||||
<button v-for="action in actions" :key="action.label" type="button"
|
||||
class="rounded border px-1.5 py-0.5 active:bg-muted" @click="run(action)">{{ action.label }}</button>
|
||||
<button type="button" class="rounded border px-1.5 py-0.5 font-semibold active:bg-muted" @click="copyLog">Copy</button>
|
||||
<button type="button" class="rounded border px-1.5 py-0.5 active:bg-muted" @click="lines = []">Clear</button>
|
||||
</div>
|
||||
<pre class="mt-1 whitespace-pre-wrap break-all">{{ lines.join('\n') }}</pre>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { REVIVED_EVENT } from '@/utils/revive-animations.js';
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
const lines = ref([]);
|
||||
const fps = ref(0);
|
||||
const maxGap = ref(0);
|
||||
const pingDelta = ref(0);
|
||||
const pingState = ref('-');
|
||||
const pingIterations = ref(0);
|
||||
const visibility = ref(document.visibilityState);
|
||||
const focused = ref(document.hasFocus());
|
||||
const scrollY = ref(Math.round(window.scrollY));
|
||||
const navHidden = ref('?');
|
||||
const lastUaTransition = ref('-');
|
||||
|
||||
const t0 = performance.now();
|
||||
const stamp = () => ((performance.now() - t0) / 1000).toFixed(2).padStart(7);
|
||||
const log = (message) => {
|
||||
lines.value.push(`${stamp()} ${message}`);
|
||||
if (lines.value.length > 60) lines.value.shift();
|
||||
};
|
||||
|
||||
const nav = () => document.querySelector('.jn-site-nav');
|
||||
const pingEl = () => nav()?.querySelector('.animate-ping') ?? null;
|
||||
const pingAnimation = () => pingEl()?.getAnimations?.()?.[0] ?? null;
|
||||
|
||||
// ---- live sampling ---------------------------------------------------------
|
||||
let frames = 0;
|
||||
let lastFrame = performance.now();
|
||||
let rafId = 0;
|
||||
const tick = (now) => {
|
||||
frames += 1;
|
||||
maxGap.value = Math.max(maxGap.value, Math.round(now - lastFrame));
|
||||
lastFrame = now;
|
||||
rafId = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
let lastPingTime = null;
|
||||
let sampler = 0;
|
||||
const sample = () => {
|
||||
fps.value = frames * 2;
|
||||
frames = 0;
|
||||
const animation = pingAnimation();
|
||||
if (animation) {
|
||||
const t = animation.currentTime ?? 0;
|
||||
pingDelta.value = lastPingTime === null ? 0 : Math.round(t - lastPingTime);
|
||||
lastPingTime = t;
|
||||
pingState.value = animation.playState;
|
||||
} else {
|
||||
pingState.value = 'none';
|
||||
}
|
||||
visibility.value = document.visibilityState;
|
||||
focused.value = document.hasFocus();
|
||||
scrollY.value = Math.round(window.scrollY);
|
||||
const header = nav();
|
||||
navHidden.value = header ? String(header.classList.contains('-translate-y-full')) : '?';
|
||||
maxGap.value = 0;
|
||||
};
|
||||
|
||||
// ---- event timeline --------------------------------------------------------
|
||||
const onPopstate = (event) => {
|
||||
lastUaTransition.value = String(event.hasUAVisualTransition);
|
||||
log(`popstate hasUAVisualTransition=${event.hasUAVisualTransition}`);
|
||||
};
|
||||
const onVisibility = () => log(`visibilitychange → ${document.visibilityState}`);
|
||||
const onPageShow = (e) => log(`pageshow persisted=${e.persisted}`);
|
||||
const onPageHide = (e) => log(`pagehide persisted=${e.persisted}`);
|
||||
const onFocus = () => log('window focus');
|
||||
const onBlur = () => log('window blur');
|
||||
const onTouch = () => log('touchstart');
|
||||
let scrollLogged = false;
|
||||
const onScroll = () => {
|
||||
if (scrollLogged) return;
|
||||
scrollLogged = true;
|
||||
log(`scroll (y=${Math.round(window.scrollY)})`);
|
||||
setTimeout(() => { scrollLogged = false; }, 1000);
|
||||
};
|
||||
const onResize = () => log(`resize ${window.innerWidth}×${window.innerHeight}`);
|
||||
const onRevived = (e) => log(`revived ${e.detail.revived} animations (+${e.detail.delayMs}ms)`);
|
||||
const onIteration = (e) => {
|
||||
if (e.animationName && e.target === pingEl()) pingIterations.value += 1;
|
||||
};
|
||||
|
||||
let removeBefore = null;
|
||||
let removeAfter = null;
|
||||
|
||||
// ---- candidate remedies ----------------------------------------------------
|
||||
const actions = [
|
||||
{ label: 'restart anims', run: () => {
|
||||
const list = nav()?.getAnimations({ subtree: true }) ?? [];
|
||||
list.forEach((a) => { a.cancel(); a.play(); });
|
||||
return `${list.length} animations`;
|
||||
} },
|
||||
{ label: 'toggle will-change', run: () => {
|
||||
const h = nav(); if (!h) return 'no nav';
|
||||
h.classList.toggle('will-change-transform');
|
||||
return h.classList.contains('will-change-transform') ? 'on' : 'off';
|
||||
} },
|
||||
{ label: 'toggle backdrop', run: () => {
|
||||
const h = nav(); if (!h) return 'no nav';
|
||||
h.classList.toggle('backdrop-blur');
|
||||
return h.classList.contains('backdrop-blur') ? 'on' : 'off';
|
||||
} },
|
||||
{ label: 'reflow nav', run: () => {
|
||||
const h = nav(); if (!h) return 'no nav';
|
||||
h.style.display = 'none'; void h.offsetHeight; h.style.display = '';
|
||||
return 'display none→back';
|
||||
} },
|
||||
{ label: 'refix nav', run: () => {
|
||||
const h = nav(); if (!h) return 'no nav';
|
||||
h.style.position = 'absolute'; void h.offsetHeight; h.style.position = '';
|
||||
return 'absolute→fixed';
|
||||
} },
|
||||
{ label: 'translateZ', run: () => {
|
||||
const h = nav(); if (!h) return 'no nav';
|
||||
h.style.transform = h.style.transform ? '' : 'translateZ(0)';
|
||||
return h.style.transform || 'cleared';
|
||||
} },
|
||||
{ label: 'nudge scroll', run: () => { window.scrollBy(0, 1); window.scrollBy(0, -1); return 'done'; } },
|
||||
{ label: 'remount pulse', run: () => {
|
||||
const el = pingEl()?.parentElement; if (!el) return 'no pulse';
|
||||
const parent = el.parentElement; const next = el.nextSibling;
|
||||
parent.removeChild(el); void parent.offsetHeight; parent.insertBefore(el, next);
|
||||
return 'detached→reattached';
|
||||
} },
|
||||
];
|
||||
const run = (action) => {
|
||||
let result;
|
||||
try { result = action.run(); } catch (e) { result = `error ${e.message}`; }
|
||||
log(`[${action.label}] ${result}`);
|
||||
};
|
||||
|
||||
const copyLog = async () => {
|
||||
const text = [
|
||||
`ua: ${navigator.userAgent}`,
|
||||
`standalone: ${window.navigator.standalone ?? matchMedia('(display-mode: standalone)').matches}`,
|
||||
`rAF ${fps.value}/s, ping ${pingState.value} +${pingDelta.value}ms/500, iter ${pingIterations.value}, vis ${visibility.value}, focus ${focused.value}, scrollY ${scrollY.value}, navHidden ${navHidden.value}`,
|
||||
...lines.value,
|
||||
].join('\n');
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
log('copied');
|
||||
} catch {
|
||||
try { await navigator.share({ text }); } catch { log('copy failed'); }
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
log(`start ${location.pathname}`);
|
||||
rafId = requestAnimationFrame(tick);
|
||||
sampler = setInterval(sample, 500);
|
||||
window.addEventListener('popstate', onPopstate, { capture: true });
|
||||
document.addEventListener('visibilitychange', onVisibility);
|
||||
window.addEventListener('pageshow', onPageShow);
|
||||
window.addEventListener('pagehide', onPageHide);
|
||||
window.addEventListener('focus', onFocus);
|
||||
window.addEventListener('blur', onBlur);
|
||||
window.addEventListener('touchstart', onTouch, { passive: true });
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener(REVIVED_EVENT, onRevived);
|
||||
document.addEventListener('animationiteration', onIteration, true);
|
||||
removeBefore = router.beforeEach((to, from) => { log(`route ${from.fullPath} → ${to.fullPath}`); });
|
||||
removeAfter = router.afterEach((to, from, failure) => { log(`afterEach ${to.path}${failure ? ' FAILED' : ''} y=${Math.round(window.scrollY)}`); });
|
||||
// A reference CSS animation in the page flow, below the fixed Nav.
|
||||
const marker = document.createElement('div');
|
||||
marker.id = 'jn-debug-flow-spinner';
|
||||
marker.className = 'fixed top-16 right-2 z-[99998] size-4 animate-spin rounded-sm bg-destructive';
|
||||
marker.style.animationDuration = '2s';
|
||||
marker.style.position = 'absolute';
|
||||
document.body.appendChild(marker);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cancelAnimationFrame(rafId);
|
||||
clearInterval(sampler);
|
||||
window.removeEventListener('popstate', onPopstate, { capture: true });
|
||||
document.removeEventListener('visibilitychange', onVisibility);
|
||||
window.removeEventListener('pageshow', onPageShow);
|
||||
window.removeEventListener('pagehide', onPageHide);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
window.removeEventListener('blur', onBlur);
|
||||
window.removeEventListener('touchstart', onTouch);
|
||||
window.removeEventListener('scroll', onScroll);
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener(REVIVED_EVENT, onRevived);
|
||||
document.removeEventListener('animationiteration', onIteration, true);
|
||||
removeBefore?.(); removeAfter?.();
|
||||
document.getElementById('jn-debug-flow-spinner')?.remove();
|
||||
});
|
||||
</script>
|
||||
@@ -2,11 +2,10 @@
|
||||
//
|
||||
// On iOS, accelerated CSS animations that were running before a swipe-back
|
||||
// gesture stay visually frozen once the gesture lands on the previous page:
|
||||
// requestAnimationFrame keeps its rate, the animations' own time keeps
|
||||
// advancing and their iteration events keep firing, yet the compositor never
|
||||
// paints a new frame for them until their layer is rebuilt (measured on
|
||||
// iOS 26 with the ?debug=nav overlay: cancel + play, a display:none round
|
||||
// trip or a re-insert all revive them; style changes on an ancestor don't).
|
||||
// requestAnimationFrame keeps its rate and the animations' own time keeps
|
||||
// advancing, yet the compositor paints no new frame for them until their
|
||||
// layer is rebuilt (measured on iOS 26: cancel + play, a display:none round
|
||||
// trip or a re-insert revive them; style changes on an ancestor don't).
|
||||
// A navigation that commits a new animation (a push with its page
|
||||
// transition) revives them as a side effect, so only history steps — where
|
||||
// the browser's own gesture animation replaces ours — are affected.
|
||||
@@ -20,7 +19,6 @@
|
||||
// dismisses its gesture snapshot late.
|
||||
|
||||
const REVIVE_DELAYS_MS = [0, 400, 1200];
|
||||
export const REVIVED_EVENT = 'jn:animations-revived';
|
||||
|
||||
const isCssAnimation = (animation) => typeof animation?.animationName === 'string';
|
||||
|
||||
@@ -59,10 +57,7 @@ export const installAnimationRevival = (router, {
|
||||
if (!historyStep) return;
|
||||
historyStep = false;
|
||||
for (const delayMs of REVIVE_DELAYS_MS) {
|
||||
schedule(() => {
|
||||
const revived = reviveAnimations(doc);
|
||||
win.dispatchEvent?.(new win.CustomEvent(REVIVED_EVENT, { detail: { revived, delayMs } }));
|
||||
}, delayMs);
|
||||
schedule(() => reviveAnimations(doc), delayMs);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -3,7 +3,7 @@
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import { reviveAnimations, installAnimationRevival, REVIVED_EVENT } from '../frontend/utils/revive-animations.js';
|
||||
import { reviveAnimations, installAnimationRevival } from '../frontend/utils/revive-animations.js';
|
||||
|
||||
const fakeAnimation = ({ css = true, playState = 'running', currentTime = 1234 } = {}) => {
|
||||
const log = [];
|
||||
@@ -42,21 +42,16 @@ describe('installAnimationRevival()', () => {
|
||||
const hooks = {};
|
||||
const router = { afterEach: (fn) => { hooks.afterEach = fn; } };
|
||||
const listeners = {};
|
||||
const dispatched = [];
|
||||
const win = {
|
||||
addEventListener: (type, fn) => { listeners[type] = fn; },
|
||||
dispatchEvent: (event) => dispatched.push(event.detail),
|
||||
CustomEvent: class { constructor(type, init) { this.type = type; this.detail = init.detail; } },
|
||||
};
|
||||
const win = { addEventListener: (type, fn) => { listeners[type] = fn; } };
|
||||
const spin = fakeAnimation();
|
||||
const doc = { getAnimations: () => [spin] };
|
||||
const scheduled = [];
|
||||
installAnimationRevival(router, { win, doc, schedule: (fn, delayMs) => scheduled.push({ fn, delayMs }) });
|
||||
return { hooks, listeners, dispatched, spin, scheduled };
|
||||
return { hooks, listeners, spin, scheduled };
|
||||
};
|
||||
|
||||
it('schedules the passes only for a navigation that came from popstate', () => {
|
||||
const { hooks, listeners, scheduled, spin, dispatched } = setup();
|
||||
const { hooks, listeners, scheduled, spin } = setup();
|
||||
hooks.afterEach();
|
||||
assert.equal(scheduled.length, 0, 'a push schedules nothing');
|
||||
listeners.popstate();
|
||||
@@ -64,17 +59,8 @@ describe('installAnimationRevival()', () => {
|
||||
assert.deepEqual(scheduled.map((s) => s.delayMs), [0, 400, 1200]);
|
||||
scheduled.forEach((s) => s.fn());
|
||||
assert.equal(spin.log.filter((l) => l === 'play').length, 3);
|
||||
assert.deepEqual(dispatched.map((d) => d.revived), [1, 1, 1]);
|
||||
hooks.afterEach();
|
||||
assert.equal(scheduled.length, 3, 'the flag is consumed');
|
||||
});
|
||||
|
||||
it('dispatches the revived event under its name', () => {
|
||||
const { hooks, listeners, scheduled, dispatched } = setup();
|
||||
listeners.popstate();
|
||||
hooks.afterEach();
|
||||
scheduled[0].fn();
|
||||
assert.equal(dispatched.length, 1);
|
||||
assert.equal(REVIVED_EVENT, 'jn:animations-revived');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user