app: refresh in place on a new deploy instead of a hard reload
You had to reload the tab by hand to pick up a new build, and when the service worker did reload it was an abrupt white flash. Now the build stamps version.json with the commit and the running app (which already knows its own __GIT_COMMIT__) polls it whenever the tab regains focus. When a newer build is live it cross-fades a themed curtain (the theme's own --bg + accent spinner) and reloads, then holds that curtain over the fresh load until the app mounts and fades it out - so it reads as an in-place refresh, no flash, no manual reload. If you're mid-message it waits until the composer is empty (or the tab is hidden) before reloading, and a loop guard stops it re-reloading for a commit it already tried. sw.js serves version.json network-only so the probe always sees the live id; index.html restores the themed bg pre-paint so dark themes don't flash light. Existing open tabs need one manual reload to pick up this machinery; after that it's automatic.
This commit is contained in:
parent
800d795ede
commit
a859381b83
6 changed files with 174 additions and 28 deletions
|
|
@ -5,7 +5,7 @@
|
|||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover, interactive-widget=resizes-content" />
|
||||
<!-- Apply the saved theme before first paint so there's no flash of the light
|
||||
default on load/reload. Full theme logic runs later in src/ui/theme.ts. -->
|
||||
<script>try{var t=localStorage.getItem('tchatou-theme');if(t)document.documentElement.dataset.theme=t}catch(e){}</script>
|
||||
<script>try{var t=localStorage.getItem('tchatou-theme');if(t)document.documentElement.dataset.theme=t;if(sessionStorage.getItem('orbit:just-updated')==='1'){var b=sessionStorage.getItem('orbit:update-bg');if(b)document.documentElement.style.background=b}}catch(e){}</script>
|
||||
<meta name="theme-color" content="#ffffff" />
|
||||
<link rel="manifest" href="/app/manifest.webmanifest" />
|
||||
<link rel="icon" href="/app/orbit-icon.svg" type="image/svg+xml" />
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
// Tchatou service worker — installable PWA + offline app shell.
|
||||
// Scope: /app/. Only handles same-origin /app/ GETs; the IRC websocket and all
|
||||
// API calls (cloudflare, change_password, upload) pass straight through.
|
||||
const CACHE = 'tchatou-v81';
|
||||
const CACHE = 'tchatou-v82';
|
||||
const SHELL = ['/app/', '/app/index.html', '/app/favicon.svg', '/app/orbit-icon.svg', '/app/manifest.webmanifest'];
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
|
|
@ -107,6 +107,9 @@ self.addEventListener('fetch', (e) => {
|
|||
e.respondWith(fetch(req).catch(() => caches.match('/app/index.html')));
|
||||
return;
|
||||
}
|
||||
// version.json: the update probe — always straight to network, never cached, so
|
||||
// a poll (src/ui/appUpdate.ts) sees the freshly deployed build id immediately.
|
||||
if (url.pathname === '/app/version.json') { e.respondWith(fetch(req)); return; }
|
||||
// config.json: network-first so a deployer's edits apply WITHOUT a rebuild;
|
||||
// fall back to the last cached copy when offline.
|
||||
if (url.pathname === '/app/config.json') {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { ConnectScreen } from './components/ConnectScreen';
|
||||
import { Chat } from './components/Chat';
|
||||
import { refreshPush } from './services/push';
|
||||
import { dismissUpdateCurtain } from './ui/appUpdate';
|
||||
import { getConfig } from './core/config';
|
||||
import { usePluginRegistry, matchShortcut } from './modules/registry';
|
||||
import { activeStore, useAllNetworksUnread } from './core/networks';
|
||||
|
|
@ -38,6 +39,10 @@ export default function App() {
|
|||
document.title = unread > 0 ? `(${unread}) ${name}` : name;
|
||||
}, [unread]);
|
||||
|
||||
// App is mounted and painting — fade out any update curtain held over from a
|
||||
// seamless-refresh reload, revealing the fresh build.
|
||||
useEffect(() => { dismissUpdateCurtain(); }, []);
|
||||
|
||||
// Re-assert the Web Push subscription on every (re)connect so it survives
|
||||
// server-side expiry and reconnects (cheap no-op if push isn't enabled).
|
||||
useEffect(() => {
|
||||
|
|
|
|||
33
src/main.tsx
33
src/main.tsx
|
|
@ -8,9 +8,14 @@ import './themes/dark.css'
|
|||
import './themes/orbit.css'
|
||||
import './themes/yomirc.css'
|
||||
import { initViewport } from './ui/viewport.ts'
|
||||
import { registerAppUpdates, markUpdateCurtain } from './ui/appUpdate.ts'
|
||||
import { loadConfig, getConfig } from './core/config.ts'
|
||||
import { AppErrorBoundary } from './components/AppErrorBoundary'
|
||||
|
||||
// If the last load reloaded to pick up a new build, hold a themed curtain up until
|
||||
// the app mounts (dismissed in App) so the fresh page fades in instead of popping.
|
||||
markUpdateCurtain()
|
||||
|
||||
// Track the visual viewport so the layout shrinks above the on-screen keyboard.
|
||||
initViewport()
|
||||
|
||||
|
|
@ -116,27 +121,7 @@ loadConfig().then(async () => {
|
|||
)
|
||||
})
|
||||
|
||||
// PWA: register the service worker (installable + offline app shell) and
|
||||
// auto-apply updates. The SW calls skipWaiting()+clients.claim(), so when a new
|
||||
// build is deployed it takes control and fires `controllerchange` — we reload
|
||||
// once (guarded against loops) so users always get the latest app without a
|
||||
// manual hard-refresh.
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Only an *existing* controller means this is an update (not the first-ever
|
||||
// install, where clients.claim() also fires controllerchange).
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
let reloading = false;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
if (!hadController || reloading) return;
|
||||
reloading = true;
|
||||
window.location.reload();
|
||||
});
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' })
|
||||
.then((reg) => {
|
||||
// Poll for a new SW occasionally (e.g. long-lived PWA sessions).
|
||||
setInterval(() => { void reg.update(); }, 60 * 60 * 1000);
|
||||
})
|
||||
.catch(() => { /* ignore */ });
|
||||
});
|
||||
}
|
||||
// PWA + seamless updates: registers the service worker (installable, offline app
|
||||
// shell, web push) and polls version.json so a fresh deploy refreshes the tab in
|
||||
// place — a themed cross-fade instead of a manual hard reload. See ./ui/appUpdate.
|
||||
registerAppUpdates()
|
||||
|
|
|
|||
139
src/ui/appUpdate.ts
Normal file
139
src/ui/appUpdate.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
// Seamless in-place updates.
|
||||
//
|
||||
// A deploy stamps dist/version.json with the git commit; the running app carries
|
||||
// its own commit in __GIT_COMMIT__. We poll version.json when the tab regains
|
||||
// focus (so a fresh deploy is picked up WITHOUT a manual reload) and, when it
|
||||
// differs, cross-fade a themed curtain and reload — masking the browser's hard
|
||||
// navigation so it reads as an internal refresh, not a page load. If the user is
|
||||
// mid-message we defer until the composer is empty (or the tab is hidden) so a
|
||||
// half-typed line is never yanked away.
|
||||
|
||||
const FLAG = 'orbit:just-updated'; // set right before reload, read on next boot
|
||||
const BG_KEY = 'orbit:update-bg'; // themed --bg, restored pre-paint (index.html) to avoid a flash
|
||||
const TRIED = 'orbit:update-tried'; // commit we already reloaded for — a reload-loop guard
|
||||
|
||||
const VERSION_URL = `${import.meta.env.BASE_URL}version.json`;
|
||||
|
||||
let reloading = false;
|
||||
let pendingCommit = '';
|
||||
|
||||
// The user has unsent text in the composer — don't reload out from under them.
|
||||
function composerBusy(): boolean {
|
||||
const el = document.querySelector('.composer__rich');
|
||||
return !!el?.textContent && el.textContent.trim().length > 0;
|
||||
}
|
||||
|
||||
// A full-viewport curtain in the theme's own --bg (so it and the reloaded page's
|
||||
// background are one continuous colour) with a centred spinner. Reused for both
|
||||
// the fade-out before reload and the cover-until-mounted after it.
|
||||
function buildCurtain(opacity: string): HTMLElement {
|
||||
const existing = document.getElementById('orbit-update');
|
||||
if (existing) return existing;
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const bg = cs.getPropertyValue('--bg').trim() || '#101012';
|
||||
const accent = cs.getPropertyValue('--accent').trim() || '#888';
|
||||
const el = document.createElement('div');
|
||||
el.id = 'orbit-update';
|
||||
el.setAttribute('style',
|
||||
`position:fixed;inset:0;z-index:2147483647;background:${bg};` +
|
||||
'display:flex;align-items:center;justify-content:center;' +
|
||||
`opacity:${opacity};transition:opacity .3s ease`);
|
||||
const spin = document.createElement('div');
|
||||
spin.setAttribute('style',
|
||||
'width:28px;height:28px;border-radius:50%;' +
|
||||
`border:2.5px solid ${accent}33;border-top-color:${accent};` +
|
||||
'animation:orbit-update-spin .7s linear infinite');
|
||||
el.appendChild(spin);
|
||||
if (!document.getElementById('orbit-update-kf')) {
|
||||
const st = document.createElement('style');
|
||||
st.id = 'orbit-update-kf';
|
||||
st.textContent = '@keyframes orbit-update-spin{to{transform:rotate(360deg)}}';
|
||||
document.head.appendChild(st);
|
||||
}
|
||||
document.body.appendChild(el);
|
||||
return el;
|
||||
}
|
||||
|
||||
function applyUpdate(toCommit: string): void {
|
||||
if (reloading) return;
|
||||
reloading = true;
|
||||
try {
|
||||
sessionStorage.setItem(FLAG, '1');
|
||||
if (toCommit) sessionStorage.setItem(TRIED, toCommit);
|
||||
const bg = getComputedStyle(document.documentElement).getPropertyValue('--bg').trim();
|
||||
if (bg) sessionStorage.setItem(BG_KEY, bg);
|
||||
} catch { /* private mode: no persistence, still reloads */ }
|
||||
const el = buildCurtain('0');
|
||||
requestAnimationFrame(() => { el.style.opacity = '1'; });
|
||||
setTimeout(() => window.location.reload(), 320); // let the curtain fade in first
|
||||
}
|
||||
|
||||
// Reload now if it's a safe moment; otherwise remember the target and wait.
|
||||
function requestUpdate(toCommit: string): void {
|
||||
if (reloading) return;
|
||||
if (composerBusy() && document.visibilityState === 'visible') { pendingCommit = toCommit; return; }
|
||||
applyUpdate(toCommit);
|
||||
}
|
||||
|
||||
async function checkVersion(): Promise<void> {
|
||||
if (reloading || !__GIT_COMMIT__) return;
|
||||
let commit: string;
|
||||
try {
|
||||
const res = await fetch(VERSION_URL, { cache: 'no-store' });
|
||||
if (!res.ok) return;
|
||||
commit = (await res.json())?.commit || '';
|
||||
} catch { return; }
|
||||
if (!commit || commit === __GIT_COMMIT__) return;
|
||||
try { if (sessionStorage.getItem(TRIED) === commit) return; } catch { /* ignore */ }
|
||||
requestUpdate(commit);
|
||||
}
|
||||
|
||||
// Boot: if the last load reloaded FOR an update, hold a themed curtain up until the
|
||||
// app has mounted (dismissUpdateCurtain), so the fresh page fades in, never pops.
|
||||
export function markUpdateCurtain(): void {
|
||||
try { if (sessionStorage.getItem(FLAG) !== '1') return; } catch { return; }
|
||||
try { sessionStorage.removeItem(FLAG); sessionStorage.removeItem(BG_KEY); } catch { /* ignore */ }
|
||||
buildCurtain('1');
|
||||
}
|
||||
|
||||
export function dismissUpdateCurtain(): void {
|
||||
const el = document.getElementById('orbit-update');
|
||||
document.documentElement.style.background = ''; // drop the pre-paint bg set in index.html
|
||||
if (!el) return;
|
||||
el.style.opacity = '0';
|
||||
setTimeout(() => el.remove(), 320);
|
||||
}
|
||||
|
||||
let lastCheck = 0;
|
||||
export function registerAppUpdates(): void {
|
||||
const maybeCheck = () => {
|
||||
if (document.visibilityState !== 'visible') return;
|
||||
const now = Date.now();
|
||||
if (now - lastCheck < 20000) return; // throttle focus storms
|
||||
lastCheck = now;
|
||||
void checkVersion();
|
||||
};
|
||||
|
||||
// Pick up a fresh deploy on the next focus/visibility, plus a slow heartbeat for
|
||||
// always-open sessions. Also flush a deferred update when it's finally safe.
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (pendingCommit && document.visibilityState === 'hidden') { const c = pendingCommit; pendingCommit = ''; applyUpdate(c); }
|
||||
maybeCheck();
|
||||
});
|
||||
window.addEventListener('focus', maybeCheck);
|
||||
document.addEventListener('input', () => {
|
||||
if (pendingCommit && !composerBusy()) { const c = pendingCommit; pendingCommit = ''; requestUpdate(c); }
|
||||
}, true);
|
||||
setInterval(maybeCheck, 15 * 60 * 1000);
|
||||
maybeCheck();
|
||||
|
||||
// The service worker still powers offline + web push; a changed SW (cache bump)
|
||||
// is an update signal too. Registration stays here so it's the single owner.
|
||||
if ('serviceWorker' in navigator) {
|
||||
const hadController = !!navigator.serviceWorker.controller;
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => { if (hadController) requestUpdate(''); });
|
||||
window.addEventListener('load', () => {
|
||||
navigator.serviceWorker.register('/app/sw.js', { scope: '/app/' }).catch(() => { /* ignore */ });
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import { defineConfig, type Plugin } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { execSync } from 'node:child_process'
|
||||
import { readFileSync } from 'node:fs'
|
||||
|
|
@ -7,11 +7,25 @@ const pkg = JSON.parse(readFileSync('./package.json', 'utf8'))
|
|||
let commit = ''
|
||||
try { commit = execSync('git rev-parse --short HEAD').toString().trim() } catch { /* not a git checkout */ }
|
||||
|
||||
// Stamp the built app with its commit so the running client can poll for a newer
|
||||
// deploy (src/ui/appUpdate.ts) and refresh itself in place, no manual reload.
|
||||
const emitVersion: Plugin = {
|
||||
name: 'emit-version',
|
||||
apply: 'build',
|
||||
generateBundle() {
|
||||
this.emitFile({
|
||||
type: 'asset',
|
||||
fileName: 'version.json',
|
||||
source: JSON.stringify({ commit, builtAt: new Date().toISOString() }),
|
||||
})
|
||||
},
|
||||
}
|
||||
|
||||
// Served from https://tchatou.fr/app/ (must be an allowed websocket origin).
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
base: '/app/',
|
||||
plugins: [react()],
|
||||
plugins: [react(), emitVersion],
|
||||
define: {
|
||||
__APP_VERSION__: JSON.stringify(pkg.version),
|
||||
__BUILD_TIME__: JSON.stringify(new Date().toISOString()),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue