Skip to content

feature: port Angular 9 Hacker News PWA to React + Vite + TypeScript#415

Open
devin-ai-integration[bot] wants to merge 2 commits into
masterfrom
devin/1782882119-angular-to-react-migration
Open

feature: port Angular 9 Hacker News PWA to React + Vite + TypeScript#415
devin-ai-integration[bot] wants to merge 2 commits into
masterfrom
devin/1782882119-angular-to-react-migration

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jul 1, 2026

Copy link
Copy Markdown

Summary

Straight functional port of the Angular 9 Hacker News PWA to React + TypeScript (Vite), living in a new react-app/ directory. The original Angular code under src/ is left untouched as reference. No features added or removed — feeds, item details with recursive collapsible comments, user profiles, theming/settings, and PWA/offline behavior are all preserved.

Data layer (RxJS Observables → async fetch + hooks)

src/app/shared/services/hackernews-api.service.ts becomes a plain async module react-app/src/services/hackernewsApi.ts (fetchFeed, fetchItemContent, fetchUser, fetchPollContent) using unfetch against https://node-hnapi.herokuapp.com. Poll aggregation is preserved:

story = await fetch(/item/:id)
if story.type === 'poll':
    results = await Promise.all(poll options: fetch(/item/(id + i + 1)))
    story.poll_votes_count = sum(r.points)

Components consume data via custom hooks useFeed / useItem / useUser (built on useEffect + useState). Each hook tags its result with the params it was fetched for and derives "stale vs. current" instead of synchronously resetting state in the effect, so loaders show correctly on navigation without tripping the react-hooks rules.

Settings/theming (Angular service → React Context)

SettingsServiceSettingsProvider + useSettings (Context). Preserves localStorage persistence for theme, titleFontSize, listSpacing, openLinkInNewTab, and the prefers-color-scheme listener that auto-selects the night theme. The theme string is applied as a class on the app root wrapper (mirrors app.component.html). Initial theme is derived from the media query in the useState initializer (rather than a mount-time setState).

Routing (react-router-dom)

/ → redirect to /news/1; feed routes /news|newest|show|ask|jobs/:page render a shared <Feed feedType=... /> (feed type passed as prop, replacing Angular route data); /item/:id and /user/:id are React.lazy + Suspense (separate chunks in the build output).

Components

Each Angular component (.ts+.html+.scss) → a React function component: App shell, Header, Footer, Settings, Feed, Item, ItemDetails, recursive Comment (collapse toggle), User, plus shared Loader / ErrorMessage. @Input() → props, ngOnInit/route subscriptions → useEffect. CommentPipeformatCommentCount helper ("N comments" / "discuss"). listStart = ((page-1)*30)+1, scroll-to-top on load, and prev/more pagination are preserved.

Styles & PWA

Ported styles.scss and the SCSS theme architecture (_themes.scss, _theme_variables.scss, _media.scss) with the default/night/amoledblack themes. Because Angular scoped component styles, each component's SCSS is nested under a unique root class (e.g. .feed-view, .item-view) to emulate that scoping while the theme selectors stay global. Assets (assets/, favicon.ico) copied to public/; the manifest + service worker are wired via vite-plugin-pwa (generateSW, registerType: 'autoUpdate'), producing manifest.webmanifest + sw.js.

Validation

  • npm install, npm run build, npm run lint, npm run typecheck all pass with no errors (Node 22).
  • Production build emits lazy chunks for ItemDetails/User and PWA sw.js + manifest.webmanifest (46 precache entries).
  • node-hnapi.herokuapp.com was reachable and returning data at time of porting.

Notes

  • Run with Node 22 (Vite requires Node 18+); the original Angular app used Node 12.
  • unfetch is pinned to 4.x — the 5.0.0 release ships a broken exports map pointing at non-existent files and fails to resolve under the bundler.

Link to Devin session: https://app.devin.ai/sessions/b1a2898e708c4e1284366c83aa0f9e97


Devin Review

Status Commit
⚪ Not started

Run Devin Review

Open in Devin Review (Staging)

Add a functional React/TypeScript port of the Angular app under react-app/,
preserving feeds, item details with recursive comments, user profiles,
theming/settings, and PWA/offline support. Angular source kept as reference.

Co-Authored-By: ameen <ameen@cognition.ai>
@devin-ai-integration

Copy link
Copy Markdown
Author

🤖 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
Author

🧪 Runtime test results

Ran the Vite dev server locally (localhost:5173) and exercised the primary flows through the UI against the live node-hnapi.herokuapp.com API.

