Skip to content

Migrate app from Angular 9 to React 18 + TypeScript (Vite)#435

Open
eashansinha wants to merge 4 commits into
masterfrom
devin/1783503983-react-migration
Open

Migrate app from Angular 9 to React 18 + TypeScript (Vite)#435
eashansinha wants to merge 4 commits into
masterfrom
devin/1783503983-react-migration

Conversation

@eashansinha

@eashansinha eashansinha commented Jul 8, 2026

Copy link
Copy Markdown

Summary

Full rewrite of the app layer from Angular 9 to React 18 + TypeScript, replacing the Angular CLI toolchain with Vite. Behavior, routes, styling, and theming are preserved 1:1.

Tooling (WS0): angular.json, karma.conf.js, tslint.json, ngsw-config.json, e2e/ (Protractor), rxjs/zone.js are gone. New: vite.config.ts (with vite-plugin-pwa replacing @angular/service-worker — Workbox generateSW, same web manifest), Vitest + React Testing Library, ESLint flat config, tsc -b typecheck in npm run build. Firebase hosting config (firebase.json, .firebaserc) untouched; dist/ output location unchanged. src/assets + manifest.json moved to public/.

Models (WS1): src/models/ — classes converted to interfaces; added missing content field on Story (was used untyped by templates).

Services (WS2):

  • HackerNewsAPIService's RxJS lazyFetch/cancelToken → plain async fns in src/services/hackernews-api.ts taking an AbortSignal (fetchFeed, fetchItemContent, fetchPollContent, fetchUser). Poll vote aggregation now uses Promise.all instead of nested subscriptions.
  • SettingsServiceSettingsContext provider + useSettings() hook, preserving localStorage persistence and the prefers-color-scheme media-query subscription (system changes switch theme unless overridden).

Routing (WS3): react-router-dom in App.tsx//news/1, /:feed/:page for the 5 feeds (feedType passed as prop), item/:id + user/:id via React.lazy/Suspense (mirrors Angular lazy modules). GA pageview tracking ported as a useLocation effect.

Components (WS4/5): each @Component → function component; ngOnInit/subscriptions → useEffect with AbortController cleanup; CommentPipecommentLabel() helper; [innerHTML]dangerouslySetInnerHTML; recursive CommentItem keeps per-node collapse state.

Styling note (deviation from the plan): component SCSS was NOT converted to CSS Modules. The shared theme engine (_themes.scss mixin) targets literal class names (.subtext, .meta, .loader, …) across components, which hashed CSS Module names would break. Instead, each component's SCSS is nested under a component root class (.hn-item, .feed-page, .comment-item, …) to emulate Angular's view encapsulation while keeping src/styles/_themes.scss global and intact (converted to @use/math.div for modern Sass).

Verified locally: npm run build, npm test (5 tests), npm run lint all pass; feeds/item/user pages, pagination, settings (theme/font/spacing persistence), and PWA service worker generation work.

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


Devin Review

Status Commit
🟢 Reviewed 1ed8695
Open in Devin Review (Staging)

Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
@eashansinha eashansinha self-assigned this Jul 8, 2026
@devin-ai-integration

Copy link
Copy Markdown
Original prompt from Eashan

Migrate the repository COG-GTM/angular2-hn (a Hacker News PWA) from Angular 9 to React with TypeScript. This is a full rewrite of the app layer. The work below is organized into parallelizable workstreams so multiple agents can collaborate; each workstream lists explicit file paths to reference in the existing Angular codebase. Before starting, explore the full src/app tree to confirm every component, template, model, pipe, and scss file, since not all were inspected.

