fix: preserve fragment data identity across snapshot updates#68
Conversation
🦋 Changeset detectedLatest commit: ac5b256 The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
There was a problem hiding this comment.
Code Review
This pull request modifies createFragmentInternal to prevent pre-clearing data to undefined before reconciling, which preserves object identity for unchanged records and avoids unnecessary re-mounts in consumers. The reviewer suggested moving setResult("error", undefined) back into the "ok" case of the switch block to avoid a redundant write during the "error" state.
| setResult("error", undefined); | ||
|
|
||
| switch (res.state) { | ||
| case "ok": | ||
| setResult("error", undefined); | ||
| setResult("pending", false); | ||
| setResult("data", reconcile(cleanSnapshot(res.value), { key: "__id", merge: true })); | ||
| break; |
There was a problem hiding this comment.
By keeping setResult("error", undefined) at the top of the batch, it is unconditionally executed for both "ok" and "error" states. In the "error" state, this results in a redundant write where error is first set to undefined and then immediately overwritten with res.error inside the switch block. Moving setResult("error", undefined) into the "ok" case avoids this redundant write and keeps the state transitions clean and explicit.
| setResult("error", undefined); | |
| switch (res.state) { | |
| case "ok": | |
| setResult("error", undefined); | |
| setResult("pending", false); | |
| setResult("data", reconcile(cleanSnapshot(res.value), { key: "__id", merge: true })); | |
| break; | |
| switch (res.state) { | |
| case "ok": | |
| setResult("error", undefined); | |
| setResult("pending", false); | |
| setResult("data", reconcile(cleanSnapshot(res.value), { key: "__id", merge: true })); | |
| break; |
commit: |
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #68 +/- ##
==========================================
- Coverage 91.95% 91.29% -0.66%
==========================================
Files 20 20
Lines 721 724 +3
Branches 137 138 +1
==========================================
- Hits 663 661 -2
- Misses 50 55 +5
Partials 8 8 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
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
317ec5d to
82c91b0
Compare
`reconcile({ key: "__id", merge: true })` only preserves identity by
walking the existing tree in place when the current store value is
wrappable. solid-js/store's `reconcile` (modifiers.ts) early-returns
the new value as-is when it isn't, so reconciling against `undefined`
silently produces a fresh top-level reference and the merge contract
goes away.
The result observer was pre-clearing `data` to `undefined` inside the
same batch that subsequently applied the reconciled snapshot, so that
contract was being defeated on every Relay update. Identity-sensitive
consumers — most prominently `<Show keyed when={data()}>` — re-mounted
their subtree on every snapshot tick, including pure field updates
such as a reaction toggle or a polled count delta. In a real fediverse
timeline (hackers.pub `/feed`, ~25 cards × multiple Kobalte primitives
per card), this dominated CPU and heap during interaction (~+10 MB/s
sustained vs. ~0 MB/s when idle on the same route).
Restructure the observer so each branch owns its full state transition
and no shared pre-clears defeat the reconcile:
- "ok" — clear `error`, set `pending` to false, reconcile `data`.
- "error" — set `data` to `undefined`, set `error`, set `pending`.
- "loading" — no case. Leave `data` / `error` / `pending` as-is for
stale-while-revalidate behaviour, matching `createLazyLoadQuery`'s
observer in the same package.
Behaviour is otherwise unchanged. Observers never saw the intermediate
pre-clear state — every mutation is inside the same `batch()` — so the
fix is invisible to anything except identity comparisons across snapshot
boundaries, which the reconcile contract is exactly there to keep
stable.
Assisted-by: Claude Code:claude-opus-4-7
82c91b0 to
d40cc62
Compare
createFragment's result observer pre-clearsdatatoundefinedinside the samebatch()that subsequently applies the reconciled snapshot. Becausereconcile({ key: "__id", merge: true })only walks the existing tree in place when the current value is wrappable (solid-js/store'smodifiers.ts— the earlyif (!isWrappable(state) || !isWrappable(v)) return v;), the pre-clear guaranteesreconcileis always reconciling againstundefinedand produces a fresh top-level reference on every snapshot. The identity-preservation contract thatkey: "__id", merge: trueis supposed to provide is silently defeated.This PR drops the shared pre-clears so each branch of the state switch owns its full state transition and
reconcilecan merge against the live store tree. Behaviour is otherwise unchanged.Fixes #70 . Also resolves the
"loading"direction discussed in # in favour of stale-while-revalidate (matchingcreateLazyLoadQuery's observer) — if the maintainer prefers a different direction there, a one-case follow-up is straightforward.Why this matters
Identity-sensitive consumers — most prominently
<Show keyed when={data()}>— re-mount their subtree on every change to the fragment data's top-level reference. Today that's every snapshot tick, including pure field updates (a single reaction toggle, a share count delta, a polled cursor refresh). In a real fediverse timeline rendering ~25 cards × multiple Kobalte primitives per card (Tooltip, Popover, DropdownMenu, HoverCard), this dominates CPU and heap during interaction.Concrete trace from a hackers.pub
/feedrecording (Firefox profiler, 30 s with one composer open/close and one reaction toggle):_$createComponentdominated the leaf-sample list, with@kobalte/core/dist/chunk/*at ~1,200 inclusive samples per ~1,733 hackers.pub-thread samples.<Show keyed>is the correct primitive for this use case (non-keyed risks the documented "Stale read from<Show>" race when the fragment flips to null inside the same tick as a downstream reactive read), so the fix needed to be at the snapshot-write site rather than at every consumer.What changed
src/primitives/createFragment.ts— thenexthandler is restructured so each branch owns its full state transition with no shared pre-clears:next(res) { queueMicrotask(() => { batch(() => { - setResult("data", undefined); - setResult("error", undefined); - switch (res.state) { case "ok": setResult("error", undefined); setResult("pending", false); setResult("data", reconcile(cleanSnapshot(res.value), { key: "__id", merge: true })); break; case "error": setResult("data", undefined); setResult("error", res.error); setResult("pending", false); break; } }); }); }State machine after the change:
res.statedataerrorpendingokreconcile(value)undefinedfalseerrorundefinedres.errorfalseloadingNotes:
batch(), so observers never see intermediate state. The previous shared pre-clears were never externally observable; they only mattered to the nextreconcilecall within the same batch."error"arm still explicitly resetsdatatoundefined, which is intentional: an errored fragment shouldn't keep its previous successful data visible."loading"state intentionally has no case. Leaving the result store untouched gives consumers stale-while-revalidate behaviour during transient missing-data snapshots rather than flashing an empty UI, and matchescreateLazyLoadQuery's observer in this same package, which has always handled only the"ok"and"error"states. See # for the discussion of this direction.Test plan
pnpm test)./feed), verify with a Firefox / Chrome profiler that toggling a reaction on a single post no longer re-mounts every Kobalte primitive on the timeline, and that hackers.pub-process heap growth during interaction falls from ~+10 MB/s to under ~1 MB/s.data(the"error"arm intentionally preserves thesetResult("data", undefined)).key()returning a new key, or returningnull) still produce the expectedundefineddata — this path is unaffected (still goes through the resource generator's explicitsetResult("data", undefined)in theif (!k)branch around line 187).Compatibility
datawill now be stable across snapshots for unchanged records, which is the documented contract ofreconcile({ key, merge: true }). Any consumer that depended on the previous behaviour (fresh reference on every update) was detecting fragment-data updates at the wrong granularity and should be reading the specific field that changed instead."loading"behaviour changes from "implicitly cleardataanderror" (via the pre-clears) to "no-op" (stale-while-revalidate). This bringscreateFragmentinto alignment withcreateLazyLoadQuery. See # for the rationale and alternatives.AI disclosure
Investigation and patch developed with Claude (Opus 4.7) as a programming assistant. The reasoning behind the change — tracing through
reconcile/applyStateinsolid-js/storeandShowinsolid-js's flow control to confirm that the pre-clear breaks identity preservation and that<Show keyed>consequently re-mounts on every snapshot — was verified by reading the upstream source. The commit message and this PR description include the relevant pointers into that source for review.