Insertion-sort per-entry on merge is fragile — use one stable final sort instead
When you merge rows from multiple sources into one ordered list, appending each new row with a pivot-search insertion (splice at the position found by scanning) is fragile the moment any row's timestamp can be a fallback/sentinel value instead of a real one; appending everything and doing a single final stable sort is the robust fix.
The signal that betrays it
Look for a loop shaped like this, run once per new entry rather than once for the whole batch:
``typescript
// ❌ Fragile: insertion sort per-entry with pivot search
for (const entry of newEntries) {
let insertAt = out.length;
for (let i = 0; i < out.length; i++) {
if (rowTs(out[i]) > entryMs) { insertAt = i; break; }
}
out.splice(insertAt, 0, entry);
}
``
Concrete tells that this exact shape is live in a codebase:
- A merge of multiple sources into one display list (e.g. chat messages + an audit/log stream + reference markers), each with its own way of producing a timestamp.
- At least one source has a fallback timestamp for the case where the real one is missing — typically Date.now() (or any "now" sentinel) substituted when an upstream field is undefined/0/null. That fallback is the trigger: it manufactures a value that looks like "the future" relative to older-but-real timestamps already in the list.
- The visible bug looks like a rendering/positioning glitch, not a sort bug: rows dated days ago appear spliced in between rows from a different, more recent cluster. In a DOM inspection this shows up as anomalous top offsets — a cluster of "recent" rows sitting immediately before/inside a much older cluster, with no error in the console.
- It reproduces specifically on rehydrate after refresh (F5), not during live/incremental append, because rehydrate is where all sources get merged in one pass and the fallback-timestamp row is already sitting in the array when the older row tries to find its insertion point.
Mechanically: for a new entry at real time T-old, the loop scans out looking for the first row whose timestamp is > T-old. If some earlier-inserted row's timestamp is actually Date.now() (because its source timestamp was missing), it reads as "greater than T-old" — true, but for the wrong reason — and the loop picks that row's index as the insertion point. The old entry gets spliced *before* that fallback-timestamped row, i.e. dropped into the middle of a cluster it has no chronological relationship with.
How it's exploited (i.e. how you actually fix it)
Replace the per-entry insertion loop with two steps: append everything unconditionally, then a single Array.prototype.sort at the end.
```typescript // ✅ Robust: append + single final sort for (const entry of newEntries) out.push(toRow(entry));
const rowTs = (r: Row): number => { const raw = /* extract this row's own timestamp, source-specific */; return Number.isFinite(raw) && raw > 0 ? raw : Number.POSITIVE_INFINITY; }; out.sort((a, b) => rowTs(a) - rowTs(b)); ```
Operational details that make this actually correct, not just "less code":
- Use `+Infinity` (not `Date.now()` or `0`) as the sentinel for invalid/missing timestamps inside the comparator. This is the actual fix, not the final-sort-vs-insertion-sort choice by itself — an insertion-sort loop rewritten to use the same +Infinity sentinel would arguably also work, but the final-sort version is simpler to get right because there's only one comparator to reason about instead of one comparator plus a loop invariant. +Infinity pushes every row with a bad timestamp to the very end, deterministically, instead of landing wherever the scan happens to place it relative to whatever fallback values are already in the array.
- **Rely on Array.prototype.sort being a *stable* sort. Per the ECMAScript 2019 spec (and MDN's documentation of it), `Array.prototype.sort` is guaranteed stable since ES2019 — equal-key elements keep their relative insertion order. That's what makes "append everything, sort once" safe for a multi-source merge: rows from different sources that legitimately share a timestamp (or all fall back to `+Infinity` together) keep the order they were appended in, which for a merge built by iterating sources in a fixed order is exactly the desired secondary ordering. Don't rely on this pre-ES2019 or in an environment where `sort` stability isn't guaranteed.
- Complexity trade-off is a non-issue at UI list sizes. A single final sort is O(n log n) versus the pivot-insertion loop's O(n²) (each new entry does an O(n) scan). For a typical chat/list of a few hundred rows the difference is sub-millisecond either way — this is not a performance fix, it's a correctness fix that happens to also be asymptotically better.
- The root fallback should still be fixed upstream where possible.** The final-sort fix neutralizes the symptom (wrong position) but the *cause* — a parser or event-mapper substituting Date.now() for a missing timestamp — still assigns a semantically wrong "now" to a message that isn't from now. If any other consumer of the same row list does its own filtering/sorting by timestamp (not just this one render path), that consumer will inherit the same distortion unless it also treats the fallback value as "invalid, sort last" rather than as a real instant. Prefer, upstream, a fallback of null/"unknown" (excluded from time-based comparisons) or inheriting the neighboring row's timestamp, over synthesizing "now".
What does NOT work
- `untrack()` around the insertion loop. If the fragility is conflated with a nearby, unrelated Svelte 5 reactivity bug (writing to $state from inside a $derived/getter), untrack() looks like a tempting one-line fix because it's the right tool for *that* problem. It is not: untrack only suppresses a *read* from becoming a reactive dependency, it does nothing to a per-entry insertion loop's ordering logic. Don't reach for it here — see Writing reactive state inside a getter called by a $derived crashes the whole render tree, not just that component for where untrack actually applies (and where it doesn't either).
- Keeping the insertion loop and just special-casing the fallback value inside the scan. It's possible to patch the pivot search to skip/deprioritize rows with a suspected-fallback timestamp, but this multiplies the number of cases the loop has to get right (real-old vs real-new vs fallback, for both the incoming entry and every existing row) instead of collapsing them into one comparator. Every additional per-entry special case is one more place the next edge case (a second kind of sentinel, a source added later) can reintroduce the same class of bug.
- Trusting that "it works in the live/incremental case" means the merge logic is correct. The insertion-sort version can look correct for a long time because live append usually adds one real, recent entry to a list that doesn't yet contain any fallback-timestamped rows — the bug only surfaces once a fallback-timestamped row is already resident in the array (typically after a full rehydrate merges multiple sources at once). Testing only the incremental-append path misses this; the rehydrate/full-merge path is what to test against.
- Fixing only the render path and assuming the data is now consistent. The sort fix corrects what one component displays; it doesn't correct what the row's timestamp field actually contains. Any other code path that reads the same underlying entities for filtering, grouping, or export by time will see the same wrong "now" timestamp unless it also treats it as invalid — the fix is local, the underlying data quality issue is not.
Related
- Writing reactive state inside a getter called by a $derived crashes the whole render tree, not just that component — the sibling bug from the same debugging session: writing to $state from a getter reached by a $derived throws and blackscreens; untrack() does not fix it either, for a different reason (it never permits the write, it only suppresses read-tracking).
- An $effect that reads state it indirectly causes to be written re-fires forever, silently — another case where an unguarded reactive read of state a component itself mutates causes a loop; same family of "know exactly which reads should and shouldn't be tracked" discipline.
- A timed event without an explicit `end` vanishes from rendering (the library's default is 'right now') — same root shape one layer up: a library silently substituting "now" for a missing field corrupts ordering/rendering downstream, in a different framework and a different field (end instead of a merge timestamp).
Verified against
24 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.