⚠️ Escalation — user profile page can't load data (NOT a port bug): the backend /user/:id endpoint now returns 404 (verified: /user/pg and /user/doctoboggan both 404, while /item + feeds return 200). The React port calls the exact same endpoint the Angular original used (src/app/shared/services/hackernews-api.service.ts:44), so the Angular app would behave identically. The port handles it correctly by rendering the "Could not load user" error screen.

Test Result
/ redirects to /news/1, 30 numbered stories ✅ passed
Jobs feed /jobs/1 shows YC header + job-only layout (no points/user/comments) ✅ passed
Pagination: page 2 numbers from 31, ‹ Prev + More › present ✅ passed
Item details render nested comments; [-][+] collapses body + replies ✅ passed
Night theme applies + persists across reload (localStorage) ✅ passed
User route /user/:id ⚠️ blocked by backend 404 (error UI verified)
Recording — full E2E flow

E2E recording

T1 — root redirect

T1

T2 — jobs feed (distinct layout)

T2

T3 — pagination (page 2 from 31)

T3

T4 — comment collapse (expanded vs collapsed)
Expanded [-] Collapsed [+]
expanded collapsed
T6 — theme change + persistence
Settings panel Night persisted after reload
settings night
⚠️ T5 — user page 404 (backend, not port)

T5

Tested by Devin — session: https://app.devin.ai/sessions/b1a2898e708c4e1284366c83aa0f9e97

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Devin Review found 9 potential issues.

Open in Devin Review

Comment thread react-app/index.html Outdated
</style>
</head>
<body>
<div id="skip"><a href="#content">skip to navigation</a></div>

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🟡 Accessibility skip link has misleading text that contradicts its destination

The skip link's visible text says "skip to navigation" (<a href="#content"> at react-app/index.html:43) but its target (#content) is the main content area, not the navigation. Standard skip links are meant to let keyboard users bypass navigation and jump to content, so the label should read "skip to content".

Impact: Keyboard and screen-reader users see a link that promises to jump to navigation but actually jumps to the main content, causing confusion.

Mechanism: the href and the label point to different conceptual targets

The id="content" is placed on the app's root content container (react-app/index.html:45). The conventional pattern is <a href="#content">skip to content</a>, allowing users to skip past the header/navigation. Here the label says the opposite of what the link does.

Suggested change
<div id="skip"><a href="#content">skip to navigation</a></div>
<div id="skip"><a href="#content">skip to content</a></div>
Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Good catch — fixed in 47fdfc0. The label now reads "skip to content" to match its #content target. (Note: the skip link isn't in the Angular original; it was added here, so correcting it doesn't affect functional parity.)

user: string;
time: number;
time_ago: number;
type: FeedType;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

📝 Info: Story model types time_ago as number but the API returns a string

The Story interface declares time_ago: number (react-app/src/models/index.ts:26) but the node-hnapi endpoint returns time_ago as a human-readable string like "2 hours ago". The original Angular app (src/app/shared/models/story.ts:11) has the same incorrect type annotation. Since TypeScript types are erased at runtime, this doesn't cause a runtime bug — the actual string value from the API is rendered correctly in templates like {item.time_ago}. However, it means TypeScript won't catch any code that incorrectly treats time_ago as a number (e.g., arithmetic operations).

Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Intentional parity — the Angular original has the same annotation (src/app/shared/models/story.ts:11 declares time_ago: number). As noted, it's erased at runtime and the string value renders fine. Kept identical for the port; correcting it to string would be a fine cleanup but is a divergence from the original model.

Comment on lines +30 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
Author

Choose a reason for hiding this comment

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

📝 Info: OS color scheme change overrides user's explicitly chosen theme

The useEffect at react-app/src/context/SettingsContext.tsx:30-44 listens for OS dark/light mode changes and calls setTheme(), which both updates React state and persists the new theme to localStorage. This means if a user explicitly selects the 'default' (light) theme while their OS is in dark mode, a subsequent OS theme toggle will override their choice and persist the override. The original Angular app (src/app/shared/services/settings.service.ts:28-36) has the same behavior, so this appears intentional. A more robust approach would be to only auto-switch when the user hasn't made an explicit choice (e.g., by tracking whether the theme was user-selected or system-derived).

Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Correct — this is intentional parity with the Angular original (src/app/shared/services/settings.service.ts:28-36), which also re-derives the theme from the prefers-color-scheme listener. Keeping the behavior identical for the functional port. The "only auto-switch when the user hasn't explicitly chosen" refinement is a reasonable future enhancement but would diverge from the original.

Comment on lines +9 to +12
async function lazyFetch<T>(url: string): Promise<T> {
const res = await fetch(url);
return res.json() as Promise<T>;
}

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

📝 Info: API fetch helper does not check HTTP response status

