From ea9132c8558d7bf4e313a9ef3a3051b92679a3c2 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 01:37:26 +0000 Subject: [PATCH 01/10] Cache every public icon file for 30 days C3 (#199) added icon-1024.png and the 192/512/maskable manifest icons, which still fell back to revalidating on every request. The test now reads public/ so a new icon size can't miss the rule. Co-Authored-By: Claude Opus 5.5 --- src/test/vercel-deployment-config.test.ts | 7 ++++++- vercel.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index be91ad859..0956483e9 100644 --- a/src/test/vercel-deployment-config.test.ts +++ b/src/test/vercel-deployment-config.test.ts @@ -331,7 +331,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", From fed458c57089c113b36b8f4b4b72e18b36e74eca Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 01:52:47 +0000 Subject: [PATCH 02/10] Load only the active locale and keep app-only code off public pages - Spanish and Hungarian strings are their own chunks, fetched when chosen; English stays in the entry as the default and fallback. The first render waits for the chosen language, and a later choice wins over one still loading. - AppLayout (header, navigation, debate dialogs) loads only under /app. mountApp starts the page chunk and the layout while the entry starts, instead of when React first reaches them. - The landing reveal is a CSS transition, so / no longer loads framer-motion's 118 KB chunk. - The session check and layout load show the route's skeleton instead of a full-screen spinner. - check-build-boundaries fails if the es/hu catalogs or AppLayout re-enter the entry or the landing, login, home, generate or attack graphs. Co-Authored-By: Claude Opus 5.5 --- scripts/check-build-boundaries.mjs | 4 +- src/App.tsx | 41 +++++++++++++---- src/components/auth/RequireAuth.tsx | 6 ++- src/components/landing/Reveal.tsx | 53 ++++++++++++--------- src/i18n.ts | 56 +++++++++++++++++++---- src/index.css | 19 ++++++++ src/lib/route-modules.ts | 17 +++++++ src/main.tsx | 11 +++-- src/test/a11y/names-and-headings.test.tsx | 16 +++---- src/test/i18n.test.ts | 15 ++++++ 10 files changed, 182 insertions(+), 56 deletions(-) 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/src/App.tsx b/src/App.tsx index 05ae4116c..09e5baf90 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, 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,6 +68,8 @@ 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. +const AppLayout = lazy(appLayoutLoader); const OnboardingTour = lazy(() => import("@/components/features/onboarding/OnboardingTour") .then(({ OnboardingTour: Tour }) => ({ default: Tour }))); @@ -215,17 +216,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 +323,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/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..99325d190 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,44 @@ 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; + } +} + +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; +} - 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 22befb693..fd51e7eda 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; @@ -637,6 +652,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/route-modules.ts b/src/lib/route-modules.ts index 79350538a..a4fc272b7 100644 --- a/src/lib/route-modules.ts +++ b/src/lib/route-modules.ts @@ -56,6 +56,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 +69,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/main.tsx b/src/main.tsx index 30f7afe46..1e5858399 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 => { @@ -44,6 +44,9 @@ 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. 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 +55,11 @@ export function mountApp(): void { flushSync(() => root.render(app)); delete container.dataset.prerendered; }; - void Promise.resolve(preloadRoute(prerendered)).then(render, render); + void Promise.all([page, appLanguageReady()]).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); + void appLanguageReady().then(() => root.render(app)); } 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); +}); From ce3cd9420793956272a9354879c5b796544f908d Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 02:07:57 +0000 Subject: [PATCH 03/10] Open the main app pages offline and keep each page's own HTML The build writes a precache list into sw.js from the Vite manifest: the shell's scripts and styles, the signed-in layout, Home, Library, History, Timer and Settings, the es/hu strings and Inter's Latin fonts. A missing manifest key fails the build. Offline, app routes get the precached app-shell.html (the paths vercel.json rewrites) and a public page gets only its own HTML, stored under its own path. Before, every navigation was stored as /index.html, so any route could come back as the last page visited. /auth/callback, Supabase paths and /_vercel/ stay network-only. Caches carry the build's id and the next build's worker deletes them. vite preview applies the vercel.json rewrites, so the offline test loads what production serves. The test's async waitForFunction calls passed at once without checking (Playwright treats the returned Promise as truthy); they now poll. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 4 +- docs/operations.md | 2 +- docs/testing.md | 4 +- public/sw.js | 124 +++++++++++++++------ scripts/test-service-worker-offline.mjs | 129 ++++++++++++++++++---- src/test/vercel-deployment-config.test.ts | 5 +- vite.config.ts | 91 ++++++++++++++- 7 files changed, 297 insertions(+), 62 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index 41aaeffbe..a959779ee 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`, `/expocit/sources`, the same paths `vercel.json` rewrites) 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..52cd5e6d4 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,51 @@ 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. +const APP_SHELL_PATHS = [/^\/app(?:\/|$)/, /^\/login\/?$/, /^\/share\//, /^\/expocit\/sources\/?$/]; + +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 +77,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 +105,40 @@ 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' }, - }); + return (await caches.match(key, { ignoreVary: true })) || offlinePage(); } } @@ -115,13 +163,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/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs index 34b2ae2ff..d68d25407 100644 --- a/scripts/test-service-worker-offline.mjs +++ b/scripts/test-service-worker-offline.mjs @@ -87,11 +87,47 @@ 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/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 +159,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 +171,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,53 +192,103 @@ 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'); 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 }); + 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(); @@ -215,7 +302,11 @@ async function main() { ); console.log(JSON.stringify({ upgradeActivated: true, - firstVisitedRouteOffline: true, + precachedRoutesOffline: ['/app', '/app/library', '/app/history', '/app/settings', '/app/timer'], + timerRunsOffline: true, + languageSwitchOffline: true, + publicPagesKeepTheirOwnHtml: true, + offlineIndicator: true, unvisitedRouteShowsRecovery: true, intentPrefetchedRouteOffline: true, offlineLogo: true, diff --git a/src/test/vercel-deployment-config.test.ts b/src/test/vercel-deployment-config.test.ts index 0956483e9..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'"); diff --git a/vite.config.ts b/vite.config.ts index 476ce87d7..9a30e6dcb 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 { readFileSync, writeFileSync } from "node:fs"; import path from "path"; import { STAGING_PREVIEW_PROXY_PATH, SUPABASE_PROJECTS } from "./src/lib/supabase/projects"; @@ -72,6 +74,93 @@ 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 { 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); + 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 = [ + "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}`); + const id = createHash("sha256").update(worker).update(assets.join("\n")).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 +216,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 ?? ""), From d607e0d26b26a70396af0781134620bff43d8d6f Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 02:13:30 +0000 Subject: [PATCH 04/10] Hold Supabase reads while offline instead of retrying them Offline, supabase-js retried every failed read three more times, so opening Settings sent about 70 doomed requests in 30 seconds (user_roles four times, user_settings 18). REST, Functions and Storage reads now wait for the 'online' event and go out once; an abort still ends them. Writes fail at once as before, and Auth is left alone because auth-js holds its session lock during a user lookup. TanStack Query also starts with the browser's real online state, so a page opened offline pauses its queries instead of running them. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 5 ++- src/lib/supabase/client.ts | 40 +++++++++++++++++++ src/test/supabase/offline-reads.test.ts | 51 +++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 1 deletion(-) create mode 100644 src/test/supabase/offline-reads.test.ts diff --git a/src/App.tsx b/src/App.tsx index 09e5baf90..c4c062144 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { Component, Suspense, lazy, useEffect, useRef, useState, type ComponentType, type ErrorInfo, type ReactNode } from "react"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route, useLocation, useNavigationType } from "react-router-dom"; import { MotionConfig } from "framer-motion"; import { AuthProvider, useAuth } from "@/lib/supabase/AuthProvider"; @@ -74,6 +74,9 @@ const OnboardingTour = lazy(() => import("@/components/features/onboarding/Onboa .then(({ OnboardingTour: Tour }) => ({ default: Tour }))); const queryClient = new QueryClient(); +// TanStack Query assumes it is online until an `offline` event. A page opened +// offline would run and fail every query; this pauses them until `online`. +if (typeof navigator !== "undefined") onlineManager.setOnline(navigator.onLine); type ToolName = | "generate" diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index e0d4edbbb..cfe3bd198 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -40,6 +40,44 @@ function requestUrl(input: RequestInfo | URL): string { return input.url; } +/** + * Reads that wait for the connection instead of failing. supabase-js retries a + * failed read three more times, so offline every query became a burst of + * requests. Writes still fail at once, so the UI can say nothing was saved. + * Auth is left out: auth-js holds its session lock during a user lookup, and a + * lookup held offline would block sign-out and every other request. + */ +function waitsForConnection(url: string, method: string): boolean { + if (method !== 'GET' && method !== 'HEAD') return false; + try { + const path = new URL(url, SUPABASE_URL).pathname; + return path.startsWith('/rest/v1/') || path.startsWith('/functions/v1/') || path.startsWith('/storage/v1/'); + } catch { + return false; + } +} + +/** Resolves once the browser is online, or rejects when the request is aborted first. */ +function untilOnline(signal?: AbortSignal | null): Promise { + if (typeof navigator === 'undefined' || navigator.onLine !== false) return Promise.resolve(); + return new Promise((resolve, reject) => { + const online = () => { + signal?.removeEventListener('abort', aborted); + resolve(); + }; + const aborted = () => { + window.removeEventListener('online', online); + reject(signal?.reason ?? new DOMException('The request was aborted.', 'AbortError')); + }; + if (signal?.aborted) { + aborted(); + return; + } + window.addEventListener('online', online, { once: true }); + signal?.addEventListener('abort', aborted, { once: true }); + }); +} + // 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,6 +92,8 @@ const watchedFetch: typeof fetch = async (input, init) => { headers: { "content-type": "application/json" }, }); } + const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); + if (waitsForConnection(requestUrl(input), method)) await untilOnline(init?.signal ?? (input instanceof Request ? input.signal : undefined)); const response = await globalThis.fetch(input, init); const url = requestUrl(input); if (activeAccessToken && isAuthRejection(url, response.status)) { diff --git a/src/test/supabase/offline-reads.test.ts b/src/test/supabase/offline-reads.test.ts new file mode 100644 index 000000000..f13586814 --- /dev/null +++ b/src/test/supabase/offline-reads.test.ts @@ -0,0 +1,51 @@ +import { afterEach, beforeEach, expect, it, vi } from 'vitest'; +import { supabase } from '@/lib/supabase/client'; + +let online = true; +const fetchMock = vi.fn(); + +beforeEach(() => { + online = true; + vi.spyOn(navigator, 'onLine', 'get').mockImplementation(() => online); + fetchMock.mockReset(); + vi.stubGlobal('fetch', fetchMock); +}); + +afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); +}); + +const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); + +it('holds a Supabase read while offline and sends it once, when the connection returns', async () => { + fetchMock.mockResolvedValue(new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } })); + online = false; + const read = supabase.from('user_roles').select('role'); + const result = read.then((answer) => answer); + await settle(); + expect(fetchMock).not.toHaveBeenCalled(); + + online = true; + window.dispatchEvent(new Event('online')); + expect((await result).error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); + +it('lets an aborted held read end without a request', async () => { + online = false; + const controller = new AbortController(); + const result = supabase.from('user_roles').select('role').abortSignal(controller.signal).then((answer) => answer); + await settle(); + controller.abort(); + expect((await result).error).not.toBeNull(); + expect(fetchMock).not.toHaveBeenCalled(); +}); + +it('fails a write at once while offline instead of waiting', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + online = false; + const { error } = await supabase.from('user_settings').update({ theme: 'dark' }).eq('user_id', 'u1'); + expect(error).not.toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); +}); From 9a9f4f3128ea07033feaa2fee085f22e85d2bc0c Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 02:15:38 +0000 Subject: [PATCH 05/10] Don't reload for a missing chunk while offline Choosing a language whose strings were never cached, offline, failed its import and triggered the stale-deploy reload, which without a service worker replaces the page with the browser's offline error. Offline the chunk is unreachable, not stale, so the error stands and the app keeps the current language. Co-Authored-By: Claude Opus 5.5 --- src/lib/reload-stale-chunk.ts | 7 ++++++- src/test/lib/reload-stale-chunk.test.ts | 12 ++++++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) 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/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(); + }); }); From a0998d0f3c6340bac056ce3b866f09ae8a31809a Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 02:47:23 +0000 Subject: [PATCH 06/10] Treat /expocit/sources as a public page offline, and always leave the startup shell /expocit/sources is prerendered and Vercel serves that file before the rewrite, so offline it now gets its own stored HTML instead of the shell (whose page chunk is not precached). vite preview serves prerendered pages before the rewrites too. The non-prerendered first render now also runs if waiting for the language rejects. Co-Authored-By: Claude Opus 5.5 --- docs/architecture.md | 2 +- public/sw.js | 4 +++- src/main.tsx | 3 ++- vite.config.ts | 5 ++++- 4 files changed, 10 insertions(+), 4 deletions(-) diff --git a/docs/architecture.md b/docs/architecture.md index a959779ee..4a75d2f52 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -166,7 +166,7 @@ CI and staging must not upload authenticated browser captures. Logging must not ## PWA and offline behavior -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`, `/expocit/sources`, the same paths `vercel.json` rewrites) 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. +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. diff --git a/public/sw.js b/public/sw.js index 52cd5e6d4..af362a838 100644 --- a/public/sw.js +++ b/public/sw.js @@ -26,7 +26,9 @@ const APP_SHELL = [ ]; // vercel.json serves app-shell.html at these paths, so offline the worker does too. -const APP_SHELL_PATHS = [/^\/app(?:\/|$)/, /^\/login\/?$/, /^\/share\//, /^\/expocit\/sources\/?$/]; +// (/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. diff --git a/src/main.tsx b/src/main.tsx index 1e5858399..fd1fc28a0 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -59,7 +59,8 @@ export function mountApp(): void { } else { // HTML prerendered for another route (an offline copy, say) is replaced like the startup shell. delete container.dataset.prerendered; - void appLanguageReady().then(() => root.render(app)); + const render = (): void => root.render(app); + void appLanguageReady().then(render, render); } registerServiceWorker(); diff --git a/vite.config.ts b/vite.config.ts index 9a30e6dcb..9196b3895 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ 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 { readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "path"; import { STAGING_PREVIEW_PROXY_PATH, SUPABASE_PROJECTS } from "./src/lib/supabase/projects"; @@ -81,6 +81,7 @@ function embedPages(): Plugin { * 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 }>; }; @@ -96,6 +97,8 @@ function appShellRewrites(): Plugin { 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(); From 520902105a9f592ab89543b11edea087f7e60b88 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 02:48:23 +0000 Subject: [PATCH 07/10] Disable Upgrade Now while offline and say why Checkout needs the network. Offline the button stays disabled with a short notice under it (one new string, pricingModal.offlineUpgrade, in all three catalogs) and re-enables on the 'online' event. Done here now that T4 (#207) has merged. Co-Authored-By: Claude Opus 5.5 --- src/components/features/subscription/PricingModal.tsx | 11 ++++++++++- src/locales/en.json | 1 + src/locales/es.json | 1 + src/locales/hu.json | 1 + 4 files changed, 13 insertions(+), 1 deletion(-) 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/locales/en.json b/src/locales/en.json index 3e88bc115..83cdd6eec 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -1444,6 +1444,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 ebef0f7fb..a4489e498 100644 --- a/src/locales/es.json +++ b/src/locales/es.json @@ -1444,6 +1444,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 8a5e5402e..83d26f077 100644 --- a/src/locales/hu.json +++ b/src/locales/hu.json @@ -1444,6 +1444,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?", From a9da7edef3aa6cb0cb22968dfbe4eea6abae3e7b Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 03:58:48 +0000 Subject: [PATCH 08/10] Load every language before the flowchart opens T9 (#206) recognises flowchart starter text saved in any language by comparing it with each catalog. With Spanish and Hungarian loaded only on demand, a chart saved in Hungarian and opened in English kept its Hungarian starter text. The flowchart route's loader now fetches every catalog before the page renders. Only that page (and a hover prefetch of it) pays for them, and they are precached for offline. The flowchart tests now load through the route loader; without this change four of them fail. Co-Authored-By: Claude Opus 5.5 --- src/i18n.ts | 9 +++++++++ src/lib/route-modules.ts | 6 +++++- src/test/lib/flowchart.test.ts | 6 +++++- src/test/pages/FlowchartPage.test.tsx | 14 +++++++++----- 4 files changed, 28 insertions(+), 7 deletions(-) diff --git a/src/i18n.ts b/src/i18n.ts index 99325d190..7c0cec48b 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -41,6 +41,15 @@ async function loadCatalogs(language: AppLanguage): Promise { } } +/** + * 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(); diff --git a/src/lib/route-modules.ts b/src/lib/route-modules.ts index a4fc272b7..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")), 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/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'); From 9cb7200eda4eec7738c7e6ca08c3d44389d05b25 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 04:24:46 +0000 Subject: [PATCH 09/10] Address review round 1: stop retries offline instead of holding reads - Offline Supabase reads are no longer held until 'online'. A held read kept the token of whoever started it and could replay after sign-out, and a browser that wrongly reports offline would never send it. Every request is now sent; one that fails while the browser is offline ends as an abort, which postgrest-js does not retry. TanStack Query is no longer seeded from navigator.onLine; failed queries refetch on 'online' instead. - A language chosen offline before its strings were cached is applied when the connection returns. - The first render waits at most 3 s for the language's strings. - The service worker's build id also covers the unhashed public files it serves cache-first (theme-init.js, icons), and an offline public page prefers the copy saved on the last visit over the one stored at install. - The landing page's code is precached with its HTML, so / opened offline is not replaced by the error page. - test:sw now visits /auth/callback before checking it is never cached, and checks the precached files sit in the install-time shell cache. Co-Authored-By: Claude Opus 5.5 --- public/sw.js | 5 ++- scripts/test-service-worker-offline.mjs | 22 +++++++++++- src/App.tsx | 13 ++++--- src/i18n.ts | 8 +++++ src/lib/supabase/client.ts | 45 +++++++++---------------- src/main.tsx | 14 ++++++-- src/test/supabase/offline-reads.test.ts | 40 +++++++++------------- vite.config.ts | 14 ++++++-- 8 files changed, 95 insertions(+), 66 deletions(-) diff --git a/public/sw.js b/public/sw.js index af362a838..c73b926ba 100644 --- a/public/sw.js +++ b/public/sw.js @@ -140,7 +140,10 @@ async function pageNavigation(event) { } return response; } catch { - return (await caches.match(key, { ignoreVary: true })) || offlinePage(); + // 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(); } } diff --git a/scripts/test-service-worker-offline.mjs b/scripts/test-service-worker-offline.mjs index d68d25407..07ab1eb46 100644 --- a/scripts/test-service-worker-offline.mjs +++ b/scripts/test-service-worker-offline.mjs @@ -119,6 +119,7 @@ async function main() { 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', @@ -221,6 +222,13 @@ async function main() { ))).flat(); return paths.every((path) => urls.includes(path)); }, [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'); @@ -269,6 +277,12 @@ async function main() { 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(); @@ -294,7 +308,12 @@ async function main() { 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)), [], @@ -302,7 +321,8 @@ async function main() { ); console.log(JSON.stringify({ upgradeActivated: true, - precachedRoutesOffline: ['/app', '/app/library', '/app/history', '/app/settings', '/app/timer'], + precachedRoutesOffline: ['/', '/app', '/app/library', '/app/history', '/app/settings', '/app/timer'], + authCallbackNeverCached: true, timerRunsOffline: true, languageSwitchOffline: true, publicPagesKeepTheirOwnHtml: true, diff --git a/src/App.tsx b/src/App.tsx index c4c062144..d0bda92c0 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -2,7 +2,7 @@ import { Component, Suspense, lazy, useEffect, useRef, useState, type ComponentType, type ErrorInfo, type ReactNode } from "react"; import { Toaster } from "@/components/ui/sonner"; import { TooltipProvider } from "@/components/ui/tooltip"; -import { QueryClient, QueryClientProvider, onlineManager } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { BrowserRouter, Routes, Route, useLocation, useNavigationType } from "react-router-dom"; import { MotionConfig } from "framer-motion"; import { AuthProvider, useAuth } from "@/lib/supabase/AuthProvider"; @@ -74,9 +74,14 @@ const OnboardingTour = lazy(() => import("@/components/features/onboarding/Onboa .then(({ OnboardingTour: Tour }) => ({ default: Tour }))); const queryClient = new QueryClient(); -// TanStack Query assumes it is online until an `offline` event. A page opened -// offline would run and fail every query; this pauses them until `online`. -if (typeof navigator !== "undefined") onlineManager.setOnline(navigator.onLine); +// 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" diff --git a/src/i18n.ts b/src/i18n.ts index 7c0cec48b..bc9cdc80f 100644 --- a/src/i18n.ts +++ b/src/i18n.ts @@ -66,6 +66,14 @@ export function applyAppLanguage(language: AppLanguage): Promise { 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); + }); +} + /** * 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 diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index cfe3bd198..5eed7604c 100644 --- a/src/lib/supabase/client.ts +++ b/src/lib/supabase/client.ts @@ -41,13 +41,11 @@ function requestUrl(input: RequestInfo | URL): string { } /** - * Reads that wait for the connection instead of failing. supabase-js retries a - * failed read three more times, so offline every query became a burst of - * requests. Writes still fail at once, so the UI can say nothing was saved. - * Auth is left out: auth-js holds its session lock during a user lookup, and a - * lookup held offline would block sign-out and every other request. + * 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 waitsForConnection(url: string, method: string): boolean { +function isRetriedRead(url: string, method: string): boolean { if (method !== 'GET' && method !== 'HEAD') return false; try { const path = new URL(url, SUPABASE_URL).pathname; @@ -57,27 +55,6 @@ function waitsForConnection(url: string, method: string): boolean { } } -/** Resolves once the browser is online, or rejects when the request is aborted first. */ -function untilOnline(signal?: AbortSignal | null): Promise { - if (typeof navigator === 'undefined' || navigator.onLine !== false) return Promise.resolve(); - return new Promise((resolve, reject) => { - const online = () => { - signal?.removeEventListener('abort', aborted); - resolve(); - }; - const aborted = () => { - window.removeEventListener('online', online); - reject(signal?.reason ?? new DOMException('The request was aborted.', 'AbortError')); - }; - if (signal?.aborted) { - aborted(); - return; - } - window.addEventListener('online', online, { once: true }); - signal?.addEventListener('abort', aborted, { once: true }); - }); -} - // 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. @@ -92,10 +69,18 @@ const watchedFetch: typeof fetch = async (input, init) => { headers: { "content-type": "application/json" }, }); } - const method = (init?.method ?? (input instanceof Request ? input.method : 'GET')).toUpperCase(); - if (waitsForConnection(requestUrl(input), method)) await untilOnline(init?.signal ?? (input instanceof Request ? input.signal : undefined)); - 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/main.tsx b/src/main.tsx index fd1fc28a0..233c5f727 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -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; @@ -46,7 +48,13 @@ 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. + // 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 @@ -55,12 +63,12 @@ export function mountApp(): void { flushSync(() => root.render(app)); delete container.dataset.prerendered; }; - void Promise.all([page, appLanguageReady()]).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; const render = (): void => root.render(app); - void appLanguageReady().then(render, render); + void languageReady.then(render, render); } registerServiceWorker(); diff --git a/src/test/supabase/offline-reads.test.ts b/src/test/supabase/offline-reads.test.ts index f13586814..03425b707 100644 --- a/src/test/supabase/offline-reads.test.ts +++ b/src/test/supabase/offline-reads.test.ts @@ -3,6 +3,7 @@ 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; @@ -16,36 +17,25 @@ afterEach(() => { vi.restoreAllMocks(); }); -const settle = () => new Promise((resolve) => setTimeout(resolve, 50)); - -it('holds a Supabase read while offline and sends it once, when the connection returns', async () => { - fetchMock.mockResolvedValue(new Response('[]', { status: 200, headers: { 'content-type': 'application/json' } })); +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 read = supabase.from('user_roles').select('role'); - const result = read.then((answer) => answer); - await settle(); - expect(fetchMock).not.toHaveBeenCalled(); - - online = true; - window.dispatchEvent(new Event('online')); - expect((await result).error).toBeNull(); + const { error } = await supabase.from('user_roles').select('role'); + expect(error).not.toBeNull(); expect(fetchMock).toHaveBeenCalledTimes(1); }); -it('lets an aborted held read end without a request', async () => { +it('still sends reads when the browser wrongly reports offline', async () => { + fetchMock.mockResolvedValue(ok()); online = false; - const controller = new AbortController(); - const result = supabase.from('user_roles').select('role').abortSignal(controller.signal).then((answer) => answer); - await settle(); - controller.abort(); - expect((await result).error).not.toBeNull(); - expect(fetchMock).not.toHaveBeenCalled(); + const { error } = await supabase.from('user_roles').select('role'); + expect(error).toBeNull(); + expect(fetchMock).toHaveBeenCalledTimes(1); }); -it('fails a write at once while offline instead of waiting', async () => { - fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); - online = false; - const { error } = await supabase.from('user_settings').update({ theme: 'dark' }).eq('user_id', 'u1'); - expect(error).not.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/vite.config.ts b/vite.config.ts index 9196b3895..54489c224 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -3,7 +3,7 @@ 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, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"; import path from "path"; import { STAGING_PREVIEW_PROXY_PATH, SUPABASE_PROJECTS } from "./src/lib/supabase/projects"; @@ -115,6 +115,8 @@ function appShellRewrites(): Plugin { * 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", @@ -158,7 +160,15 @@ function serviceWorkerPrecache(): Plugin { 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}`); - const id = createHash("sha256").update(worker).update(assets.join("\n")).digest("hex").slice(0, 12); + // The unhashed files the worker serves cache-first (theme-init.js, the + // icons) 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|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 })};`)); }, }; From 02e6509c438d2bb1a5059e53d79ea232e70ab664 Mon Sep 17 00:00:00 2001 From: Adam Date: Fri, 23 Oct 2026 04:43:25 +0000 Subject: [PATCH 10/10] Address review round 2: load layout and page together after sign-in - After a client-side navigation into /app (sign-in, the callback), the page chunk now starts with the layout chunk instead of after it. - The offline no-retry rule also matches reads through the dev:preview staging proxy path. - hero-visual.jpg and placeholder.svg also feed the worker's build id. Co-Authored-By: Claude Opus 5.5 --- src/App.tsx | 9 +++++++-- src/lib/supabase/client.ts | 5 ++++- vite.config.ts | 6 +++--- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d0bda92c0..8da96a23c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -14,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 { appLayoutLoader, 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,7 +69,12 @@ 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. -const AppLayout = lazy(appLayoutLoader); +// 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 }))); diff --git a/src/lib/supabase/client.ts b/src/lib/supabase/client.ts index 5eed7604c..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 }; @@ -48,7 +49,9 @@ function requestUrl(input: RequestInfo | URL): string { function isRetriedRead(url: string, method: string): boolean { if (method !== 'GET' && method !== 'HEAD') return false; try { - const path = new URL(url, SUPABASE_URL).pathname; + // `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; diff --git a/vite.config.ts b/vite.config.ts index 54489c224..e42be2c97 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -161,11 +161,11 @@ function serviceWorkerPrecache(): Plugin { 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) change the id too, so a deploy that changes only them still - // ships a new worker. + // 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|webmanifest)$/.test(name) && name !== "sw.js").sort()) { + 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);