diff --git a/.github/workflows/desktop-build.yml b/.github/workflows/desktop-build.yml index 8a208a6a..799c5723 100644 --- a/.github/workflows/desktop-build.yml +++ b/.github/workflows/desktop-build.yml @@ -262,7 +262,7 @@ jobs: EDGE_EVER_DESKTOP_BUILD: "1" run: bun run build:web - - name: Verify Web precache budget + - name: Verify Web performance budget if: matrix.shared_validation run: bun run verify:web-performance diff --git a/apps/web/src/i18n/index.test.mjs b/apps/web/src/i18n/index.test.mjs index b5c33e19..c91e41ac 100644 --- a/apps/web/src/i18n/index.test.mjs +++ b/apps/web/src/i18n/index.test.mjs @@ -19,8 +19,8 @@ describe("lazy Japanese catalog", () => { expect(mobileEditorSource).toContain("void bootstrapI18n().then(() => {"); }); - test("keeps the Japanese chunk out of PWA precache and HTML modulepreload", () => { - expect(viteConfig).toContain('"**/i18n-ja-*.js"'); + test("keeps the Japanese chunk out of HTML modulepreload and caches it after first use", () => { + expect(viteConfig).toContain("globPatterns: []"); expect(viteConfig).toContain("name: \"i18n-ja\""); expect(viteConfig).toContain("edgeever-optional-locales"); expect(viteConfig).toContain("i18n-ja-"); diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index 1b40a1c1..b6cdeddb 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -143,6 +143,7 @@ export default defineConfig({ react(), VitePWA({ registerType: "autoUpdate", + includeAssets: [], includeManifestIcons: false, manifest: { name: "EdgeEver", @@ -175,34 +176,12 @@ export default defineConfig({ ], }, workbox: { - globPatterns: ["**/*.{js,css,html,ico,png,svg,webp,woff2}"], + // Install-time precache excludes the app shell. The plugin still + // injects the web manifest so the app stays installable; other + // caches fill on first use. + globPatterns: [], + globIgnores: ["**/*"], cleanupOutdatedCaches: true, - additionalManifestEntries: [ - { - revision: buildId, - url: `/index.html?edgeever-offline-shell=${encodeURIComponent(buildId)}`, - }, - ], - globIgnores: [ - "index.html", - // Noto Sans SC is used only by the on-demand print entry. Precaching every - // CJK unicode-range shard adds ~4.5 MiB to every PWA installation. - "**/noto-sans-sc-*.woff2", - "**/*beautiful-mermaid*.js", - "**/*mermaid.core-*.js", - "**/vendor-mermaid-*.js", - "**/*Diagram-*.js", - "**/*DiagramEditorPane-*.js", - "**/vendor-x6-*.js", - "**/vendor-codemirror-*.js", - // PDF.js is loaded only when a PDF preview or thumbnail is rendered. - // Keep its runtime out of the install-time app-shell precache and cache - // it after first use instead. - "**/vendor~pdf-*.js", - // Japanese is a first-class locale but is loaded on demand so Chinese - // and English PWA installs do not pay for that catalog up front. - "**/i18n-ja-*.js", - ], navigateFallback: null, navigationPreload: true, runtimeCaching: [ @@ -217,9 +196,6 @@ export default defineConfig({ cacheableResponse: { statuses: [0, 200], }, - precacheFallback: { - fallbackURL: `/index.html?edgeever-offline-shell=${encodeURIComponent(buildId)}`, - }, }, }, { @@ -299,8 +275,8 @@ export default defineConfig({ emptyOutDir: true, // ELK is distributed as one ~1.6 MiB module by beautiful-mermaid. It is // loaded only when a diagram is rendered and is excluded from HTML - // modulepreload and PWA precache; verify-web-performance.mjs enforces - // those constraints for every chunk above Vite's default 500 KiB limit. + // modulepreload; verify-web-performance.mjs enforces those constraints + // for every chunk above Vite's default 500 KiB limit. chunkSizeWarningLimit: OPTIONAL_CHUNK_WARNING_LIMIT_KB, modulePreload: isDesktopBuild ? false diff --git a/docs/desktop-performance-baseline.md b/docs/desktop-performance-baseline.md index 65ea9fd9..809b6290 100644 --- a/docs/desktop-performance-baseline.md +++ b/docs/desktop-performance-baseline.md @@ -14,13 +14,15 @@ must not be part of the critical path for opening or editing a note. | Create/edit local memo acknowledgement | `< 50 ms` | | Cloud sync | Background only | -The Web/PWA build keeps Mermaid, diagram renderer, code-highlighting, and -editor-only Tiptap/ProseMirror chunks out of both the service-worker precache -and the initial HTML `modulepreload` list. They load and cache on first use -instead, so ordinary note-list startup does not pay for optional editor/diagram -support. The initial modulepreload list is also capped at 700 KiB uncompressed; -the build verifier reports the measured bytes and fails if this critical-path -budget regresses. +The Web/PWA build keeps the install-time service-worker precache limited to the +web manifest. Mermaid, +diagram renderer, code-highlighting, and editor-only Tiptap/ProseMirror chunks +stay out of the initial HTML `modulepreload` list and fill runtime caches on +first use, so ordinary note-list startup does not pay for optional +editor/diagram support. The initial modulepreload list is also capped at 750 KiB +uncompressed; the build verifier reports the measured bytes and fails if this +critical-path budget regresses. The web app remains installable through the +manifest. Remote resource blobs use a separate 90-day, 500-entry cache-first runtime cache. When the browser is offline, newly attached files are written to the @@ -53,8 +55,9 @@ renderer: notebook discovery, memo creation, memo list, and memo detail. Its `thresholds` object is suitable for CI gating; remote sync is deliberately not included in the interactive critical path. -Verify that optional editor and diagram chunks stay out of the initial PWA -precache and HTML modulepreload list: +Verify that the install-time PWA precache contains only the web manifest and +that optional editor and diagram chunks stay out of the initial HTML +modulepreload list: ```sh bun run build:web diff --git a/scripts/desktop-build-workflow.test.mjs b/scripts/desktop-build-workflow.test.mjs index ff3cf5be..b3fd6f23 100644 --- a/scripts/desktop-build-workflow.test.mjs +++ b/scripts/desktop-build-workflow.test.mjs @@ -64,7 +64,7 @@ describe("desktop release workflow", () => { test("runs architecture-independent checks once", () => { for (const name of [ "Cache Bun dependencies for shared validation", - "Verify Web precache budget", + "Verify Web performance budget", "Run project type checks", "Build debug sidecar for integration tests", "Run desktop regression tests", diff --git a/scripts/verify-web-performance.mjs b/scripts/verify-web-performance.mjs index 2e42480b..039b9353 100644 --- a/scripts/verify-web-performance.mjs +++ b/scripts/verify-web-performance.mjs @@ -12,32 +12,20 @@ assert.match(serviceWorker, /edgeever-app-shell/, "PWA navigation must use a ded assert.match(serviceWorker, /edgeever-optional-pdf/, "PWA must cache the optional PDF runtime after first use"); assert.match(serviceWorker, /edgeever-optional-locales/, "PWA must cache the Japanese catalog after first use"); assert.match(serviceWorker, /NetworkFirst/, "PWA navigation must prefer the current deployment over cached HTML"); -assert.match(serviceWorker, /PrecacheFallbackPlugin/, "PWA navigation must retain an offline app-shell fallback"); -assert.doesNotMatch(serviceWorker, /NavigationRoute/, "PWA navigation must not always serve the precached HTML shell"); +assert.doesNotMatch(serviceWorker, /PrecacheFallbackPlugin/, "PWA navigation must not fall back to an install-time HTML shell"); +assert.doesNotMatch(serviceWorker, /NavigationRoute/, "PWA navigation must not always serve a cached HTML shell"); assert.match(staticHeaders, /\/sw\.js\s+Cache-Control: no-cache, no-store, must-revalidate/, "Service worker updates must not use stale browser cache"); assert.match(staticHeaders, /\/assets\/\*\s+Cache-Control: public, max-age=31536000, immutable/, "Fingerprinted assets must use immutable browser caching"); const precacheStart = serviceWorker.indexOf("precacheAndRoute("); -const precacheEnd = serviceWorker.indexOf("cleanupOutdatedCaches", precacheStart); -assert.ok(precacheStart >= 0 && precacheEnd > precacheStart, "Web service worker must contain a precache manifest"); - -const precacheManifest = serviceWorker.slice(precacheStart, precacheEnd); -assert.doesNotMatch(precacheManifest, /\{url:"index\.html",/, "Current HTML must not be served by a cache-first precache route"); -assert.match(precacheManifest, /index\.html\?edgeever-offline-shell=/, "PWA must retain a versioned offline HTML shell"); -const optionalDiagramPattern = /(?:beautiful-mermaid|vendor-mermaid|mermaid\.core|vendor-x6|vendor-codemirror|[^"']*Diagram(?:EditorPane)?-)[^"']*\.js/; -assert.doesNotMatch(precacheManifest, optionalDiagramPattern, "Optional diagram chunks must remain out of the initial PWA precache"); -assert.doesNotMatch(precacheManifest, /vendor~pdf-[^"']*\.js/, "Optional PDF.js runtime must remain out of the initial PWA precache"); -assert.doesNotMatch(precacheManifest, /i18n-ja-[^"']*\.js/, "Japanese catalog must remain out of the initial PWA precache"); -assert.doesNotMatch(precacheManifest, /noto-sans-sc-[^"']*\.woff2/, "Print-only Noto Sans SC shards must remain out of the PWA precache"); - -const precacheURLs = [...precacheManifest.matchAll(/\{url:"([^"]+)"/g)].map((match) => match[1]); -const entryCount = precacheURLs.length; -assert.ok(entryCount > 0, "Web service worker precache manifest must not be empty"); -const precacheBytes = precacheURLs - .filter((url) => !url.startsWith("/") && !url.includes("?")) - .map((url) => statSync(join(distDirectory, url)).size) - .reduce((total, size) => total + size, 0); -const PRECACHE_BUDGET = 5 * 1024 * 1024; -assert.ok(precacheBytes <= PRECACHE_BUDGET, `PWA precache budget exceeded: ${precacheBytes} > ${PRECACHE_BUDGET}`); +assert.ok(precacheStart >= 0, "Web service worker must still register a precache route so outdated caches can be cleaned up"); +const precacheCall = serviceWorker.slice(precacheStart, serviceWorker.indexOf(");", precacheStart) + 2); +const precacheURLs = [...precacheCall.matchAll(/\{url:"([^"]+)"/g)].map((match) => match[1]); +assert.deepEqual( + precacheURLs, + ["manifest.webmanifest"], + `Install-time PWA precache may only keep the web manifest, found: ${precacheURLs.join(", ")}`, +); +assert.doesNotMatch(serviceWorker, /edgeever-offline-shell/, "PWA must not retain a versioned offline HTML shell"); const modulePreloads = indexHtml.match(/]+>/g)?.join("\n") ?? ""; const initialOptionalPattern = /vendor-code-highlight|vendor-D3|beautiful-mermaid|vendor-(?:mermaid|tiptap|prosemirror|floating|codemirror|x6)|ui-primitives|mermaid\.core|[^"']*Diagram(?:EditorPane)?-/; assert.doesNotMatch(modulePreloads, initialOptionalPattern, "Optional editor and diagram chunks must remain out of the initial HTML modulepreload list"); @@ -66,17 +54,10 @@ assert.ok( largeChunks.every(({ name }) => !modulePreloads.includes(name)), "Large optional chunks must not be module-preloaded by the app entry", ); -const nonPrecachedLargeChunks = largeChunks.filter(({ name }) => !name.startsWith("vendor-code-highlight-")); -assert.ok( - nonPrecachedLargeChunks.every(({ name }) => !precacheManifest.includes(name)), - "Large optional diagram chunks must not be included in the PWA precache", -); console.log(JSON.stringify({ ok: true, - precacheEntries: entryCount, - precacheBytes, - precacheBudget: PRECACHE_BUDGET, + precacheEntries: precacheURLs.length, initialModulePreloadBytes, initialModulePreloadBudget: INITIAL_MODULE_PRELOAD_BUDGET, largeDeferredChunks: largeChunks,