Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
150 changes: 150 additions & 0 deletions e2e/pwa-first-install-offline.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
/**
* PWA first-install → offline reload test
*
* Verifies that after the service worker installs and activates for the first
* time, the app shell (HTML + critical CSS + bootstrap JS) is served entirely
* from the precache on the very next reload even when the network is offline.
*
* This test MUST run against the production build served by `vite preview`
* (port 5174) because the SW is intentionally disabled in dev mode.
* It is placed in the pwa-chromium Playwright project for that reason.
*
* Bug history: the Workbox JS runtime route used `/^\/assets\/.*\.js$/i` which
* never matched against full `request.url` hrefs, so optional chunks were never
* cached at runtime. More critically the entry bootstrap was removed from the
* precache manifest, so the first offline reload failed because the bootstrap
* had been fetched before the new worker controlled the page.
*/

import { expect, test } from '@playwright/test';

// Use a fresh browser context for every test so we never inherit a pre-existing
// service worker registration or Cache Storage entries from another spec.
test.use({ contextOptions: { storageState: undefined } });

test.describe('PWA precache — first install then offline reload', () => {
test('app shell loads from cache on first offline reload after SW install', async ({
page,
context,
}) => {
// ── Step 1: online first load ────────────────────────────────────────────
// Navigate to the app. The SW installs and activates on this visit.
await page.goto('/');

// Wait for the service worker to become activated. The SW uses
// 'registerType: prompt' so it does not call skipWaiting automatically;
// we trigger it programmatically via the ServiceWorker API.
await page.waitForFunction(
() =>
navigator.serviceWorker.controller !== null ||
navigator.serviceWorker.getRegistration().then((r) => r?.active != null),
{ timeout: 20_000 },
);

// Force the waiting SW to take control immediately (simulates the user
// accepting an update prompt or the first install finishing activation).
await page.evaluate(async () => {
const reg = await navigator.serviceWorker.getRegistration();
if (!reg) throw new Error('No SW registration found');

// If there is a waiting worker, activate it now.
if (reg.waiting) {
reg.waiting.postMessage({ type: 'SKIP_WAITING' });
}

// Wait until a controller is set (i.e. the SW is active and controlling).
if (!navigator.serviceWorker.controller) {
await new Promise<void>((resolve) => {
navigator.serviceWorker.addEventListener('controllerchange', () => resolve(), {
once: true,
});
});
}
});

// Allow precache population to complete: the SW caches all injected
// manifest entries during the `install` event, so by the time the worker
// controls the page the precache should already be warm. One extra tick
// is enough.
await page.waitForTimeout(500);

// ── Step 2: go offline ───────────────────────────────────────────────────
await context.setOffline(true);

// ── Step 3: reload while offline ────────────────────────────────────────
// The page must load from the service worker's precache, not the network.
const [response] = await Promise.all([
page.waitForResponse((res) => res.url().includes(page.url().replace(/\/$/, ''))),
page.reload(),
]);

// A network-served response would throw while offline; getting here means
// the SW intercepted the navigation request and served it from cache.

// ── Step 4: verify the page rendered ────────────────────────────────────
// The app should render without a network error page. We look for the
// root `<div id="root">` being non-empty, which proves React bootstrapped.
await expect(page.locator('#root')).not.toBeEmpty({ timeout: 10_000 });

// ── Step 5: verify bootstrap JS was served from cache ───────────────────
// Check that at least one assets/index-*.js request was served (status 200)
// during the offline reload, which would only be possible if it came from
// the service worker's precache.
const swCacheHasBootstrap = await page.evaluate(async () => {
const cacheNames = await caches.keys();
// The bootstrap may be in either the precache or the runtime assets cache.
for (const name of cacheNames) {
const cache = await caches.open(name);
const keys = await cache.keys();
const bootstrapEntry = keys.find((req) =>
/\/assets\/index-[^/]+\.js$/.test(new URL(req.url).pathname),
);
if (bootstrapEntry) return true;
}
return false;
});

expect(swCacheHasBootstrap).toBe(true);

// ── Step 6: verify the JS runtime route matches by pathname ─────────────
// A non-bootstrap chunk (e.g. a route chunk that was fetched during the
// initial online load) should be findable in the runtime 'assets' cache,
// proving the pathname-based route matcher works.
const swRuntimeCacheHasChunk = await page.evaluate(async () => {
const cacheNames = await caches.keys();
const assetsCacheName = cacheNames.find((n) => n.includes('-assets-'));
if (!assetsCacheName) {
// If no extra chunks were fetched yet that's also acceptable — the
// route is correct as long as the precache bootstrap is present.
return null;
}
const cache = await caches.open(assetsCacheName);
const keys = await cache.keys();
return keys.some((req) => new URL(req.url).pathname.startsWith('/assets/'));
});

// null means no extra chunk was fetched, which is fine.
// true means the runtime assets cache is working with correct pathname matching.
expect(swRuntimeCacheHasChunk).not.toBe(false);
});

test('offline navigation to /send serves the precached shell', async ({ page, context }) => {
// Install the SW by loading the app once online.
await page.goto('/');

await page.waitForFunction(() => navigator.serviceWorker.controller !== null, {
timeout: 20_000,
});

await context.setOffline(true);

// Navigate to a different route while offline. The SW must serve the
// index.html shell (which handles all SPA routes via precache + fallback).
await page.goto('/send');

await expect(page.locator('#root')).not.toBeEmpty({ timeout: 10_000 });

// The page title should still include the app name (not a browser error page).
await expect(page).toHaveTitle(/Wraith/i);
});
});
33 changes: 28 additions & 5 deletions playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,42 @@ export default defineConfig({
{
name: 'chromium',
use: { ...devices['Desktop Chrome'] },
testIgnore: ['**/e2e/pwa-*.spec.ts'],
},
{
name: 'firefox',
use: { ...devices['Desktop Firefox'] },
testIgnore: ['**/e2e/pwa-*.spec.ts'],
},
{
name: 'webkit',
use: { ...devices['Desktop Safari'] },
testIgnore: ['**/e2e/pwa-*.spec.ts'],
},
// PWA tests require a production build (the SW is disabled in dev mode).
// They run only on Chromium, which has the most complete SW + Cache API
// support required for the offline-reload scenario.
{
name: 'pwa-chromium',
use: {
...devices['Desktop Chrome'],
baseURL: 'http://127.0.0.1:5174',
},
testMatch: ['**/e2e/pwa-*.spec.ts'],
},
],
webServer: [
{
command: 'pnpm dev',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
},
{
// The pwa-chromium project points at the preview server. The build must
// have run before `playwright test` is invoked (pnpm build && pnpm test).
command: 'pnpm preview --port 5174',
url: 'http://127.0.0.1:5174',
reuseExistingServer: !process.env.CI,
},
],
webServer: {
command: 'pnpm dev',
url: 'http://127.0.0.1:5173',
reuseExistingServer: !process.env.CI,
},
});
20 changes: 20 additions & 0 deletions src/lib/swCache.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import {
CACHE_KINDS,
CACHE_PREFIX,
CACHE_VERSION,
CACHE_VERSION_SUFFIX,
Expand Down Expand Up @@ -117,3 +118,22 @@ describe('activation cache migration', () => {
await expect(purgeObsoleteCaches(failingDelete)).resolves.toEqual([]);
});
});

