Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
2ebde5b
feature: React Phase 0 foundation (Vite/TS scaffold, models, API + qu…
devin-ai-integration[bot] Oct 8, 2026
df334ce
bug: harden Phase 0 foundation (poll option failures, malformed setti…
devin-ai-integration[bot] Oct 8, 2026
a2c6935
feature: port item details page and recursive comment tree to React
devin-ai-integration[bot] Oct 8, 2026
3e67084
feature: port user profile page to React
devin-ai-integration[bot] Oct 8, 2026
036804f
feature: port feeds (Feed, Item) to React
devin-ai-integration[bot] Oct 8, 2026
ac4eb3f
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] Oct 8, 2026
dc100fd
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] Oct 8, 2026
cdefeac
feature: port app shell (header, settings, footer) and theme engine t…
devin-ai-integration[bot] Oct 8, 2026
207dfd8
feature: keep the space between feed item title and domain
devin-ai-integration[bot] Oct 8, 2026
4b5aec7
feature: PWA (vite-plugin-pwa), Firebase hosting and GitHub Actions C…
devin-ai-integration[bot] Oct 8, 2026
615de8c
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] Oct 8, 2026
4c221c5
feature: match Angular comment meta spacing, mobile title truncation …
devin-ai-integration[bot] Oct 8, 2026
66899b8
feature: merge workstream B (feeds) #880
devin-ai-integration[bot] Oct 8, 2026
275a8d2
bug: make profile back button keyboard accessible
devin-ai-integration[bot] Oct 8, 2026
2a5d9ae
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] Oct 8, 2026
a9ee685
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] Oct 8, 2026
a9d7056
feature: no-cache for the root URL on Firebase, add robots.txt
devin-ai-integration[bot] Oct 8, 2026
edab23c
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] Oct 8, 2026
a7c32c9
feature: assert the feed renders from the SW cache offline
devin-ai-integration[bot] Oct 8, 2026
19d17b9
bug: keyboard-accessible collapse/back controls, zero-vote poll bars,…
devin-ai-integration[bot] Oct 8, 2026
8d5b9ff
bug: keyboard-operable settings cog/close, persist number inputs on c…
devin-ai-integration[bot] Oct 8, 2026
0fc9e47
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] Oct 8, 2026
9e5e423
feature: merge workstream C (item details + comments) #882
devin-ai-integration[bot] Oct 8, 2026
03c2ed1
feature: merge workstream D (user profiles) #881
devin-ai-integration[bot] Oct 8, 2026
b18597d
feature: warm the SW runtime cache with API requests made before it t…
devin-ai-integration[bot] Oct 8, 2026
1127f1d
feature: document first-visit runtime cache warm-up
devin-ai-integration[bot] Oct 8, 2026
dc71ecf
Merge remote-tracking branch 'origin/devin/1791469878-react-phase0-fo…
devin-ai-integration[bot] Oct 8, 2026
aa0b0b4
feature: offline e2e visits the item page online and asserts it rende…
devin-ai-integration[bot] Oct 8, 2026
099752d
bug: retire the Angular ngsw worker for returning visitors and dedupe…
devin-ai-integration[bot] Oct 8, 2026
3114eab
feature: merge workstream A (app shell + theming) #879
devin-ai-integration[bot] Oct 8, 2026
ebc47f9
feature: merge workstream E (PWA, Firebase, GitHub Actions) #883
devin-ai-integration[bot] Oct 8, 2026
63991fa
feature: Playwright e2e for feeds, pagination, item/comments, user, s…
devin-ai-integration[bot] Oct 8, 2026
b29b266
bug: scroll user profile to top on navigation (Devin Review)
devin-ai-integration[bot] Oct 16, 2026
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
100 changes: 100 additions & 0 deletions .github/workflows/react-app.yml
Original file line number Diff line number Diff line change
@@ -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
23 changes: 0 additions & 23 deletions .travis.yml

This file was deleted.

18 changes: 17 additions & 1 deletion firebase.json
Original file line number Diff line number Diff line change
Expand Up @@ -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" }]
}
]
}
}
8 changes: 8 additions & 0 deletions react-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules
dist
dev-dist
coverage
playwright-report
test-results
*.local
*.tsbuildinfo
8 changes: 8 additions & 0 deletions react-app/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
dist
dev-dist
coverage
node_modules
package-lock.json
public
playwright-report
test-results
83 changes: 83 additions & 0 deletions react-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
# angular2-hn — React port

