Skip to content

feature: React TypeScript port of the Angular HN PWA + visual regression harness#336

Open
devin-ai-integration[bot] wants to merge 1 commit into
masterfrom
devin/1781626512-react-port
Open

feature: React TypeScript port of the Angular HN PWA + visual regression harness#336
devin-ai-integration[bot] wants to merge 1 commit into
masterfrom
devin/1781626512-react-port

Conversation

@devin-ai-integration

@devin-ai-integration devin-ai-integration Bot commented Jun 16, 2026

Copy link
Copy Markdown

Summary

Ports the Angular 9 Hacker News PWA to a React + TypeScript app (react-hn/) with full visual parity, and adds a Playwright + pixelmatch visual-regression harness (visual-tests/) that proves it. The original Angular app is left untouched.

All 28 views pass at <0.7% pixel mismatch (threshold 2%) — every feed (news/newest/show/ask/jobs), pagination, item detail, user profile, settings panel, all 3 themes (default/night/amoledblack), and the error state, each at desktop (1280×800) and mobile (375×812).

React app (react-hn/)

CRA + TypeScript, react-router-dom@6, sass. Mirrors the Angular architecture 1:1:

  • Angular SettingsService → React SettingsContext (localStorage + prefers-color-scheme media query).
  • RxJS Observable API service → Promise-based native fetch (services/hackerNewsApi.ts), including the poll vote-aggregation logic.
  • CommentPipeutils/commentFormat.ts.
  • Components/pages: Header, Footer, Settings, Loader, ErrorMessage, FeedItem, Feed, Comment (recursive/collapsible), ItemDetails, User.
  • The SCSS theme engine (_theme_variables, _themes, _media) is copied verbatim so the global .{theme} .wrapper … selectors keep working.

Key porting subtlety: replicating Angular's CSS encapsulation

Angular's ViewEncapsulation scopes each component's element selectors (p, a, ul, h1) to that component's own template. React has no such isolation, so naive global SCSS bleeds across component boundaries. Two bleeds were the main sources of mismatch and are fixed:

  • header h1 { display:inline-block; font-size:16px; font-weight:normal } was hitting the Settings popup <h1> (because <Settings/> rendered inside <header>), shrinking the "Settings" title. Fixed by rendering <Settings/> as a sibling of <header>.
  • item-details' p { margin: 2px 0 } was compressing the comment-body paragraphs rendered via dangerouslySetInnerHTML (Angular keeps them at the UA default 1em). Fixed by scoping that rule to .laptop > p only.

Each page's element-level styles are wrapped under a page class (.feed, .item-page, .user-page, .comment) to emulate encapsulation; the shared .main-content lives once in styles.scss.

Visual-tests harness (visual-tests/)

  • capture-lib.ts intercepts the live HN API (node-hnapi.herokuapp.com) and serves JSON fixtures/, so both apps render identical data (the live API changes constantly).
  • capture-angular.ts / capture-react.ts capture the matrix in config.ts; compare.ts runs pixelmatch and fails any view >2%.
  • screenshots/angular/ is the committed baseline; screenshots/react/ and screenshots/diffs/ are generated output and git-ignored.

Notes

  • The live /user/:id endpoint on the downsized Heroku API returns 404, so a synthetic fixtures/user.json is used for the user-profile captures (both apps render it identically).
  • npx tsc --noEmit and npm run build both pass clean.

Link to Devin session: https://app.devin.ai/sessions/7df008ba60934dcca141fcaf8848b1d8
Requested by: @lburgers


Devin Review

Status Commit
🟢 Reviewed 15636d8
Open in Devin Review (Staging)

…on harness

Add react-hn (CRA + TypeScript) port of the Angular Hacker News PWA with full
visual parity across all 5 feeds, item detail, user profile, settings, the 3
themes, and mobile/desktop. Add visual-tests/ Playwright + pixelmatch harness;
all 28 views match the Angular baseline at <0.7% pixel mismatch (threshold 2%).

Co-Authored-By: Lukas Burger <lukaskburger@gmail.com>
@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 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 3 potential issues.

Open in Devin Review

points: number;
user: string;
time: number;
time_ago: number;

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.

🟡 Story.time_ago typed as number but API returns a string