describe('bounded precache schema (v2)', () => {
it('owns bounded runtime caches for optional chain/media assets', () => {
expect(CACHE_KINDS).toContain('assets');
expect(CACHE_KINDS).toContain('images');
expect(cacheName('assets')).toBe(`wraith-assets-${CACHE_VERSION_SUFFIX}`);
expect(cacheName('images')).toBe(`wraith-images-${CACHE_VERSION_SUFFIX}`);
});

it('purges every v1 cache on v2 activation (storage-pressure release)', () => {
const v1 = expectedCacheNames(CACHE_VERSION - 1);
const v2 = expectedCacheNames();

const plan = planCacheMigration([...v1, ...v2]);

expect(plan.keep).toEqual(v2);
expect(plan.delete).toEqual(v1);
});
});
12 changes: 10 additions & 2 deletions src/lib/swCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,21 @@ export const CACHE_PREFIX = 'wraith';
* Cache schema version. Increment to invalidate every cache this app owns.
* Keep in sync with the release notes: a bump is a cache migration.
*/
export const CACHE_VERSION = 1;
export const CACHE_VERSION = 2;

/** Suffix appended to every versioned cache name (`v1`, `v2`, …). */
export const CACHE_VERSION_SUFFIX = `v${CACHE_VERSION}`;

