Skip to content

fix: preserve fragment data identity across snapshot updates#68

Merged
XiNiHa merged 4 commits into
XiNiHa:mainfrom
nyanrus:fix/reconcile-preserve-identity
May 27, 2026
Merged

fix: preserve fragment data identity across snapshot updates#68
XiNiHa merged 4 commits into
XiNiHa:mainfrom
nyanrus:fix/reconcile-preserve-identity

Conversation

@nyanrus
Copy link
Copy Markdown
Contributor

@nyanrus nyanrus commented May 27, 2026

createFragment's result observer pre-clears data to undefined inside the same batch() that subsequently applies the reconciled snapshot. Because reconcile({ key: "__id", merge: true }) only walks the existing tree in place when the current value is wrappable (solid-js/store's modifiers.ts — the early if (!isWrappable(state) || !isWrappable(v)) return v;), the pre-clear guarantees reconcile is always reconciling against undefined and produces a fresh top-level reference on every snapshot. The identity-preservation contract that key: "__id", merge: true is 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 reconcile can 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 (matching createLazyLoadQuery'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 /feed recording (Firefox profiler, 30 s with one composer open/close and one reaction toggle):

  • hackers.pub process heap: +10 MB/s sustained during interaction (vs. ~0 MB/s when idle on the same route).
  • _$createComponent dominated 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 — the next handler 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.state data error pending
ok reconcile(value) undefined false
error undefined res.error false
loading unchanged unchanged unchanged

Notes:

  • All updates remain inside the same batch(), so observers never see intermediate state. The previous shared pre-clears were never externally observable; they only mattered to the next reconcile call within the same batch.
  • The "error" arm still explicitly resets data to undefined, which is intentional: an errored fragment shouldn't keep its previous successful data visible.
  • The "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 matches createLazyLoadQuery'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

  • Existing test suite passes (pnpm test).
  • In a downstream consumer (hackers.pub /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.
  • Verify that fragment errors still clear stale data (the "error" arm intentionally preserves the setResult("data", undefined)).
  • Verify that fragment key transitions (key() returning a new key, or returning null) still produce the expected undefined data — this path is unaffected (still goes through the resource generator's explicit setResult("data", undefined) in the if (!k) branch around line 187).

Compatibility

  • No public API changes.
  • Identity preservation is a behavioural change in the sense that the top-level reference of data will now be stable across snapshots for unchanged records, which is the documented contract of reconcile({ 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 clear data and error" (via the pre-clears) to "no-op" (stale-while-revalidate). This brings createFragment into alignment with createLazyLoadQuery. 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 / applyState in solid-js/store and Show in solid-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.

@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 27, 2026

🦋 Changeset detected

Latest commit: ac5b256

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
solid-relay Patch

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

Copy link
Copy Markdown
Contributor

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

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

Comment thread src/primitives/createFragment.ts Outdated
Comment on lines 145 to 151
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;
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.

medium

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.

Suggested change
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;

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new Bot commented May 27, 2026

Open in StackBlitz

npm i https://pkg.pr.new/solid-relay@68

commit: d3c271c

@codecov
Copy link
Copy Markdown

codecov Bot commented May 27, 2026

Codecov Report

❌ Patch coverage is 0% with 5 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.29%. Comparing base (1bfb1a9) to head (ac5b256).

Files with missing lines Patch % Lines
src/primitives/createFragment.ts 0.00% 5 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

nyanrus added a commit to nyanrus/hackerspub that referenced this pull request May 27, 2026
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 added a commit to nyanrus/hackerspub that referenced this pull request May 27, 2026
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 fix/reconcile-preserve-identity branch from 317ec5d to 82c91b0 Compare May 27, 2026 05:47
`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
@XiNiHa XiNiHa merged commit 311ab2b into XiNiHa:main May 27, 2026
1 of 4 checks passed
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.

createFragment defeats its own reconcile identity contract

2 participants