Skip to content

fix: throw instead of corrupting the chunk list on an overlapping move - #326

Merged
antfu merged 2 commits into
Rich-Harris:masterfrom
theRizwan:fix/move-overlapping-range-cycle
Aug 19, 2026
Merged

fix: throw instead of corrupting the chunk list on an overlapping move#326
antfu merged 2 commits into
Rich-Harris:masterfrom
theRizwan:fix/move-overlapping-range-cycle

Conversation

@theRizwan

Copy link
Copy Markdown
Contributor

Fixes #325.

The problem

const s = new MagicString('abcdef')
s.move(0, 2, 3)
s.move(1, 3, 0)
s.toString() // never returns

Both calls are in bounds and neither throws. The second one corrupts the chunk list into a cycle, and every later toString() or generateMap() spins at 100% CPU.

move() looks the range up as byStart.get(start) and byEnd.get(end) and then splices, which assumes those two chunks are still the ends of a forward run. After move(0, 2, 3) the order is c(2,3) a(0,1) b(1,2) d(3,4)…, so the second call gets first = b(1,2) and last = c(2,3) with last sitting before first. The pointer rewrites then leave a(0,1).next === a(0,1), and the walk in toString() has nowhere to stop.

The change

Before splicing, walk from first to last and confirm every chunk on the way is still inside [start, end). If the run has been broken up, throw a MagicStringError instead of building a cycle. That matches what move() already does for the other case it cannot honour, cannot move a selection inside itself.

Only move() reorders chunks, so the walk is skipped entirely until one has run. A first move, which is the overwhelmingly common case, costs nothing extra. I added hasMovedChunks for that, and clone() copies it: a clone rebuilds the chunks in their current order, so it inherits any reordering and needs the same check. Without that line a clone of a moved string still hung, which I only noticed after reading clone(), so there is a test for it.

I went with throwing rather than trying to define an ordering for overlapping moves, since that is a semantics decision rather than a bug fix, and the issue offered either. Happy to look at handling the case properly instead if you would prefer that.

Tests

Two cases in the existing move block, both failing on main:

  • the sequence from the issue throws, and the first move is left intact and printable
  • the same through clone(), covering the flag being carried over

233 passing, lint clean. The existing handles moves to same index test, which does two moves where the second does not straddle the first, still passes and was the main thing I wanted to be sure of.

@antfu
antfu merged commit 9a9207c into Rich-Harris:master Aug 19, 2026
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.

Two overlapping move() calls create a chunk-list cycle → toString() infinite loops

2 participants