The Story model at react-hn/src/models/Story.ts:11 declares time_ago: number, but the HN API actually returns a string like "2 hours ago" (confirmed from the fixture data in visual-tests/fixtures/item.json and visual-tests/fixtures/news_p1.json). Notably, the Comment model in the same PR correctly types time_ago: string at react-hn/src/models/Comment.ts:6, making this an internal inconsistency. Because the API response is cast via res.json() as Promise<T> (react-hn/src/services/hackerNewsApi.ts:12), TypeScript trusts the assertion and won't catch the mismatch. This means TypeScript would incorrectly allow number operations on time_ago (e.g., item.time_ago.toFixed()) while preventing valid string operations (e.g., .includes()), undermining type safety.

Suggested change
time_ago: number;
time_ago: string;
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 on the inconsistency. This is intentional fidelity, though: the port's mandate was to mirror the Angular models 1:1, and the original src/app/shared/models/story.ts declares time_ago: number while comment.ts declares time_ago: string — so this exact inconsistency is inherited verbatim from the source app, not introduced here.

It has no runtime impact: Story.time_ago is only ever rendered as text ({item.time_ago} in FeedItem/ItemDetails) — no numeric operations are performed on it. I've left it as-is to keep the model a faithful copy, but happy to "fix-forward" to string (matching the API/fixture and the Comment model) if we'd rather correct the original's latent bug here.

@@ -0,0 +1 @@
export type FeedType = 'poll' | 'story' | 'job';

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.

🟡 FeedType union is missing 'link' and 'ask' values returned by the API

The FeedType type is defined as 'poll' | 'story' | 'job', but the HN API actually returns 'link' for regular stories and 'ask' for Ask HN posts (confirmed from fixtures: news_p1.json contains "type": "link", ask_p1.json contains "type": "ask"). The value 'story' is included but never appears in API responses. Since Story.type is typed as FeedType (react-hn/src/models/Story.ts:12), TypeScript would incorrectly reject valid comparisons like item.type === 'link' as always-false, preventing correct exhaustive type checks. Current code works because it only checks for 'job' and 'poll', but the type is factually wrong about the API contract.

Suggested change
export type FeedType = 'poll' | 'story' | 'job';
export type FeedType = 'poll' | 'link' | 'job' | 'ask';
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 that the API uses link/ask. Same rationale as the time_ago thread: this is a verbatim copy of the Angular original's src/app/shared/models/feed-type.type.ts ('poll' | 'story' | 'job'), kept identical per the 1:1-port requirement.

No runtime impact: the app only ever branches on type === 'job' (job-styling) and type === 'poll' (poll rendering); it never compares against 'story'/'link'/'ask', so the inaccurate union members are never exercised. Glad to widen it to 'poll' | 'link' | 'job' | 'ask' if we'd prefer the type to reflect the real API contract over matching the source app.

setSpacing: (listSpace: string) => void;
}

const darkColorSchemeMedia = window.matchMedia('(prefers-color-scheme: dark)');

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.

🚩 Module-scope window.matchMedia call may break in test environments

At react-hn/src/contexts/SettingsContext.tsx:20, window.matchMedia('(prefers-color-scheme: dark)') is called at module scope (outside any component or function). In the Angular original (src/app/shared/services/settings.service.ts:17), this was a class property evaluated at construction time. The React version runs the moment the module is imported. In CRA's browser runtime this is fine, but Jest + jsdom (CRA's default test runner) does not provide window.matchMedia out of the box. Any test that imports a component using useSettings will fail unless a polyfill is added. Not a production bug, but will block unit testing without workarounds.

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.

Agreed it's a test-environment concern only, not a production issue. This mirrors the Angular original (settings.service.ts evaluates window.matchMedia(...) at construction time); evaluating at module scope is the React equivalent and works in CRA's browser runtime, the production build, and the Playwright visual suite (all green).

There are no Jest/jsdom unit tests in this PR, so nothing is currently broken. If unit tests are added later, the clean fix is a guard (typeof window !== 'undefined' && window.matchMedia ? window.matchMedia('(prefers-color-scheme: dark)') : null) or a matchMedia polyfill in setupTests.ts. Leaving as-is for now to keep parity with the source.