React + TypeScript port of the Angular 9 app in `../src`. Vite, React 19, React Router 7, TanStack Query 5, Sass. The Angular app keeps running from the repo root until the migration is finished.

```bash
cd react-app
npm ci
npm run dev # http://localhost:4200
npm run build # tsc -b && vite build -> dist/
npm run preview # serves dist/ on :4200
npm run lint # eslint
npm run typecheck # tsc -b
npm run format:check
npm test # vitest + Testing Library (jsdom)
npm run e2e # playwright
```

Requires Node >= 20.19.

## Layout

| Angular (`src/app/...`) | React (`react-app/src/...`) |
| ------------------------------------------- | ---------------------------------------------------------------------------- |
| `shared/models/*` | `shared/models/*` (classes became interfaces; same fields) |
| `shared/services/hackernews-api.service.ts` | `shared/services/hackernews-api.ts` (Promises) + `shared/hooks/queries.ts` |
| `shared/services/settings.service.ts` | `shared/services/settings.ts` + `shared/context/SettingsProvider.tsx` |
| `shared/pipes/comment.pipe.ts` | `shared/utils/comment.ts` (`formatCommentCount`) |
| `shared/components/{loader,error-message}` | `shared/components/{Loader,ErrorMessage}.tsx` |
| `shared/scss/*` | `shared/scss/*` (verbatim; `_themes.scss` imported from `styles.scss`) |
| `app.routes.ts` | `router/routes.tsx` (`handle: { feedType }`, `useFeedType`, `usePageNumber`) |
| `app.component.*` | `layout/AppLayout.tsx` |
| `core/{header,footer,settings}` | `core/{Header,Footer,Settings}.tsx` |
| `feeds/{feed,item}` | `feeds/{Feed,Item}.tsx` |
| `item-details/` + `comment/` | `item-details/{ItemDetails,Comment}.tsx` |
| `user/` | `user/User.tsx` |

## Conventions

- **Data**: components call `useFeed(feedType, page)`, `useItem(id)`, `useUser(id)` from `shared/hooks`. Don't call `fetch` directly. All API functions throw `ApiError` for HTTP errors, `null` bodies and non-JSON bodies (HNPWA returns 200 with `null` or plain text for unknown users).
- **Backends**: feeds/items use `node-hnapi.herokuapp.com` (same as Angular). Users use `api.hnpwa.com/v0`, because node-hnapi's `/user` endpoint returns 404 for everyone.
- **Settings**: `useSettings()` returns `{ settings, toggleSettings, closeSettings, toggleOpenLinksInNewTab, setTheme, setFont, setSpacing }`. It uses the same localStorage keys as the Angular app (`theme`, `openLinkInNewTab`, `titleFontSize`, `listSpacing`), and an OS `prefers-color-scheme` change switches between `default` and `night`.
- **HTML from HN** (`content`, `about`, poll text): always render via `dangerouslySetInnerHTML={{ __html: sanitizeHtml(html) }}`. Angular sanitized `[innerHTML]`; React doesn't.
- **Styles**: global SCSS, one file next to each component (`Feed.tsx` + `Feed.scss`), imported by the component. The theme mixins in `_themes.scss` target global class names (`.wrapper a`, `#header`, `.nav a`, ...), so keep the Angular class names/ids and **don't** use CSS modules. Angular's styles were view-encapsulated: nest generic selectors (`h1`, `a`, `.name`, `.title`) under the component's root class so they don't leak.
- **Links**: use `<Link>` from `react-router` for internal routes (`/item/:id`, `/user/:id`, `/:feed/:page`). For `routerLinkActive="active"`, use `<NavLink>` (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 `<link rel="manifest">` 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/<name>-<8 char hash>.<ext>`). 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.
134 changes: 134 additions & 0 deletions react-app/e2e/app.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
/// <reference lib="dom" />
import { expect, test, type Page } from '@playwright/test';

const story = (id: number, extra: Record<string, unknown> = {}) => ({
id,
title: `Story ${id}`,
points: 10 + id,
user: 'pg',
time: 1700000000,
time_ago: '2 hours ago',
type: 'link',
url: `https://example.com/${id}`,
domain: 'example.com',
comments_count: 2,
...extra,
});

