diff --git a/apps/page/playwright/offline.e2e.js b/apps/page/playwright/offline.e2e.js
index 9f271d6b..abefda22 100644
--- a/apps/page/playwright/offline.e2e.js
+++ b/apps/page/playwright/offline.e2e.js
@@ -57,3 +57,17 @@ test('loads the home page offline from the bare project path', async ({ context,
expect(requestedOutsideProjectPath).not.toContain('/index.js');
expect(requestedOutsideProjectPath).not.toContain('/manifest.json');
});
+
+test('loads and runs the embed page offline', async ({ context, page }) => {
+ await page.goto('/sl/', { waitUntil: 'networkidle' });
+ await waitForServiceWorkerControl(page);
+
+ await context.setOffline(true);
+ await page.goto('/sl/embed.html?trainType=d51&smoke=true', { waitUntil: 'domcontentloaded' });
+
+ await expect(page.getByRole('heading', { name: 'SL Steam Locomotive Animation' })).toBeAttached();
+ const terminal = page.getByRole('img', { name: 'Animated ASCII art train moving across terminal screen' });
+ await expect(terminal).toBeVisible();
+ await expect.poll(() => terminal.textContent()).toMatch(/[^\s\u00a0]/);
+ await expect(terminal).not.toContainText('Failed to load animation');
+});
diff --git a/apps/page/src/service-worker.test.ts b/apps/page/src/service-worker.test.ts
index edaf31c0..fd07e8aa 100644
--- a/apps/page/src/service-worker.test.ts
+++ b/apps/page/src/service-worker.test.ts
@@ -149,4 +149,42 @@ describe('service worker regressions', () => {
expect(response.headers.get('Location')).toBe('https://example.com/sl/');
expect(cacheOpenCalls).toBe(0);
});
+
+ test('serves the cached embed shell when the extensionless route returns 404', async () => {
+ openCache = async () =>
+ ({
+ match: async (request: RequestInfo | URL) => {
+ expect(request).toBe('/sl/embed.html');
+ return new Response('
SL embed', {
+ headers: {
+ 'Content-Type': 'text/html'
+ }
+ });
+ }
+ }) as unknown as Cache;
+ fetchImpl = (async () => new Response('Not found', { status: 404 })) as unknown as typeof fetch;
+
+ const fetchHandler = listeners.get('fetch');
+ expect(fetchHandler).toBeDefined();
+
+ let responsePromise: Promise | undefined;
+ fetchHandler?.({
+ request: {
+ destination: 'document',
+ method: 'GET',
+ mode: 'navigate',
+ url: 'https://example.com/sl/embed?trainType=d51'
+ },
+ respondWith: (promise: Promise) => {
+ responsePromise = promise;
+ }
+ });
+
+ expect(responsePromise).toBeDefined();
+ const response = await responsePromise;
+
+ expect(response.status).toBe(200);
+ expect(await response.text()).toContain('SL embed');
+ expect(cacheOpenCalls).toBe(1);
+ });
});
diff --git a/apps/page/src/service-worker.ts b/apps/page/src/service-worker.ts
index 013caab1..8ef7254c 100644
--- a/apps/page/src/service-worker.ts
+++ b/apps/page/src/service-worker.ts
@@ -179,7 +179,15 @@ serviceWorker.addEventListener('fetch', (event: FetchEventLike) => {
fetch(request)
.then((response) => {
if (!response.ok) {
- return response;
+ const navigationCachePath = getNavigationCachePath(BASE_PATH, url.pathname);
+ if (!navigationCachePath) {
+ return response;
+ }
+
+ return caches
+ .open(CACHE_NAME)
+ .then((cache) => cache.match(navigationCachePath))
+ .then((cachedResponse) => cachedResponse || response);
}
// Clone the response before caching
diff --git a/apps/page/take-screenshots.mjs b/apps/page/take-screenshots.mjs
index 4945fe5b..81071d9a 100644
--- a/apps/page/take-screenshots.mjs
+++ b/apps/page/take-screenshots.mjs
@@ -52,7 +52,7 @@ console.log('Offline screenshot saved.');
// Also take offline embed screenshot
const embedPage = await context.newPage();
-await embedPage.goto(`${baseURL}/sl/embed?train=D1&loop=true`, { waitUntil: 'domcontentloaded' });
+await embedPage.goto(`${baseURL}/sl/embed.html?train=D1&loop=true`, { waitUntil: 'domcontentloaded' });
await embedPage.waitForTimeout(2000);
await embedPage.screenshot({ path: path.join(__dirname, 'offline-embed.png'), fullPage: false });
console.log('Offline embed screenshot saved.');