LearnNewsExamplesServices
Frontmatter
id16533
titleA per-move layout heal makes every DOM reorder O(n²): 590-item sort costs 8137ms instead of 18ms
stateClosed
labels
bug
assigneesneo-opus-grace
createdAtAug 5, 2026, 10:56 AM
updatedAtAug 5, 2026, 12:29 PM
githubUrlhttps://github.com/neomjs/neo/issues/16533
authorneo-opus-grace
commentsCount0
parentIssuenull
subIssues[]
subIssuesCompleted0
subIssuesTotal0
contentTrust
projected
quarantined0
signals[]
blockedBy[]
blocking[ ] 16536 A per-move layout flush can be reintroduced and nothing fails
closedAtAug 5, 2026, 12:29 PM

A per-move layout heal makes every DOM reorder O(n²): 590-item sort costs 8137ms instead of 18ms

neo-opus-grace
neo-opus-grace commented on Aug 5, 2026, 10:56 AM

Context

Discord report: "animations are no longer working" on examples/component/helix. Root-caused. The animation loss is a symptom; the defect is a per-move forced layout that makes DOM reordering quadratic.

Reproduce: npm run server-start, open examples/component/helix/index.html, set the amount field to 600 (sort is disabled below that — buffered-store sorting is unimplemented), click Sort by Firstname.

Not implicated: the VDom engine, delta throughput, data.Store, collections

Stated up front because the symptom invites all four. Delta throughput is healthy right now — mouse-wheel rotation on the same 600 items sustains ~70k delta updates/sec (600 transform-matrix updates per frame), and the helix exists precisely as that stress demo. In the instrumented sort run, updateNode cost 7ms across 1182 calls.

Rotation is unaffected because it only ever updates transforms. It never moves a node, and the cost below is entirely in node movement.

Root Cause

src/main/DeltaUpdates.mjs:631-633, inside moveNode's nativeMoveBefore branch:

parentNode.style.display = 'none';
void parentNode.offsetHeight;      // forced synchronous reflow
parentNode.style.display = displayValue;

A forced synchronous layout on every single moveNode call. Its cost scales with the parent's child count, so reordering N siblings costs O(N²). In the helix the parent is .group — 590 3D-transformed children under transform-style: preserve-3d + perspective: 800px — so each move tears down and rebuilds that entire 3D box tree.

Measured causation, same page, same sort, toggling only DeltaUpdates.nativeMoveBefore:

path moveNode total main-thread long tasks
heal enabled (current) 8137ms one, 8133ms
insertBefore fallback (no heal) 18ms none

452×. Wall-clock varies run to run (6.6s–8.1s observed); the fallback comparison is the stable signal.

Origin

Introduced in ac231036b5 (2026-06-11), "fix(grid): heal mid-drag cell-mapping staleness + Chromium moveBefore stale flex layout"#12894, closing #12883. A grid column-drag fix, applied to every moveNode rather than scoped to the case it repaired.

The window is therefore ~8 weeks, not the "possibly a year" the report's age suggested.

The code comment at :624 refers to a heal-gating follow-up; no such ticket is on the board, so this ticket carries that tracking.

Blast Radius — by construction, measured only on the helix

moveNode is the shared path for every sibling reorder, so by construction this cost applies to any consumer moving many siblings within one parent: grids, lists, trees, drag-and-drop, sorted store-driven views. Measured only on the helix so far — the helix makes it visible by moving 590 nodes at once inside a 3D scene. Quantifying it on a grid is worth doing but is not evidence this ticket currently holds.

The Animation Loss (the reported symptom)

applyItemTransitions (src/component/Helix.mjs:462) adds neo-transition-1000, runs the callback, then schedules class removal via setTimeout(animationTime + 200) — a wall-clock timer anchored to the class addition. The long task starves it, so it fires immediately on release. Measured in one run: transform applied at 6780ms, class removed at 6793ms — 13ms later, so transition-duration drops to 0s before the browser renders a frame. Items snap.

Fixing the heal restores the animation incidentally, but the timer stays anchored to the wrong event and will lose again on any slow frame. Worth fixing separately, anchored to the transform application or transitionend.

The Fix

  1. Batch the heal. It runs per move but only needs to run once per update() batch: no paint occurs between deltas inside a single task, so the intermediate rebuilds are unobservable. This alone converts O(N²) to O(N) while preserving the repaired end state.
  2. Consider gating it. #12894 addressed a stale flex sibling chain; applying it to every parent, including non-flex and 3D-transformed containers, is broader than the defect it fixed.
  3. Re-anchor the transition window in Helix.applyItemTransitions — independent of 1 and 2.

Acceptance Criteria

  • Reordering N siblings costs O(N), proven by a timing assertion that fails against the current per-move heal.
  • #12883 stays fixed: a grid column drag to the locked-end region still re-homes correctly.
  • Sorting 600 helix items produces no main-thread long task beyond a stated, measured budget.
  • Items visibly animate to their new positions, the transition outliving the transform application by at least one rendered frame.
  • Regression coverage for a large sibling reorder — the cost is invisible at small N.

Out of Scope

  • Sorting for buffered stores (why the buttons are disabled below 600).
  • neo-transition-600 is added at Helix.mjs:747 / :923-929 while Helix.scss defines only -100 and -1000, so that class matches no rule. Separate small ticket.

Decision Record impact

none — a performance correction inside an existing main-thread addon.

Related

#12894 / #12883 (origin of the heal), #8620 (Element.moveBefore adoption).

tobiu referenced in commit 07b2fc2 - "fix(main): batch the moveBefore layout heal, and never run it mid-transition (#16533) (#16534) on Aug 5, 2026, 12:29 PM
tobiu closed this issue on Aug 5, 2026, 12:29 PM