Skip to content

web-next: Reduce per-interaction allocations#308

Open
nyanrus wants to merge 2 commits into
hackers-pub:mainfrom
nyanrus:perf/reduce-interaction-allocations
Open

web-next: Reduce per-interaction allocations#308
nyanrus wants to merge 2 commits into
hackers-pub:mainfrom
nyanrus:perf/reduce-interaction-allocations

Conversation

@nyanrus

@nyanrus nyanrus commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Firefox profiler traces of https://hackers.pub/feed showed a sustained
+10 MB/s heap growth during interaction while the same route was
flat (~0 MB/s) when idle. This PR fixes the two app-level hotspots that
explain a large share of that, and documents the remaining items.

The single biggest interaction-time allocator was opening the
NoteComposer and triggering LanguageSelect; further re-mount churn
of Kobalte primitives traces to an upstream solid-relay bug (see below).

What's in this PR

LanguageSelect.tsx — memoize the locale list, cache Intl formatters

  • locales was a plain function (not a memo), so every reactive read
    from <Combobox> re-ran a .map() over ~200 POSSIBLE_LOCALES
    entries. Each iteration allocated a fresh
    Intl.DisplayNames(locale, { type: "language" }), which loads the
    CLDR table for its target locale. Steady-state cost: ~200 CLDR-backed
    instances per reactive tick.
  • Wrap locales in createMemo, hoist englishNames to module scope
    (its locale is constant), and memoize displayNames so it only
    rebuilds when i18n.locale actually changes.
  • Cache per-locale Intl.DisplayNames instances at module scope so the
    native-name lookup costs one allocation over the lifetime of the page.

