Skip to content

web-next: Reduce per-interaction allocations#308

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

web-next: Reduce per-interaction allocations#308
nyanrus wants to merge 1 commit 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. The remaining follow-ups are tracked in #333 (see "What's not in this PR").

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

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 from this investigation, tracked in #333 rather than an in-repo TODO file:

  • 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.
Title check ✅ Passed The title clearly matches the PR's main goal of reducing interaction-time allocations.
Description check ✅ Passed The description is detailed and directly describes the allocation and remount fixes in this PR.

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.

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