The version poller already diffs /api/version against the manifest captured at boot -- hot-swapping changed CSS, reloading for changed JS/HTML once playback allows -- but only on its own 60s tick or when the tab regains visibility. Pressing refresh now runs that same check in the background, so a tab left open across a deploy picks the new build up when the user asks for fresh content rather than up to a minute later. App.version.checkNow() exposes it; with no baseline (the endpoint was down at boot) it just adopts the current manifest, since there is nothing to compare against yet. Verified against the running app: appending to style.css and pressing refresh swapped the tag to style.css?v=<hash> with the page still alive, and appending to a .js file reloaded the page. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01QBDkEXP4htyXTCZUwMLphd
156 lines
6.0 KiB
JavaScript
156 lines
6.0 KiB
JavaScript
window.App = window.App || {};
|
|
App.version = App.version || {};
|
|
|
|
(function() {
|
|
const VERSION_URL = '/api/version';
|
|
const POLL_INTERVAL_MS = 60000;
|
|
|
|
// Baseline manifest captured on startup: { version, files: { rel: hash } }.
|
|
let baseline = null;
|
|
let timer = null;
|
|
// Set once a JS/HTML change is detected; the page reloads at a safe moment.
|
|
let reloadPending = false;
|
|
let checking = false;
|
|
|
|
async function fetchVersion() {
|
|
const resp = await fetch(VERSION_URL, { cache: 'no-store' });
|
|
if (!resp.ok) throw new Error('version fetch failed: ' + resp.status);
|
|
return resp.json();
|
|
}
|
|
|
|
// Swap a stylesheet <link> in place using a cache-busted href so updated CSS
|
|
// applies instantly. The old link is removed only after the new one loads to
|
|
// avoid a flash of unstyled content.
|
|
function hotReloadCss(relPath, hash) {
|
|
const links = Array.from(document.querySelectorAll('link[rel="stylesheet"]'));
|
|
const match = links.find((l) => {
|
|
const href = (l.getAttribute('href') || '').split('?')[0];
|
|
return href.endsWith(relPath) || href.endsWith('/' + relPath);
|
|
});
|
|
if (!match) return false;
|
|
const base = (match.getAttribute('href') || '').split('?')[0];
|
|
const fresh = match.cloneNode(false);
|
|
fresh.setAttribute('href', base + '?v=' + hash);
|
|
fresh.addEventListener('load', () => { if (match.parentNode) match.remove(); });
|
|
fresh.addEventListener('error', () => { if (fresh.parentNode) fresh.remove(); });
|
|
match.parentNode.insertBefore(fresh, match.nextSibling);
|
|
return true;
|
|
}
|
|
|
|
function diffFiles(oldFiles, newFiles) {
|
|
const changed = [];
|
|
const keys = new Set([
|
|
...Object.keys(oldFiles || {}),
|
|
...Object.keys(newFiles || {})
|
|
]);
|
|
keys.forEach((k) => {
|
|
if ((oldFiles || {})[k] !== (newFiles || {})[k]) changed.push(k);
|
|
});
|
|
return changed;
|
|
}
|
|
|
|
// A reload is "safe" when the user isn't mid-playback: no open custom
|
|
// player, no active reels feed, and no playing <video>. App state survives
|
|
// a reload because it is restored from localStorage on boot.
|
|
function isSafeToReload() {
|
|
if (App.state && App.state.feedOpen) return false;
|
|
const player = document.getElementById('custom-player');
|
|
if (player && player.classList.contains('open')) {
|
|
const video = player.querySelector('.cp-video');
|
|
if (video && !video.paused && !video.ended) return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function showUpdateBanner() {
|
|
const banner = document.getElementById('update-banner');
|
|
if (!banner) return;
|
|
banner.classList.add('show');
|
|
const btn = document.getElementById('update-banner-btn');
|
|
if (btn) btn.onclick = () => window.location.reload();
|
|
}
|
|
|
|
function tryReloadWhenSafe() {
|
|
if (!reloadPending) return;
|
|
if (isSafeToReload()) {
|
|
window.location.reload();
|
|
} else {
|
|
showUpdateBanner();
|
|
}
|
|
}
|
|
|
|
function apply(latest) {
|
|
const changed = diffFiles(baseline.files, latest.files);
|
|
if (!changed.length) return;
|
|
|
|
let needsReload = false;
|
|
changed.forEach((file) => {
|
|
if (file.endsWith('.css') && hotReloadCss(file, latest.files[file])) {
|
|
return; // hot-swapped without reload
|
|
}
|
|
// JS and HTML can't be safely live-patched; they require a reload.
|
|
needsReload = true;
|
|
});
|
|
|
|
// Adopt the new manifest so we don't re-trigger on the same change.
|
|
baseline = latest;
|
|
|
|
if (needsReload) {
|
|
reloadPending = true;
|
|
tryReloadWhenSafe();
|
|
}
|
|
}
|
|
|
|
async function check() {
|
|
if (checking || !baseline) return;
|
|
checking = true;
|
|
try {
|
|
const latest = await fetchVersion();
|
|
apply(latest);
|
|
} catch (e) {
|
|
// Network blips are non-fatal; we retry on the next tick.
|
|
} finally {
|
|
checking = false;
|
|
}
|
|
}
|
|
|
|
// Same check the poller runs, on demand: the top-bar refresh button asks for
|
|
// it so a tab left open across a deploy picks the new build up right then,
|
|
// rather than up to POLL_INTERVAL_MS later. Changed CSS hot-swaps; changed
|
|
// JS/HTML reloads as soon as that won't interrupt playback.
|
|
App.version.checkNow = function() {
|
|
if (!baseline) {
|
|
// start() never got a manifest (endpoint down, or it hasn't run
|
|
// yet). Adopt whatever the server reports now so there's something
|
|
// to diff against next time -- there's no baseline to compare this
|
|
// one against, so nothing can be concluded from it today.
|
|
return fetchVersion().then((latest) => { baseline = latest; }).catch(() => {});
|
|
}
|
|
return check();
|
|
};
|
|
|
|
App.version.start = async function() {
|
|
try {
|
|
baseline = await fetchVersion();
|
|
} catch (e) {
|
|
return; // endpoint unavailable; skip version checking entirely
|
|
}
|
|
timer = setInterval(check, POLL_INTERVAL_MS);
|
|
// Check promptly when the user returns to the tab so updates land while
|
|
// they were away, and retry a pending reload once playback stops.
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.visibilityState === 'visible') {
|
|
tryReloadWhenSafe();
|
|
check();
|
|
}
|
|
});
|
|
// Re-attempt a deferred reload whenever a video finishes/pauses. The
|
|
// custom player's <video> is torn down and rebuilt on every open(), so
|
|
// bind on the capture phase at the document level instead of to a
|
|
// specific element (media events don't bubble, but capture still sees
|
|
// them on ancestors).
|
|
document.addEventListener('pause', tryReloadWhenSafe, true);
|
|
document.addEventListener('ended', tryReloadWhenSafe, true);
|
|
};
|
|
})();
|