Drive-by fix: the existing guard pushed props.value.baseName onto
the locale list only when it was already present (producing a
duplicate) and never extended the list to cover a non-standard locale.
The condition is inverted so the value is appended when missing, which
matches the apparent intent (the combobox needs to be able to show the
active value even if it's outside the canonical set).

Timestamp.tsx — cache Intl.RelativeTimeFormat

  • formatRelativeTime constructed a new Intl.RelativeTimeFormat on
    every render, which fires once per visible timestamp every
    1s / 30s / 1min depending on age. Cache instances by
    (locale, numeric, style) at module scope.
  • The value=0 fallback path is also routed through the cache and now
    uses the caller's options, so its numeric/style stays
    consistent with the loop above (the previous code passed no options
    in the fallback, falling through to Intl's "always" default while
    the loop honoured the caller's "auto").

web-next/PERF_TODO.md

New file capturing the rest of the audit so the remaining items don't
get lost. See "What's not in this PR" below for the contents.

What's not in this PR

The original draft also dropped keyed from two <Show keyed when={…()}> wrappers (PostEngagementBar, PublicTimeline). The
local lint plugin web-next/lint-plugins/keyed-show.ts correctly
enforces keyed for solid-relay-backed accessors — non-keyed risks
the documented "Stale read from <Show>" race when a fragment flips
to null inside the same tick as a downstream reactive read — so that
draft has been reverted.

The perf cost that motivated the original drop is real: with the
current solid-relay@1.0.0-beta.25, <Show keyed> over a Relay
fragment re-mounts the entire subtree on every snapshot tick,
including pure field updates such as a reaction toggle or a polled
count delta. Profiler runs showed _$createComponent dominating the
leaf-sample list during interaction, with the Kobalte chunks
(@kobalte/core/dist/chunk/*) at ~1,200 inclusive samples per ~1,733
hackers.pub samples.

The root cause is upstream: createFragment pre-clears data to
undefined immediately before applying its identity-preserving
reconcile({ key: "__id", merge: true }). Solid's reconcile early-
exits to "return the new value as-is" when the current state isn't
wrappable (see solid-js/store modifiers.ts), so the pre-clear
guarantees a fresh top-level reference on every snapshot — defeating
the merge.

Upstream fix proposed at XiNiHa/solid-relay#68. Once that ships,
the keyed Shows in this codebase stop re-mounting on field updates
with no change needed here. As an interim, the workspace could vendor
it via pnpm-workspace.yaml#patchedDependencies (the workspace
already patches @kobalte/core, @solidjs/start, and solid-js).

Other follow-ups noted in web-next/PERF_TODO.md:

  • Lazy-mount Tooltip in PostEngagementBar (~100 primitives mounted
    at all times on a 25-card timeline).
  • Lazy-mount ActorHoverCard (×25 author avatars + per-mention).
  • Replace ui/skeleton.tsx with a plain element (Kobalte's Skeleton
    is <div role="status" aria-busy="true">; the 18 in-app uses are
    decorative shapes).
  • Replace ui/separator.tsx with <hr> (one call site).
  • Drop ui/button.tsx wrapper for non-polymorphic uses (~46 call
    sites; mechanical but high churn for small gain).

@coderabbitai

coderabbitai Bot commented May 27, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

This PR implements module-level memoization of Intl.DisplayNames and Intl.RelativeTimeFormat instances in two components to reduce per-render allocation overhead, and adds a performance follow-up checklist documenting profiler observations and remaining optimization opportunities.

Changes

Intl Formatter Caching and Performance Documentation

Layer / File(s) Summary
LanguageSelect Intl.DisplayNames caching and memoization
web-next/src/components/LanguageSelect.tsx
Imports createMemo, adds module-scope nativeDisplayNamesCache and getNativeDisplayNames helper, introduces englishNames module-level instance, converts displayNames and locales computation to createMemo driven by i18n.locale changes, incorporates disabled/exclude logic and locale name sorting within the memo.
Timestamp Intl.RelativeTimeFormat caching and reuse
web-next/src/components/Timestamp.tsx
Adds module-level relativeTimeFormatCache Map and getRelativeTimeFormat(locale, options) helper memoizing formatters by locale and options; formatRelativeTime and fallback path use cached formatters, respecting caller-supplied numeric/style options rather than constructing new instances per call.
Performance follow-up checklist and profiler observations
web-next/PERF_TODO.md
Documents Firefox profiler findings, summarizes two shipped Intl caching fixes, enumerates prioritized follow-ups including upstream solid-relay change for keyed reconciliation, lazy-mounting Kobalte primitives in PostEngagementBar and ActorHoverCard, replacing Kobalte Skeleton/Separator with native alternatives, and optional UI simplifications; includes post-change verification procedure with target metrics.

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~12 minutes


Suggested labels

enhancement


Suggested reviewers

  • dahlia
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description check ✅ Passed The pull request description comprehensively describes the changeset, detailing performance issues identified via profiling and explaining specific fixes to LanguageSelect.tsx and Timestamp.tsx, plus documentation in PERF_TODO.md.
Title check ✅ Passed The title accurately captures the main objective of the PR: reducing per-interaction allocations through memoization of expensive Intl instances and caching strategies.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces performance optimizations by caching expensive Intl.DisplayNames and Intl.RelativeTimeFormat instances, and switching to non-keyed <Show> components in PostEngagementBar and PublicTimeline to prevent unnecessary DOM remounts. A review comment identifies a behavioral inconsistency in Timestamp.tsx where the fallback formatting hardcodes { numeric: "auto" } instead of respecting the passed options object.

Comment thread web-next/src/components/Timestamp.tsx Outdated
nyanrus added 2 commits May 27, 2026 14:35
Two CPU/allocation hotspots surfaced by a Firefox profiler trace of
`/feed` opening NoteComposer (+10 MB/s sustained during the
interactive window, vs. ~0 MB/s while idle on the same route).

LanguageSelect.tsx
  - `locales` was a plain function, so every reactive read from the
    `<Combobox>` re-ran a `.map()` over ~200 `POSSIBLE_LOCALES`
    entries that allocated a fresh `Intl.DisplayNames(locale,
    { type: "language" })` per iteration. Each `Intl.DisplayNames`
    loads CLDR tables for its target locale, so the steady-state cost
    of opening the composer was ~200 fresh CLDR-backed instances per
    reactive tick.
  - Wrap `locales` in `createMemo`, hoist `englishNames` to module
    scope (its locale doesn't depend on anything), and memoise
    `displayNames` so it only rebuilds when `i18n.locale` actually
    changes. Cache per-locale `Intl.DisplayNames` instances at module
    scope so the per-row native-name lookup costs one allocation
    over the lifetime of the page.
  - Drive-by: the pre-existing guard pushed `props.value.baseName`
    onto the locale list only when it was *already* present, which
    produced a duplicate entry and never extended the list to cover a
    non-standard locale. Invert the condition so the value is appended
    when missing — that matches the apparent intent (the comboboxneeds
    to be able to show the active value even if it's outside the
    canonical set).

Timestamp.tsx
  - `formatRelativeTime` constructed a new `Intl.RelativeTimeFormat`
    every render, which fires once per visible timestamp every
    1s/30s/1min depending on age. Cache instances keyed by
    `(locale, numeric, style)` at module scope. Apply the same
    `options` in the value=0 fallback path that the main loop uses,
    so the fallback's `numeric`/`style` no longer drifts from the
    caller (the previous code happened to pass no options in the
    fallback, defaulting to Intl's `"always"`, while the loop honoured
    the caller's `"auto"` — pulling the same `options` through keeps
    them consistent).

Not included here: the original draft of this branch also dropped
`keyed` from two `<Show keyed when={…()}>` wrappers (PostEngagementBar
and PublicTimeline). The local lint plugin `keyed-show.ts` is right
to enforce `keyed` for solid-relay-backed accessors — non-keyed
risks the documented "Stale read from <Show>" race when a fragment
flips to null inside the same tick as a downstream reactive read.

The perf benefit that motivated those drops (avoiding mass remount
of Kobalte primitives on every Relay snapshot tick) was real, but
its root cause is upstream: `createFragment` in solid-relay
pre-clears `data` to `undefined` before applying its
identity-preserving `reconcile`, defeating the merge and producing a
fresh top-level reference on every snapshot. Fixed in
nyanrus/solid-relay#fix/reconcile-preserve-identity (proposed
upstream at XiNiHa/solid-relay#68). Once that lands, the `keyed`
Shows in this codebase stop re-mounting on field updates without
any change needed here. The remaining follow-ups (Tooltip and
ActorHoverCard lazy-mount, Skeleton/Separator/Button cleanup) are
captured in `web-next/PERF_TODO.md`.

Assisted-by: Claude Code:claude-opus-4-7
Adds web-next/PERF_TODO.md with the leftover items from the same
investigation that produced the previous commit, grouped by priority.

The highest-impact item — `<Show keyed when={…()}>` over a Relay
fragment re-mounting Kobalte primitive subtrees on every snapshot tick
even for pure field updates — is documented as blocked on the upstream
solid-relay fix (XiNiHa/solid-relay#68), with the root cause traced to
its `createFragment` pre-clearing data before reconcile and breaking
its own identity-preservation contract. Once that lands, no code change
is needed here.

Remaining items (lazy-mount Tooltip/ActorHoverCard, replace
Skeleton/Separator with native equivalents, drop non-polymorphic Button
wrapper) are described with call sites and the trade-off so a future
change can be picked up cleanly. Also records the verification
scenario so it can be measured against the same profiler trace shape
that surfaced the original regression.

Assisted-by: Claude Code:claude-opus-4-7
@nyanrus nyanrus force-pushed the perf/reduce-interaction-allocations branch from 86e1ec3 to 5ac7f33 Compare May 27, 2026 05:36
@nyanrus nyanrus changed the title web-next: Reduce per-interaction allocations and Kobalte remounts web-next: Reduce per-interaction allocations May 27, 2026

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

🧹 Nitpick comments (1)
web-next/src/components/Timestamp.tsx (1)

91-108: 💤 Low value

Cache implementation reduces allocations effectively.

The module-level cache and key generation are correct for the current usage. One optional robustness improvement: the cache key could include localeMatcher from Intl.RelativeTimeFormatOptions to guard against future code passing different values for that option.

♻️ Optional: include localeMatcher in cache key
-  const key = `${locale}|${options.numeric ?? ""}|${options.style ?? ""}`;
+  const key = `${locale}|${options.numeric ?? ""}|${options.style ?? ""}|${options.localeMatcher ?? ""}`;

This ensures the cache key reflects all options that affect formatter behavior, preventing subtle bugs if localeMatcher is used in the future.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-next/src/components/Timestamp.tsx` around lines 91 - 108, Update the
cache key for Intl.RelativeTimeFormat to include the localeMatcher option so
different localeMatcher values produce distinct entries; modify the key
construction inside getRelativeTimeFormat (which uses relativeTimeFormatCache)
to append options.localeMatcher (or a safe default) along with options.numeric
and options.style before reading/setting the map.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@web-next/src/components/Timestamp.tsx`:
- Around line 91-108: Update the cache key for Intl.RelativeTimeFormat to
include the localeMatcher option so different localeMatcher values produce
distinct entries; modify the key construction inside getRelativeTimeFormat
(which uses relativeTimeFormatCache) to append options.localeMatcher (or a safe
default) along with options.numeric and options.style before reading/setting the
map.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: df5b8d87-6791-443a-8883-e47a8c067cd1

📥 Commits

Reviewing files that changed from the base of the PR and between 2e7b0c9 and 5ac7f33.

📒 Files selected for processing (3)
  • web-next/PERF_TODO.md
  • web-next/src/components/LanguageSelect.tsx
  • web-next/src/components/Timestamp.tsx
✅ Files skipped from review due to trivial changes (1)
  • web-next/PERF_TODO.md

@nyanrus

nyanrus commented May 27, 2026

Copy link
Copy Markdown
Contributor Author

for the nitpick, there seems no call site passes localeMatcher
done!

Comment thread web-next/PERF_TODO.md

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I think this file should be removed!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I will move it to github issues later. Thank you for feedback!

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.

2 participants