Skip to content

feat(react): migrate Angular2-HN PWA to React (Vite + TS)#430

Open
devanshi-gpta wants to merge 3 commits into
masterfrom
feat/react-migration
Open

feat(react): migrate Angular2-HN PWA to React (Vite + TS)#430
devanshi-gpta wants to merge 3 commits into
masterfrom
feat/react-migration

Conversation

@devanshi-gpta

@devanshi-gpta devanshi-gpta commented Jul 6, 2026

Copy link
Copy Markdown

Summary

Full feature-parity migration of the Angular 9 Hacker News PWA to React 18 + Vite + TypeScript + react-router-dom, living in a new react-app/ directory. The Angular source is left untouched for reference.

The port preserves behavior and visuals 1:1: same HN API (https://node-hnapi.herokuapp.com), same localStorage keys, same theming (default / night / amoledblack), same routes, same SCSS (ported and manually scoped since React has no Angular-style view encapsulation), and code-splitting parity via React.lazy for the item/user routes. Service-worker/PWA support moves from @angular/service-worker (ngsw-config.json) to vite-plugin-pwa (Workbox generateSW, with a NetworkFirst runtime cache for the HN API).

Angular → React mapping

Angular React
shared/models/*.ts src/models/*.ts
shared/services/hackernews-api.service.ts (RxJS) src/api/hnApi.ts (plain module, fetch → Promises) + src/hooks/useFetch.ts
shared/services/settings.service.ts src/context/SettingsContext.tsx (Context provider)
shared/pipes/comment.pipe.ts src/utils/formatCommentCount.ts
app.component.* src/components/App.tsx + router.tsx
core/header, core/footer, core/settings Header.tsx, Footer.tsx, Settings.tsx
feeds/feed, feeds/item Feed.tsx, Item.tsx
item-details, item-details/comment ItemDetails.tsx, Comment.tsx (recursive)
user User.tsx
shared/components (loader, error) Loader.tsx, ErrorMessage.tsx
app.routes.ts src/router.tsx (createBrowserRouter, lazy item/user)
ngsw-config.json + app.module SW registration vite-plugin-pwa in vite.config.ts

Poll expansion is preserved: fetchItemContent fetches each poll option (id+i+1) in parallel and accumulates poll_votes_count.

// hnApi.ts (poll branch)
const results = await Promise.all(
  Array.from({ length: story.poll.length }, (_, i) => fetchPollContent(story.id + i + 1))
);
results.forEach((r, i) => { story.poll[i] = r; story.poll_votes_count += r.points; });

Analytics decision (scope step 8)

Kept, guarded. App.tsx fires a Google Analytics pageview on every route change, but only when GA is actually present, so it is a no-op in dev/test/without the snippet:

useEffect(() => {
  if (typeof window.ga === 'function') {
    window.ga('set', 'page', location.pathname);
    window.ga('send', 'pageview');
  }
}, [location.pathname]);

Test suite (newly established)

The Angular repo had no unit specs and a single stale Protractor e2e asserting an obsolete welcome message. Rather than porting, a fresh suite was created:

  • Vitest + React Testing Library (npm test) — 34 tests, all passing. Covers hnApi (mocked fetch, incl. poll expansion), SettingsContext (localStorage + prefers-color-scheme subscription), formatCommentCount, and rendering of Item, Comment (recursion + collapse + deleted), Feed (pagination + job header + error), ItemDetails (proportional poll bars), User, and Settings (theme/font/spacing/new-tab toggles).
  • Playwright (npm run e2e) — 4 tests, all passing. Rewritten from the obsolete Protractor spec to the real UI with API responses mocked via route interception: / redirects to /news/1, feed renders, navigation to an item detail (with comments) and a user profile, and toggling the Night theme flips the wrapper class (div.defaultdiv.night).

Verification

  • npm run build — passes (tsc + vite build; PWA sw.js generated; ItemDetails/User emitted as separate lazy chunks).
  • npm run lint — passes (0 errors; 1 benign react-refresh warning on the Context file).
  • npm test — 34/34 passing.
  • npm run e2e — 4/4 passing.
  • Manual smoke test against the live HN API confirmed all five feeds, pagination, item detail with recursive/collapsible comments, theming, and PWA registration.

Note: the public node-hnapi /user/:id endpoint currently returns HTML 404 for all users (affects the original Angular app equally). The React User component and its error state both render correctly — the happy path is verified via mocked unit tests, and the live error path renders the ErrorMessage component as designed.

Screenshots (live API)

News feed Item detail (recursive comments)
news item
Night theme Settings panel (night)
night settings

Link to Devin session: https://app.devin.ai/sessions/93e6eb012ae04869a7c0091c223b51fe
Requested by: @devanshi-gpta


Devin Review

Status Commit
⚪ Not started

Run Devin Review

Open in Devin Review (Staging)

Full feature-parity React port under react-app/ (Angular source retained):
- Models, native-fetch hnApi (incl. poll expansion), SettingsContext
  (localStorage + prefers-color-scheme), formatCommentCount utility
- Components: App, Header, Footer, Settings, Feed, Item, ItemDetails,
  Comment (recursive), User, Loader, ErrorMessage with ported SCSS
- Lazy-loaded item/user routes, vite-plugin-pwa (Workbox) service worker
- Guarded Google Analytics pageview effect on route change
- Vitest + RTL unit tests and Playwright e2e (rewritten from stale Protractor)

Co-Authored-By: Devanshi Gupta <devanshi.gupta@cognition.ai>
@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

Copy link
Copy Markdown

Runtime E2E test results

Ran the production vite preview build locally against the live HN API and exercised the primary browsing flow in-browser. All 5 executed tests passed.

  • / redirects to /news/1 and renders the news feed (title, points, author, comments)
  • ✅ Pagination: "More ›" → /news/2 with list numbering starting at 31 (+ "‹ Prev" appears)
  • ✅ Item detail shows recursive threaded comments; [-] collapses a comment to [+] and hides its subtree
  • ✅ Night theme applies (wrapper class → night, localStorage.theme="night") and persists across reload
  • ✅ Regression: header nav switches feeds (/ask/1 shows "discuss" for 0 comments; /jobs/1 shows the YC jobs header)

Caveat: the public node-hnapi /user/:id endpoint currently returns HTML 404 for all users (affects the original Angular app too), so a real user profile could not be verified at runtime — the User component + error state render correctly and the happy path is covered by mocked unit tests.

Item detail — recursive comments (Night theme)

item detail night theme

Pagination — /news/2 numbering starts at 31

page 2 starts at 31

News feed — default (light) theme

news feed default

Devin session: https://app.devin.ai/sessions/93e6eb012ae04869a7c0091c223b51fe

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

Open in Devin Review

Comment thread react-app/src/context/SettingsContext.tsx Outdated
@@ -0,0 +1 @@
export type FeedType = 'poll' | 'story' | 'job';

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: FeedType union is incomplete — missing 'link' and 'ask' types returned by the HN API

The FeedType type at react-app/src/models/feed-type.type.ts:1 is defined as 'poll' | 'story' | 'job', but the HN API actually returns additional types like 'link' (for regular link stories) and 'ask' (for Ask HN posts). This is copied from the original Angular code at src/app/shared/models/feed-type.type.ts:1. Currently no runtime errors occur because the code only compares against 'job' and 'poll', and TypeScript types are erased at runtime. However, if someone later adds exhaustive type checking (e.g., a switch statement with a never default), the missing variants would cause issues. The e2e test data at react-app/e2e/app.spec.ts:11 and react-app/e2e/app.spec.ts:23 already uses 'ask' and 'link' types.

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 — informational. This is a verbatim port of the Angular feed-type.type.ts union, kept identical for parity. It's only used nominally (compared against 'job'/'poll'), so the missing 'link'/'ask' variants don't cause runtime issues today. If we later add exhaustive switch handling, expanding the union (and updating the Angular source in lockstep) would be the fix.

Comment on lines +48 to +49
const handleChange = (event: MediaQueryListEvent) => {
setTheme(event.matches ? 'night' : 'default');

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 color scheme changes always override user's manually selected theme

The handleChange callback at react-app/src/context/SettingsContext.tsx:48-49 calls setTheme unconditionally when the OS color scheme changes, which also persists the new theme to localStorage via react-app/src/context/SettingsContext.tsx:40. This means if a user manually selects 'amoledblack' and then the system switches from light to dark, their choice is permanently replaced with 'night'. This behavior IS explicitly tested at react-app/src/context/SettingsContext.test.tsx:89-95, so it appears intentional. However, it may surprise users who expect their manual theme choice to be sticky. A more user-friendly approach would be to only auto-switch themes when the user hasn't made a manual selection.

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 faithful to the Angular original: SettingsService.handleSystemPreferredColorSchemeChange (src/app/shared/services/settings.service.ts:28-36) also calls setTheme unconditionally on every OS color-scheme change, persisting it. The spec for this migration was to preserve that subscription behavior, and the unit test at SettingsContext.test.tsx locks it in. Making the manual choice "sticky" would be a deliberate behavior change from the source app, so I'm leaving it as-is for parity — happy to change it if you'd prefer the stickier UX.

Comment on lines +7 to +10
async function lazyFetch<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, options);
return res.json() as Promise<T>;
}

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: API fetch wrapper does not check HTTP status codes

The lazyFetch function at react-app/src/api/hnApi.ts:7-10 calls fetch and immediately calls res.json() without checking res.ok. The fetch API only rejects on network failures, not on HTTP error statuses (4xx, 5xx). If the API returns a non-2xx response with a non-JSON body, res.json() will throw a parse error, which is caught by useFetch's .catch() handler — so the app won't crash. However, if the API returns a JSON error body (e.g., {"error": "not found"}), it would be silently treated as valid data and could cause confusing rendering. This matches the original Angular service at src/app/shared/services/hackernews-api.service.ts which also doesn't check status codes.

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 — informational, and as noted this matches the Angular original which also didn't check status codes. A non-2xx non-JSON response rejects in res.json() and is caught by useFetch's .catch, rendering the error state. Adding an explicit if (!res.ok) throw would make error handling more robust/explicit; leaving out of this parity-focused PR but noting as a reasonable follow-up.

Comment on lines +40 to +41
// eslint-disable-next-line react-hooks/exhaustive-deps
}, deps);

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: useFetch hook uses raw deps array with eslint-disable — fragile but functional

The useFetch hook at react-app/src/hooks/useFetch.ts:41 passes the caller-supplied deps array directly to useEffect with an eslint-disable-next-line react-hooks/exhaustive-deps comment. This means the fetcher function and errorMessage string are not tracked as dependencies. This works correctly today because all callers include the relevant variables (feedType, pageNum, itemID, userID) in their deps arrays, and those same variables are captured by the inline fetcher closures. However, this pattern is fragile — a future caller could forget to include a dependency, and the eslint rule suppression would hide the issue.

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 — informational. The deps array is intentional to mirror the callers' route-param dependencies, and every current caller includes the variables its fetcher closes over. Leaving as-is for now; if this hook grows more callers, wrapping fetcher in useCallback at the call sites (and dropping the eslint-disable) would be the cleaner long-term shape.

Comment thread react-app/src/components/Comment.tsx
Comment thread react-app/src/components/ItemDetails.tsx
Comment thread react-app/src/components/User.tsx
Co-Authored-By: Devanshi Gupta <devanshi.gupta@cognition.ai>

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

Open in Devin Review

Comment on lines +48 to +49
const handleChange = (event: MediaQueryListEvent) => {
setTheme(event.matches ? 'night' : 'default');

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 color-scheme change overrides the user's manually chosen theme

The OS color-scheme listener unconditionally replaces the active theme (setTheme(event.matches ? 'night' : 'default') at react-app/src/context/SettingsContext.tsx:49) even after the user has explicitly picked a different theme, so a user who selected "Black (AMOLED)" loses that choice whenever their OS toggles between light and dark mode.

Impact: A user's explicit theme preference is silently discarded when the operating system's color scheme changes.

Mechanism: the handleChange listener lacks the same guard used during initialization

At react-app/src/context/SettingsContext.tsx:54-56, the initial theme setup correctly checks whether a theme is already saved:

if (!localStorage.getItem('theme')) {
  setTheme(media.matches ? 'night' : 'default');
}

However, the handleChange listener registered at line 48-49 has no such guard:

const handleChange = (event: MediaQueryListEvent) => {
  setTheme(event.matches ? 'night' : 'default');
};

Since setTheme also writes to localStorage (react-app/src/context/SettingsContext.tsx:40), the user's saved preference is permanently overwritten.

To fix this, the handleChange should either:

  • Only apply if the user hasn't explicitly chosen a theme (e.g., track whether the theme was user-set vs system-set)
  • Or at minimum, only override if the current theme is one of the two system-derived themes ('default' or 'night')
Prompt for agents
The handleChange callback in SettingsContext.tsx (lines 48-49) unconditionally overrides the theme when the OS color scheme changes. It should respect the user's explicit theme choice. One approach: only apply the system preference if the current theme is one of the two system-derived values ('default' or 'night'). You could read the current settings state inside the handler (using a ref or the setState updater pattern) and skip the override if the theme is something else like 'amoledblack'. Another approach: introduce a boolean flag (e.g., 'userExplicitlySetTheme') that gets set to true when the user manually changes the theme in Settings, and have handleChange skip the override when that flag is true.
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 a faithful port of the Angular original, which has the identical behavior: handleSystemPreferredColorSchemeChange (src/app/shared/services/settings.service.ts:28-36) unconditionally calls setTheme('night'|'default') on any OS color-scheme change, with no guard for a user's manually-chosen theme (e.g. amoledblack). So an OS toggle wiping an AMOLED selection reproduces in the Angular app too. I've kept parity here rather than silently changing behavior. Happy to add a guard (skip the override when the current theme isn't one of the two system-derived values) if you'd like the React port to improve on the original — just confirm and I'll apply it.


export function SettingsProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState<Settings>(createInitialSettings);
const darkColorSchemeMedia = useRef<MediaQueryList | null>(null);

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: Unused ref: darkColorSchemeMedia is assigned but never read

The darkColorSchemeMedia ref (react-app/src/context/SettingsContext.tsx:37) is assigned inside the effect at line 46 but is never read anywhere else in the component. It appears to be leftover scaffolding or intended for future use. It doesn't cause any harm but is dead code that could be removed for clarity.

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.

Removed in a23a856. The darkColorSchemeMedia ref was leftover scaffolding — the effect now uses a local media const and the ref (plus the useRef import) is gone.

return {
showSettings: false,
openLinkInNewTab: openLinkInNewTab ? JSON.parse(openLinkInNewTab) : false,
theme: localStorage.getItem('theme') || 'default',

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: Initial theme flash: state defaults to 'default' before effect applies system preference

When no theme is saved in localStorage and the system prefers dark mode, createInitialSettings() returns theme: 'default' (line 29). The useEffect then runs and calls setTheme('night') (line 55). This creates a brief render cycle where the 'default' theme class is applied before switching to 'night', potentially causing a visible flash of the light theme. This could be avoided by reading matchMedia synchronously inside createInitialSettings when no localStorage value exists.

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 in a23a856. createInitialSettings now reads matchMedia('(prefers-color-scheme: dark)') synchronously when no saved theme exists, so the first render already uses night for system-dark users — no flash. The effect still runs setTheme once to persist the system-derived theme to localStorage (matching Angular's initTheme dispatch), but since state already holds the correct value there's no visible transition. Existing unit tests (system-dark defaults to night, light defaults to default + persists) still pass.

</div>
<div className="comment-tree">
<div hidden={collapse}>
<p className="comment-text" dangerouslySetInnerHTML={{ __html: comment.content }}></p>

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟨 Unsanitized HTML from third-party API rendered via dangerouslySetInnerHTML

Multiple components render HTML content from the third-party HN API (node-hnapi.herokuapp.com) directly into the DOM using dangerouslySetInnerHTML without any sanitization. If the API is compromised or serves malicious content, arbitrary JavaScript could execute in users' browsers. Affected locations: react-app/src/components/Comment.tsx:35, react-app/src/components/ItemDetails.tsx:104, react-app/src/components/ItemDetails.tsx:120, react-app/src/components/User.tsx:43.

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 mirrors the Angular original's [innerHTML] bindings on the same fields (comment/story/user content), and the content comes pre-sanitized from the node-hnapi proxy. The real difference is that Angular's DomSanitizer scrubs it automatically whereas React's dangerouslySetInnerHTML does not, so a shared DOMPurify helper is the right hardening for the React port. I've flagged this to the repo owner as a follow-up decision rather than adding a dependency unilaterally; will wire up DOMPurify across all four call sites (Comment/ItemDetails x2/User) if they want it in this PR.

Co-Authored-By: Devanshi Gupta <devanshi.gupta@cognition.ai>
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