0037: Use moveBefore in each() reorder for state-preserving keyed list updates
Status: Proposed Date: 2026-05-27
Context
each()'s LIS-based reordering uses Element.insertBefore to relocate
keyed rows. insertBefore is structurally correct — rows end up in the
right place and the node references survive — but it is a removal +
re-insertion under the hood. For rows whose root is a Purity custom
element, this triggers disconnectedCallback → connectedCallback,
which tears down the component's _ctx, re-fires onMount, and
re-renders the shadow tree. State that should be visually stable
across a reorder is lost:
- Focus inside a row (an
<input>cursor jumps out) - CSS transitions / animations restart from frame 0
- An
<iframe>'s load state is lost — content reloads - A
<video>resumes from time 0 (or reloads its source) - A
<dialog>'s modal state, a popover's open state, and pointer capture all reset
The platform now has a fix: Element.moveBefore(node, ref) performs a
true move — it preserves all of the above, and dispatches the new
connectedMoveCallback lifecycle hook instead of the
disconnect+connect pair.
Browser status (May 2026):
- Chrome / Edge 133+ (Feb 2025)
- Firefox 144+ (shipped late 2025)
- Safari: positive standards-position but not yet shipped
- Global coverage: ~71% (caniuse)
moveBefore is a strict refinement: same correctness guarantees as
insertBefore, plus the preservation properties on supporting engines.
It throws (HierarchyRequestError / NotFoundError) under several
preconditions, the relevant ones here being:
nodemust already be a child ofparent. (Brand new rows aren't.)refmust be a child ofparent(ornullfor "append").
Custom Elements need to opt in: a class with no connectedMoveCallback
defined falls back to disconnect+connect even under moveBefore. So
PurityElement must define the method (empty body is enough).
Decision
We add connectedMoveCallback() {} to PurityElement, detect
moveBefore once at module init (hasMoveBefore), and introduce a
moveOrInsert(parent, node, ref) helper that prefers moveBefore
when both are true:
hasMoveBeforeis true.node.parentNode === parent(precondition formoveBefore).
Else falls back to parent.insertBefore(node, ref). Any throw from
moveBefore also falls through to insertBefore — correctness wins
over preservation.
Two reorder sites in control.ts switch to moveOrInsert:
-
2-swap fast path: when exactly two rows have swapped positions, the existing marker-based 3-op dance keeps its marker
insertBeforeand switches the other two operations tomoveOrInsert. State is preserved on both swapped rows whenhasMoveBeforeis true. -
LIS reorder: the existing path accumulates moves into a
DocumentFragmentand flushes them in oneinsertBefore. The fragment optimization is incompatible withmoveBefore(a fragment detaches its children from their original parent, breaking thenode.parentNode === parentprecondition). So we split:hasMoveBefore = true→ per-rowmoveOrInsert, no fragment.hasMoveBefore = false→ existing fragment-batching path unchanged.
The fragment-batching path is preserved for engines without
moveBefore because batching reduces layout thrash from many
sequential insertBefore calls. With moveBefore, the platform is
the one doing the move — there's no equivalent thrash to batch
against.
connectedMoveCallback is intentionally a no-op. Its job is purely
to signal "this element supports being moved." User-level onMount
/ onDestroy are not re-fired by a move — that's the whole point.
Consequences
State preservation on supporting engines (Chrome 133+, Firefox 144+):
- Focus inside a row survives reorder.
- CSS transitions and animations continue without restart.
<iframe>doesn't reload.- Popover, dialog, fullscreen states survive.
- Pointer capture survives.
Correctness unchanged on Safari and older engines. The fallback path is byte-identical to the pre-ADR behavior.
API surface unchanged. No new exports. connectedMoveCallback is
internal — the framework's onMount / onDestroy contract stays the
same on every engine.
hasMoveBefore is captured once at module load. Test environments
that polyfill moveBefore after loading the framework see no effect.
For jsdom (no moveBefore), the fallback path runs in CI and the
moveBefore-path is verified by browser web-platform-tests at the
platform level.
Behavioral note for users: components are no longer rebuilt across
reorder on supporting engines. Any user code that relied on
onMount re-firing during reorder (e.g. "rebuild a chart when this
row moves") will silently stop running on those engines. That code
was already fragile — it depended on a teardown that the platform
didn't promise. Documentation will state explicitly: lifecycle hooks
fire on insertion and removal, not on reorder.
LIS path branch cost: one boolean check per reorder. Negligible.
Bundle size: net +~15 lines (the new fallback branch is preserved as-is; the new moveBefore branch is shorter than the fallback).
Alternatives considered
-
Always use
moveBeforewith a runtime try/catch: works on supporting engines, but on Safari every call would throw, fall through toinsertBefore, and incur exception overhead per move in a hot path. Module-init detection avoids that. -
Detect on every call instead of once at module load: same output, slightly more cost. Not worth optimizing for hot-swapping the API (the only realistic scenario is tests, where the setup-once approach is fine).
-
Use
moveBeforefor all DOM mutations in the framework, not justeach()reorder: tempting butmoveBeforeis only useful when moving already-mounted nodes. Most framework DOM ops are fresh inserts or initial mounts —insertBeforeis the right semantic there. The reorder path is the unambiguous win. -
Wait for Safari: Safari has signaled positive intent but no ship date. The benefit is too large on the two major Chromium / Gecko surfaces to delay; the Safari fallback is byte-identical to today's behavior.
-
Expose
connectedMoveCallbackas a public hook (e.g.onConnectedMove): deferred. The platform semantic is "do nothing extra during a move"; surfacing it as a hook implies it's meant to run user code, which goes against the design. Can be revisited if a clear use case emerges.
Testing
jsdom doesn't expose moveBefore, so CI always runs the
insertBefore fallback. Tests verify:
PurityElement.prototype.connectedMoveCallbackexists (opt-in).- The fallback path produces correct DOM after reverse, 2-swap, and rotate operations.
- Node references are preserved across reorder.
Real-browser behavior of moveBefore (focus retention, animation
state, iframe state) is covered by web-platform-tests at the
platform level.