Skip to content

Migrate Angular 9 Hacker News PWA to React 19 + Vite#365

Open
eashansinha wants to merge 22 commits into
masterfrom
devin/1781803653-angular-to-react-migration
Open

Migrate Angular 9 Hacker News PWA to React 19 + Vite#365
eashansinha wants to merge 22 commits into
masterfrom
devin/1781803653-angular-to-react-migration

Conversation

@eashansinha

@eashansinha eashansinha commented Jun 18, 2026

Copy link
Copy Markdown

Summary

Full rewrite of the Angular 9 Hacker News PWA to React 19 + TypeScript + Vite 8, preserving behavior, routes, theming, and PWA/offline support. All Angular tooling (Angular CLI/Webpack, NGSW, Karma, Protractor, TSLint) is removed; the build is now tsc -b && vite build and the service worker + web manifest are generated by vite-plugin-pwa (Workbox). Output dir stays dist/, so Firebase hosting and deploy are unchanged.

The work was orchestrated in 6 phases (parallel sub-tasks within each, merged at hard gates):

  1. Scaffold (Vite react-ts, deps, dir layout, PWA assets)
  2. Types, styles/theming (SCSS → CSS vars + CSS Modules), shared UI (Loader, ErrorMessage)
  3. SettingsContext, API hooks
  4. Feature components (layout, feed, item-details, user)
  5. App shell + routing + PWA config
  6. Integration, cleanup, config migration

Architecture mapping

Angular React
RouterModule routes react-router-dom v6 <Routes> in src/routes.tsx
HackernewsApiService (RxJS Observable) useFeed / useItemDetails / useUser hooks (fetch + AbortController)
SettingsService SettingsContext + useSettings()
*.component.scss *.module.css (CSS Modules)
_themes.scss (:root/.night/.amoled) src/styles/themes.css CSS custom properties
NGSW (ngsw-config.json) vite-plugin-pwa (Workbox generateSW)

Routing (src/routes.tsx)

"/"                 -> <Navigate to="/news/1" replace />
"/:feedType"        -> <FeedPage />
"/:feedType/:page"  -> <FeedPage />          // Angular ":page?" optional param = two explicit routes
"/item/:id"         -> lazy(ItemDetailsPage) // in <Suspense fallback={<Loader/>}>
"/user/:id"         -> lazy(UserPage)        // in <Suspense fallback={<Loader/>}>

App shell composition (src/App.tsx): BrowserRouter > SettingsProvider > AppShell, where AppShell applies the theme class (<div className={settings.theme}>) and renders Header / Routes / Footer. GA pageview tracking is preserved via useLocation() + useEffect.

PWA (vite.config.ts)