async function mockApi(page: Page) {
await page.route('https://node-hnapi.herokuapp.com/**', async (route) => {
const url = new URL(route.request().url());
const [, kind, id] = url.pathname.split('/');
if (kind === 'item') {
return route.fulfill({
json: story(Number(id), {
content: '<p>Body <script>window.__xss = 1</script></p>',
comments: [
{
id: 1,
level: 0,
user: 'alice',
time: 1,
time_ago: '1 hour ago',
content: '<p>Top comment</p>',
comments: [
{
id: 2,
level: 1,
user: 'bob',
time: 2,
time_ago: '30 minutes ago',
content: '<p>Nested reply</p>',
comments: [],
},
],
},
],
}),
});
}
const page = Number(url.searchParams.get('page') ?? 1);
const count = page === 1 ? 30 : 5;
return route.fulfill({
json: Array.from({ length: count }, (_, i) =>
story((page - 1) * 30 + i + 1, { title: `${kind} ${page}-${i + 1}` })
),
});
});
await page.route('https://api.hnpwa.com/v0/user/**', (route) =>
route.request().url().endsWith('/pg.json')
? route.fulfill({ json: { id: 'pg', karma: 155000, created: '19 years ago', about: '<p>Bug fixer.</p>' } })
: route.fulfill({ status: 200, contentType: 'application/json', body: 'null' })
);
}

const consoleErrors: string[] = [];

test.beforeEach(async ({ page }) => {
consoleErrors.length = 0;
page.on('console', (msg) => {
// playwright.config.ts blocks service workers here, so registration always fails; offline.spec.ts covers the SW.
if (msg.type() === 'error' && !msg.text().startsWith('Service worker registration failed')) {
consoleErrors.push(msg.text());
}
});
page.on('pageerror', (err) => consoleErrors.push(err.message));
await mockApi(page);
});

test.afterEach(() => {
// Ported from the Protractor spec: no severe browser errors.
expect(consoleErrors).toEqual([]);
});

test('redirects / to /news/1 and renders 30 stories', async ({ page }) => {
await page.goto('/');
await expect(page).toHaveURL(/\/news\/1$/);
await expect(page.locator('li.post')).toHaveCount(30);
});

for (const feed of ['newest', 'show', 'ask', 'jobs']) {
test(`header nav opens the ${feed} feed`, async ({ page }) => {
await page.goto('/news/1');
const label = feed === 'newest' ? 'new' : feed;
await page.locator('.header-nav a', { hasText: new RegExp(`^${label}$`) }).click();
await expect(page).toHaveURL(new RegExp(`/${feed}/1$`));
await expect(page.locator('li.post').first()).toContainText(`${feed} 1-1`);
});
}

test('pagination: More goes to page 2 and numbering continues at 31', async ({ page }) => {
await page.goto('/news/1');
await page.locator('.nav a.more').click();
await expect(page).toHaveURL(/\/news\/2$/);
await expect(page.locator('ol')).toHaveAttribute('start', '31');
await expect(page.locator('.nav a.more')).toHaveCount(0);
await page.locator('.nav a.prev').click();
await expect(page).toHaveURL(/\/news\/1$/);
});

test('item page renders sanitized content and nested comments', async ({ page }) => {
await page.goto('/item/42');
await expect(page.locator('.item .title').first()).toContainText('Story 42');
await expect(page.getByText('Top comment')).toBeVisible();
await expect(page.getByText('Nested reply')).toBeVisible();
expect(await page.evaluate(() => (window as unknown as { __xss?: number }).__xss)).toBeUndefined();
});

test('user page renders a profile and an error for unknown users', async ({ page }) => {
await page.goto('/user/pg');
await expect(page.locator('.profile .name')).toHaveText('pg');
await expect(page.getByText('Bug fixer.')).toBeVisible();
await page.goto('/user/nobody-xyz');
await expect(page.locator('.profile')).toHaveCount(0);
});

test('settings: theme choice applies and persists across reloads', async ({ page }) => {
await page.goto('/news/1');
await page.locator('img.settings').click();
await page.locator('#popup1').getByText(/night/i).first().click();
await expect(page.locator('.night')).toHaveCount(1);
expect(await page.evaluate(() => localStorage.getItem('theme'))).toBe('night');
await page.reload();
await expect(page.locator('.night')).toHaveCount(1);
});
Loading
Loading