/** Cache kinds the app owns. `precache`/`runtime` match Workbox's own names. */
export const CACHE_KINDS = ['precache', 'runtime', 'rpc', 'fonts-styles', 'fonts-files'] as const;
export const CACHE_KINDS = [
'precache',
'runtime',
'rpc',
'fonts-styles',
'fonts-files',
'assets',
'images',
] as const;

export type CacheKind = (typeof CACHE_KINDS)[number];

Expand Down
39 changes: 39 additions & 0 deletions src/sw/app-sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,45 @@ registerRoute(
}),
);

// ─── Runtime caching: optional chain & media assets ──────────────────────────
// The application shell (HTML, critical CSS, the entry bootstrap JS) is
// precached — see vite.config.ts globPatterns. All other JS chunks (route
// chunks, chain SDKs) and media are NOT precached. They are cached on first
// use in bounded, expiring caches so a deploy or storage pressure cannot let
// them grow without bound. Vite hashes file names by content, so CacheFirst is
// safe: a new deploy serves new URLs and automatically busts old entries.
//
// NOTE: Workbox RegExpRoute evaluates the full `request.url` href, so a
// pattern anchored with `^/` would never match `https://host/assets/file.js`.
// All same-origin asset routes therefore use a callback that tests
// `url.pathname` directly.

// JS chunks — non-bootstrap route chunks, chain SDKs, workers.
// Excludes the entry bootstrap (assets/index-*.js) which is already precached.
registerRoute(
({ url, sameOrigin }) =>
sameOrigin && url.pathname.startsWith('/assets/') && url.pathname.endsWith('.js'),
new CacheFirst({
cacheName: cacheName('assets'),
plugins: [
new ExpirationPlugin({ maxEntries: 60, maxAgeSeconds: 7 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
}),
);

// Images — icons, OG image, logos, favicons not already precached.
registerRoute(
({ url, sameOrigin }) => sameOrigin && /\.(?:png|jpg|jpeg|svg|gif|webp|ico)$/i.test(url.pathname),
new CacheFirst({
cacheName: cacheName('images'),
plugins: [
new ExpirationPlugin({ maxEntries: 40, maxAgeSeconds: 30 * 24 * 60 * 60 }),
new CacheableResponsePlugin({ statuses: [0, 200] }),
],
}),
);

// ─── Stellar Notification Service Worker logic ────────────────────────────────
// (merged from src/sw/stellar-notification-sw.ts)

Expand Down
18 changes: 14 additions & 4 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,11 +81,21 @@ export default defineConfig({

// injectManifest config: controls what gets injected into self.__WB_MANIFEST
injectManifest: {
// Precache all built assets (JS, CSS, HTML, icons, fonts)
// Precache all built assets: JS (including the entry bootstrap that the
// browser fetches before the new worker controls the page), CSS, HTML,
// icons, and fonts. The bootstrap must be in the precache so the first
// offline reload after installation succeeds.
//
// Vite names several chunks index-*.js (vendor bundles, the entry
// bootstrap, etc.) so there is no reliable glob that selects only the
// small entry file. Precaching everything and raising the per-file
// limit for the heavy SDK bundles is the correct approach.
globPatterns: ['**/*.{js,css,html,ico,png,svg,woff2}'],
// app-sw.ts is the service worker itself, not a prefetchable asset
globIgnores: ['**/app-sw*'],
// Raise file size limit — Stellar SDK + wagmi chunks exceed 2 MiB
// app-sw.ts is the service worker itself, not a prefetchable asset.
// Also exclude any legacy stellar-notification-sw bundle.
globIgnores: ['**/app-sw*', '**/stellar-notification-sw*'],
// Raise the per-file limit: the Stellar SDK and wagmi vendor chunks
// exceed Workbox's default 2 MiB cap.
maximumFileSizeToCacheInBytes: 4 * 1024 * 1024, // 4 MiB
},

Expand Down
Loading