diff --git a/docs/architecture.md b/docs/architecture.md
index 41aaeffbe..4a75d2f52 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -166,7 +166,9 @@ CI and staging must not upload authenticated browser captures. Logging must not
## PWA and offline behavior
-The service worker and manifest provide installable web assets and an offline app shell. It precaches only the navigation shell and static identity assets; route chunks enter the runtime cache when a user visits them. On first registration, the client also reports same-origin `/assets/` resources that loaded before the worker took control so the first visited route works offline without a forced reload. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make unvisited routes, authenticated Supabase, or AI operations available without a network.
+The service worker ([`public/sw.js`](../public/sw.js)) and manifest provide installable web assets and an offline app. At install it precaches the landing page, `app-shell.html`, the static identity assets, and a list the build writes into the worker (`serviceWorkerPrecache` in [`vite.config.ts`](../vite.config.ts)): the shell's scripts and styles, the signed-in layout, the Home, Library, History, Timer and Settings pages, the Spanish and Hungarian strings, and Inter's Latin fonts. After one online visit those pages open offline and the language can be switched there. Other route chunks enter the runtime cache when a user visits them or hovers their link; on first registration the client also reports same-origin `/assets/` resources that loaded before the worker took control. Offline, app routes (`/app`, `/login`, `/share`, the paths `vercel.json` rewrites to the shell) get the precached shell, and a public page gets only its own HTML, stored under its own path when visited. `/auth/callback`, Supabase paths and `/_vercel/` never touch a cache. Each build's caches carry the build's id and the next build's worker deletes them on activation. Updates wait for the normal service-worker lifecycle instead of reloading an active page. Offline support does not make other unvisited routes, authenticated Supabase, or AI operations available without a network.
+
+Only English strings ship in the entry. [`src/i18n.ts`](../src/i18n.ts) fetches the Spanish or Hungarian catalogs when that language is chosen, and the first render waits for them.
## Deployment boundary
diff --git a/docs/operations.md b/docs/operations.md
index ec3a5dadf..84f62d23d 100644
--- a/docs/operations.md
+++ b/docs/operations.md
@@ -46,7 +46,7 @@ Vercel builds and serves the frontend from the Git repository. A push to `main`
- `trailingSlash: false`, which 308-redirects `/app/settings/` to `/app/settings` before routing, since rewrite and redirect sources match exactly;
- 301 redirects from the retired brand pages (`/debatelab`, `/debatelab-by-programcode`, and the rest) to `/`;
- 301 redirects from retired guide slugs to the guide that replaced them, such as `/debate-weighing-guide` to `/how-to-weigh-in-debate`. The `trailingSlash` 308 runs first, so `/debate-weighing-guide/` reaches the same page in two permanent hops and needs no rule of its own;
-- revalidating cache headers (`public, max-age=0, must-revalidate`, without `no-store`, so a document can enter the back/forward cache) for `/`, `index.html`, `app-shell.html`, and `theme-init.js`. `sw.js` stays `no-store` so a new service worker is not stuck in the HTTP cache. Hashed `/assets/` are immutable. The unhashed images in `public/` (`favicon.ico`, `favicon.png`, `favicon-72.png`, `hero-visual.jpg`, `placeholder.svg`) are cached for 30 days. Jam's capture iframe is removed on `pagehide` (never via `unload`) so that cross-origin frame cannot veto the cache;
+- revalidating cache headers (`public, max-age=0, must-revalidate`, without `no-store`, so a document can enter the back/forward cache) for `/`, `index.html`, `app-shell.html`, and `theme-init.js`. `sw.js` stays `no-store` so a new service worker is not stuck in the HTTP cache. Hashed `/assets/` are immutable. The unhashed images in `public/` (the favicons, `apple-touch-icon.png`, the `icon-*.png` manifest icons, `hero-visual.jpg`, `placeholder.svg`) are cached for 30 days; the test reads `public/`, so a new icon file must be added to that rule. Jam's capture iframe is removed on `pagehide` (never via `unload`) so that cross-origin frame cannot veto the cache;
- `noindex` headers for `/app`, `/login`, `/share` and `/embed/debate-timer`. The app shell's robots meta is `noindex, nofollow`, the same policy. Public pages, including the prerendered `/expocit/sources`, are `index, follow`. A route deletes the platform `Access-Control-Allow-Origin: *` header on HTML responses and leaves it on static assets;
- Content Security Policy and the other response security headers, including the `Permissions-Policy` that allows the microphone for the app's own origin only. Jam is limited to `https://js.jam.dev` and `https://recorder.jam.dev`; `script-src` does not include `unsafe-eval`. Web Analytics and Speed Insights stay on this origin (`/_vercel/insights` and `/_vercel/speed-insights`), which `'self'` already allows. Every path except `/embed/debate-timer` refuses to be framed (`X-Frame-Options: DENY` and `frame-ancestors 'none'`). `/embed/debate-timer` allows any site to frame it and gets its own CSP, which permits only same-origin scripts and requests. The two rules use complementary sources (`/((?!embed/debate-timer$).*)` and `/embed/debate-timer`) so no path gets both, a new embed needs its path added to both, and `src/test/vercel-deployment-config.test.ts` fails if two matching rules ever set the same header.
diff --git a/docs/testing.md b/docs/testing.md
index d7835a81d..8200a4ce1 100644
--- a/docs/testing.md
+++ b/docs/testing.md
@@ -102,7 +102,7 @@ This sends one tiny typed choice to OpenRouter's Decisions endpoint using the pi
npm run build
```
-This proves the Vite production bundle compiles, prerenders the public pages, checks the built site's links, fragments, JSON-LD and basic accessibility with `scripts/check-site.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment.
+This proves the Vite production bundle compiles, prerenders the public pages, checks the built site's links, fragments, JSON-LD and basic accessibility with `scripts/check-site.mjs`, and then walks the emitted Vite manifest to reject feature-only PDF, DOCX, chart, ReactFlow, export, and Admin modules, the Spanish and Hungarian catalogs, and the signed-in layout from the static `index.html` dependency graph. It does not prove authentication, database behavior, provider access, or production deployment.
## Production service-worker smoke
@@ -110,7 +110,7 @@ This proves the Vite production bundle compiles, prerenders the public pages, ch
npm run test:sw
```
-Run this after `npm run build`. It serves `dist/` through Vite preview and proves that a v2 worker cache upgrades to v3 and claims the page, a first-visited route and its display logo reload offline, an unvisited route shows recovery UI, an intent-prefetched route opens offline, and private API paths do not enter Cache Storage. It uses a stub session and blocks external requests, so it is not authenticated backend or deployed-host proof.
+Run this after `npm run build`. It serves `dist/` through Vite preview (which applies the `vercel.json` rewrites, so app routes get `app-shell.html` as in production) and proves that a v2 worker cache upgrades to this build's caches and claims the page; that after one visit to `/app`, Home, Library, History, Settings and Timer open offline as fresh navigations, the timer runs, the offline indicator shows, and switching to Spanish works; that a visited public page answers offline only with itself and an unvisited one gets the offline page; that an unvisited route shows recovery UI and an intent-prefetched one opens; and that private API paths and `/auth/callback` do not enter Cache Storage. It uses a stub session and blocks external requests, so it is not authenticated backend or deployed-host proof.
## Credential-free browser gate
diff --git a/public/sw.js b/public/sw.js
index d548b6463..c73b926ba 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,10 +1,19 @@
-const CACHE_VERSION = 'debate-lab-v5';
+// Filled in by the build (serviceWorkerPrecache in vite.config.ts): an id for
+// this build's caches, and the hashed scripts, styles and fonts that open the
+// app shell and its main pages offline and switch language there. Empty in
+// development, where the worker is never registered.
+const BUILD = { id: 'dev', assets: [] };
+
+const CACHE_PREFIX = 'debate-lab-v6';
+const CACHE_VERSION = `${CACHE_PREFIX}-${BUILD.id}`;
const APP_SHELL_CACHE = `${CACHE_VERSION}-shell`;
const RUNTIME_CACHE = `${CACHE_VERSION}-runtime`;
+const PAGES_CACHE = `${CACHE_VERSION}-pages`;
+// Unhashed files, fetched past the HTTP cache on every install. The two HTML
+// documents are required; an install that cannot store them fails and retries.
+const REQUIRED_DOCUMENTS = ['/', '/app-shell.html'];
const APP_SHELL = [
- '/',
- '/index.html',
'/theme-init.js',
'/manifest.webmanifest',
'/favicon.ico',
@@ -16,25 +25,53 @@ const APP_SHELL = [
'/icon-maskable-512.png',
];
-const API_PATH_PREFIXES = [
+// vercel.json serves app-shell.html at these paths, so offline the worker does too.
+// (/expocit/sources is rewritten too, but its prerendered file is served first,
+// so it is a public page here.)
+const APP_SHELL_PATHS = [/^\/app(?:\/|$)/, /^\/login\/?$/, /^\/share\//];
+
+const NETWORK_ONLY_PREFIXES = [
+ // Supabase, also when a preview proxies it on this origin.
'/auth/v1',
'/rest/v1',
'/functions/v1',
'/storage/v1',
+ '/__staging-supabase/',
+ // The sign-in callback exchanges a one-time code; it must always reach the server.
+ '/auth/callback',
+ // Vercel Analytics and Speed Insights update their own scripts.
+ '/_vercel/',
];
+function htmlMislabeledAsAsset(response, request) {
+ const type = (response.headers.get('content-type') || '').toLowerCase();
+ if (!type.includes('text/html')) return false;
+ const path = new URL(request.url).pathname;
+ return request.destination === 'script'
+ || request.destination === 'style'
+ || path.startsWith('/assets/');
+}
+
+async function store(cache, url, init) {
+ const request = new Request(url, init);
+ const response = await fetch(request);
+ if (!response.ok || response.redirected || htmlMislabeledAsAsset(response, request)) {
+ throw new Error(`Could not precache ${url}`);
+ }
+ await cache.put(url, response);
+}
+
self.addEventListener('install', (event) => {
- event.waitUntil(
- caches
- .open(APP_SHELL_CACHE)
- .then((cache) => Promise.allSettled(
- APP_SHELL.map(async (url) => {
- const response = await fetch(url, { cache: 'reload' });
- if (response.ok) await cache.put(url, response);
- }),
- ))
- .catch(() => undefined),
- );
+ event.waitUntil((async () => {
+ const cache = await caches.open(APP_SHELL_CACHE);
+ // Hashed assets never change, so the HTTP cache may answer them; the page
+ // that registered this worker has usually just downloaded most of them.
+ await Promise.all([
+ ...REQUIRED_DOCUMENTS.map((url) => store(cache, url, { cache: 'reload' })),
+ ...BUILD.assets.map((url) => store(cache, url)),
+ ]);
+ await Promise.allSettled(APP_SHELL.map((url) => store(cache, url, { cache: 'reload' })));
+ })());
});
self.addEventListener('activate', (event) => {
@@ -42,23 +79,15 @@ self.addEventListener('activate', (event) => {
caches
.keys()
.then((keys) => Promise.all(
+ // Earlier builds' caches: their pages reference assets this build no longer has.
keys
- .filter((key) => !key.startsWith(CACHE_VERSION))
+ .filter((key) => !key.startsWith(`${CACHE_VERSION}-`))
.map((key) => caches.delete(key)),
))
.then(() => self.clients.claim()),
);
});
-function htmlMislabeledAsAsset(response, request) {
- const type = (response.headers.get('content-type') || '').toLowerCase();
- if (!type.includes('text/html')) return false;
- const path = new URL(request.url).pathname;
- return request.destination === 'script'
- || request.destination === 'style'
- || path.startsWith('/assets/');
-}
-
async function cacheFirst(request) {
const cached = await caches.match(request, { ignoreVary: true });
if (cached && !htmlMislabeledAsAsset(cached, request)) return cached;
@@ -78,19 +107,43 @@ async function cacheFirst(request) {
}
}
-async function navigationFallback(request) {
+function offlinePage() {
+ return new Response('
Offline
Offline
DebateLab is unavailable until your connection returns.
', {
+ status: 503,
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ });
+}
+
+/** App routes: the network, or offline the shell this build precached. */
+async function appShellNavigation(request) {
+ try {
+ return await fetch(request);
+ } catch {
+ return (await caches.match('/app-shell.html', { cacheName: APP_SHELL_CACHE })) || offlinePage();
+ }
+}
+
+/**
+ * Public pages are prerendered, each with its own HTML. Each is stored under
+ * its own path, so offline a page is only ever answered with itself.
+ */
+async function pageNavigation(event) {
+ const url = new URL(event.request.url);
+ const key = `${url.origin}${url.pathname}`;
try {
- const response = await fetch(request);
- if (response.ok) {
- const cache = await caches.open(APP_SHELL_CACHE);
- cache.put('/index.html', response.clone());
+ const response = await fetch(event.request);
+ // A redirected response cannot answer a navigation later.
+ if (response.ok && !response.redirected) {
+ // Copied now: the page starts reading the original as soon as it is returned.
+ const copy = response.clone();
+ event.waitUntil(caches.open(PAGES_CACHE).then((cache) => cache.put(key, copy)));
}
return response;
} catch {
- return (await caches.match('/index.html')) ||
- new Response('Offline
Offline
DebateLab is unavailable until your connection returns.
', {
- headers: { 'Content-Type': 'text/html; charset=utf-8' },
- });
+ // The copy saved on the last visit, before the one stored at install.
+ return (await caches.match(key, { cacheName: PAGES_CACHE, ignoreVary: true }))
+ || (await caches.match(key, { ignoreVary: true }))
+ || offlinePage();
}
}
@@ -115,13 +168,17 @@ self.addEventListener('fetch', (event) => {
const url = new URL(request.url);
if (url.origin !== self.location.origin) return;
- if (API_PATH_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
+ if (NETWORK_ONLY_PREFIXES.some((prefix) => url.pathname.startsWith(prefix))) return;
// Embeds are framed on other sites: never store one as the app shell, and
// never answer one with the cached shell, which refuses to be framed.
if (url.pathname.startsWith('/embed/')) return;
if (request.mode === 'navigate') {
- event.respondWith(navigationFallback(request));
+ if (APP_SHELL_PATHS.some((pattern) => pattern.test(url.pathname))) {
+ event.respondWith(appShellNavigation(request));
+ } else {
+ event.respondWith(pageNavigation(event));
+ }
return;
}
diff --git a/scripts/check-build-boundaries.mjs b/scripts/check-build-boundaries.mjs
index d71f15923..b86b7efc8 100644
--- a/scripts/check-build-boundaries.mjs
+++ b/scripts/check-build-boundaries.mjs
@@ -21,7 +21,9 @@ function staticGraphFrom(file) {
return graph;
}
-const forbiddenFeature = /(?:\/src\/pages\/AdminPage\.tsx$|\/src\/lib\/(?:pdf-export-utils|export-utils)\.ts$|\/node_modules\/(?:jspdf|jspdf-autotable|docx|recharts|reactflow)\/)/i;
+// Also kept out: the Spanish and Hungarian catalogs (loaded only when chosen)
+// and the signed-in layout (loaded only under /app).
+const forbiddenFeature = /(?:\/src\/pages\/AdminPage\.tsx$|\/src\/lib\/(?:pdf-export-utils|export-utils)\.ts$|\/node_modules\/(?:jspdf|jspdf-autotable|docx|recharts|reactflow)\/|\/src\/locales\/(?:tools\/)?(?:es|hu)\.json$|\/src\/components\/layout\/AppLayout\.tsx$)/i;
const initialRoutes = [
{ key: entryKey, pageModules: new Set() },
...[
diff --git a/scripts/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs
index 34b2ae2ff..07ab1eb46 100644
--- a/scripts/test-service-worker-offline.mjs
+++ b/scripts/test-service-worker-offline.mjs
@@ -87,11 +87,48 @@ async function cachePaths(page) {
)).flat());
}
+/**
+ * Polls an async check in the page until it is true. page.waitForFunction
+ * treats the Promise an async function returns as truthy and passes at once.
+ */
+async function waitFor(page, check, arg, label) {
+ const started = Date.now();
+ while (Date.now() - started < CONTENT_TIMEOUT_MS) {
+ try {
+ if (await page.evaluate(check, arg)) return;
+ } catch {
+ // A navigation replaced the page mid-check; try again.
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+ throw new Error(`Timed out waiting for ${label}`);
+}
+
+const CACHE_KEY = /^debate-lab-v6-[0-9a-f]{12}-(?:shell|runtime|pages)$/;
+
+async function cacheKeys(page) {
+ return page.evaluate(async () => caches.keys());
+}
+
async function main() {
const manifest = JSON.parse(await readFile('dist/.vite/manifest.json', 'utf8'));
- const entryPath = `/${Object.values(manifest).find((entry) => entry.isEntry).file}`;
- const homePath = `/${manifest['src/pages/HomePage.tsx'].file}`;
- const attackPath = `/${manifest['src/pages/AttackPage.tsx'].file}`;
+ const en = JSON.parse(await readFile('src/locales/en.json', 'utf8'));
+ const es = JSON.parse(await readFile('src/locales/es.json', 'utf8'));
+ const chunk = (key) => `/${manifest[key].file}`;
+ const entryPath = chunk('index.html');
+ const attackPath = chunk('src/pages/AttackPage.tsx');
+ // Precached at install: opened offline after one visit to /app, without visiting them first.
+ const precachedPaths = [
+ 'src/pages/LandingPage.tsx',
+ 'src/components/layout/AppLayout.tsx',
+ 'src/pages/HomePage.tsx',
+ 'src/pages/LibraryPage.tsx',
+ 'src/pages/HistoryPage.tsx',
+ 'src/pages/TimerPage.tsx',
+ 'src/pages/SettingsPage.tsx',
+ 'src/locales/es.json',
+ 'src/locales/hu.json',
+ ].map(chunk);
const port = await freePort();
const origin = `http://127.0.0.1:${port}`;
const server = spawn('npm', ['run', 'preview', '--', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], {
@@ -123,6 +160,7 @@ async function main() {
return url.origin === origin || url.protocol === 'data:' ? route.continue() : route.abort();
});
};
+ const homeHeading = (page) => page.locator('main h1', { hasText: en.home.heading });
const upgradePage = await context.newPage();
await prepare(upgradePage);
@@ -134,20 +172,20 @@ async function main() {
}));
await upgradePage.goto(origin, { waitUntil: 'load' });
await upgradePage.evaluate(async () => navigator.serviceWorker.ready);
- await upgradePage.waitForFunction(async () => {
+ await waitFor(upgradePage, async () => {
const registration = await navigator.serviceWorker.getRegistration();
return registration?.active?.state === 'activated' &&
navigator.serviceWorker.controller !== null &&
(await caches.keys()).includes('debate-lab-v2-shell');
- });
+ }, undefined, 'the v2 worker to activate');
await context.unroute(`${origin}/sw.js`);
await upgradePage.evaluate(async () => {
const registration = await navigator.serviceWorker.getRegistration();
await registration?.update();
});
- await upgradePage.waitForFunction(async () =>
+ await waitFor(upgradePage, async () =>
Boolean((await navigator.serviceWorker.getRegistration())?.waiting),
- undefined, { timeout: CONTENT_TIMEOUT_MS });
+ undefined, 'the new worker to install');
await upgradePage.close();
await new Promise((resolve) => setTimeout(resolve, 1_000));
@@ -155,59 +193,127 @@ async function main() {
await prepare(activatedPage);
await activatedPage.goto(origin, { waitUntil: 'load' });
await activatedPage.evaluate(async () => navigator.serviceWorker.ready);
- await activatedPage.waitForFunction(async () => {
+ await waitFor(activatedPage, async () => {
const keys = await caches.keys();
return navigator.serviceWorker.controller !== null &&
- keys.includes('debate-lab-v5-shell') &&
+ keys.some((key) => /^debate-lab-v6-[0-9a-f]{12}-shell$/.test(key)) &&
!keys.some((key) => key.startsWith('debate-lab-v2'));
- }, undefined, { timeout: CONTENT_TIMEOUT_MS });
+ }, undefined, 'the new worker to replace the v2 caches');
+ const upgradedKeys = await cacheKeys(activatedPage);
+ assert.ok(upgradedKeys.every((key) => CACHE_KEY.test(key)), `unexpected caches after upgrade: ${upgradedKeys}`);
await activatedPage.close();
await context.close();
context = await createContext();
+ // One online visit to /app installs the worker; a public page visited after it is kept too.
const page = await context.newPage();
await prepare(page);
await page.goto(`${origin}/app`, { waitUntil: 'load' });
- await page.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await homeHeading(page).waitFor({ timeout: CONTENT_TIMEOUT_MS });
await page.evaluate(async () => navigator.serviceWorker.ready);
- await page.waitForFunction(async (paths) => {
+ await page.waitForFunction(() => navigator.serviceWorker.controller !== null, undefined, { timeout: CONTENT_TIMEOUT_MS });
+ await page.goto(`${origin}/terms`, { waitUntil: 'load' });
+ await page.locator('main h1', { hasText: en.termsPage.title }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await page.goto(`${origin}/app`, { waitUntil: 'load' });
+ await homeHeading(page).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await waitFor(page, async (paths) => {
const urls = (await Promise.all((await caches.keys()).map(async (key) =>
(await (await caches.open(key)).keys()).map((request) => new URL(request.url).pathname)
))).flat();
return paths.every((path) => urls.includes(path));
- }, [entryPath, homePath, '/favicon-72.png'], { timeout: CONTENT_TIMEOUT_MS });
+ }, [entryPath, ...precachedPaths, '/app-shell.html', '/terms', '/favicon-72.png'], 'the precache and the visited Terms page');
+ // Installed up front, not picked up by browsing: they sit in this build's shell cache.
+ const shellCache = await page.evaluate(async () => {
+ const key = (await caches.keys()).find((name) => name.endsWith('-shell'));
+ return key ? (await (await caches.open(key)).keys()).map((request) => new URL(request.url).pathname) : [];
+ });
+ const notPrecached = [entryPath, ...precachedPaths, '/', '/app-shell.html'].filter((path) => !shellCache.includes(path));
+ assert.deepEqual(notPrecached, [], 'expected in the install-time precache');
const firstVisitCache = await cachePaths(page);
assert.equal(firstVisitCache.includes(attackPath), false, 'unvisited Attack chunk was cached');
+ assert.equal(firstVisitCache.some((path) => path.startsWith('/auth/')), false, 'the sign-in callback entered Cache Storage');
+ assert.equal(firstVisitCache.includes('/app'), false, 'an app route was stored under its own URL instead of the shell');
+
await context.setOffline(true);
await page.reload({ waitUntil: 'domcontentloaded' });
- await page.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await homeHeading(page).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await page.getByRole('status').filter({ hasText: en.offlineIndicator.label }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
const logo = page.locator('header a[href="/app"] img');
assert.equal(await logo.getAttribute('src'), '/favicon-72.png');
assert.ok(await logo.evaluate((image) => image.naturalWidth > 0), 'offline logo did not load');
await page.locator('main [data-tour="mode-attack"]').click();
- await page.getByRole('heading', { name: 'Unable to load this page' }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await page.getByRole('heading', { name: en.routeError.heading }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+
+ // Each precached page opens offline as a fresh navigation, never visited before.
+ for (const [path, title] of [
+ ['/app/library', en.libraryPage.title],
+ ['/app/history', en.historyPage.title],
+ ['/app/settings', en.settings.title],
+ ['/app/timer', en.timer.title],
+ ]) {
+ await page.goto(`${origin}${path}`, { waitUntil: 'domcontentloaded' });
+ await page.locator('main h1', { hasText: title }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ }
+
+ // The timer runs offline.
+ const clock = page.getByRole('timer', { name: en.timer.displayLabel });
+ const before = await clock.textContent();
+ await page.getByRole('button', { name: en.timer.start, exact: true }).click();
+ await page.waitForFunction(([name, start]) =>
+ document.querySelector(`[role="timer"][aria-label="${name}"]`)?.textContent !== start,
+ [en.timer.displayLabel, before], { timeout: CONTENT_TIMEOUT_MS });
+
+ // Switching language offline uses the precached Spanish strings.
+ await page.getByRole('button', { name: en.langSwitcher.label }).click();
+ await page.getByRole('radiogroup', { name: en.langSwitcher.uiLanguage }).getByRole('radio', { name: 'Español' }).click();
+ await page.locator('main h1', { hasText: es.timer.title }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ assert.equal(await page.evaluate(() => document.documentElement.lang), 'es');
+
+ // Public pages answer only with themselves: Terms was visited, Privacy was not.
+ await page.goto(`${origin}/terms`, { waitUntil: 'domcontentloaded' });
+ assert.equal(await page.locator('#root').getAttribute('data-prerendered'), null);
+ await page.locator('main h1', { hasText: es.termsPage.title }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await page.goto(`${origin}/privacy`, { waitUntil: 'domcontentloaded' });
+ await page.getByRole('heading', { name: 'Offline', exact: true }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ // The landing page was never visited, but its HTML and code were precached:
+ // React takes it over offline instead of replacing it with the error page.
+ await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' });
+ await waitFor(page, () => document.getElementById('root')?.dataset.prerendered === undefined, undefined, 'React to take over the landing page');
+ await page.locator('h1', { hasText: es.hero.headline }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ assert.equal(await page.getByRole('heading', { name: es.routeError.heading }).count(), 0, 'the landing page turned into the error page offline');
+ await page.goto(`${origin}/app`, { waitUntil: 'domcontentloaded' });
+ await page.locator('main h1', { hasText: es.home.heading }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await page.close();
await context.setOffline(false);
const prefetchedPage = await context.newPage();
await prepare(prefetchedPage);
+ await prefetchedPage.addInitScript(() => {
+ localStorage.setItem('debate-trainer-settings', JSON.stringify({ state: { language: 'en', outputLanguage: 'en', theme: 'system' }, version: 2 }));
+ });
await prefetchedPage.goto(`${origin}/app`, { waitUntil: 'load' });
- await prefetchedPage.locator('main h1', { hasText: 'What are you working on?' }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ await homeHeading(prefetchedPage).waitFor({ timeout: CONTENT_TIMEOUT_MS });
const attackLink = prefetchedPage.locator('main [data-tour="mode-attack"]');
await attackLink.hover();
- await prefetchedPage.waitForFunction(async (path) => {
+ await waitFor(prefetchedPage, async (path) => {
const urls = (await Promise.all((await caches.keys()).map(async (key) =>
(await (await caches.open(key)).keys()).map((request) => new URL(request.url).pathname)
))).flat();
return urls.includes(path);
- }, attackPath, { timeout: CONTENT_TIMEOUT_MS });
+ }, attackPath, 'the hovered Attack chunk to be cached');
await prefetchedPage.waitForTimeout(500);
await context.setOffline(true);
await attackLink.click();
await prefetchedPage.getByRole('heading', { name: 'Attack', exact: true }).waitFor({ timeout: CONTENT_TIMEOUT_MS });
+ // The sign-in callback always goes to the network and is never stored.
+ await context.setOffline(false);
+ await prefetchedPage.goto(`${origin}/auth/callback?code=t11-offline-test`, { waitUntil: 'load' });
+ await prefetchedPage.waitForTimeout(1_000);
const finalCache = await cachePaths(prefetchedPage);
+ assert.equal(finalCache.some((path) => path.startsWith('/auth/')), false, 'the sign-in callback entered Cache Storage');
assert.deepEqual(
finalCache.filter((path) => /^\/(?:auth|rest|functions|storage)\/v1/.test(path)),
[],
@@ -215,7 +321,12 @@ async function main() {
);
console.log(JSON.stringify({
upgradeActivated: true,
- firstVisitedRouteOffline: true,
+ precachedRoutesOffline: ['/', '/app', '/app/library', '/app/history', '/app/settings', '/app/timer'],
+ authCallbackNeverCached: true,
+ timerRunsOffline: true,
+ languageSwitchOffline: true,
+ publicPagesKeepTheirOwnHtml: true,
+ offlineIndicator: true,
unvisitedRouteShowsRecovery: true,
intentPrefetchedRouteOffline: true,
offlineLogo: true,
diff --git a/src/App.tsx b/src/App.tsx
index 05ae4116c..8da96a23c 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,7 +5,6 @@ import { TooltipProvider } from "@/components/ui/tooltip";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { BrowserRouter, Routes, Route, useLocation, useNavigationType } from "react-router-dom";
import { MotionConfig } from "framer-motion";
-import { AppLayout } from "@/components/layout/AppLayout";
import { AuthProvider, useAuth } from "@/lib/supabase/AuthProvider";
import { RequireAuth } from "@/components/auth/RequireAuth";
import { AccountQueryCache } from "@/components/auth/AccountQueryCache";
@@ -15,7 +14,7 @@ import { hasCompletedOnboarding, useOnboardingStore } from "@/hooks/use-onboardi
import { OfflineIndicator } from "@/components/OfflineIndicator";
import { StagingBanner } from "@/components/StagingBanner";
import { captureException, captureProductEvent, toTelemetrySafeError } from "@/lib/observability";
-import { routeLoaders, type RouteLoader } from "@/lib/route-modules";
+import { appLayoutLoader, preloadStartupRoute, routeLoaders, type RouteLoader } from "@/lib/route-modules";
import type { PrerenderedRoute } from "@/lib/seo/pages";
import { usePrivateRouteHead } from "@/lib/seo/use-page-head";
import { RouteSkeleton, type RouteSkeletonVariant } from "@/components/loading/RouteSkeleton";
@@ -69,10 +68,25 @@ const DebateTimerToolPage = prerenderedPage("/tools/debate-timer");
const MotionGeneratorToolPage = prerenderedPage("/tools/debate-motion-generator");
const FlowSheetToolPage = prerenderedPage("/tools/debate-flow-sheet");
const SpeechTimeCalculatorToolPage = prerenderedPage("/tools/speech-time-calculator");
+// Public pages never load the signed-in header, navigation and debate dialogs.
+// Arriving from sign-in, the page being opened loads alongside the layout
+// instead of after it.
+const AppLayout = lazy(() => {
+ void preloadStartupRoute(window.location.pathname);
+ return appLayoutLoader();
+});
const OnboardingTour = lazy(() => import("@/components/features/onboarding/OnboardingTour")
.then(({ OnboardingTour: Tour }) => ({ default: Tour })));
const queryClient = new QueryClient();
+// TanStack Query refetches after an `offline` then `online` pair, but a page
+// opened offline never sees `offline`. Retry whatever failed once the
+// connection returns, such as Settings' admin-role check.
+if (typeof window !== "undefined") {
+ window.addEventListener("online", () => {
+ void queryClient.refetchQueries({ type: "active", predicate: (query) => query.state.status === "error" });
+ });
+}
type ToolName =
| "generate"
@@ -215,17 +229,37 @@ export function ToolRouteAnalytics(): null {
return null;
}
+function useRouteLabel(pathname: string): { fallback: RouteFallback; label: string } {
+ const { t } = useTranslation();
+ const fallback = getRouteFallback(pathname);
+ return { fallback, label: fallback.labelKey ? t(fallback.labelKey, fallback.label) : fallback.label };
+}
+
+/** The skeleton of the page being opened, shown while its session, layout or code loads. */
+function PendingRoute() {
+ const { pathname } = useLocation();
+ const { t } = useTranslation();
+ const { fallback, label } = useRouteLabel(pathname);
+ return ;
+}
+
+/** The pending page before the signed-in layout exists, so it is the page's one . */
+function PendingAppRoute() {
+ return (
+
+
+
+ );
+}
+
function RouteContent({ children }: { children: ReactNode }) {
const location = useLocation();
- const { t } = useTranslation();
- const fallback = getRouteFallback(location.pathname);
- const label = fallback.labelKey ? t(fallback.labelKey, fallback.label) : fallback.label;
- const statusText = t("common.openingPage", { page: label });
+ const { label } = useRouteLabel(location.pathname);
usePrivateRouteHead(location.pathname, label);
return (
- }>
+ }>
{children}
@@ -302,10 +336,12 @@ function AppRoutes() {
{/* Protected App Routes */}
+ }>
-
+ }>
+
+
}
>
diff --git a/src/components/auth/RequireAuth.tsx b/src/components/auth/RequireAuth.tsx
index e1a8c66da..c26efd4e1 100644
--- a/src/components/auth/RequireAuth.tsx
+++ b/src/components/auth/RequireAuth.tsx
@@ -1,11 +1,11 @@
-import { useEffect, useRef } from 'react';
+import { useEffect, useRef, type ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useAuth } from '@/lib/supabase/AuthProvider';
import { Loader2 } from 'lucide-react';
import { consumeWorkspaceInvite, stashWorkspaceInvite } from '@/lib/workspace/invite-handoff';
-export function RequireAuth({ children }: { children: React.ReactNode }) {
+export function RequireAuth({ children, fallback }: { children: ReactNode; fallback?: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
const { t } = useTranslation();
@@ -17,6 +17,8 @@ export function RequireAuth({ children }: { children: React.ReactNode }) {
}, [user]);
if (loading) {
+ // The caller's fallback brings its own .
+ if (fallback) return <>{fallback}>;
// Signed-out views have no AppLayout, so this is the page's one .
return (
diff --git a/src/components/features/subscription/PricingModal.tsx b/src/components/features/subscription/PricingModal.tsx
index c732dd18f..b5010b9fe 100644
--- a/src/components/features/subscription/PricingModal.tsx
+++ b/src/components/features/subscription/PricingModal.tsx
@@ -9,6 +9,7 @@ import { redirectToCheckout, redeemPromoCode } from '@/lib/stripe';
import { useToast } from '@/hooks/use-toast';
import { useSubscription } from '@/hooks/use-subscription';
import { useExternalRedirectPending } from '@/hooks/use-external-redirect-pending';
+import { useOnlineStatus } from '@/hooks/use-online-status';
interface PricingModalProps {
open: boolean;
@@ -23,6 +24,8 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod
const { pending: checkoutLoading, run: runCheckout, reset: resetCheckout } = useExternalRedirectPending();
const [showPromo, setShowPromo] = useState(false);
const { toast } = useToast();
+ // Checkout needs the network; offline the button would only fail.
+ const online = useOnlineStatus();
// The modal stays mounted while closed, so reopening it must not show a
// button still waiting on a checkout the user already left.
@@ -143,13 +146,19 @@ export function PricingModal({ open, onOpenChange, onPromoRedeemed }: PricingMod
+ {!online && (
+
+ {t('pricingModal.offlineUpgrade')}
+
+ )}
diff --git a/src/components/landing/Reveal.tsx b/src/components/landing/Reveal.tsx
index b02913495..8e4ef7e6c 100644
--- a/src/components/landing/Reveal.tsx
+++ b/src/components/landing/Reveal.tsx
@@ -1,10 +1,5 @@
-import { useState, type ReactNode } from "react";
-import { motion, useReducedMotion } from "framer-motion";
-
-// Full transform strings run on the compositor; the x/y shorthands animate on the main thread.
-const HIDDEN = { opacity: 0, transform: "translateY(12px)" };
-const SHOWN = { opacity: 1, transform: "translateY(0px)" };
-const EASE_OUT_STRONG = [0.23, 1, 0.32, 1] as const;
+import { useEffect, useRef, useState, type ReactNode } from "react";
+import { cn } from "@/lib/utils";
interface RevealProps {
children: ReactNode;
@@ -18,27 +13,41 @@ interface RevealProps {
/**
* The one landing reveal: a short fade-up the first time content scrolls into view.
* Hover effects belong on an inner element, never on this one, so the two never fight.
+ * A CSS transition (.landing-reveal), so the landing page loads no animation library.
*/
export function Reveal({ children, className, delay = 0, onLoad = false }: RevealProps) {
- const reduceMotion = useReducedMotion();
// Prerendered HTML already shows this content. Running the entrance on the
// render that replaces it would hide it for a moment, so only later mounts animate.
const [prerendered] = useState(() => typeof document === "undefined" || document.getElementById("root")?.dataset.prerendered !== undefined);
- if (reduceMotion || prerendered) return
{children}
;
- const transition = { duration: 0.35, ease: EASE_OUT_STRONG, delay: Math.min(delay, 0.2) };
- return onLoad ? (
-
- {children}
-
- ) : (
- (null);
+
+ useEffect(() => {
+ const element = ref.current;
+ if (shown || !element) return;
+ if (onLoad || typeof IntersectionObserver === "undefined") {
+ // One frame at the hidden state first, so the transition has somewhere to start.
+ const frame = requestAnimationFrame(() => setShown(true));
+ return () => cancelAnimationFrame(frame);
+ }
+ const observer = new IntersectionObserver((entries) => {
+ if (!entries.some((entry) => entry.isIntersecting)) return;
+ setShown(true);
+ observer.disconnect();
+ }, { rootMargin: "0px 0px -15% 0px" });
+ observer.observe(element);
+ return () => observer.disconnect();
+ }, [onLoad, shown]);
+
+ if (prerendered) return
{children}
;
+ return (
+
{children}
-
+
);
}
diff --git a/src/i18n.ts b/src/i18n.ts
index f6811b338..bc9cdc80f 100644
--- a/src/i18n.ts
+++ b/src/i18n.ts
@@ -1,20 +1,23 @@
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import en from './locales/en.json';
-import es from './locales/es.json';
-import hu from './locales/hu.json';
import toolsEn from './locales/tools/en.json';
-import toolsEs from './locales/tools/es.json';
-import toolsHu from './locales/tools/hu.json';
import { type AppLanguage } from './lib/languages';
+type Catalogs = [{ default: object }, { default: object }];
+
+// English ships in the entry: it is the default and every other language's
+// fallback. Spanish and Hungarian are their own chunks, fetched only when chosen.
+const CATALOGS: Record, () => Promise> = {
+ es: () => Promise.all([import('./locales/es.json'), import('./locales/tools/es.json')]),
+ hu: () => Promise.all([import('./locales/hu.json'), import('./locales/tools/hu.json')]),
+};
+
// Bundled resources make init synchronous; the returned promise cannot reject.
void i18n.use(initReactI18next).init({
// The free /tools pages keep their strings in their own namespace.
resources: {
en: { translation: en, tools: toolsEn },
- es: { translation: es, tools: toolsEs },
- hu: { translation: hu, tools: toolsHu },
},
ns: ['translation', 'tools'],
defaultNS: 'translation',
@@ -23,11 +26,61 @@ void i18n.use(initReactI18next).init({
interpolation: { escapeValue: false },
});
-export async function applyAppLanguage(language: AppLanguage): Promise {
- await i18n.changeLanguage(language);
- if (typeof document === 'undefined') return;
+/** Whether the language's strings are in i18next, fetching them first if needed. */
+async function loadCatalogs(language: AppLanguage): Promise {
+ if (language === 'en' || i18n.hasResourceBundle(language, 'translation')) return true;
+ try {
+ const [app, tools] = await CATALOGS[language]();
+ i18n.addResourceBundle(language, 'translation', app.default);
+ i18n.addResourceBundle(language, 'tools', tools.default);
+ return true;
+ } catch (error) {
+ // Offline before this language was ever cached: stay on the current one.
+ console.warn(`[i18n] Could not load the ${language} strings:`, error);
+ return false;
+ }
+}
+
+/**
+ * Fetches every language's strings without switching to any of them. The
+ * flowchart recognises starter text saved in any language, so its page waits
+ * for this.
+ */
+export async function loadAllAppLanguages(): Promise {
+ await Promise.all((Object.keys(CATALOGS) as AppLanguage[]).map(loadCatalogs));
+}
+
+let requestedLanguage: AppLanguage = 'en';
+let pendingLanguage: Promise = Promise.resolve();
+
+export function applyAppLanguage(language: AppLanguage): Promise {
+ requestedLanguage = language;
+ pendingLanguage = (async () => {
+ // A later choice wins, even if this one's strings arrive after it.
+ if (!(await loadCatalogs(language)) || requestedLanguage !== language) return;
+ await i18n.changeLanguage(language);
+ if (typeof document === 'undefined') return;
+
+ document.documentElement.lang = language;
+ })();
+ return pendingLanguage;
+}
+
+// A language chosen offline, before its strings were ever cached, stays on the
+// current one; try again when the connection returns.
+if (typeof window !== 'undefined') {
+ window.addEventListener('online', () => {
+ if (i18n.language !== requestedLanguage) void applyAppLanguage(requestedLanguage);
+ });
+}
- document.documentElement.lang = language;
+/**
+ * Settles once the last requested language is showing (or has failed to load).
+ * The first render waits for it, so a Spanish or Hungarian visitor never sees
+ * English first.
+ */
+export function appLanguageReady(): Promise {
+ return pendingLanguage;
}
export default i18n;
diff --git a/src/index.css b/src/index.css
index 19a5c7e1b..45ceb6d9e 100644
--- a/src/index.css
+++ b/src/index.css
@@ -375,6 +375,21 @@ All colors MUST be HSL.
animation: motion-swap var(--motion-duration-normal) var(--motion-ease-fluid) both;
}
+ /* Landing sections fade up once as they scroll in (components/landing/Reveal.tsx).
+ Full transform strings run on the compositor. */
+ .landing-reveal {
+ opacity: 0;
+ transform: translateY(12px);
+ transition-property: opacity, transform;
+ transition-duration: 350ms;
+ transition-timing-function: var(--motion-ease-out-strong);
+ }
+
+ .landing-reveal[data-shown] {
+ opacity: 1;
+ transform: translateY(0px);
+ }
+
.motion-expand {
display: grid;
@@ -656,6 +671,10 @@ All colors MUST be HSL.
animation-name: motion-fade-out !important;
}
+ .landing-reveal {
+ transform: none;
+ }
+
/* Spinners and live indicators become a slow opacity pulse so they never look hung. */
.animate-spin,
.streaming-caret {
diff --git a/src/lib/reload-stale-chunk.ts b/src/lib/reload-stale-chunk.ts
index 82f39bd98..1b94547be 100644
--- a/src/lib/reload-stale-chunk.ts
+++ b/src/lib/reload-stale-chunk.ts
@@ -1,14 +1,19 @@
const RELOAD_PREFIX = 'debatelab-stale-chunk:';
-type ReloadTarget = Pick;
+type ReloadTarget = Pick & {
+ navigator: Pick;
+};
/**
* After a deploy, an open tab can ask for a chunk the new build no longer has.
* Reload once so the fresh document loads the new names. A second failure on
* the same path stays put, so a missing file cannot refresh the tab forever.
+ * Offline the chunk is not stale, only unreachable: a reload could replace the
+ * page with the browser's offline error, so the error stands instead.
*/
export function installStaleChunkReload(target: ReloadTarget = window): void {
target.addEventListener('vite:preloadError', (event) => {
+ if (target.navigator.onLine === false) return;
const path = target.location.pathname || '/';
const key = `${RELOAD_PREFIX}${path}`;
let alreadyReloaded = false;
diff --git a/src/lib/route-modules.ts b/src/lib/route-modules.ts
index 79350538a..33ba629d7 100644
--- a/src/lib/route-modules.ts
+++ b/src/lib/route-modules.ts
@@ -1,3 +1,5 @@
+import { loadAllAppLanguages } from "@/i18n";
+
export type RouteLoader = (() => Promise) & {
/** The module once it has loaded, so a page can render without suspending. */
loaded: () => T | undefined;
@@ -45,7 +47,9 @@ export const routeLoaders = {
"/app/library": cacheLoader(() => import("@/pages/LibraryPage")),
"/app/timer": cacheLoader(() => import("@/pages/TimerPage")),
"/app/sparring": cacheLoader(() => import("@/pages/SparringPage")),
- "/app/flowchart": cacheLoader(() => import("@/pages/FlowchartPage")),
+ // Charts saved before nodes kept their text keys hold starter text in the
+ // language they were made in; recognising it needs every catalog.
+ "/app/flowchart": cacheLoader(() => Promise.all([import("@/pages/FlowchartPage"), loadAllAppLanguages()]).then(([page]) => page)),
"/app/settings": cacheLoader(() => import("@/pages/SettingsPage")),
"/app/admin": cacheLoader(() => import("@/pages/AdminPage")),
"/tools": cacheLoader(() => import("@/pages/tools/ToolsHubPage")),
@@ -56,6 +60,10 @@ export const routeLoaders = {
"*": cacheLoader(() => import("@/pages/NotFound")),
} as const;
+/** The signed-in header and navigation around every /app page. */
+export const appLayoutLoader = cacheLoader(() => import("@/components/layout/AppLayout")
+ .then(({ AppLayout }) => ({ default: AppLayout })));
+
type RoutePath = keyof typeof routeLoaders;
function isRoutePath(pathname: string): pathname is RoutePath {
@@ -65,3 +73,16 @@ function isRoutePath(pathname: string): pathname is RoutePath {
export function preloadRoute(pathname: string): Promise | undefined {
return isRoutePath(pathname) ? routeLoaders[pathname]() : undefined;
}
+
+/**
+ * Starts fetching the page being opened (and the signed-in layout under /app)
+ * while the entry is still starting up, instead of when React first reaches it.
+ */
+export function preloadStartupRoute(pathname: string): Promise | undefined {
+ const normalized = pathname.length > 1 ? pathname.replace(/\/+$/, "") : pathname;
+ if (normalized === "/app" || normalized.startsWith("/app/")) void appLayoutLoader().catch((): void => undefined);
+ const page = preloadRoute(normalized);
+ // A failed load is reported by the route's own lazy load when it renders.
+ page?.catch((): void => undefined);
+ return page;
+}
diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts
index e0d4edbbb..d324110b6 100644
--- a/src/lib/supabase/client.ts
+++ b/src/lib/supabase/client.ts
@@ -6,6 +6,7 @@ import {
} from '@supabase/supabase-js';
import { e2eRefusesProductionRequest } from './e2e-guard';
import { getActiveEnv, SUPABASE_URL, SUPABASE_ANON_KEY } from './env';
+import { STAGING_PREVIEW_PROXY_PATH } from './projects';
import { verifySessionAfterRejection } from './session-expiry';
export { SUPABASE_URL };
@@ -40,6 +41,23 @@ function requestUrl(input: RequestInfo | URL): string {
return input.url;
}
+/**
+ * A Supabase read whose failure supabase-js would retry three more times (1, 2
+ * and 4 seconds apart). Offline that turned every query into a burst of
+ * doomed requests. Auth is left out: its own client decides how to retry.
+ */
+function isRetriedRead(url: string, method: string): boolean {
+ if (method !== 'GET' && method !== 'HEAD') return false;
+ try {
+ // `npm run dev:preview` reaches Supabase through a proxy path on its own origin.
+ const fullPath = new URL(url, SUPABASE_URL).pathname;
+ const path = fullPath.startsWith(`${STAGING_PREVIEW_PROXY_PATH}/`) ? fullPath.slice(STAGING_PREVIEW_PROXY_PATH.length) : fullPath;
+ return path.startsWith('/rest/v1/') || path.startsWith('/functions/v1/') || path.startsWith('/storage/v1/');
+ } catch {
+ return false;
+ }
+}
+
// A signed-in request the server refused may mean a revoked session. supabase-js
// holds its session lock while some of these requests run, so the check (which
// takes that lock) is queued for after the request instead of run inside it.
@@ -54,8 +72,18 @@ const watchedFetch: typeof fetch = async (input, init) => {
headers: { "content-type": "application/json" },
});
}
- const response = await globalThis.fetch(input, init);
const url = requestUrl(input);
+ const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase();
+ // Every request is sent, so a browser that wrongly reports offline still works.
+ // A read that fails while the browser is offline is ended as an abort, the one
+ // failure postgrest-js does not retry.
+ const response = await globalThis.fetch(input, init).catch((error: unknown) => {
+ const aborted = error instanceof Error && error.name === 'AbortError';
+ if (!aborted && navigator.onLine === false && isRetriedRead(url, method)) {
+ throw new DOMException('Offline: the request was not retried.', 'AbortError');
+ }
+ throw error;
+ });
if (activeAccessToken && isAuthRejection(url, response.status)) {
let userLookup = false;
try {
diff --git a/src/locales/en.json b/src/locales/en.json
index 5cd9d3f8f..22e9629ee 100644
--- a/src/locales/en.json
+++ b/src/locales/en.json
@@ -1445,6 +1445,7 @@
"proFeature5": "All debate features",
"redirecting": "Redirecting...",
"upgradeNow": "Upgrade Now",
+ "offlineUpgrade": "You are offline. Connect to the internet to upgrade.",
"applying": "Applying...",
"apply": "Apply",
"promoCode": "Have a promo code?",
diff --git a/src/locales/es.json b/src/locales/es.json
index 089dd2a80..10fa55c55 100644
--- a/src/locales/es.json
+++ b/src/locales/es.json
@@ -1445,6 +1445,7 @@
"proFeature5": "Todas las funciones de debate",
"redirecting": "Redirigiendo...",
"upgradeNow": "Actualizar ahora",
+ "offlineUpgrade": "Estás sin conexión. Conéctate a internet para mejorar tu plan.",
"applying": "Aplicando...",
"apply": "Aplicar",
"promoCode": "¿Tienes un código promocional?",
diff --git a/src/locales/hu.json b/src/locales/hu.json
index 8ed1c115f..9e9b349b0 100644
--- a/src/locales/hu.json
+++ b/src/locales/hu.json
@@ -1445,6 +1445,7 @@
"proFeature5": "Minden vitafunkció",
"redirecting": "Átirányítás...",
"upgradeNow": "Frissítés most",
+ "offlineUpgrade": "Nincs internetkapcsolat. A váltáshoz csatlakozz az internethez.",
"applying": "Alkalmazás...",
"apply": "Alkalmaz",
"promoCode": "Van promóciós kódod?",
diff --git a/src/main.tsx b/src/main.tsx
index 30f7afe46..233c5f727 100644
--- a/src/main.tsx
+++ b/src/main.tsx
@@ -2,13 +2,13 @@ import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import App from "./App.tsx";
import { WebTelemetry } from "./components/WebTelemetry";
-import "./i18n";
+import { appLanguageReady } from "./i18n";
import "@fontsource-variable/inter";
import "./index.css";
import { registerServiceWorker } from "./register-service-worker";
import { installStaleChunkReload } from "./lib/reload-stale-chunk";
import { initializeObservability } from "./lib/observability";
-import { preloadRoute } from "./lib/route-modules";
+import { preloadStartupRoute } from "./lib/route-modules";
import { legacyCallbackPath } from "./lib/auth-callback";
const initializeTelemetry = (): void => {
@@ -20,6 +20,8 @@ const initializeTelemetry = (): void => {
}
};
+const LANGUAGE_WAIT_MS = 3_000;
+
/** The route whose HTML the build prerendered into #root, when it is the page being opened. */
function prerenderedRoute(container: HTMLElement): string | undefined {
const route = container.dataset.prerendered;
@@ -44,6 +46,15 @@ export function mountApp(): void {
>
);
const prerendered = prerenderedRoute(container);
+ const page = preloadStartupRoute(prerendered === "*" ? "*" : window.location.pathname);
+ // The first render waits for the chosen language's strings, so a Spanish or
+ // Hungarian visitor never sees the English text first. A stalled request
+ // must not hold the startup screen: after a few seconds English renders and
+ // the language switches when its strings arrive.
+ const languageReady = Promise.race([
+ appLanguageReady(),
+ new Promise((resolve) => { window.setTimeout(resolve, LANGUAGE_WAIT_MS); }),
+ ]);
if (prerendered) {
// The prerendered page stays on screen until its module is loaded, then one
// synchronous render replaces it with the same page. A failed load renders
@@ -52,11 +63,12 @@ export function mountApp(): void {
flushSync(() => root.render(app));
delete container.dataset.prerendered;
};
- void Promise.resolve(preloadRoute(prerendered)).then(render, render);
+ void Promise.all([page, languageReady]).then(render, render);
} else {
// HTML prerendered for another route (an offline copy, say) is replaced like the startup shell.
delete container.dataset.prerendered;
- root.render(app);
+ const render = (): void => root.render(app);
+ void languageReady.then(render, render);
}
registerServiceWorker();
diff --git a/src/test/a11y/names-and-headings.test.tsx b/src/test/a11y/names-and-headings.test.tsx
index b969dc966..ef69bf381 100644
--- a/src/test/a11y/names-and-headings.test.tsx
+++ b/src/test/a11y/names-and-headings.test.tsx
@@ -1,7 +1,7 @@
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { MemoryRouter } from 'react-router-dom';
-import i18n from '@/i18n';
+import { applyAppLanguage } from '@/i18n';
import { CardTitle } from '@/components/ui/card';
import { Toaster } from '@/components/ui/sonner';
import { LanguageSwitcher } from '@/components/LanguageSwitcher';
@@ -33,7 +33,7 @@ beforeEach(() => {
afterEach(async () => {
cleanup();
- await i18n.changeLanguage('en');
+ await applyAppLanguage('en');
});
describe('accessible names and headings', () => {
@@ -54,11 +54,11 @@ describe('accessible names and headings', () => {
// Sonner appends the focus shortcut to containerAriaLabel.
expect(screen.getByRole('region', { name: 'Notifications alt+T' })).toBeInTheDocument();
- await act(async () => { await i18n.changeLanguage('es'); });
+ await act(async () => { await applyAppLanguage('es'); });
rerender();
expect(screen.getByRole('region', { name: 'Notificaciones alt+T' })).toBeInTheDocument();
- await act(async () => { await i18n.changeLanguage('hu'); });
+ await act(async () => { await applyAppLanguage('hu'); });
rerender();
expect(screen.getByRole('region', { name: 'Értesítések alt+T' })).toBeInTheDocument();
});
@@ -69,13 +69,13 @@ describe('accessible names and headings', () => {
expect(screen.getByRole('radiogroup', { name: 'Interface language' })).toBeInTheDocument();
expect(screen.getByRole('radiogroup', { name: 'AI output language' })).toBeInTheDocument();
- await act(async () => { await i18n.changeLanguage('es'); });
+ await act(async () => { await applyAppLanguage('es'); });
rerender();
expect(screen.getByRole('button', { name: 'Idioma' })).toBeInTheDocument();
expect(screen.getByRole('radiogroup', { name: 'Idioma de la interfaz' })).toBeInTheDocument();
expect(screen.getByRole('radiogroup', { name: 'Idioma de salida de IA' })).toBeInTheDocument();
- await act(async () => { await i18n.changeLanguage('hu'); });
+ await act(async () => { await applyAppLanguage('hu'); });
rerender();
expect(screen.getByRole('button', { name: 'Nyelv' })).toBeInTheDocument();
expect(screen.getByRole('radiogroup', { name: 'Felület nyelve' })).toBeInTheDocument();
@@ -108,11 +108,11 @@ describe('accessible names and headings', () => {
expect(bar).toHaveAttribute('aria-valuenow', '0');
expect(bar).not.toHaveAttribute('data-state', 'indeterminate');
- await act(async () => { await i18n.changeLanguage('es'); });
+ await act(async () => { await applyAppLanguage('es'); });
rerender();
expect(screen.getByRole('progressbar', { name: 'Progreso de Introductor' })).toBeInTheDocument();
- await act(async () => { await i18n.changeLanguage('hu'); });
+ await act(async () => { await applyAppLanguage('hu'); });
rerender();
expect(screen.getByRole('progressbar', { name: 'Bevezető előrehaladása' })).toBeInTheDocument();
});
diff --git a/src/test/i18n.test.ts b/src/test/i18n.test.ts
index 5c7ff89c7..a49e844c1 100644
--- a/src/test/i18n.test.ts
+++ b/src/test/i18n.test.ts
@@ -1,4 +1,5 @@
import { expect, test } from 'vitest';
+import i18n, { applyAppLanguage } from '../i18n';
import en from '../locales/en.json';
import es from '../locales/es.json';
import hu from '../locales/hu.json';
@@ -24,3 +25,17 @@ test('Spanish and Hungarian free-tools catalogs match English keys', () => {
expect(keys(toolsEs).sort()).toEqual(english);
expect(keys(toolsHu).sort()).toEqual(english);
});
+
+test('a later language choice wins over one whose strings are still loading', async () => {
+ // Spanish has to be fetched; English is already there and applies at once.
+ const spanish = applyAppLanguage('es');
+ const english = applyAppLanguage('en');
+ await Promise.all([spanish, english]);
+ expect(i18n.language).toBe('en');
+ expect(i18n.t('nav.settings')).toBe(en.nav.settings);
+
+ await applyAppLanguage('hu');
+ expect(i18n.language).toBe('hu');
+ expect(i18n.t('nav.settings')).toBe(hu.nav.settings);
+ expect(i18n.t('tools:embed.attribution')).toBe(toolsHu.embed.attribution);
+});
diff --git a/src/test/lib/flowchart.test.ts b/src/test/lib/flowchart.test.ts
index e30596e4c..21b4b0ad5 100644
--- a/src/test/lib/flowchart.test.ts
+++ b/src/test/lib/flowchart.test.ts
@@ -1,5 +1,6 @@
-import { describe, expect, it } from 'vitest';
+import { beforeAll, describe, expect, it } from 'vitest';
import i18n from '@/i18n';
+import { routeLoaders } from '@/lib/route-modules';
import { edgeLabel, editNodeData, flowchartFromCase, nodeDescription, nodeLabel, starterFlowchart, studentNodeData, type FlowNodeData } from '@/lib/flowchart';
import type { GenerateOutput } from '@/lib/ai/schemas/generate';
@@ -7,6 +8,9 @@ const en = i18n.getFixedT('en');
const hu = i18n.getFixedT('hu');
describe('flowchart app text', () => {
+ // The page's route loader fetches every language, as the app does before rendering it.
+ beforeAll(async () => { await routeLoaders['/app/flowchart'](); });
+
it('shows starter nodes in the current language, not the one they were created in', () => {
const thesis = starterFlowchart(en).nodes[0].data;
expect(thesis.label).toBe('Your Thesis');
diff --git a/src/test/lib/reload-stale-chunk.test.ts b/src/test/lib/reload-stale-chunk.test.ts
index 9189f47b6..54516c0ac 100644
--- a/src/test/lib/reload-stale-chunk.test.ts
+++ b/src/test/lib/reload-stale-chunk.test.ts
@@ -18,6 +18,7 @@ function fakeWindow() {
length: 0,
},
location: { pathname: '/app/ask', reload },
+ navigator: { onLine: true },
};
return { target, listeners, reload };
}
@@ -39,4 +40,15 @@ describe('stale chunk reload', () => {
expect(second.defaultPrevented).toBe(false);
expect(reload).toHaveBeenCalledTimes(1);
});
+
+ it('never reloads while offline, where a missing chunk is unreachable rather than stale', () => {
+ const { target, listeners, reload } = fakeWindow();
+ target.navigator.onLine = false;
+ installStaleChunkReload(target as unknown as Window);
+
+ const offline = new Event('vite:preloadError', { cancelable: true });
+ listeners.get('vite:preloadError')!(offline);
+ expect(offline.defaultPrevented).toBe(false);
+ expect(reload).not.toHaveBeenCalled();
+ });
});
diff --git a/src/test/pages/FlowchartPage.test.tsx b/src/test/pages/FlowchartPage.test.tsx
index 77b492d58..289d1b30e 100644
--- a/src/test/pages/FlowchartPage.test.tsx
+++ b/src/test/pages/FlowchartPage.test.tsx
@@ -1,7 +1,8 @@
-import { afterEach, describe, expect, it } from 'vitest';
+import { afterEach, beforeAll, describe, expect, it } from 'vitest';
import { act, fireEvent, render, screen, within } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
-import i18n from '@/i18n';
+import { applyAppLanguage } from '@/i18n';
+import { routeLoaders } from '@/lib/route-modules';
import FlowchartPage from '@/pages/FlowchartPage';
import { useSessionStore } from '@/lib/stores/session-store';
import { useDebateStore } from '@/lib/stores/debate-store';
@@ -15,8 +16,11 @@ function renderPage() {
);
}
+// Rendered as the route does: its loader fetches every language first.
+beforeAll(async () => { await routeLoaders['/app/flowchart'](); });
+
afterEach(async () => {
- await act(() => i18n.changeLanguage('en'));
+ await act(() => applyAppLanguage('en'));
useSessionStore.setState({ generateResult: null, generateResultDebateId: undefined });
useDebateStore.setState({ currentDebateId: null, oneOff: false });
});
@@ -25,7 +29,7 @@ describe('FlowchartPage', () => {
it('shows the starter nodes in the language the student switches to', async () => {
renderPage();
expect(screen.getByText('Your Thesis')).toBeInTheDocument();
- await act(() => i18n.changeLanguage('hu'));
+ await act(() => applyAppLanguage('hu'));
expect(screen.getByText('A tézised')).toBeInTheDocument();
expect(screen.getByText('1. érv')).toBeInTheDocument();
expect(screen.queryByText('Your Thesis')).not.toBeInTheDocument();
@@ -33,7 +37,7 @@ describe('FlowchartPage', () => {
it('says inline that a node needs a label and a saved chart needs a title', async () => {
renderPage();
- await act(() => i18n.changeLanguage('hu'));
+ await act(() => applyAppLanguage('hu'));
fireEvent.click(screen.getByRole('button', { name: 'Csomópont hozzáadása' }));
const nodeDialog = screen.getByRole('dialog');
diff --git a/src/test/supabase/offline-reads.test.ts b/src/test/supabase/offline-reads.test.ts
new file mode 100644
index 000000000..03425b707
--- /dev/null
+++ b/src/test/supabase/offline-reads.test.ts
@@ -0,0 +1,41 @@
+import { afterEach, beforeEach, expect, it, vi } from 'vitest';
+import { supabase } from '@/lib/supabase/client';
+
+let online = true;
+const fetchMock = vi.fn();
+const ok = () => new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } });
+
+beforeEach(() => {
+ online = true;
+ vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => online);
+ fetchMock.mockReset();
+ vi.stubGlobal('fetch', fetchMock);
+});
+
+afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+it('sends a failed read once while offline instead of retrying it three more times', async () => {
+ fetchMock.mockRejectedValue(new TypeError('Failed to fetch'));
+ online = false;
+ const { error } = await supabase.from('user_roles').select('role');
+ expect(error).not.toBeNull();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+});
+
+it('still sends reads when the browser wrongly reports offline', async () => {
+ fetchMock.mockResolvedValue(ok());
+ online = false;
+ const { error } = await supabase.from('user_roles').select('role');
+ expect(error).toBeNull();
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+});
+
+it('keeps retrying a failed read while online', async () => {
+ fetchMock.mockRejectedValueOnce(new TypeError('Failed to fetch')).mockResolvedValue(ok());
+ const { error } = await supabase.from('user_roles').select('role');
+ expect(error).toBeNull();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+});
diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts
index be91ad859..b6182c47b 100644
--- a/src/test/vercel-deployment-config.test.ts
+++ b/src/test/vercel-deployment-config.test.ts
@@ -234,9 +234,10 @@ describe("Vercel frontend deployment configuration", () => {
expect(packageJson.scripts?.build).toBe("vite build && node scripts/prerender.mjs && node scripts/sitemap.mjs && node scripts/check-site.mjs && node scripts/check-build-boundaries.mjs");
});
- it("caches the offline shell and only caches feature assets after they are visited", () => {
+ it("answers app routes with the cached shell and never stores a navigation as another page", () => {
const serviceWorker = readProjectFile("public/sw.js");
- expect(serviceWorker).toContain("cache.put('/index.html', response.clone())");
+ expect(serviceWorker).toContain("caches.match('/app-shell.html', { cacheName: APP_SHELL_CACHE })");
+ expect(serviceWorker).not.toContain("cache.put('/index.html'");
expect(serviceWorker).not.toContain("'/app/crossfire'");
expect(serviceWorker).not.toContain("'/app/sparring'");
expect(serviceWorker).toContain("'/favicon-72.png'");
@@ -331,7 +332,12 @@ describe("Vercel frontend deployment configuration", () => {
it("caches the unhashed public images for 30 days", () => {
const config = loadConfig();
- for (const path of ["/favicon.ico", "/favicon.png", "/favicon-72.png", "/hero-visual.jpg", "/placeholder.svg"]) {
+ // Every icon in public/, so a new size or a renamed file can't fall back to Vercel's default revalidate-every-time.
+ const icons = readdirSync(resolve(process.cwd(), "public"))
+ .filter((name) => /^(?:favicon|apple-touch-icon|icon)[\w-]*\.(?:ico|png)$/.test(name))
+ .map((name) => `/${name}`);
+ expect(icons).toEqual(expect.arrayContaining(["/icon-1024.png", "/icon-maskable-512.png", "/apple-touch-icon.png"]));
+ for (const path of [...icons, "/hero-visual.jpg", "/placeholder.svg"]) {
expect(headersAt(config, path)["Cache-Control"]).toBe("public, max-age=2592000");
}
expect(headersAt(config, "/assets/index-abc123.js")["Cache-Control"]).toBe(
diff --git a/vercel.json b/vercel.json
index 5dbaefb7d..28c70e97f 100644
--- a/vercel.json
+++ b/vercel.json
@@ -275,7 +275,7 @@
]
},
{
- "source": "/(favicon\\.ico|favicon\\.png|favicon-72\\.png|hero-visual\\.jpg|placeholder\\.svg)",
+ "source": "/(favicon\\.ico|favicon\\.png|favicon-72\\.png|apple-touch-icon\\.png|icon-192\\.png|icon-512\\.png|icon-1024\\.png|icon-maskable-512\\.png|hero-visual\\.jpg|placeholder\\.svg)",
"headers": [
{
"key": "Cache-Control",
diff --git a/vite.config.ts b/vite.config.ts
index 476ce87d7..e42be2c97 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -2,6 +2,8 @@ import { defineConfig, loadEnv, type Connect, type Plugin, type PluginOption } f
import react from "@vitejs/plugin-react-swc";
import { sentryVitePlugin } from "@sentry/vite-plugin";
import { execSync } from "node:child_process";
+import { createHash } from "node:crypto";
+import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import path from "path";
import { STAGING_PREVIEW_PROXY_PATH, SUPABASE_PROJECTS } from "./src/lib/supabase/projects";
@@ -72,6 +74,106 @@ function embedPages(): Plugin {
};
}
+/**
+ * Vercel answers app routes with app-shell.html (the `rewrites` in vercel.json),
+ * while vite preview would answer them with the prerendered landing page. The
+ * preview server gets the same rewrites, so the offline test and local
+ * measurements load what production serves.
+ */
+function appShellRewrites(): Plugin {
+ const outDir = path.resolve(__dirname, "dist");
+ const { rewrites = [] } = JSON.parse(readFileSync(path.resolve(__dirname, "vercel.json"), "utf8")) as {
+ rewrites?: Array<{ source: string; destination: string }>;
+ };
+ const routes = rewrites.map(({ source, destination }) => {
+ // vercel.json uses only exact paths and a trailing `/:path*` (any rest of the path).
+ const exact = source.replace(/\/:path\*$/, "");
+ if (exact.includes(":")) throw new Error(`appShellRewrites cannot read the vercel.json source ${source}`);
+ const rest = exact === source ? "" : "(?:/.*)?";
+ return { pattern: new RegExp(`^${exact.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}${rest}$`), destination };
+ });
+ return {
+ name: "app-shell-rewrites",
+ configurePreviewServer: (server) => {
+ server.middlewares.use((req, _res, next) => {
+ const [pathname, query] = (req.url ?? "").split("?", 2);
+ // Vercel serves files before rewrites, so a prerendered page (/expocit/sources) wins.
+ if (existsSync(path.join(outDir, pathname, "index.html"))) return next();
+ const route = routes.find(({ pattern }) => pattern.test(pathname));
+ if (route) req.url = `${route.destination}${query === undefined ? "" : `?${query}`}`;
+ next();
+ });
+ },
+ };
+}
+
+/**
+ * What the service worker downloads when it installs, beyond the app shell's
+ * own scripts and styles: the signed-in layout, the pages a student should be
+ * able to open offline after one visit, and the other languages' strings. Each
+ * key must exist in the Vite manifest, so a renamed page fails the build
+ * instead of quietly dropping out of offline use.
+ */
+const OFFLINE_MODULES = [
+ // The landing page's HTML is precached too; without its code it would turn into the error page.
+ "src/pages/LandingPage.tsx",
+ "src/components/layout/AppLayout.tsx",
+ "src/pages/HomePage.tsx",
+ "src/pages/LibraryPage.tsx",
+ "src/pages/HistoryPage.tsx",
+ "src/pages/TimerPage.tsx",
+ "src/pages/SettingsPage.tsx",
+ "src/locales/es.json",
+ "src/locales/tools/es.json",
+ "src/locales/hu.json",
+ "src/locales/tools/hu.json",
+];
+// Inter's Latin subsets cover English, Spanish and Hungarian.
+const OFFLINE_FONTS = /^assets\/inter-latin(?:-ext)?-wght-normal-[\w-]+\.woff2$/;
+const SERVICE_WORKER_BUILD = "const BUILD = { id: 'dev', assets: [] };";
+
+/** Writes this build's precache list and cache id into dist/sw.js. */
+function serviceWorkerPrecache(): Plugin {
+ return {
+ name: "service-worker-precache",
+ apply: "build",
+ writeBundle(options, bundle) {
+ const outDir = options.dir ?? path.resolve(__dirname, "dist");
+ const manifest = JSON.parse(readFileSync(path.join(outDir, ".vite/manifest.json"), "utf8")) as Record;
+ const files = new Set();
+ const visit = (key: string) => {
+ const entry = manifest[key];
+ if (!entry) throw new Error(`serviceWorkerPrecache: the Vite manifest has no entry for ${key}`);
+ if (files.has(entry.file)) return;
+ files.add(entry.file);
+ for (const css of entry.css ?? []) files.add(css);
+ for (const imported of entry.imports ?? []) visit(imported);
+ };
+ visit("index.html");
+ OFFLINE_MODULES.forEach(visit);
+ // The shell's stylesheet and preloads, exactly as index.html links them.
+ const shell = readFileSync(path.join(outDir, "index.html"), "utf8");
+ for (const [, file] of shell.matchAll(/(?:href|src)="\/(assets\/[^"]+)"/g)) files.add(file);
+ for (const file of Object.keys(bundle)) if (OFFLINE_FONTS.test(file)) files.add(file);
+
+ const assets = [...files].sort().map((file) => `/${file}`);
+ const workerPath = path.join(outDir, "sw.js");
+ const worker = readFileSync(workerPath, "utf8");
+ if (!worker.includes(SERVICE_WORKER_BUILD)) throw new Error(`serviceWorkerPrecache: public/sw.js no longer contains ${SERVICE_WORKER_BUILD}`);
+ // The unhashed files the worker serves cache-first (theme-init.js, the
+ // icons and images) change the id too, so a deploy that changes only
+ // them still ships a new worker.
+ const hash = createHash("sha256").update(worker).update(assets.join("\n"));
+ const publicDir = path.resolve(__dirname, "public");
+ for (const file of readdirSync(publicDir).filter((name) => /\.(?:js|png|ico|jpg|svg|webmanifest)$/.test(name) && name !== "sw.js").sort()) {
+ hash.update(file).update(readFileSync(path.join(publicDir, file)));
+ }
+ const id = hash.digest("hex").slice(0, 12);
+ writeFileSync(workerPath, worker.replace(SERVICE_WORKER_BUILD, `const BUILD = ${JSON.stringify({ id, assets })};`));
+ },
+ };
+}
+
/**
* `npm run dev:preview` sets VITE_STAGING_PREVIEW=true, which makes the app call
* Supabase at /__staging-supabase on its own origin (see src/lib/supabase/env.ts). Forward
@@ -127,7 +229,7 @@ export default defineConfig(({ mode }) => {
proxy,
allowedHosts,
},
- plugins: [react(), bundleGraph(), embedPages(), ...uploadSourceMaps],
+ plugins: [react(), bundleGraph(), embedPages(), appShellRewrites(), serviceWorkerPrecache(), ...uploadSourceMaps],
define: {
"import.meta.env.VITE_SENTRY_RELEASE": JSON.stringify(release ?? ""),
"import.meta.env.VITE_SENTRY_ENVIRONMENT": JSON.stringify(process.env.VERCEL_ENV ?? ""),