From 4b5aec70c916b1e56e05db1d5be9595164efe3c7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:38:51 +0000 Subject: [PATCH 1/7] feature: PWA (vite-plugin-pwa), Firebase hosting and GitHub Actions CI for the React app Co-Authored-By: Ishita Gupta --- .github/workflows/react-app.yml | 100 +++++++++++++++++++++++++ .travis.yml | 23 ------ firebase.json | 18 ++++- react-app/README.md | 36 +++++++++ react-app/e2e/offline.spec.ts | 108 +++++++++++++++++++++++++++ react-app/playwright.config.ts | 50 +++++++++++++ react-app/src/main.tsx | 1 + react-app/src/pwa/index.ts | 5 ++ react-app/src/pwa/registerSW.test.ts | 108 +++++++++++++++++++++++++++ react-app/src/pwa/registerSW.ts | 40 ++++++++++ react-app/tsconfig.app.json | 2 +- react-app/vite.config.ts | 65 +++++++++++++++- 12 files changed, 529 insertions(+), 27 deletions(-) create mode 100644 .github/workflows/react-app.yml delete mode 100644 .travis.yml create mode 100644 react-app/e2e/offline.spec.ts create mode 100644 react-app/playwright.config.ts create mode 100644 react-app/src/pwa/index.ts create mode 100644 react-app/src/pwa/registerSW.test.ts create mode 100644 react-app/src/pwa/registerSW.ts diff --git a/.github/workflows/react-app.yml b/.github/workflows/react-app.yml new file mode 100644 index 000000000..cee6adfec --- /dev/null +++ b/.github/workflows/react-app.yml @@ -0,0 +1,100 @@ +name: React app + +on: + push: + branches: [master] + pull_request: + workflow_dispatch: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +permissions: + contents: read + +defaults: + run: + working-directory: react-app + +jobs: + check: + name: Lint, typecheck, test, build + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: react-app/package-lock.json + - run: npm ci + - run: npm run lint + - run: npm run typecheck + - run: npm test + - run: npm run build + - run: npm run format:check + + e2e: + name: Playwright (offline / PWA) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: react-app/package-lock.json + - run: npm ci + - run: npx playwright install --with-deps chromium + - run: npm run e2e + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: playwright-report + path: | + react-app/playwright-report + react-app/test-results + retention-days: 7 + + deploy: + name: Deploy to Firebase Hosting + needs: [check, e2e] + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + runs-on: ubuntu-latest + defaults: + run: + working-directory: . + steps: + - name: Check for Firebase credentials + id: creds + env: + FIREBASE_SERVICE_ACCOUNT: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + run: | + [ -n "$FIREBASE_SERVICE_ACCOUNT" ] && echo "service_account=true" >> "$GITHUB_OUTPUT" + [ -n "$FIREBASE_TOKEN" ] && echo "token=true" >> "$GITHUB_OUTPUT" + if [ -z "$FIREBASE_SERVICE_ACCOUNT" ] && [ -z "$FIREBASE_TOKEN" ]; then + echo "::notice::No FIREBASE_SERVICE_ACCOUNT or FIREBASE_TOKEN secret; skipping deploy." + fi + - uses: actions/checkout@v4 + if: steps.creds.outputs.service_account == 'true' || steps.creds.outputs.token == 'true' + - uses: actions/setup-node@v4 + if: steps.creds.outputs.service_account == 'true' || steps.creds.outputs.token == 'true' + with: + node-version: 20 + cache: npm + cache-dependency-path: react-app/package-lock.json + # firebase.json's hosting predeploy runs `npm ci` + `npm run build` in react-app/. + - name: Deploy (service account) + if: steps.creds.outputs.service_account == 'true' + uses: FirebaseExtended/action-hosting-deploy@v0 + with: + firebaseServiceAccount: ${{ secrets.FIREBASE_SERVICE_ACCOUNT }} + projectId: angular2-hn + channelId: live + - name: Deploy (token) + if: steps.creds.outputs.service_account != 'true' && steps.creds.outputs.token == 'true' + env: + FIREBASE_TOKEN: ${{ secrets.FIREBASE_TOKEN }} + run: npx --yes firebase-tools@15 deploy --only hosting --project default --non-interactive diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index ff155b5fa..000000000 --- a/.travis.yml +++ /dev/null @@ -1,23 +0,0 @@ -language: node_js -node_js: - - "6.9" - -branches: - only: - - master - -before_script: - - npm install -g firebase-tools - - npm install -g @angular/cli - -script: - - npm run build - -after_success: - - firebase use default - - firebase deploy --token $FIREBASE_TOKEN - -notifications: - email: - on_failure: change - on_success: change diff --git a/firebase.json b/firebase.json index 67d566789..1b7999bd4 100644 --- a/firebase.json +++ b/firebase.json @@ -3,12 +3,28 @@ "rules": "database.rules.json" }, "hosting": { - "public": "dist", + "public": "react-app/dist", + "ignore": ["firebase.json", "**/.*", "**/node_modules/**"], + "predeploy": ["npm --prefix \"$PROJECT_DIR/react-app\" ci", "npm --prefix \"$PROJECT_DIR/react-app\" run build"], "rewrites": [ { "source": "**", "destination": "/index.html" } + ], + "headers": [ + { + "regex": "^/assets/[^/]+-[A-Za-z0-9_-]{8}\\.[a-z0-9]+$", + "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] + }, + { + "source": "/@(sw.js|index.html|manifest.webmanifest)", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] + }, + { + "source": "**/!(*.*)", + "headers": [{ "key": "Cache-Control", "value": "no-cache" }] + } ] } } diff --git a/react-app/README.md b/react-app/README.md index c41ac7579..ffeef91e6 100644 --- a/react-app/README.md +++ b/react-app/README.md @@ -44,3 +44,39 @@ Requires Node >= 20.19. - **Links**: use `` from `react-router` for internal routes (`/item/:id`, `/user/:id`, `/:feed/:page`). For `routerLinkActive="active"`, use `` (it adds `active`). - **Tests**: Vitest + Testing Library, `*.test.tsx` next to the component. Use the helpers in `src/test`: `renderApp({ route })` (full route tree), `renderWithProviders(ui, { path, route, settings, handle })`, `mockFetch({ 'url-substring': body | { status, body } })`, and the fixtures in `test/fixtures.ts`. - **Formatting**: Prettier settings are copied from the root `package.json` (4 spaces, single quotes, 120 cols, es5 trailing commas). + +## Build & deploy + +### PWA + +`vite-plugin-pwa` (`generateSW`, `registerType: 'autoUpdate'`) is configured in `vite.config.ts` and only runs on `npm run build`; the dev server has no service worker. + +- **Manifest**: generated as `dist/manifest.webmanifest` from the same fields as the Angular `src/manifest.json`. The plugin adds `` to `index.html`. +- **Precache**: JS/CSS/HTML/ico/png/svg in `dist/` (the app shell, lazy route chunks and `public/assets` icons) plus the manifest. Only `workbox.globPatterns` selects files. Don't add `includeAssets`, and keep `includeManifestIcons: false`: a URL listed twice makes Workbox throw `add-to-cache-list-conflicting-entries`, and the SW installs but caches nothing. Check `dist/sw.js` after changing the config. +- **Deep links**: `navigateFallback: 'index.html'`, so `/item/123` loads offline. +- **API**: `node-hnapi.herokuapp.com` and `api.hnpwa.com` use NetworkFirst (5 s timeout, 7 day / 200 + 100 entry cap). Pages visited online work offline. +- **Registration**: `src/pwa/index.ts` (imported by `main.tsx`) calls `registerServiceWorker` from `src/pwa/registerSW.ts`. The new SW activates immediately (`skipWaiting` + `clientsClaim`), and open tabs check for an update every hour while online. + +### E2E + +`npm run e2e` builds the app and runs Playwright against `vite preview` on port 4173. Service workers are blocked by default, so `page.route` mocks keep working. `e2e/offline.spec.ts` opts back in with `test.use({ serviceWorkers: 'allow' })`. `playwright.config.ts` sets `PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS=1` so `context.route` also sees the SW's fetches. `context.setOffline(true)` doesn't stop the SW's own fetches, so the test also aborts every route. If `npx playwright install` is blocked, the config uses the system Chrome under `/opt/.devin/chrome` or `PLAYWRIGHT_CHROME_PATH`. + +### Firebase Hosting + +`firebase.json` (repo root) serves `react-app/dist`: + +- `predeploy` runs `npm ci` + `npm run build` in `react-app/`. +- SPA rewrite `** → /index.html`. +- `Cache-Control: no-cache` for `sw.js`, `index.html`, `manifest.webmanifest` and extensionless SPA routes. +- `public, max-age=31536000, immutable` for Vite's hashed files (`/assets/-<8 char hash>.`). The unhashed icons in `/assets/icons` and `/assets/images` keep Firebase's default caching. +- The `database` rules entry is unchanged. + +Manual deploy from the repo root: `npx firebase-tools deploy --only hosting` (project aliases are in `.firebaserc`). + +### CI + +`.github/workflows/react-app.yml` replaces `.travis.yml`: + +- **check**: on PRs and pushes to `master`, runs `npm ci`, lint, typecheck, test, build and format:check on Node 20 with the npm cache. +- **e2e**: runs the Playwright offline/PWA tests and uploads the report if they fail. +- **deploy**: on pushes to `master`, after both jobs pass. It uses `FirebaseExtended/action-hosting-deploy` if the `FIREBASE_SERVICE_ACCOUNT` secret is set, or falls back to `firebase-tools` with `FIREBASE_TOKEN`. With neither secret it skips with a notice, so forks don't fail. diff --git a/react-app/e2e/offline.spec.ts b/react-app/e2e/offline.spec.ts new file mode 100644 index 000000000..164d7545a --- /dev/null +++ b/react-app/e2e/offline.spec.ts @@ -0,0 +1,108 @@ +/// +import { expect, test, type BrowserContext, type Page } from '@playwright/test'; + +const story = { + id: 8863, + title: 'My YC app: Dropbox - Throw away your USB drive', + points: 104, + user: 'dhouston', + time: 1175714200, + time_ago: '15 years ago', + comments_count: 71, + type: 'link', + url: 'http://www.getdropbox.com/u/2/screencast.html', + domain: 'getdropbox.com', +}; +const FEED_URL = 'https://node-hnapi.herokuapp.com/news?page=1'; +const ITEM_URL = 'https://node-hnapi.herokuapp.com/item/8863'; + +test.use({ serviceWorkers: 'allow' }); + +async function mockApi(context: BrowserContext) { + await context.route('https://node-hnapi.herokuapp.com/**', (route) => { + const { pathname } = new URL(route.request().url()); + const body = pathname.startsWith('/item/') ? { ...story, comments: [], content: '' } : [story]; + return route.fulfill({ json: body, headers: { 'access-control-allow-origin': '*' } }); + }); +} + +async function waitForServiceWorkerControl(page: Page) { + await page.evaluate(async () => { + await navigator.serviceWorker.ready; + if (!navigator.serviceWorker.controller) { + await new Promise((resolve) => + navigator.serviceWorker.addEventListener('controllerchange', resolve, { once: true }) + ); + } + }); +} + +// setOffline doesn't cover the SW's own fetches and route mocks keep answering while offline, +// so swap the mocks for a route that fails every request (SW fetches included). +async function goOffline(context: BrowserContext) { + await context.unrouteAll(); + await context.route('**/*', (route) => route.abort('internetdisconnected')); + await context.setOffline(true); +} + +function fetchJson(page: Page, url: string) { + return page.evaluate(async (u) => (await fetch(u)).json(), url); +} + +test('app shell, deep links and visited API responses work offline', async ({ context, page }) => { + await mockApi(context); + + await page.goto('/news/1'); + await expect(page.locator('#root .main-content')).toBeVisible(); + await waitForServiceWorkerControl(page); + + // Feed/item requests made online go through the SW's NetworkFirst cache. + // (Made explicitly so this test doesn't depend on the page components' markup.) + expect(await fetchJson(page, FEED_URL)).toEqual([story]); + expect(await fetchJson(page, ITEM_URL)).toMatchObject({ id: story.id }); + + await goOffline(context); + + const reload = await page.reload(); + expect(reload?.fromServiceWorker()).toBe(true); + await expect(page.locator('#root .main-content')).toBeVisible(); + await expect(page).toHaveTitle('Angular 2 HN'); + expect(await fetchJson(page, FEED_URL)).toEqual([story]); + + // Deep link that was never loaded as a document: served by navigateFallback, lazy chunk from precache. + const deepLink = await page.goto('/item/8863'); + expect(deepLink?.fromServiceWorker()).toBe(true); + await expect(page).toHaveURL(/\/item\/8863$/); + await expect(page.locator('#root .main-content')).toBeVisible(); + expect(await fetchJson(page, ITEM_URL)).toMatchObject({ id: story.id, title: story.title }); + + // Pages never visited online have no cached API data. + await expect(fetchJson(page, 'https://node-hnapi.herokuapp.com/item/1')).rejects.toThrow(); +}); + +test('serves a valid web app manifest', async ({ page }) => { + await page.goto('/news/1'); + const href = await page.locator('link[rel="manifest"]').getAttribute('href'); + expect(href).toBe('/manifest.webmanifest'); + await expect(page.locator('meta[name="theme-color"]')).toHaveAttribute('content', '#b92b27'); + + const manifest = await (await page.request.get(href!)).json(); + expect(manifest).toMatchObject({ + name: 'Angular 2 HN', + short_name: 'Angular 2 HN', + theme_color: '#b92b27', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: './?utm_source=web_app_manifest', + }); + expect(manifest.icons.map((icon: { sizes: string }) => icon.sizes)).toEqual([ + '144x144', + '192x192', + '256x256', + '512x512', + ]); + for (const icon of manifest.icons) { + expect((await page.request.get(icon.src)).ok()).toBe(true); + } +}); diff --git a/react-app/playwright.config.ts b/react-app/playwright.config.ts new file mode 100644 index 000000000..f1c5bc7dc --- /dev/null +++ b/react-app/playwright.config.ts @@ -0,0 +1,50 @@ +// Let context.route intercept requests made by the service worker (offline/PWA tests). +process.env.PW_EXPERIMENTAL_SERVICE_WORKER_NETWORK_EVENTS = '1'; + +import { existsSync, readdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { defineConfig, devices } from '@playwright/test'; + +const PORT = 4173; +const DEVIN_CHROME_DIR = '/opt/.devin/chrome/chrome'; + +// Falls back to the system Chrome when `npx playwright install` isn't available. +function systemChrome(): string | undefined { + if (process.env.PLAYWRIGHT_CHROME_PATH) { + return process.env.PLAYWRIGHT_CHROME_PATH; + } + if (process.env.CI || !existsSync(DEVIN_CHROME_DIR)) { + return undefined; + } + return readdirSync(DEVIN_CHROME_DIR) + .filter((dir) => dir.startsWith('linux-')) + .map((dir) => join(DEVIN_CHROME_DIR, dir, 'chrome-linux64', 'chrome')) + .find((path) => existsSync(path)); +} + +export default defineConfig({ + testDir: './e2e', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL: `http://localhost:${PORT}`, + trace: 'retain-on-failure', + // Once a SW controls the page, its fetches skip page.route mocks. Offline tests opt back in. + serviceWorkers: 'block', + }, + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'], launchOptions: { executablePath: systemChrome() } }, + }, + ], + // Production build: the service worker is only generated by `vite build`. + webServer: { + command: `npm run build && npx vite preview --port ${PORT} --strictPort`, + url: `http://localhost:${PORT}`, + reuseExistingServer: !process.env.CI, + timeout: 180_000, + }, +}); diff --git a/react-app/src/main.tsx b/react-app/src/main.tsx index 1f7c2c721..c1864e999 100644 --- a/react-app/src/main.tsx +++ b/react-app/src/main.tsx @@ -3,6 +3,7 @@ import { createRoot } from 'react-dom/client'; import { App } from './App'; import './styles.scss'; +import './pwa'; createRoot(document.getElementById('root')!).render( diff --git a/react-app/src/pwa/index.ts b/react-app/src/pwa/index.ts new file mode 100644 index 000000000..94ca479af --- /dev/null +++ b/react-app/src/pwa/index.ts @@ -0,0 +1,5 @@ +import { registerSW } from 'virtual:pwa-register'; + +import { registerServiceWorker } from './registerSW'; + +registerServiceWorker(registerSW); diff --git a/react-app/src/pwa/registerSW.test.ts b/react-app/src/pwa/registerSW.test.ts new file mode 100644 index 000000000..36185cf3a --- /dev/null +++ b/react-app/src/pwa/registerSW.test.ts @@ -0,0 +1,108 @@ +import type { RegisterSWOptions } from 'virtual:pwa-register'; + +import { registerServiceWorker, type RegisterSW } from './registerSW'; + +function setup({ serviceWorker = true, onLine = true } = {}) { + if (serviceWorker) { + Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: {} }); + } + vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(onLine); + let options: RegisterSWOptions = {}; + const updateSW = vi.fn(); + const register = vi.fn((opts = {}) => { + options = opts; + return updateSW; + }); + const logger = { info: vi.fn(), error: vi.fn() }; + const result = registerServiceWorker(register, { updateCheckIntervalMs: 1000, logger }); + return { register, updateSW, logger, result, options: () => options }; +} + +function fakeRegistration(overrides: Partial = {}) { + return { + installing: null, + update: vi.fn().mockResolvedValue(undefined), + ...overrides, + } as unknown as ServiceWorkerRegistration; +} + +describe('registerServiceWorker', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.restoreAllMocks(); + Reflect.deleteProperty(navigator, 'serviceWorker'); + }); + + it('does nothing when service workers are unsupported', () => { + const { register, result } = setup({ serviceWorker: false }); + expect(register).not.toHaveBeenCalled(); + expect(result).toBeUndefined(); + }); + + it('registers immediately and returns the update function', () => { + const { register, updateSW, result, options } = setup(); + expect(register).toHaveBeenCalledTimes(1); + expect(options().immediate).toBe(true); + expect(result).toBe(updateSW); + }); + + it('polls for a new service worker while online', async () => { + const { options } = setup(); + const registration = fakeRegistration(); + options().onRegisteredSW?.('/sw.js', registration); + + expect(registration.update).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1000); + expect(registration.update).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1000); + expect(registration.update).toHaveBeenCalledTimes(2); + }); + + it('skips update checks while offline', async () => { + const { options } = setup({ onLine: false }); + const registration = fakeRegistration(); + options().onRegisteredSW?.('/sw.js', registration); + + await vi.advanceTimersByTimeAsync(1000); + expect(registration.update).not.toHaveBeenCalled(); + }); + + it('skips update checks while a new worker is installing', async () => { + const { options } = setup(); + const registration = fakeRegistration({ installing: {} as ServiceWorker }); + options().onRegisteredSW?.('/sw.js', registration); + + await vi.advanceTimersByTimeAsync(1000); + expect(registration.update).not.toHaveBeenCalled(); + }); + + it('does not poll when registration is missing', () => { + const { options } = setup(); + expect(() => options().onRegisteredSW?.('/sw.js', undefined)).not.toThrow(); + expect(vi.getTimerCount()).toBe(0); + }); + + it('logs failed update checks instead of throwing', async () => { + const { options, logger } = setup(); + const error = new Error('network'); + const registration = fakeRegistration({ update: vi.fn().mockRejectedValue(error) }); + options().onRegisteredSW?.('/sw.js', registration); + + await vi.advanceTimersByTimeAsync(1000); + expect(logger.error).toHaveBeenCalledWith('Service worker update failed', error); + }); + + it('logs offline-ready and registration errors', () => { + const { options, logger } = setup(); + options().onOfflineReady?.(); + expect(logger.info).toHaveBeenCalledWith('App is ready to work offline.'); + + const error = new Error('blocked'); + options().onRegisterError?.(error); + expect(logger.error).toHaveBeenCalledWith('Service worker registration failed', error); + }); +}); diff --git a/react-app/src/pwa/registerSW.ts b/react-app/src/pwa/registerSW.ts new file mode 100644 index 000000000..ef8d6de25 --- /dev/null +++ b/react-app/src/pwa/registerSW.ts @@ -0,0 +1,40 @@ +import type { RegisterSWOptions } from 'virtual:pwa-register'; + +export type RegisterSW = (options?: RegisterSWOptions) => (reloadPage?: boolean) => Promise; + +export const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; + +interface Options { + updateCheckIntervalMs?: number; + logger?: Pick; +} + +// The SW auto-updates (skipWaiting + clientsClaim); a long-lived tab also polls for a new SW. +export function registerServiceWorker( + register: RegisterSW, + { updateCheckIntervalMs = UPDATE_CHECK_INTERVAL_MS, logger = console }: Options = {} +) { + if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { + return undefined; + } + return register({ + immediate: true, + onRegisteredSW(_swUrl, registration) { + if (!registration) { + return; + } + setInterval(() => { + if (registration.installing || !navigator.onLine) { + return; + } + registration.update().catch((error: unknown) => logger.error('Service worker update failed', error)); + }, updateCheckIntervalMs); + }, + onOfflineReady() { + logger.info('App is ready to work offline.'); + }, + onRegisterError(error: unknown) { + logger.error('Service worker registration failed', error); + }, + }); +} diff --git a/react-app/tsconfig.app.json b/react-app/tsconfig.app.json index 5d62d3263..d8e33e9f1 100644 --- a/react-app/tsconfig.app.json +++ b/react-app/tsconfig.app.json @@ -12,7 +12,7 @@ "moduleDetection": "force", "noEmit": true, "jsx": "react-jsx", - "types": ["vite/client", "vitest/globals", "@testing-library/jest-dom"], + "types": ["vite/client", "vite-plugin-pwa/client", "vitest/globals", "@testing-library/jest-dom"], "strict": true, "noUnusedLocals": true, "noUnusedParameters": true, diff --git a/react-app/vite.config.ts b/react-app/vite.config.ts index c3718cac9..2d94848d2 100644 --- a/react-app/vite.config.ts +++ b/react-app/vite.config.ts @@ -1,10 +1,71 @@ /// import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; +import { VitePWA } from 'vite-plugin-pwa'; + +const API_CACHE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; -// PWA (vite-plugin-pwa) is configured by the PWA/build/deploy workstream. export default defineConfig({ - plugins: [react()], + plugins: [ + react(), + VitePWA({ + strategies: 'generateSW', + registerType: 'autoUpdate', + // src/pwa registers the SW itself (virtual:pwa-register). + injectRegister: false, + // Never combine includeAssets with globPatterns: duplicate precache URLs make Workbox + // throw add-to-cache-list-conflicting-entries and the SW caches nothing. + includeManifestIcons: false, + // Same fields as the Angular app's src/manifest.json. + manifest: { + name: 'Angular 2 HN', + short_name: 'Angular 2 HN', + icons: [144, 192, 256, 512].map((size) => ({ + src: `assets/icons/android-chrome-${size}x${size}.png`, + sizes: `${size}x${size}`, + type: 'image/png', + })), + theme_color: '#b92b27', + background_color: '#ffffff', + display: 'standalone', + orientation: 'portrait', + start_url: './?utm_source=web_app_manifest', + }, + workbox: { + // App shell: JS/CSS/HTML/icons/svg. manifest.webmanifest is added by the plugin. + globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + navigateFallback: 'index.html', + // Firebase reserved URLs and file requests must not get the app shell. + navigateFallbackDenylist: [/^\/__\//, /\/[^/?]+\.[^/]+$/], + cleanupOutdatedCaches: true, + // autoUpdate only sets these itself when injectRegister is 'auto'. + skipWaiting: true, + clientsClaim: true, + runtimeCaching: [ + { + urlPattern: /^https:\/\/node-hnapi\.herokuapp\.com\/.*/, + handler: 'NetworkFirst', + options: { + cacheName: 'hn-api', + networkTimeoutSeconds: 5, + expiration: { maxEntries: 200, maxAgeSeconds: API_CACHE_MAX_AGE_SECONDS }, + cacheableResponse: { statuses: [200] }, + }, + }, + { + urlPattern: /^https:\/\/api\.hnpwa\.com\/.*/, + handler: 'NetworkFirst', + options: { + cacheName: 'hnpwa-api', + networkTimeoutSeconds: 5, + expiration: { maxEntries: 100, maxAgeSeconds: API_CACHE_MAX_AGE_SECONDS }, + cacheableResponse: { statuses: [200] }, + }, + }, + ], + }, + }), + ], server: { port: 4200, }, From a9d70565857f44062bc4da5c1bdea4cc6cc6e4b5 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:41:37 +0000 Subject: [PATCH 2/7] feature: no-cache for the root URL on Firebase, add robots.txt Co-Authored-By: Ishita Gupta --- firebase.json | 2 +- react-app/public/robots.txt | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) create mode 100644 react-app/public/robots.txt diff --git a/firebase.json b/firebase.json index 1b7999bd4..775c35ecf 100644 --- a/firebase.json +++ b/firebase.json @@ -22,7 +22,7 @@ "headers": [{ "key": "Cache-Control", "value": "no-cache" }] }, { - "source": "**/!(*.*)", + "source": "{/,**/!(*.*)}", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] } ] diff --git a/react-app/public/robots.txt b/react-app/public/robots.txt new file mode 100644 index 000000000..c2a49f4fb --- /dev/null +++ b/react-app/public/robots.txt @@ -0,0 +1,2 @@ +User-agent: * +Allow: / From a7c32c9e7c3ff3995bc383edb66b7a2f8f6d72e7 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:42:08 +0000 Subject: [PATCH 3/7] feature: assert the feed renders from the SW cache offline Co-Authored-By: Ishita Gupta --- react-app/e2e/offline.spec.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/react-app/e2e/offline.spec.ts b/react-app/e2e/offline.spec.ts index 164d7545a..2b058ac2a 100644 --- a/react-app/e2e/offline.spec.ts +++ b/react-app/e2e/offline.spec.ts @@ -54,6 +54,7 @@ test('app shell, deep links and visited API responses work offline', async ({ co await page.goto('/news/1'); await expect(page.locator('#root .main-content')).toBeVisible(); + await expect(page.getByText(story.title).first()).toBeVisible(); await waitForServiceWorkerControl(page); // Feed/item requests made online go through the SW's NetworkFirst cache. @@ -66,6 +67,8 @@ test('app shell, deep links and visited API responses work offline', async ({ co const reload = await page.reload(); expect(reload?.fromServiceWorker()).toBe(true); await expect(page.locator('#root .main-content')).toBeVisible(); + // The feed renders from the SW's runtime cache. + await expect(page.getByText(story.title).first()).toBeVisible(); await expect(page).toHaveTitle('Angular 2 HN'); expect(await fetchJson(page, FEED_URL)).toEqual([story]); From b18597d34107cdd0bbd1c6a53b1950d8ac7b33a9 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:47:07 +0000 Subject: [PATCH 4/7] feature: warm the SW runtime cache with API requests made before it took control Co-Authored-By: Ishita Gupta --- react-app/e2e/offline.spec.ts | 16 +++- react-app/src/pwa/registerSW.test.ts | 111 ++++++++++++++++++++++++++- react-app/src/pwa/registerSW.ts | 37 +++++++++ react-app/src/pwa/runtime-cache.ts | 9 +++ react-app/vite.config.ts | 32 +++----- 5 files changed, 176 insertions(+), 29 deletions(-) create mode 100644 react-app/src/pwa/runtime-cache.ts diff --git a/react-app/e2e/offline.spec.ts b/react-app/e2e/offline.spec.ts index 2b058ac2a..5b689f764 100644 --- a/react-app/e2e/offline.spec.ts +++ b/react-app/e2e/offline.spec.ts @@ -18,8 +18,12 @@ const ITEM_URL = 'https://node-hnapi.herokuapp.com/item/8863'; test.use({ serviceWorkers: 'allow' }); +// Slow enough that the first feed request is still in flight when the SW takes control. +const API_LATENCY_MS = 1500; + async function mockApi(context: BrowserContext) { - await context.route('https://node-hnapi.herokuapp.com/**', (route) => { + await context.route('https://node-hnapi.herokuapp.com/**', async (route) => { + await new Promise((resolve) => setTimeout(resolve, API_LATENCY_MS)); const { pathname } = new URL(route.request().url()); const body = pathname.startsWith('/item/') ? { ...story, comments: [], content: '' } : [story]; return route.fulfill({ json: body, headers: { 'access-control-allow-origin': '*' } }); @@ -45,6 +49,10 @@ async function goOffline(context: BrowserContext) { await context.setOffline(true); } +function isCached(page: Page, cacheName: string, url: string) { + return page.evaluate(async ([name, u]) => !!(await (await caches.open(name)).match(u)), [cacheName, url] as const); +} + function fetchJson(page: Page, url: string) { return page.evaluate(async (u) => (await fetch(u)).json(), url); } @@ -57,9 +65,9 @@ test('app shell, deep links and visited API responses work offline', async ({ co await expect(page.getByText(story.title).first()).toBeVisible(); await waitForServiceWorkerControl(page); - // Feed/item requests made online go through the SW's NetworkFirst cache. - // (Made explicitly so this test doesn't depend on the page components' markup.) - expect(await fetchJson(page, FEED_URL)).toEqual([story]); + // First visit: the feed request started before the SW took control, so registerSW re-requests it. + await expect.poll(() => isCached(page, 'hn-api', FEED_URL)).toBe(true); + // Item details render a placeholder for now, so request the item through the SW directly. expect(await fetchJson(page, ITEM_URL)).toMatchObject({ id: story.id }); await goOffline(context); diff --git a/react-app/src/pwa/registerSW.test.ts b/react-app/src/pwa/registerSW.test.ts index 36185cf3a..e9ce088b0 100644 --- a/react-app/src/pwa/registerSW.test.ts +++ b/react-app/src/pwa/registerSW.test.ts @@ -1,10 +1,46 @@ import type { RegisterSWOptions } from 'virtual:pwa-register'; -import { registerServiceWorker, type RegisterSW } from './registerSW'; +import { IN_FLIGHT_WINDOW_MS, registerServiceWorker, warmRuntimeCache, type RegisterSW } from './registerSW'; -function setup({ serviceWorker = true, onLine = true } = {}) { +const entry = (name: string, startTime = 0) => ({ name, startTime }) as PerformanceEntry; + +function stubResourceEntries(entries: PerformanceEntry[]) { + vi.spyOn(performance, 'getEntriesByType').mockReturnValue(entries); +} + +function stubPerformanceObserver() { + const observer = { + callback: undefined as PerformanceObserverCallback | undefined, + observe: vi.fn(), + disconnect: vi.fn(), + }; + vi.stubGlobal( + 'PerformanceObserver', + class { + observe = observer.observe; + disconnect = observer.disconnect; + constructor(callback: PerformanceObserverCallback) { + observer.callback = callback; + } + } + ); + return { + observer, + emit: (entries: PerformanceEntry[]) => + observer.callback?.( + { getEntries: () => entries } as PerformanceObserverEntryList, + {} as PerformanceObserver + ), + }; +} + +function setup({ serviceWorker = true, onLine = true, controller = null as ServiceWorker | null } = {}) { + const addEventListener = vi.fn(); if (serviceWorker) { - Object.defineProperty(navigator, 'serviceWorker', { configurable: true, value: {} }); + Object.defineProperty(navigator, 'serviceWorker', { + configurable: true, + value: { controller, addEventListener }, + }); } vi.spyOn(navigator, 'onLine', 'get').mockReturnValue(onLine); let options: RegisterSWOptions = {}; @@ -15,7 +51,7 @@ function setup({ serviceWorker = true, onLine = true } = {}) { }); const logger = { info: vi.fn(), error: vi.fn() }; const result = registerServiceWorker(register, { updateCheckIntervalMs: 1000, logger }); - return { register, updateSW, logger, result, options: () => options }; + return { register, updateSW, logger, result, addEventListener, options: () => options }; } function fakeRegistration(overrides: Partial = {}) { @@ -34,9 +70,44 @@ describe('registerServiceWorker', () => { afterEach(() => { vi.useRealTimers(); vi.restoreAllMocks(); + vi.unstubAllGlobals(); Reflect.deleteProperty(navigator, 'serviceWorker'); }); + it('re-requests API responses that started before the SW took control on the first visit', () => { + vi.useFakeTimers(); + const fetchMock = vi.fn().mockResolvedValue(new Response('[]')); + vi.stubGlobal('fetch', fetchMock); + vi.spyOn(performance, 'now').mockReturnValue(100); + stubResourceEntries([entry('https://node-hnapi.herokuapp.com/news?page=1', 10)]); + const { observer, emit } = stubPerformanceObserver(); + const { addEventListener } = setup(); + + expect(addEventListener).toHaveBeenCalledWith('controllerchange', expect.any(Function), { once: true }); + expect(fetchMock).not.toHaveBeenCalled(); + addEventListener.mock.calls[0][1](); + expect(fetchMock).toHaveBeenCalledWith('https://node-hnapi.herokuapp.com/news?page=1'); + expect(observer.observe).toHaveBeenCalledWith({ type: 'resource' }); + + // A request in flight at control time is re-requested; one started afterwards went through the SW. + emit([ + entry('https://node-hnapi.herokuapp.com/item/1', 50), + entry('https://node-hnapi.herokuapp.com/item/2', 150), + ]); + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://node-hnapi.herokuapp.com/news?page=1', + 'https://node-hnapi.herokuapp.com/item/1', + ]); + + vi.advanceTimersByTime(IN_FLIGHT_WINDOW_MS); + expect(observer.disconnect).toHaveBeenCalled(); + }); + + it('does not warm the cache when a SW already controls the page', () => { + const { addEventListener } = setup({ controller: {} as ServiceWorker }); + expect(addEventListener).not.toHaveBeenCalled(); + }); + it('does nothing when service workers are unsupported', () => { const { register, result } = setup({ serviceWorker: false }); expect(register).not.toHaveBeenCalled(); @@ -106,3 +177,35 @@ describe('registerServiceWorker', () => { expect(logger.error).toHaveBeenCalledWith('Service worker registration failed', error); }); }); + +describe('warmRuntimeCache', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('fetches each runtime-cached API URL once and ignores other resources', async () => { + const entries = [ + 'https://node-hnapi.herokuapp.com/news?page=1', + 'https://node-hnapi.herokuapp.com/news?page=1', + 'https://api.hnpwa.com/v0/user/pg.json', + 'http://localhost:4200/assets/index.js', + 'https://node-hnapi.herokuapp.com.evil.test/news', + ].map((name) => entry(name)); + const fetchMock = vi.fn().mockResolvedValue(new Response('{}')); + + await warmRuntimeCache(entries, fetchMock); + + expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([ + 'https://node-hnapi.herokuapp.com/news?page=1', + 'https://api.hnpwa.com/v0/user/pg.json', + ]); + }); + + it('settles when a request fails', async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError('offline')); + + await expect(warmRuntimeCache([entry('https://node-hnapi.herokuapp.com/item/1')], fetchMock)).resolves.toEqual([ + { status: 'rejected', reason: expect.any(TypeError) }, + ]); + }); +}); diff --git a/react-app/src/pwa/registerSW.ts b/react-app/src/pwa/registerSW.ts index ef8d6de25..2d1237ebf 100644 --- a/react-app/src/pwa/registerSW.ts +++ b/react-app/src/pwa/registerSW.ts @@ -1,5 +1,7 @@ import type { RegisterSWOptions } from 'virtual:pwa-register'; +import { isRuntimeCachedUrl } from './runtime-cache'; + export type RegisterSW = (options?: RegisterSWOptions) => (reloadPage?: boolean) => Promise; export const UPDATE_CHECK_INTERVAL_MS = 60 * 60 * 1000; @@ -9,6 +11,38 @@ interface Options { logger?: Pick; } +export const IN_FLIGHT_WINDOW_MS = 60 * 1000; + +export function warmRuntimeCache(entries: PerformanceEntryList, fetchFn: typeof fetch = fetch) { + const urls = new Set(entries.map((entry) => entry.name).filter(isRuntimeCachedUrl)); + return Promise.allSettled([...urls].map((url) => fetchFn(url))); +} + +// On the first visit the API requests start before the SW takes control, so they never reach its +// runtime cache. Once it controls the page, request them again through the SW, including requests +// that were still in flight at that moment. +function warmRuntimeCacheOnFirstControl() { + navigator.serviceWorker.addEventListener( + 'controllerchange', + () => { + const controlledAt = performance.now(); + const startedBeforeControl = (entries: PerformanceEntryList) => + entries.filter((entry) => entry.startTime < controlledAt); + + void warmRuntimeCache(startedBeforeControl(performance.getEntriesByType('resource'))); + if (typeof PerformanceObserver === 'undefined') { + return; + } + const observer = new PerformanceObserver( + (list) => void warmRuntimeCache(startedBeforeControl(list.getEntries())) + ); + observer.observe({ type: 'resource' }); + setTimeout(() => observer.disconnect(), IN_FLIGHT_WINDOW_MS); + }, + { once: true } + ); +} + // The SW auto-updates (skipWaiting + clientsClaim); a long-lived tab also polls for a new SW. export function registerServiceWorker( register: RegisterSW, @@ -17,6 +51,9 @@ export function registerServiceWorker( if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) { return undefined; } + if (!navigator.serviceWorker.controller) { + warmRuntimeCacheOnFirstControl(); + } return register({ immediate: true, onRegisteredSW(_swUrl, registration) { diff --git a/react-app/src/pwa/runtime-cache.ts b/react-app/src/pwa/runtime-cache.ts new file mode 100644 index 000000000..52dfcea4f --- /dev/null +++ b/react-app/src/pwa/runtime-cache.ts @@ -0,0 +1,9 @@ +// API origins the service worker caches at runtime (NetworkFirst). Shared with vite.config.ts. +export const API_CACHES = [ + { origin: 'https://node-hnapi.herokuapp.com', cacheName: 'hn-api', maxEntries: 200 }, + { origin: 'https://api.hnpwa.com', cacheName: 'hnpwa-api', maxEntries: 100 }, +] as const; + +export function isRuntimeCachedUrl(url: string) { + return API_CACHES.some(({ origin }) => url.startsWith(`${origin}/`)); +} diff --git a/react-app/vite.config.ts b/react-app/vite.config.ts index 2d94848d2..8be9ec02b 100644 --- a/react-app/vite.config.ts +++ b/react-app/vite.config.ts @@ -3,6 +3,8 @@ import { defineConfig } from 'vite'; import react from '@vitejs/plugin-react'; import { VitePWA } from 'vite-plugin-pwa'; +import { API_CACHES } from './src/pwa/runtime-cache'; + const API_CACHE_MAX_AGE_SECONDS = 7 * 24 * 60 * 60; export default defineConfig({ @@ -41,28 +43,16 @@ export default defineConfig({ // autoUpdate only sets these itself when injectRegister is 'auto'. skipWaiting: true, clientsClaim: true, - runtimeCaching: [ - { - urlPattern: /^https:\/\/node-hnapi\.herokuapp\.com\/.*/, - handler: 'NetworkFirst', - options: { - cacheName: 'hn-api', - networkTimeoutSeconds: 5, - expiration: { maxEntries: 200, maxAgeSeconds: API_CACHE_MAX_AGE_SECONDS }, - cacheableResponse: { statuses: [200] }, - }, - }, - { - urlPattern: /^https:\/\/api\.hnpwa\.com\/.*/, - handler: 'NetworkFirst', - options: { - cacheName: 'hnpwa-api', - networkTimeoutSeconds: 5, - expiration: { maxEntries: 100, maxAgeSeconds: API_CACHE_MAX_AGE_SECONDS }, - cacheableResponse: { statuses: [200] }, - }, + runtimeCaching: API_CACHES.map(({ origin, cacheName, maxEntries }) => ({ + urlPattern: new RegExp(`^${origin.replace(/[.]/g, '\\.')}/`), + handler: 'NetworkFirst' as const, + options: { + cacheName, + networkTimeoutSeconds: 5, + expiration: { maxEntries, maxAgeSeconds: API_CACHE_MAX_AGE_SECONDS }, + cacheableResponse: { statuses: [200] }, }, - ], + })), }, }), ], From 1127f1d159634eccf34cd74de8e54cd418cd5714 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:47:16 +0000 Subject: [PATCH 5/7] feature: document first-visit runtime cache warm-up Co-Authored-By: Ishita Gupta --- react-app/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react-app/README.md b/react-app/README.md index ffeef91e6..7931f06d2 100644 --- a/react-app/README.md +++ b/react-app/README.md @@ -55,7 +55,7 @@ Requires Node >= 20.19. - **Precache**: JS/CSS/HTML/ico/png/svg in `dist/` (the app shell, lazy route chunks and `public/assets` icons) plus the manifest. Only `workbox.globPatterns` selects files. Don't add `includeAssets`, and keep `includeManifestIcons: false`: a URL listed twice makes Workbox throw `add-to-cache-list-conflicting-entries`, and the SW installs but caches nothing. Check `dist/sw.js` after changing the config. - **Deep links**: `navigateFallback: 'index.html'`, so `/item/123` loads offline. - **API**: `node-hnapi.herokuapp.com` and `api.hnpwa.com` use NetworkFirst (5 s timeout, 7 day / 200 + 100 entry cap). Pages visited online work offline. -- **Registration**: `src/pwa/index.ts` (imported by `main.tsx`) calls `registerServiceWorker` from `src/pwa/registerSW.ts`. The new SW activates immediately (`skipWaiting` + `clientsClaim`), and open tabs check for an update every hour while online. +- **Registration**: `src/pwa/index.ts` (imported by `main.tsx`) calls `registerServiceWorker` from `src/pwa/registerSW.ts`. The new SW activates immediately (`skipWaiting` + `clientsClaim`), and open tabs check for an update every hour while online. On the first visit the API requests start before the SW controls the page, so once it takes control `registerSW` re-requests them (from `performance` resource entries) through the SW; otherwise the page you landed on would not work offline. ### E2E From aa0b0b4a1ca48ea40fb9827657b3625b56de8603 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:51:03 +0000 Subject: [PATCH 6/7] feature: offline e2e visits the item page online and asserts it renders from cache Co-Authored-By: Ishita Gupta --- react-app/e2e/offline.spec.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/react-app/e2e/offline.spec.ts b/react-app/e2e/offline.spec.ts index 5b689f764..b79569e14 100644 --- a/react-app/e2e/offline.spec.ts +++ b/react-app/e2e/offline.spec.ts @@ -62,13 +62,16 @@ test('app shell, deep links and visited API responses work offline', async ({ co await page.goto('/news/1'); await expect(page.locator('#root .main-content')).toBeVisible(); - await expect(page.getByText(story.title).first()).toBeVisible(); + await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible(); await waitForServiceWorkerControl(page); // First visit: the feed request started before the SW took control, so registerSW re-requests it. await expect.poll(() => isCached(page, 'hn-api', FEED_URL)).toBe(true); - // Item details render a placeholder for now, so request the item through the SW directly. - expect(await fetchJson(page, ITEM_URL)).toMatchObject({ id: story.id }); + // Visit the item page client-side (through the SW) so its API response is cached too. + await page.getByRole('link', { name: `${story.comments_count} comments` }).click(); + await expect(page).toHaveURL(/\/item\/8863$/); + await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible(); + await page.goBack(); await goOffline(context); @@ -76,7 +79,7 @@ test('app shell, deep links and visited API responses work offline', async ({ co expect(reload?.fromServiceWorker()).toBe(true); await expect(page.locator('#root .main-content')).toBeVisible(); // The feed renders from the SW's runtime cache. - await expect(page.getByText(story.title).first()).toBeVisible(); + await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible(); await expect(page).toHaveTitle('Angular 2 HN'); expect(await fetchJson(page, FEED_URL)).toEqual([story]); @@ -85,6 +88,7 @@ test('app shell, deep links and visited API responses work offline', async ({ co expect(deepLink?.fromServiceWorker()).toBe(true); await expect(page).toHaveURL(/\/item\/8863$/); await expect(page.locator('#root .main-content')).toBeVisible(); + await expect(page.getByText(story.title).filter({ visible: true }).first()).toBeVisible(); expect(await fetchJson(page, ITEM_URL)).toMatchObject({ id: story.id, title: story.title }); // Pages never visited online have no cached API data. From 099752d056d91ce06f051ede6537d5a4b8f5b8c0 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:57:04 +0000 Subject: [PATCH 7/7] bug: retire the Angular ngsw worker for returning visitors and dedupe first-visit cache warm-up Co-Authored-By: Ishita Gupta --- firebase.json | 2 +- react-app/README.md | 1 + react-app/e2e/offline.spec.ts | 24 ++++++++++++++++++++++++ react-app/public/ngsw-worker.js | 16 ++++++++++++++++ react-app/src/pwa/registerSW.test.ts | 2 ++ react-app/src/pwa/registerSW.ts | 17 ++++++++++++----- react-app/vite.config.ts | 2 ++ 7 files changed, 58 insertions(+), 6 deletions(-) create mode 100644 react-app/public/ngsw-worker.js diff --git a/firebase.json b/firebase.json index 775c35ecf..5c3e99038 100644 --- a/firebase.json +++ b/firebase.json @@ -18,7 +18,7 @@ "headers": [{ "key": "Cache-Control", "value": "public, max-age=31536000, immutable" }] }, { - "source": "/@(sw.js|index.html|manifest.webmanifest)", + "source": "/@(sw.js|ngsw-worker.js|index.html|manifest.webmanifest)", "headers": [{ "key": "Cache-Control", "value": "no-cache" }] }, { diff --git a/react-app/README.md b/react-app/README.md index 7931f06d2..0c129187d 100644 --- a/react-app/README.md +++ b/react-app/README.md @@ -56,6 +56,7 @@ Requires Node >= 20.19. - **Deep links**: `navigateFallback: 'index.html'`, so `/item/123` loads offline. - **API**: `node-hnapi.herokuapp.com` and `api.hnpwa.com` use NetworkFirst (5 s timeout, 7 day / 200 + 100 entry cap). Pages visited online work offline. - **Registration**: `src/pwa/index.ts` (imported by `main.tsx`) calls `registerServiceWorker` from `src/pwa/registerSW.ts`. The new SW activates immediately (`skipWaiting` + `clientsClaim`), and open tabs check for an update every hour while online. On the first visit the API requests start before the SW controls the page, so once it takes control `registerSW` re-requests them (from `performance` resource entries) through the SW; otherwise the page you landed on would not work offline. +- **Retiring the Angular SW**: returning visitors still have `@angular/service-worker` registered at `/ngsw-worker.js`. `public/ngsw-worker.js` is a safety worker served at that URL (excluded from the precache, `no-cache` on Firebase): on the browser's next update check it replaces the old worker, deletes the `ngsw:*` caches, unregisters itself and reloads open tabs, which then register `sw.js`. ### E2E diff --git a/react-app/e2e/offline.spec.ts b/react-app/e2e/offline.spec.ts index b79569e14..cb1d1a9df 100644 --- a/react-app/e2e/offline.spec.ts +++ b/react-app/e2e/offline.spec.ts @@ -121,3 +121,27 @@ test('serves a valid web app manifest', async ({ page }) => { expect((await page.request.get(icon.src)).ok()).toBe(true); } }); + +test('ngsw-worker.js retires a previously installed Angular service worker', async ({ page }) => { + await page.goto('/'); + await waitForServiceWorkerControl(page); + + // Simulate a returning Angular visitor: Angular caches plus a registration for ngsw-worker.js. + // The safety worker reloads the page once it has cleaned up. + const reloaded = page.waitForEvent('load'); + await page.evaluate(async () => { + await (await caches.open('ngsw:/:db:control')).put('/x', new Response('x')); + void navigator.serviceWorker.register('/ngsw-worker.js'); + }); + await reloaded; + + await expect + .poll(() => page.evaluate(async () => (await caches.keys()).filter((key) => key.startsWith('ngsw:')))) + .toEqual([]); + // The reloaded page registers the React SW again. + await expect + .poll(() => + page.evaluate(async () => (await navigator.serviceWorker.getRegistration())?.active?.scriptURL ?? '') + ) + .toMatch(/\/sw\.js$/); +}); diff --git a/react-app/public/ngsw-worker.js b/react-app/public/ngsw-worker.js new file mode 100644 index 000000000..bf91ddcd5 --- /dev/null +++ b/react-app/public/ngsw-worker.js @@ -0,0 +1,16 @@ +// Safety worker for visitors who still have the Angular app's @angular/service-worker installed. +// The browser fetches this script on its next update check; it replaces the old worker, clears the +// Angular caches, unregisters itself and reloads open tabs so they pick up the React app and its SW. +self.addEventListener('install', () => self.skipWaiting()); + +self.addEventListener('activate', (event) => { + event.waitUntil( + (async () => { + const keys = await caches.keys(); + await Promise.all(keys.filter((key) => key.startsWith('ngsw:')).map((key) => caches.delete(key))); + await self.registration.unregister(); + const windows = await self.clients.matchAll({ type: 'window' }); + await Promise.all(windows.map((client) => client.navigate(client.url))); + })() + ); +}); diff --git a/react-app/src/pwa/registerSW.test.ts b/react-app/src/pwa/registerSW.test.ts index e9ce088b0..7bec92043 100644 --- a/react-app/src/pwa/registerSW.test.ts +++ b/react-app/src/pwa/registerSW.test.ts @@ -90,7 +90,9 @@ describe('registerServiceWorker', () => { expect(observer.observe).toHaveBeenCalledWith({ type: 'resource' }); // A request in flight at control time is re-requested; one started afterwards went through the SW. + // The snapshot entry reported again by the observer is not re-requested a second time. emit([ + entry('https://node-hnapi.herokuapp.com/news?page=1', 10), entry('https://node-hnapi.herokuapp.com/item/1', 50), entry('https://node-hnapi.herokuapp.com/item/2', 150), ]); diff --git a/react-app/src/pwa/registerSW.ts b/react-app/src/pwa/registerSW.ts index 2d1237ebf..a10d26b16 100644 --- a/react-app/src/pwa/registerSW.ts +++ b/react-app/src/pwa/registerSW.ts @@ -13,9 +13,15 @@ interface Options { export const IN_FLIGHT_WINDOW_MS = 60 * 1000; -export function warmRuntimeCache(entries: PerformanceEntryList, fetchFn: typeof fetch = fetch) { - const urls = new Set(entries.map((entry) => entry.name).filter(isRuntimeCachedUrl)); - return Promise.allSettled([...urls].map((url) => fetchFn(url))); +export function warmRuntimeCache( + entries: PerformanceEntryList, + fetchFn: typeof fetch = fetch, + warmed = new Set() +) { + const urls = entries.map((entry) => entry.name).filter((url) => isRuntimeCachedUrl(url) && !warmed.has(url)); + const unique = [...new Set(urls)]; + unique.forEach((url) => warmed.add(url)); + return Promise.allSettled(unique.map((url) => fetchFn(url))); } // On the first visit the API requests start before the SW takes control, so they never reach its @@ -26,15 +32,16 @@ function warmRuntimeCacheOnFirstControl() { 'controllerchange', () => { const controlledAt = performance.now(); + const warmed = new Set(); const startedBeforeControl = (entries: PerformanceEntryList) => entries.filter((entry) => entry.startTime < controlledAt); - void warmRuntimeCache(startedBeforeControl(performance.getEntriesByType('resource'))); + void warmRuntimeCache(startedBeforeControl(performance.getEntriesByType('resource')), fetch, warmed); if (typeof PerformanceObserver === 'undefined') { return; } const observer = new PerformanceObserver( - (list) => void warmRuntimeCache(startedBeforeControl(list.getEntries())) + (list) => void warmRuntimeCache(startedBeforeControl(list.getEntries()), fetch, warmed) ); observer.observe({ type: 'resource' }); setTimeout(() => observer.disconnect(), IN_FLIGHT_WINDOW_MS); diff --git a/react-app/vite.config.ts b/react-app/vite.config.ts index 8be9ec02b..93a5687da 100644 --- a/react-app/vite.config.ts +++ b/react-app/vite.config.ts @@ -36,6 +36,8 @@ export default defineConfig({ workbox: { // App shell: JS/CSS/HTML/icons/svg. manifest.webmanifest is added by the plugin. globPatterns: ['**/*.{js,css,html,ico,png,svg}'], + // Retires the Angular app's service worker; see public/ngsw-worker.js. + globIgnores: ['ngsw-worker.js'], navigateFallback: 'index.html', // Firebase reserved URLs and file requests must not get the app shell. navigateFallbackDenylist: [/^\/__\//, /\/[^/?]+\.[^/]+$/],