feature: port Angular 9 Hacker News PWA to React + Vite + TypeScript#415
feature: port Angular 9 Hacker News PWA to React + Vite + TypeScript#415devin-ai-integration[bot] wants to merge 2 commits into
Conversation
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 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:
|
🧪 Runtime test resultsRan the Vite dev server locally (
Tested by Devin — session: https://app.devin.ai/sessions/b1a2898e708c4e1284366c83aa0f9e97 |
| </style> | ||
| </head> | ||
| <body> | ||
| <div id="skip"><a href="#content">skip to navigation</a></div> |
There was a problem hiding this comment.
🟡 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.
| <div id="skip"><a href="#content">skip to navigation</a></div> | |
| <div id="skip"><a href="#content">skip to content</a></div> |
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
📝 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).
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
| 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]); |
There was a problem hiding this comment.
📝 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).
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
| async function lazyFetch<T>(url: string): Promise<T> { | ||
| const res = await fetch(url); | ||
| return res.json() as Promise<T>; | ||
| } |
There was a problem hiding this comment.
📝 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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'; |
There was a problem hiding this comment.
🔍 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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; |
There was a problem hiding this comment.
📝 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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 }} |
There was a problem hiding this comment.
🟥 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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.
| {item.content && ( | ||
| <p | ||
| className="subject" | ||
| dangerouslySetInnerHTML={{ __html: item.content }} | ||
| ></p> |
There was a problem hiding this comment.
🟥 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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> |
There was a problem hiding this comment.
🟥 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.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
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>
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 undersrc/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.tsbecomes a plain async modulereact-app/src/services/hackernewsApi.ts(fetchFeed,fetchItemContent,fetchUser,fetchPollContent) usingunfetchagainsthttps://node-hnapi.herokuapp.com. Poll aggregation is preserved:Components consume data via custom hooks
useFeed/useItem/useUser(built onuseEffect+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 thereact-hooksrules.Settings/theming (Angular service → React Context)
SettingsService→SettingsProvider+useSettings(Context). PreserveslocalStoragepersistence fortheme,titleFontSize,listSpacing,openLinkInNewTab, and theprefers-color-schemelistener that auto-selects thenighttheme. The theme string is applied as a class on the app root wrapper (mirrorsapp.component.html). Initial theme is derived from the media query in theuseStateinitializer (rather than a mount-timesetState).Routing (
react-router-dom)/→ redirect to/news/1; feed routes/news|newest|show|ask|jobs/:pagerender a shared<Feed feedType=... />(feed type passed as prop, replacing Angular routedata);/item/:idand/user/:idareReact.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, recursiveComment(collapse toggle),User, plus sharedLoader/ErrorMessage.@Input()→ props,ngOnInit/route subscriptions →useEffect.CommentPipe→formatCommentCounthelper ("N comments" / "discuss").listStart = ((page-1)*30)+1, scroll-to-top on load, and prev/more pagination are preserved.Styles & PWA
Ported
styles.scssand 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 topublic/; the manifest + service worker are wired viavite-plugin-pwa(generateSW,registerType: 'autoUpdate'), producingmanifest.webmanifest+sw.js.Validation
npm install,npm run build,npm run lint,npm run typecheckall pass with no errors (Node 22).ItemDetails/Userand PWAsw.js+manifest.webmanifest(46 precache entries).node-hnapi.herokuapp.comwas reachable and returning data at time of porting.Notes
unfetchis pinned to4.x— the5.0.0release ships a brokenexportsmap pointing at non-existent files and fails to resolve under the bundler.Link to Devin session: https://app.devin.ai/sessions/b1a2898e708c4e1284366c83aa0f9e97
Devin Review