#``# Workstream 0 — Scaffolding & tooling (must land first, blocks others)

  • Scaffold a new React + TypeScript app using Vite (assume Vite unless the team prefers Create React App).
  • Replace the Angular CLI build tooling. The current package.json (scripts ng serve/ng build/ng test/ng lint/ng e2e, dependencies ``@angular/* `~9.0.1`, `rxjs`, `rxjs-compat`, `zone.js`, dev deps `karma`, `jasmine`, `protractor`, `codelyzer`, `tslint`, `typescript ~3.7.5`) must be replaced with a React toolchain: `react`, `react-dom`, `react-router-dom`, and dev tooling (Vitest/Jest + React Testing Library instead of Karma/Jasmine, ESLint instead of TSLint).
  • Remove Angular-specific config files (angular.json, karma.conf.js, tsconfig.app.json, tsconfig.spec.json as applicable) and add React equivalents (vite.config.ts, updated tsconfig.json).
  • Drop rxjs/rxjs-compat — they are unnecessary in React.
  • Preserve Firebase hosting config (firebase.json, .firebaserc). Replace ``@angular/service-worker + `ngsw-config.json` PWA setup with a React-compatible Workbox / Vite PWA plugin setup.

#``# Workstream 1 — Models (low risk, do early)

  • Port the TypeScript interfaces under src/app/shared/models/ (e.g. story.ts, user.ts, comment.ts, and any others found there) into the new React app largely as-is. Confirm the full list by exploring the directory.

#``# Workstream 2 — Services → hooks/context

  • src/app/shared/services/hackernews-api.service.ts: HackerNewsAPIService fetches from `https://... (3116 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

@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 4 potential issues.

Open in Devin Review

Comment thread src/services/hackernews-api.ts
Comment on lines +37 to +44
useEffect(() => {
const darkColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
setTheme(event.matches ? 'night' : 'default');
};
darkColorSchemeMedia.addEventListener('change', handleChange);
return () => darkColorSchemeMedia.removeEventListener('change', handleChange);
}, [setTheme]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: System color scheme change overrides user's manually selected theme

In src/contexts/SettingsContext.tsx:37-44, the useEffect listens for OS-level prefers-color-scheme changes and calls setTheme() unconditionally. If a user has manually selected 'amoledblack' (or any theme) via the settings panel, and then their OS switches between light/dark mode, the user's choice will be overwritten with 'night' or 'default'. This matches the old Angular service's behavior, so it's not a regression, but it's a UX issue worth noting — the listener should arguably only apply when no explicit user preference is saved in localStorage.

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.

Acknowledged — this intentionally mirrors the old Angular SettingsService behavior (system scheme changes always call setTheme). Left as-is to keep the migration behavior-preserving; happy to gate it on a saved preference in a follow-up.

Comment on lines +18 to +23
if (story.type === 'poll') {
const pollResults = await Promise.all(
story.poll.map((_, i) => fetchPollContent(story.id + i + 1, signal))
);
story.poll = pollResults;
story.poll_votes_count = pollResults.reduce((sum, result) => sum + result.points, 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Poll data type mismatch between API response and TypeScript interface

In fetchItemContent (src/services/hackernews-api.ts:18-23), story.poll is typed as PollResult[] but the actual API response likely contains an array of numeric IDs (not objects with points and content). The code works correctly at runtime because it only uses story.poll.map((_, i) => ...) (ignoring the array values, using only the length/index), then immediately reassigns story.poll = pollResults with actual PollResult objects. This is safe but relies on the type assertion in fetchJSON being inaccurate for the intermediate state.

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.

Acknowledged — correct, only the array length/indices of the raw poll field are used before it's reassigned with real PollResult objects, matching the original Angular logic. The intermediate typing is loose but safe.

Comment thread src/components/CommentItem.tsx Outdated
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>

@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 3 potential issues.

Open in Devin Review (Staging)
Debug

Playground

Comment thread index.html

@staging-devin-ai-integration staging-devin-ai-integration Bot Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Static manifest removal is consistent with vite-plugin-pwa auto-injection

The incremental change deletes public/manifest.json and removes the <link rel="manifest"> from index.html:35. This is consistent with vite.config.ts:9-31, where VitePWA is configured with an inline manifest block and thus generates manifest.webmanifest and auto-injects the manifest link at build time. Keeping the static reference would have produced a duplicate/conflicting manifest link, so removing it is the correct approach.

Open in Devin Review (Staging)

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

Debug

Playground

Comment thread index.html
<head>
<meta charset="utf-8">
<title>Angular 2 HN</title>
<base href="/">

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Page logo and icons break when the site is opened directly on a nested URL

The document's root link was removed (<base href="/"> at index.html:6) while the page keeps relative asset paths, so opening the app directly on a nested address like /item/123 makes the loading logo and icons resolve against the wrong folder and fail to load.
Impact: Users who deep-link or refresh on any page other than the home page see a broken loading logo and missing favicons/touch icons.

Why relative URLs break without a base element

This is a single-page app served with index.html as the fallback for every route. The pre-loader markup still uses relative URLs, e.g. assets/images/logo.svg at index.html:55, and the icon/meta links at index.html:14,38-44 are also relative. With <base href="/"> these resolved against the site root. After its removal, on a URL such as /item/123 the browser resolves assets/images/logo.svg to /item/assets/images/logo.svg, which 404s. The Angular version kept <base href="/"> precisely to avoid this. Note the in-app Header uses absolute paths (/assets/images/logo.svg), so only the pre-React loader image and the document icons are affected.

Prompt for agents
The <base href="/"> element was removed from index.html during the Angular-to-Vite migration. Because this is an SPA whose index.html is served as the fallback for deep routes (e.g. /item/123, /user/foo), the remaining relative asset URLs in index.html (the pre-loader logo at 'assets/images/logo.svg', favicon 'favicon.ico', touch icons under 'assets/icons/...', and og/twitter image 'assets/images/logo-loading.png') now resolve relative to the current path instead of the site root, causing 404s on nested routes. Fix by either re-adding <base href="/"> in <head>, or converting these relative asset references to root-absolute paths (leading '/').
Open in Devin Review (Staging)

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

Debug

Playground

Comment on lines +37 to +44
useEffect(() => {
const darkColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');
const handleChange = (event: MediaQueryListEvent) => {
setTheme(event.matches ? 'night' : 'default');
};
darkColorSchemeMedia.addEventListener('change', handleChange);
return () => darkColorSchemeMedia.removeEventListener('change', handleChange);
}, [setTheme]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 System dark-mode change overrides and overwrites the user's explicit theme choice

The media-query listener in src/contexts/SettingsContext.tsx:37-44 calls setTheme(event.matches ? 'night' : 'default') on every OS color-scheme change, and setTheme (src/contexts/SettingsContext.tsx:32-35) also writes to localStorage. This means if a user has explicitly selected e.g. amoledblack, an OS theme switch will silently replace it with night/default and clobber their saved preference. This mirrors the original Angular SettingsService behavior (which always subscribed and always called setTheme), so it is a preserved pre-existing quirk rather than a new regression — but since the settings/theme logic was fully rewritten here, it is worth confirming whether this override is intended.

Open in Devin Review (Staging)

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

Debug

Playground

@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 5 new potential issues.

⚠️ 1 issue in files not directly in the diff

⚠️ Contributor setup guide is now broken and will mislead new contributors (CONTRIBUTING.md:4-10)

The contribution guide is not updated to reflect the new build system (CONTRIBUTING.md:4-10), so contributors following it will be told to install Angular CLI, run ng init, use npm run precache, and use npm run static-serve — none of which exist any more.

Impact: Any new contributor following the documented setup steps will fail immediately.

Outdated instructions that no longer work after the Angular-to-React migration

The PR updated README.md (lines 76–85) with the new Vite/React build instructions (npm start for Vite dev server, npm run build, npm run preview), but CONTRIBUTING.md still references:

  • Step 4: npm install -g angular-cli@latest — Angular CLI is removed from the project
  • Step 5: ng init — no longer applicable
  • Step 7: localhost:4200 — Vite defaults to port 5173
  • Step 9: npm run static-serve — script removed from package.json
  • Step 10: npm run precache and npm run static-serve — both removed; replaced by npm run preview
Open in Devin Review

Comment on lines +46 to +67
const value = useMemo<SettingsContextValue>(
() => ({
settings,
setTheme,
toggleSettings: () => setSettings((prev) => ({ ...prev, showSettings: !prev.showSettings })),
toggleOpenLinksInNewTab: () =>
setSettings((prev) => {
const openLinkInNewTab = !prev.openLinkInNewTab;
localStorage.setItem('openLinkInNewTab', JSON.stringify(openLinkInNewTab));
return { ...prev, openLinkInNewTab };
}),
setFont: (titleFontSize: string) => {
setSettings((prev) => ({ ...prev, titleFontSize }));
localStorage.setItem('titleFontSize', titleFontSize);
},
setSpacing: (listSpacing: string) => {
setSettings((prev) => ({ ...prev, listSpacing }));
localStorage.setItem('listSpacing', listSpacing);
},
}),
[settings, setTheme]
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Context value recreated on every settings change, causing broad re-renders

The useMemo at src/contexts/SettingsContext.tsx:46-67 includes settings in its dependency array. Since toggleSettings, toggleOpenLinksInNewTab, setFont, and setSpacing all use the functional updater form of setSettings and don't actually read settings, they could be wrapped in useCallback independently. As-is, every settings change (even toggling the settings panel) creates a new context value object with new function references, causing all useSettings() consumers to re-render. For this app's scale it's unlikely to matter, but it's worth noting for future optimization.

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.

Acknowledged — agreed the setters could be memoized independently; leaving as-is given the app's small consumer count, can optimize in a follow-up if needed.

Comment thread vite.config.ts
Comment on lines +12 to +26
manifest: {
name: 'Angular 2 HN',
short_name: 'Angular 2 HN',
icons: [
{ src: 'assets/icons/android-chrome-144x144.png', sizes: '144x144', type: 'image/png' },
{ src: 'assets/icons/android-chrome-192x192.png', sizes: '192x192', type: 'image/png' },
{ src: 'assets/icons/android-chrome-256x256.png', sizes: '256x256', type: 'image/png' },
{ src: 'assets/icons/android-chrome-512x512.png', sizes: '512x512', type: 'image/png' },
],
theme_color: '#b92b27',
background_color: '#ffffff',
display: 'standalone',
orientation: 'portrait',
start_url: './?utm_source=web_app_manifest',
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔍 Potential duplicate manifest links in production build

The index.html:35 has a hardcoded <link rel="manifest" href="/manifest.json"> pointing to the static file in public/manifest.json. Meanwhile, vite-plugin-pwa with the manifest option in vite.config.ts:12-26 generates its own manifest file and typically injects a <link rel="manifest"> tag during build. This could result in two manifest links in the built HTML. Since both manifests have identical content, this won't cause functional issues, but it means changes to the manifest config in vite.config.ts may not take effect if the browser uses the first link. Consider removing either the static public/manifest.json + the HTML link, or the manifest option from the Vite PWA config.

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.

Fixed — removed the hardcoded <link rel="manifest"> from index.html and deleted the static public/manifest.json; the built HTML now contains only the single manifest.webmanifest link injected by vite-plugin-pwa.

Comment thread src/pages/ItemDetails.tsx
<div className="subtext">{pollResult.points} points</div>
<div
className="pollBar"
style={{ width: `${(pollResult.points / item.poll_votes_count) * 100}%` }}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Poll bar width calculation can produce NaN when total votes are zero

At src/pages/ItemDetails.tsx:117, the poll bar width is computed as (pollResult.points / item.poll_votes_count) * 100. If all poll options have 0 points, poll_votes_count (computed at src/services/hackernews-api.ts:26) will be 0, resulting in division by zero and a NaN% CSS width. This is a pre-existing issue from the Angular code which had the identical calculation. In practice, polls with zero total votes are extremely rare on HN, so the impact is minimal.

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.

Acknowledged — pre-existing behavior carried over from the Angular code; zero-vote polls are extremely rare, so leaving as-is to keep the migration behavior-preserving.

Comment thread src/pages/Feed.tsx
Comment on lines +17 to +32
useEffect(() => {
const controller = new AbortController();
setItems(null);
setErrorMessage('');
fetchFeed(feedType, pageNum, controller.signal)
.then((stories) => {
setItems(stories);
window.scrollTo(0, 0);
})
.catch((error: unknown) => {
if (!(error instanceof DOMException && error.name === 'AbortError')) {
setErrorMessage(`Could not load ${feedType} stories.`);
}
});
return () => controller.abort();
}, [feedType, pageNum]);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📝 Info: Well-structured abort controller pattern for data fetching

The data fetching in Feed.tsx:17-32, ItemDetails.tsx:21-34, and UserPage.tsx:17-29 all correctly use AbortController with cleanup in the useEffect return, and properly filter out AbortError in the catch handler. This is a significant improvement over the old Angular code which used RxJS subscriptions with a manual cancel token pattern. The abort signal is also correctly threaded through to the fetchJSON helper at src/services/hackernews-api.ts:8.

Open in Devin Review

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

devin-ai-integration Bot and others added 2 commits July 8, 2026 10:04
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
Co-Authored-By: Eashan Sinha <eashan.sinha@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown

Addressed the CONTRIBUTING.md issue flagged by Devin Review — setup steps now reflect the Vite workflow (npm install, npm start on localhost:5173, npm run build/preview, npm test/lint); all Angular CLI / precache / static-serve references removed.

@devin-ai-integration

Copy link
Copy Markdown

End-to-end test results (production build, npm run preview @ localhost:4173, desktop + mobile)

Escalation: the user profile page could not be fully tested — the upstream API https://node-hnapi.herokuapp.com/user/:id 404s for all users (Cannot GET /user/jl via curl). Pre-existing external limitation (same endpoint as the old Angular app); the app correctly shows its error UI.

  • / redirects to /news/1, 30 stories render with points/user/comments
  • ✅ Pagination (More ›/news/2, items 31+) and feed switching (new/newest/1)
  • ✅ Item details render nested comment tree; [-]/[+] collapse & re-expand works
  • ⚠️ User profile — untested (upstream API 404s; error UI displays correctly)
  • ✅ Night theme applies instantly and persists across reload (localStorage)
  • ✅ Mobile viewport: responsive layout; item back-chevron returns to feed
  • ✅ PWA: sw.js registered (CDP-verified) and manifest.webmanifest served 200
Desktop feed Item details with comments
Desktop feed Item details
🟢 Night theme persisted after reload Mobile feed layout
Night theme Mobile feed
More evidence (collapse toggle, settings panel, mobile item, user error UI)
Comment collapsed to [+] Settings panel (Night selected)
Collapsed Settings
Mobile item details ⚠️ User page error UI (upstream 404)
Mobile item User error

PWA shell evidence:

sw.js: 200
manifest.webmanifest: 200
SW registrations: http://localhost:4173/sw.js  (CDP Runtime.evaluate)

Devin session: https://app.devin.ai/sessions/b26082ca0cf14af2824526f0bd24933f

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