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..5c3e99038 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|ngsw-worker.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..0c129187d 100644
--- a/react-app/README.md
+++ b/react-app/README.md
@@ -44,3 +44,40 @@ 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. 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
+
+`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..cb1d1a9df
--- /dev/null
+++ b/react-app/e2e/offline.spec.ts
@@ -0,0 +1,147 @@
+///
+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' });
+
+// 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/**', 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': '*' } });
+ });
+}
+
+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 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);
+}
+
+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 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);
+ // 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);
+
+ 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).filter({ visible: true }).first()).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();
+ 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.
+ 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);
+ }
+});
+
+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/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/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/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: /
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..7bec92043
--- /dev/null
+++ b/react-app/src/pwa/registerSW.test.ts
@@ -0,0 +1,213 @@
+import type { RegisterSWOptions } from 'virtual:pwa-register';
+
+import { IN_FLIGHT_WINDOW_MS, registerServiceWorker, warmRuntimeCache, type RegisterSW } from './registerSW';
+
+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: { controller, addEventListener },
+ });
+ }
+ 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, addEventListener, 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();
+ 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.
+ // 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),
+ ]);
+ 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();
+ 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);
+ });
+});
+
+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
new file mode 100644
index 000000000..a10d26b16
--- /dev/null
+++ b/react-app/src/pwa/registerSW.ts
@@ -0,0 +1,84 @@
+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;
+
+interface Options {
+ updateCheckIntervalMs?: number;
+ logger?: Pick;
+}
+
+export const IN_FLIGHT_WINDOW_MS = 60 * 1000;
+
+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
+// 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 warmed = new Set();
+ const startedBeforeControl = (entries: PerformanceEntryList) =>
+ entries.filter((entry) => entry.startTime < controlledAt);
+
+ void warmRuntimeCache(startedBeforeControl(performance.getEntriesByType('resource')), fetch, warmed);
+ if (typeof PerformanceObserver === 'undefined') {
+ return;
+ }
+ const observer = new PerformanceObserver(
+ (list) => void warmRuntimeCache(startedBeforeControl(list.getEntries()), fetch, warmed)
+ );
+ 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,
+ { updateCheckIntervalMs = UPDATE_CHECK_INTERVAL_MS, logger = console }: Options = {}
+) {
+ if (typeof navigator === 'undefined' || !('serviceWorker' in navigator)) {
+ return undefined;
+ }
+ if (!navigator.serviceWorker.controller) {
+ warmRuntimeCacheOnFirstControl();
+ }
+ 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/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/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..93a5687da 100644
--- a/react-app/vite.config.ts
+++ b/react-app/vite.config.ts
@@ -1,10 +1,63 @@
///
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;
-// 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}'],
+ // 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: [/^\/__\//, /\/[^/?]+\.[^/]+$/],
+ cleanupOutdatedCaches: true,
+ // autoUpdate only sets these itself when injectRegister is 'auto'.
+ skipWaiting: true,
+ clientsClaim: true,
+ 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] },
+ },
+ })),
+ },
+ }),
+ ],
server: {
port: 4200,
},