Migrate from Angular 9 to React 18 + TypeScript + Vite#432
Migrate from Angular 9 to React 18 + TypeScript + Vite#432devanshi-gpta wants to merge 3 commits into
Conversation
Rewrite the Hacker News PWA as a React SPA, preserving all features and visual design. Replace Angular tooling with Vite + TypeScript, react-router-dom v6, sass, and vite-plugin-pwa (Workbox). Drop RxJS and zone.js. - Models converted to plain interfaces/types (src/models) - API rewritten as async fetch module (src/api/hackernews.ts) - Settings/theme engine as SettingsContext + useSettings hook - Routes via react-router-dom v6 with lazy-loaded item/user routes - All components ported to React function components with per-component SCSS - comment pipe -> formatComments helper - PWA via vite-plugin-pwa mirroring ngsw-config caching Co-Authored-By: Devanshi Gupta <devanshi.gupta@cognition.ai>
Co-Authored-By: Devanshi Gupta <devanshi.gupta@cognition.ai>
🤖 Devin AI EngineerI'll be helping with this pull request! Here's what you should know: ✅ I will automatically:
Note: I can only respond to comments from users who have write access to this repository. ⚙️ Control Options:
|
| </div> | ||
| {item.type === 'poll' && ( | ||
| <div className="pollResults"> | ||
| {item.poll.map((pollResult, i) => ( |
There was a problem hiding this comment.
🔴 Poll section crashes when a poll item lacks poll options data
The poll options list is accessed without a null guard (item.poll.map(...) at src/components/ItemDetails.tsx:111) even though the API fetch already guards against missing data (story.poll check at src/api/hackernews.ts:23), so viewing a poll item whose options are absent crashes the page.
Impact: Users viewing certain poll items will see a blank crash instead of the item details.
The API guard proves poll can be falsy, but the renderer assumes it's always an array
In src/api/hackernews.ts:23, the code checks if (story.type === 'poll' && story.poll) before accessing the poll array — this guard proves story.poll can be undefined or null. However, in src/components/ItemDetails.tsx:111, item.poll.map(...) is called without optional chaining when item.type === 'poll' is true. If the API returns a poll-type item without a poll array, this will throw TypeError: Cannot read properties of undefined (reading 'map').
Notably, the comments array on line 125 correctly uses optional chaining: item.comments?.map(...). The poll array should do the same.
The old Angular code used *ngFor="let pollResult of item.poll" which silently handles undefined/null by not iterating.
| {item.poll.map((pollResult, i) => ( | |
| {item.poll?.map((pollResult, i) => ( |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 2d66dc9 — changed to item.poll?.map(...) to match the old *ngFor behavior of silently skipping when poll is absent.
| async function lazyFetch<T>(url: string, options?: RequestInit): Promise<T> { | ||
| const res = await fetch(url, options); | ||
| return (await res.json()) as T; | ||
| } |
There was a problem hiding this comment.
🔍 API fetch helper silently treats HTTP error responses as valid data
The lazyFetch function at src/api/hackernews.ts:7-9 does not check res.ok before parsing the response body. If the HN API returns a non-2xx status with a JSON body (e.g. {"error": "not found"}), the promise resolves successfully with that error object. Components like src/components/Feed.tsx:26-35 and src/components/ItemDetails.tsx:25-31 rely on promise rejection to display error messages — so an HTTP 404/500 with a JSON body would silently pass through and the UI would attempt to render the error object as a Story or User, likely showing blank or broken content instead of the error message. This was the same in the old Angular code's lazyFetch, so it's pre-existing, but worth addressing since the migration is a good opportunity to fix it.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 2d66dc9 — lazyFetch now throws on non-ok responses, so HTTP errors surface through the components' existing .catch error handling instead of being parsed as valid data.
| const pollResults = await Promise.all( | ||
| Array.from({ length: numberOfPollOptions }, (_, i) => | ||
| fetchPollContent(story.id + i + 1) | ||
| ) | ||
| ); | ||
|
|
||
| pollResults.forEach((pollResult, i) => { | ||
| story.poll[i] = pollResult; | ||
| story.poll_votes_count += pollResult.points; | ||
| }); |
There was a problem hiding this comment.
📝 Info: Poll data fetching is now properly awaited, fixing a race condition from the old code
The old Angular code in hackernews-api.service.ts fetched poll results inside an RxJS map operator using nested subscribe() calls. This meant the story was emitted to subscribers before poll results were fetched — the poll data was added asynchronously by mutating the already-emitted object, which was a race condition (the UI could render before poll data arrived, and Angular change detection might not pick up the mutation). The new code at src/api/hackernews.ts:27-31 correctly uses await Promise.all(...) so the promise only resolves after all poll results are fetched and attached. This is a genuine improvement.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Thanks — yes, that was the intent: await Promise.all(...) ensures poll results are attached before the promise resolves, so the component only renders once the data is complete.
| const handleChange = (event: MediaQueryListEvent) => { | ||
| setTheme(event.matches ? 'night' : 'default'); |
There was a problem hiding this comment.
🔍 System color scheme changes override user's manually selected theme
In src/context/SettingsContext.tsx:68-69, the handleChange listener unconditionally calls setTheme() when the OS color scheme changes. setTheme at line 31-33 also persists to localStorage. This means if a user manually selects 'amoledblack' via the Settings UI, and then their OS switches between light/dark mode, their choice is overwritten with either 'night' or 'default' — and that override is persisted to localStorage so it survives page reloads. This was the same behavior in the old Angular SettingsService, but the migration would be a good time to fix it by only auto-switching when the user hasn't explicitly chosen a theme (e.g. by checking whether a saved theme exists before applying the system preference).
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Leaving this as-is intentionally: this preserves the original Angular SettingsService behavior, and the migration task explicitly asked to preserve the prefers-color-scheme listener that defaults the theme to night/default. Happy to change it to only auto-switch when the user hasn't explicitly picked a theme as a follow-up if desired — let me know.
|
|
||
| const SettingsContext = createContext<SettingsContextValue | undefined>(undefined); | ||
|
|
||
| const darkColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)'); |
There was a problem hiding this comment.
📝 Info: Module-level window.matchMedia call prevents server-side rendering
The darkColorSchemeMedia constant at src/context/SettingsContext.tsx:15 calls window.matchMedia(...) at module scope (outside any component or effect). This executes immediately when the module is imported, which would throw a ReferenceError in any SSR or test environment where window is not defined. For a client-only SPA this works fine, but it would block any future SSR adoption. Moving this inside the useEffect or guarding with a typeof window !== 'undefined' check would make it SSR-safe.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Acknowledged — intentional for now since this is a client-only SPA (Vite, no SSR). If SSR is adopted later, this should move into the effect or be guarded with typeof window !== 'undefined'.
| </div> | ||
| <div className="comment-tree"> | ||
| <div hidden={collapse}> | ||
| <p className="comment-text" dangerouslySetInnerHTML={{ __html: comment.content }}></p> |
There was a problem hiding this comment.
🟥 Unsanitized HTML rendering of user-generated comment content via dangerouslySetInnerHTML
The Comment component renders comment.content from the Hacker News API using dangerouslySetInnerHTML (src/components/Comment.tsx:34) without any HTML sanitization. The old Angular code used [innerHTML] which is automatically sanitized by Angular's DomSanitizer, stripping dangerous elements like <script> tags and event handlers. React's dangerouslySetInnerHTML performs no sanitization, so any XSS payload in the API response would execute in the user's browser. Since comments are user-generated content, a malicious HN user or a compromised API proxy could inject arbitrary scripts.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 2d66dc9 — added a sanitizeHtml helper backed by DOMPurify and applied it here, restoring the sanitization Angular's [innerHTML]/DomSanitizer provided. Verified comments still render (links preserved) after the change.
| <div dangerouslySetInnerHTML={{ __html: pollResult.content }}></div> | ||
| <div className="subtext">{pollResult.points} points</div> | ||
| <div | ||
| className="pollBar" | ||
| style={{ width: `${(pollResult.points / item.poll_votes_count) * 100}%` }} | ||
| ></div> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
| <p className="subject" dangerouslySetInnerHTML={{ __html: item.content }}></p> |
There was a problem hiding this comment.
🟥 Unsanitized HTML rendering of item content and poll results via dangerouslySetInnerHTML
The ItemDetails component renders item.content (src/components/ItemDetails.tsx:123) and pollResult.content (src/components/ItemDetails.tsx:113) from the Hacker News API using dangerouslySetInnerHTML without sanitization. The old Angular code's [innerHTML] binding was automatically sanitized by Angular's DomSanitizer. This migration to React removes that protection, allowing any XSS payload in the API response to execute.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 2d66dc9 — both item.content and pollResult.content are now run through sanitizeHtml (DOMPurify) before dangerouslySetInnerHTML, restoring parity with Angular's sanitized [innerHTML].
| </div> | ||
| {user.about && ( | ||
| <div className="other-details"> | ||
| <p dangerouslySetInnerHTML={{ __html: user.about }}></p> |
There was a problem hiding this comment.
🟥 Unsanitized HTML rendering of user profile 'about' field via dangerouslySetInnerHTML
The User component renders user.about from the Hacker News API using dangerouslySetInnerHTML (src/components/User.tsx:56) without sanitization. The 'about' field is user-editable profile content on Hacker News, making it a direct XSS vector. The old Angular [innerHTML] binding was sanitized by Angular's DomSanitizer; this protection is lost in the React migration.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 2d66dc9 — user.about is now sanitized via sanitizeHtml (DOMPurify) before rendering, restoring parity with Angular's sanitized [innerHTML].
…es.ok - Add sanitizeHtml helper (DOMPurify) and apply to all dangerouslySetInnerHTML sites (Comment, ItemDetails content + poll, User about) to restore the sanitization Angular's [innerHTML] provided - Guard item.poll?.map to avoid crash when a poll item lacks options - Throw on non-ok HTTP responses in lazyFetch so error states render Co-Authored-By: Devanshi Gupta <devanshi.gupta@cognition.ai>
Summary
Rewrites the Hacker News PWA from Angular 9 to a React 18 + TypeScript SPA, preserving all features and the visual design. Angular tooling (Angular CLI, karma/jasmine/protractor,
@angular/*) is replaced with Vite + TypeScript +react-router-domv6 +sass+vite-plugin-pwa. RxJS and zone.js are dropped.The app builds (
npm run build), lints (npm run lint), and runs (npm start). Verified at runtime: feeds render + paginate, item details render recursive comments, error states render, and the production build generates + registers a Workbox service worker.Data layer —
src/api/hackernews.tsRxJS
Observablewrapper replaced with nativefetchreturning Promises. Poll-fetching logic infetchItemContentpreserved:Settings / theme engine —
src/context/SettingsContext.tsxAngular singleton service becomes a
SettingsProvider+useSettings()hook. PreserveslocalStoragepersistence (openLinkInNewTab,titleFontSize,listSpacing,theme), theprefers-color-schemelistener (defaults tonight/defaulton first visit), and all setters (setTheme,setFont,setSpacing,toggleOpenLinksInNewTab,toggleSettings). The theme class is applied on the root wrapper inApp.tsx(equivalent to<div class="{{ settings.theme }}">).Routing —
src/App.tsxapp.routes.tsrecreated withreact-router-domv6./→/news/1;/{news,newest,show,ask,jobs}/:pagerender<Feed feedType=... />;/item/:idand/user/:idareReact.lazy+Suspenseto preserve lazy loading.Models, helper, components
src/app/shared/models/*classes → plaininterface/typeinsrc/models/.comment.pipe.ts→formatComments(count)helper ("discuss"/"N comment(s)")..ts/.html/.scss) ported to a React function component undersrc/components/with its.scssimported globally (kept global so the theme engine's descendant selectors like.night .subtextkeep matching). Template syntax converted throughout (*ngIf→conditional,*ngFor→.map()withkey,[ngStyle]→inlinestyle,[innerHTML]→dangerouslySetInnerHTML,(click)→onClick,[routerLink]/routerLinkActive→<Link>/<NavLink>).PWA
@angular/service-worker+ngsw-config.jsonreplaced withvite-plugin-pwa(Workbox): precache of the app shell,StaleWhileRevalidatefor/assets/**, andNetworkFirstcaching of the HN API — mirroring the oldassetGroups.manifest.jsonkept and the manifest re-declared in the plugin config for installability.Removed
main.ts,polyfills.ts,test.ts,*.module.ts,app.routes.ts,angular.json,ngsw-config.json,karma.conf.js,tslint.json,tsconfig.app/spec.json,browserslist,e2e/,yarn.lock. Static assets moved fromsrc/topublic/. README build instructions updated for the Vite tooling.Notes
node-hnapi.herokuapp.com) does not implement/user/:id(returns 404 HTML) — this is a pre-existing upstream limitation (the Angular app hit the same URL); the User page correctly shows the error state.Link to Devin session: https://app.devin.ai/sessions/3ce20c0509744e03ba5646ef5ee43878
Requested by: @devanshi-gpta
Devin Review