@staging-devin-ai-integration staging-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 5 potential issues.

View 4 additional findings in Devin Review.

Open in Devin Review (Staging)
Debug

Playground

points: number;
user: string;
time: number;
time_ago: number;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Story.time_ago typed as number but API returns a string

Story.time_ago is declared as number at react-hn/src/models/Story.ts:11, but the HN API returns a string like "2 hours ago" (confirmed by the PR's own fixture data in visual-tests/fixtures/news_p1.json). Within the same PR, Comment.time_ago is correctly typed as string at react-hn/src/models/Comment.ts:6. While the current rendering code ({item.time_ago} in JSX) works for both types, this incorrect type definition means TypeScript will allow number-only operations (e.g., .toFixed(), arithmetic) on what is actually a string at runtime, which would crash.

Suggested change
time_ago: number;
time_ago: string;
Open in Devin Review (Staging)

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

Debug

Playground

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

🟡 FeedType union is missing 'link', the most common item type from the API

FeedType is defined as 'poll' | 'story' | 'job', but the HN API returns type: "link" for most stories (confirmed by every entry in the PR's own fixture files like visual-tests/fixtures/news_p1.json). Since Story.type is typed as FeedType, TypeScript will reject valid comparisons like item.type === 'link' and will incorrectly consider a switch over 'poll' | 'story' | 'job' to be exhaustive, missing the most common case.

Suggested change
export type FeedType = 'poll' | 'story' | 'job';
export type FeedType = 'poll' | 'link' | 'story' | 'job';
Open in Devin Review (Staging)

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

Debug

Playground

<div
className="pollBar"
style={{
width: `${(pollResult.points / item.poll_votes_count) * 100}%`,

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Division by zero in poll bar width when all poll options have zero points

In ItemDetails.tsx:119, the poll bar width is computed as (pollResult.points / item.poll_votes_count) * 100. The poll_votes_count is the sum of all poll option points (react-hn/src/services/hackerNewsApi.ts:27-35). If every poll option has 0 points, poll_votes_count is 0, and the expression evaluates to 0/0 * 100 = NaN, producing width: "NaN%" — invalid CSS that causes the poll bars to render incorrectly.

Suggested change
width: `${(pollResult.points / item.poll_votes_count) * 100}%`,
width: `${item.poll_votes_count > 0 ? (pollResult.points / item.poll_votes_count) * 100 : 0}%`,
Open in Devin Review (Staging)

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

Debug

Playground

Comment on lines +55 to +63
useEffect(() => {
const handleChange = (event: MediaQueryListEvent) => {
setTheme(event.matches ? 'night' : 'default');
};
darkColorSchemeMedia.addEventListener('change', handleChange);
return () => {
darkColorSchemeMedia.removeEventListener('change', handleChange);
};
}, [setTheme]);

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 listener overrides user-saved theme preference

When the OS dark-mode preference changes, the useEffect at react-hn/src/contexts/SettingsContext.tsx:55-63 calls setTheme() which writes the new theme to localStorage. This means if a user explicitly chose 'default' (light) theme and later their OS switches to dark mode, their explicit preference is silently overwritten with 'night'. The Angular version likely had the same behavior, but it's worth considering whether the listener should only apply when no explicit user preference is saved.

Open in Devin Review (Staging)

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

Debug

Playground

$theme-amoledblack-subtext-color,
$theme-amoledblack-secondary-color,
$theme-amoledblack-body-background-color,
$theme-amoledblack-secondary-color

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚩 AMOLED black theme passes a bare color as the $border parameter

In react-hn/src/scss/_themes.scss:238, the amoledblack theme invocation passes $theme-amoledblack-secondary-color (which is rgba(255, 255, 255, 0.6)) as the $border mixin parameter. Other themes pass full border shorthand like 2px solid #b92b27. Since CSS border-bottom: rgba(255, 255, 255, 0.6) has no width or style, borders won't render for the AMOLED theme. This is faithfully ported from the Angular original (src/app/shared/scss/_themes.scss:238) so it's a pre-existing issue, but may be worth fixing while the SCSS is being set up fresh.

Open in Devin Review (Staging)

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

Debug

Playground

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