The lazyFetch function at react-app/src/services/hackernewsApi.ts:9-12 calls res.json() without first checking res.ok. If the server returns a 404 or 500, fetch won't reject — it will attempt to parse the error response body as JSON. Depending on the server's error response format, this could silently return unexpected data instead of triggering the .catch() error handlers in the hooks. The original Angular app has the same pattern (src/app/shared/services/hackernews-api.service.ts:48-65), so this is a carried-over design choice rather than a regression.

Open in Devin Review

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

Copy link
Copy Markdown
Author

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 fetch helper (src/app/shared/services/hackernews-api.service.ts), which also doesn't check response status. In practice node-hnapi returns JSON error bodies, and the hooks' .catch() handles network/parse failures (verified during testing: the /user/:id 404 surfaces the "Could not load user" error screen correctly). Adding an explicit res.ok check is a sensible hardening follow-up but changes behavior vs. the original.

import type { User } from '../models';
import type { PollResult } from '../models';

const baseUrl = 'https://node-hnapi.herokuapp.com';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

🔍 API base URL points to a Heroku endpoint that may no longer be available

The API base URL https://node-hnapi.herokuapp.com at react-app/src/services/hackernewsApi.ts:7 is a Heroku app. Heroku discontinued free dynos in November 2022, so this endpoint may no longer be operational. The original Angular app uses the same URL (src/app/shared/services/hackernews-api.service.ts:16). If the endpoint is down, all data fetching will fail, rendering the app non-functional. This is worth verifying before shipping.

Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Verified during testing — the endpoint is partially up: /news|newest|show|ask|jobs and /item/:id all return 200 with valid data, so feeds and item/comment pages work. However /user/:id currently returns 404 (the backend dropped that route), so the user profile page shows the "Could not load user" error screen. This matches the Angular original (same base URL, same endpoints). Reported in the test summary above as a backend limitation rather than a port bug.

export interface User {
id: string;
crated_time: number;
created: string;

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

📝 Info: User model has a typo in field name: crated_time instead of created_time

The User interface at react-app/src/models/index.ts:42 declares a field crated_time which appears to be a typo for created_time. This field is never referenced in the React code (only user.id, user.karma, user.created, and user.about are used), so it has no runtime impact. However, it's a cosmetic issue that could confuse future developers.

Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Carried over verbatim from the Angular original (src/app/shared/models/user.ts has the same crated_time typo). The field is unused, so no runtime impact. Left as-is to preserve model parity; happy to correct it if the maintainer prefers cleaning up the typo.

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

Copy link
Copy Markdown
Author

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 in comments

User-generated comment content from the HN API is rendered directly into the DOM via dangerouslySetInnerHTML={{ __html: comment.content }} (react-app/src/components/Comment.tsx:37) without any client-side sanitization (e.g., DOMPurify). If the third-party API (node-hnapi.herokuapp.com) is compromised or passes through unsanitized user content, arbitrary scripts could execute in users' browsers.

Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Acknowledged. This is intentional parity with the Angular original, which renders the same field via [innerHTML] (src/app/item-details/comment/comment.component.html:9). Adding client-side sanitization (e.g. DOMPurify) would introduce behavior/deps beyond the Angular app, which is out of scope for this straight functional port. I've flagged the sanitization question (this + the item/user cases) to the maintainer to decide as a potential follow-up.

Comment on lines +139 to +143
{item.content && (
<p
className="subject"
dangerouslySetInnerHTML={{ __html: item.content }}
></p>

Copy link
Copy Markdown
Author

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 in item details

Item content and poll content from the HN API are rendered without client-side sanitization at react-app/src/pages/ItemDetails.tsx:142 (item.content) and react-app/src/pages/ItemDetails.tsx:119 (pollResult.content). These use dangerouslySetInnerHTML to inject raw HTML from the third-party API directly into the DOM.

Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same as the comment thread — intentional parity with the Angular original, which uses [innerHTML] for both pollResult.content and item.content (src/app/item-details/item-details.component.html:47,52). Sanitization would go beyond the straight port; flagged to the maintainer as a possible follow-up.

</div>
{user.about && (
<div className="other-details">
<p dangerouslySetInnerHTML={{ __html: user.about }}></p>

Copy link
Copy Markdown
Author

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 in user profile

The user's about field from the HN API is rendered without sanitization via dangerouslySetInnerHTML={{ __html: user.about }} at react-app/src/pages/User.tsx:36. This field contains user-authored HTML content from Hacker News profiles.

Open in Devin Review

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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Same as the other dangerouslySetInnerHTML threads — parity with the Angular original (src/app/user/user.component.html:17 uses [innerHTML]="user.about"). Sanitization is out of scope for the straight port; flagged to the maintainer as a possible follow-up.

…t target

Co-Authored-By: ameen <ameen@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.

0 participants