Skip to content

Migrate from Angular 9 to React 18 + TypeScript + Vite#432

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

Migrate from Angular 9 to React 18 + TypeScript + Vite#432
devanshi-gpta wants to merge 3 commits into
masterfrom
devin/1783431553-react-migration

Conversation

@devanshi-gpta

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

Copy link
Copy Markdown

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-dom v6 + 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.ts

RxJS Observable wrapper replaced with native fetch returning Promises. Poll-fetching logic in fetchItemContent preserved:

const story = await lazyFetch<Story>(`${baseUrl}/item/${id}`);
if (story.type === 'poll' && story.poll) {
  story.poll_votes_count = 0;
  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; });
}

Settings / theme engine — src/context/SettingsContext.tsx

Angular singleton service becomes a SettingsProvider + useSettings() hook. Preserves localStorage persistence (openLinkInNewTab, titleFontSize, listSpacing, theme), the prefers-color-scheme listener (defaults to night/default on first visit), and all setters (setTheme, setFont, setSpacing, toggleOpenLinksInNewTab, toggleSettings). The theme class is applied on the root wrapper in App.tsx (equivalent to <div class="{{ settings.theme }}">).

Routing — src/App.tsx

app.routes.ts recreated with react-router-dom v6. //news/1; /{news,newest,show,ask,jobs}/:page render <Feed feedType=... />; /item/:id and /user/:id are React.lazy + Suspense to preserve lazy loading.

Models, helper, components

  • src/app/shared/models/* classes → plain interface/type in src/models/.
  • comment.pipe.tsformatComments(count) helper ("discuss" / "N comment(s)").
  • Each Angular component (.ts/.html/.scss) ported to a React function component under src/components/ with its .scss imported globally (kept global so the theme engine's descendant selectors like .night .subtext keep matching). Template syntax converted throughout (*ngIf→conditional, *ngFor.map() with key, [ngStyle]→inline style, [innerHTML]dangerouslySetInnerHTML, (click)onClick, [routerLink]/routerLinkActive<Link>/<NavLink>).

PWA

@angular/service-worker + ngsw-config.json replaced with vite-plugin-pwa (Workbox): precache of the app shell, StaleWhileRevalidate for /assets/**, and NetworkFirst caching of the HN API — mirroring the old assetGroups. manifest.json kept 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 from src/ to public/. README build instructions updated for the Vite tooling.

Notes

  • The public HN API (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

Status Commit
⚪ Not started

Run Devin Review

Open in Devin Review (Staging)

devin-ai-integration Bot and others added 2 commits July 7, 2026 13:45
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-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 8 potential issues.

Open in Devin Review

Comment thread src/components/ItemDetails.tsx Outdated
</div>
{item.type === 'poll' && (
<div className="pollResults">
{item.poll.map((pollResult, i) => (

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 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.

Suggested change
{item.poll.map((pollResult, i) => (
{item.poll?.map((pollResult, i) => (
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 2d66dc9 — changed to item.poll?.map(...) to match the old *ngFor behavior of silently skipping when poll is absent.

Comment thread src/api/hackernews.ts
Comment on lines +7 to +10
async function lazyFetch<T>(url: string, options?: RequestInit): Promise<T> {
const res = await fetch(url, options);
return (await res.json()) as 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.

🔍 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.

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 2d66dc9lazyFetch now throws on non-ok responses, so HTTP errors surface through the components' existing .catch error handling instead of being parsed as valid data.

Comment thread src/api/hackernews.ts
Comment on lines +27 to +36
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;
});

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 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.

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.

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.

Comment on lines +68 to +69
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 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).

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.

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)');

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: 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.

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 — 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'.

Comment thread src/components/Comment.tsx Outdated
</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 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.

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 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.

Comment thread src/components/ItemDetails.tsx Outdated
Comment on lines +113 to +123
<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>

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 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.

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 2d66dc9 — both item.content and pollResult.content are now run through sanitizeHtml (DOMPurify) before dangerouslySetInnerHTML, restoring parity with Angular's sanitized [innerHTML].

Comment thread src/components/User.tsx Outdated
</div>
{user.about && (
<div className="other-details">
<p dangerouslySetInnerHTML={{ __html: user.about }}></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 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.

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 2d66dc9user.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>
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