VitePWA({ registerType: 'autoUpdate', manifest: { name: 'React HN', theme_color: '#b92b27', ... }, workbox: { runtimeCaching: [NetworkFirst for https://node-hnapi.herokuapp.com] } }). The static public/manifest.json and the manual <link rel="manifest"> are dropped in favor of the generated manifest. Build emits dist/sw.js, dist/workbox-*.js, dist/manifest.webmanifest (27 precache entries).

Notable porting decisions

  • Faithful port preserved even where the original had quirks: the crated_time field name and time_ago: number typing are carried over from the Angular models (flagged but intentionally unchanged to keep the port 1:1).
  • Theme setting values normalized to 'default' | 'night' | 'amoled' to match the ported CSS class names (Angular template used 'amoledblack' for the AMOLED radio, but the SCSS class was .amoled).
  • Added optional content? / text? to the Story type — the item-details view reads these fields, which Angular's loose template typing allowed but strict TS requires.

Cleanup / config

  • Removed: angular.json, tslint.json, karma.conf.js, ngsw-config.json, browserslist, tsconfig.spec.json, e2e/, src/app/, src/environments/, src/assets/ (mirrored into public/assets/), and Angular src entry files (main.ts, polyfills.ts, test.ts, index.html, styles.scss, manifest.json, favicon.ico).
  • .travis.yml: Node 22, npm ci && npm run lint && npm run build (dropped global @angular/cli); Firebase deploy unchanged.
  • README.md: Angular → React, build section rewritten for Vite + vite-plugin-pwa.
  • firebase.json already targets dist with an SPA rewrite; package.json name is react-hn.

npm run lint and npm run build are both green. src/app/ is intentionally gone (porting reference is in git history).

Link to Devin session: https://app.devin.ai/sessions/4fc319e753e14842847693d72052042c
Requested by: @eashansinha


Devin Review

Status Commit
🟢 Reviewed 6dcfa5e
Open in Devin Review (Staging)

devin-ai-integration Bot and others added 19 commits June 18, 2026 17:33
- Add Vite/React-TS toolchain (package.json, vite.config.ts, tsconfig*, eslint.config.js)
- Install react-router-dom v6 and vite-plugin-pwa
- Root index.html with React HN metadata, manifest link, PWA icons
- Minimal App.tsx/main.tsx placeholder shell
- Create src/{components,shared,styles} directory skeleton (.gitkeep)
- Copy assets + manifest.json into public/
- Exclude legacy src/app (kept as porting reference) from tsc/eslint
- Switch lockfile from yarn to npm

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
…nt CSS modules

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
…devin/phase2b-styles' and 'origin/devin/phase2c-shared-ui' into devin/1781803653-angular-to-react-migration
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
…st render

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
…' and 'origin/devin/phase3e-api-hooks' into devin/1781803653-angular-to-react-migration
…-details

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
…/devin/phase4g-feed', 'origin/devin/phase4h-itemdetails' and 'origin/devin/phase4i-user' into devin/1781803653-angular-to-react-migration
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
…rigin/devin/phase5k-pwa' into devin/1781803653-angular-to-react-migration
- Delete angular.json, tslint.json, karma.conf.js, ngsw-config.json, browserslist, tsconfig.spec.json, e2e/, src/app/, src/environments/, src/assets/, and Angular src entry files (main.ts, polyfills.ts, test.ts, index.html, styles.scss, manifest.json, favicon.ico)
- Remove dead Vite scaffold CSS (src/index.css, src/App.css)
- Trim now-dead Angular paths from tsconfig.app.json exclude and eslint ignores
- Update .travis.yml to Node 22 + npm ci/lint/build (drop @angular/cli)
- Rewrite README build section for Vite + vite-plugin-pwa; Angular -> React references

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown
Original prompt from Eashan

Migrate the Angular 9 Hacker News PWA in repository COG-GTM/angular2-hn to React, organized into sequential phases where each phase contains parallel child devin tasks.


#``# PHASE 1 — Scaffold & Foundation (Sequential, single devin)

This must complete before any parallel work begins. A single devin scaffolds the project so all child devins have a shared workspace.

  • In the repo root, run npm create vite@latest . -- --template react-ts (or scaffold into a temp dir and merge)
  • Install dependencies: react-router-dom (v6), vite-plugin-pwa
  • Create the directory structure:
    src/
    ├── components/layout/
    ├── components/feed/
    ├── components/item-details/
    ├── components/user/
    ├── shared/types/
    ├── shared/hooks/
    ├── shared/context/
    ├── shared/components/
    ├── styles/
    
  • Copy src/assets/icons/ to public/assets/icons/
  • Copy src/manifest.json to public/manifest.json
  • Commit this skeleton so all child devins branch from it

#``# PHASE 2 — Types, Styles, and Shared Utilities (3 parallel child devins)

These have zero interdependencies and can all run simultaneously.

#``#``# Child Devin A: TypeScript Types
Port all Angular model interfaces from src/app/shared/models/ to src/shared/types/:

  • story.ts — from src/app/shared/models/story.ts
  • comment.ts — from src/app/shared/models/comment.ts
  • user.ts — from src/app/shared/models/user.ts
  • poll-result.ts — from src/app/shared/models/poll-result.ts
  • settings.ts — from src/app/shared/models/settings.ts
  • feed-type.ts — from src/app/shared/models/feed-type.type.ts
  • Create a barrel src/shared/types/index.ts re-exporting everything

#``#``# Child Devin B: Global Styles & Theming

  • Convert SCSS theme variables from src/app/shared/scss/_theme_variables.scss and src/app/shared/scss/_themes.scss to CSS custom properties in src/styles/themes.css, defined under :root (default), .night, and .amoled selectors
  • Port src/app/shared/scss/_media.scss br... (8613 chars truncated...)

@devin-ai-integration

Copy link
Copy Markdown

🤖 Devin AI Engineer

I'll be helping with this pull request! Here's what you should know:

✅ I will automatically:

  • Address comments on this PR. Add '(aside)' to your comment to have me ignore it.
  • Look at CI failures and help fix them

Note: I can only respond to comments from users who have write access to this repository.

⚙️ Control Options:

  • Disable automatic comment, CI, and merge conflict monitoring

@staging-devin-ai-integration staging-devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 4 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review (Staging)
Debug

Playground

Comment thread src/styles/global.css Outdated
Comment thread src/styles/global.css
Comment thread src/components/layout/Settings.tsx
Comment on lines +5 to +12
a {
text-decoration: none;
font-weight: bold;
}

a:hover {
text-decoration: underline;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Unscoped tag selectors in CSS module files leak globally

In src/styles/FeedPage.module.css:5-8 and src/styles/FeedPage.module.css:14-17, bare tag selectors like a { ... } and ol { ... } are used. CSS modules only mangle class-based selectors; tag selectors remain global. This means these styles (e.g., a { text-decoration: none; font-weight: bold }) apply to ALL <a> and <ol> elements in the document, not just those within FeedPage. The same issue exists in src/styles/ItemDetailsPage.module.css with bare a, p, ul, and li selectors. This replicates the Angular component-scoped behavior (ViewEncapsulation) but CSS modules don't provide the same scoping for tag selectors. It works here because the old styles were broadly similar, but it could cause surprising style bleed if new components are added.

Open in Devin Review (Staging)

Was this helpful? React with 👍 or 👎 to provide feedback.

Debug

Playground

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Acknowledged — this is intentional. The bare a/ol/li tag selectors in the .module.css files are global (CSS Modules only mangle class selectors), which deliberately reproduces the broadly-global styling the original Angular app relied on. As you note, it works correctly here because these element styles match the prior behavior. I'm leaving it as-is to keep the port faithful; tightening these into class-scoped selectors would be a reasonable cleanup follow-up if we want true per-component isolation, but it's out of scope for the 1:1 migration.

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review

Comment thread src/components/layout/Settings.tsx
Comment thread src/styles/global.css
Comment thread src/routes.tsx
Comment on lines +29 to +30
<Route path="/:feedType" element={<FeedPage />} />
<Route path="/:feedType/:page" element={<FeedPage />} />

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Route /:feedType acts as a catch-all and may match unintended paths

The route /:feedType at src/routes.tsx:29 is a catch-all dynamic segment. While React Router v6's specificity ranking ensures /item/:id and /user/:id take precedence over /:feedType for those paths, any other unknown path (e.g., /favicon.ico, /random) will match /:feedType and render FeedPage, which will attempt an API call to https://node-hnapi.herokuapp.com/random?page=1 and show an error. The old Angular app had explicit route definitions (news, newest, show, ask, jobs) so unknown paths would not accidentally render the feed component. Consider adding a catch-all 404 route or restricting /:feedType to known values.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is intentional and matches the migration spec, which mapped Angular's {path: ':feedType/:page?'} to a dynamic React Router route rather than enumerating news|newest|show|ask|jobs. As you note, React Router specificity still ranks /item/:id and /user/:id above /:feedType, so only genuinely unknown single-segment paths fall through — and they degrade gracefully to FeedPage's existing error state (the same "could not load" UI a failed API call produces), not a crash. The Angular app's behavior here was effectively the same for an unknown feed slug. I'm leaving the dynamic route as-specified; constraining feedType to a known set (or adding an explicit 404) is a reasonable follow-up if we want unknown slugs to render a dedicated not-found page instead.

devin-ai-integration Bot and others added 2 commits June 18, 2026 18:53
- getInitialTheme: migrate persisted 'amoledblack' -> 'amoled' so existing users keep the AMOLED theme (class is .amoled)
- global.css: restore distinct visited-link color via var(--subtext-color), matching the original Angular theme mixin ($item-a-visited-color = subtext color)

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
…ling

The global '.nav a' color rule never matched because the <nav> element uses a CSS-module-scoped class (styles.nav). Moved the secondary-color rule into FeedPage.module.css scoped under .main-content so it applies to the mangled class and beats the global '.wrapper a:visited' specificity in both visited and unvisited states, matching the original Angular theme.

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
devin-ai-integration[bot]

This comment was marked as resolved.

staging-devin-ai-integration[bot]

This comment was marked as resolved.

…NTRIBUTING

- FeedPage: add feedType to the scroll-to-top effect deps so browser back/forward between feed types (same page) scrolls to top, matching the original Angular component which re-ran ngOnInit on every route-params change.

- CONTRIBUTING.md: replace Angular CLI / ng init / port 4200 / precache / static-serve setup with the React + Vite workflow (npm install, npm run dev, npm run lint, npm run build, npm run preview).

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown

Re: the CONTRIBUTING.md not updated for React + Vite finding — fixed in 6dcfa5e. Replaced the Angular-specific setup (install Angular CLI, ng init, npm start on port 4200, npm run precache / npm run static-serve) with the React + Vite workflow:

  • npm install
  • npm run dev (Vite dev server)
  • npm run lint + npm run build (tsc -b && vite build)
  • npm run preview to test the service worker / PWA on the production build (now generated by vite-plugin-pwa rather than the old precache script)

Also updated the intro line ("little to no experience with Angular" → "React").

@devin-ai-integration

Copy link
Copy Markdown

E2E test results — Angular→React migration

Tested the full app on the dev server (npm run dev, desktop functional + regression pass) and the production build (npm run build + npm run preview, PWA pass), across desktop and a 412px mobile viewport. Colors and scroll positions were verified via DevTools Protocol. Screen recording is in the Devin session.

7 of 8 tests passed. Lint + build green. One partial failure (T6) to decide on — details first:

Warning

T6 — scroll-to-top on feed-type change is only partial.

  • Header NavLink clicks → PASS (newsjobs scrolls to top; the header links also call onClick={() => window.scrollTo(0,0)}).
  • Browser Back/Forward → FAIL. Scroll a feed to the bottom, navigate to another feed, scroll to the bottom, then press the browser Back button — the page is restored to the previous (bottom) scroll position, not the top.
  • Root cause (confirmed): the browser default history.scrollRestoration === 'auto' restores scroll after the useEffect(..., [feedType, page]) runs, overriding scrollTo(0,0). Setting history.scrollRestoration = 'manual' makes Back/Forward land at scrollY = 0.
  • This is browser-default SPA behavior, not a crash/data bug. Decision needed: should Back/Forward also force scroll-to-top? If yes, set history.scrollRestoration = 'manual' once in main.tsx/App.tsx. No code was changed during testing.

Results

  • T1 — Feed routes (news/newest/show/ask/jobs) render live HN data — passed
  • T2 — Item detail (nested comment tree + collapse) + user page — passed
  • T3 — Settings theme switching (Default/Night/AMOLED accents actually change) — passed
  • T4 — Settings font size, list spacing, open-in-new-tab (on + off) — passed
  • T5 — Regression: pagination links are accent red #b92b27 (rgb(185,43,39)), not black — passed
  • T6 — Regression: scroll-to-top on feed-type change — partial (header clicks pass; browser Back/Forward fails — see above)
  • T7 — Mobile layout at 412px renders + theme switch works — passed
  • T8 — PWA: service worker activated, manifest correct, offline reload renders — passed
T8 — PWA / offline (production build) — key evidence

Service worker sw.js is activated and controlling the page. Manifest: name "React HN", theme_color #b92b27, standalone, 4 icons. With Network set to Offline, reloading /news/1 shows 0 B transferred — all 11 requests served by the Service Worker (document, JS, CSS, icons, manifest.webmanifest, and news?page=1 from the NetworkFirst cache). The full app renders offline, not just the shell.

T8 offline reload + network panel
T8 offline requests served by service worker

T5 — pagination accent color (regression fix)

/news/2 — both ‹ Prev and More › render in accent red.

T5 pagination links zoom

T7 — mobile layout (412px), Default + Night

Header collapses, list full-width, settings panel usable, pagination floats to corner, theme switch works.

Default Night
mobile default mobile night
Notes
  • T2 user page: happy path verified. The live node-hnapi.herokuapp.com/user/:id endpoint intermittently 404s (works on api.hnpwa.com) — upstream API surface issue, not a migration bug; useUser/UserPage render correctly when data is returned and show the error component otherwise.
  • T1/T3/T4/T6 desktop interactions are in the recording in the Devin session linked above.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant