magic-move: precompiled step transitions block the main thread with larger token counts
Describe the bug
@shikijs/[email protected] can block the main thread for several seconds when ShikiMagicMovePrecompiled animates a change to a longer code block. The page stalls before the next animation frame, even with stagger: 0.
The reproduction precompiles both token sets in Node before starting Vite. Clicking the button only switches the step prop between two versions of the same declarations in opposite order. No syntax highlighting or application sorting runs during the click; the component's normal token-key synchronization still runs.
Expected: switching precompiled steps should set up the transition promptly and keep the page responsive while the animation runs.
Actual: the 60-line / 1080-token example takes about 10–11 seconds to reach the next animation frame in the tested browser. Disabling animation reduces this to about 34 ms.
Reproduction
https://github.com/ntnyq/shiki-magic-move-performance-repro
git clone https://github.com/ntnyq/shiki-magic-move-performance-repro.git
cd shiki-magic-move-performance-repro
pnpm install --frozen-lockfile
pnpm build
pnpm preview- Open http://127.0.0.1:5197 with Chromium.
- Keep 60 lines and Animate enabled, then click Toggle order.
- Observe the stall and the on-page timing. Wait for the transition to finish before another sample.
- Uncheck Animate and toggle again for comparison.
- Select 20 lines to compare scaling. The 120-line option is an optional stress case.
The only renderer options are { duration: 600, stagger: 0, animateContainer: true }. The repository pins the published package to 4.4.3, without patches or overrides. It has no VitePress, ESLint, or Twoslash dependencies.
Measurements and environment
Production build, Apple M1 Pro, macOS 15.7.7, Chromium 152.0.0.0 (Codex in-app browser), no CPU throttling configured. Node 24.19.0, pnpm 12.4.1, Vue 3.5.42, Vite 8.3.0, Shiki 4.4.3.
| Lines | Keyed tokens per step | Animate | Click handler → next animation frame |
|---|---|---|---|
| 20 | 360 | on | 427.1 ms |
| 20 | 360 | off | 10.1 ms |
| 60 | 1080 | on | 11,407.5 ms / 10,353.0 ms |
| 60 | 1080 | off | 33.7 ms |
These are individual samples, not averages. The timer covers the click handler through Vue's update to a requestAnimationFrame callback; it does not wait for animation completion or claim to measure the exact paint time. Token counts include whitespace and line breaks. The 600 ms transition duration and zero stagger do not explain the multi-second synchronous stall.
A CDP Performance.getMetrics interval around the 20-line animated click and result read recorded 343 layouts, 347 style recalculations, and 445 ms TaskDuration. That interval includes automation/result-inspection overhead. DevTools Performance recordings can be used to investigate the repeated layout/style work. Browser verification showed correct reversed code and no console warnings/errors. Build and typecheck pass.
Suspected implementation hot spots
In packages/magic-move/src/renderer.ts:
- The move loop interleaves
getBoundingClientRect()with transition/transform style writes for every token. registerTransitionEnd()callsel.getAnimations()per element, with all registrations executed after transition styles are applied.
Would it be possible to batch destination-rectangle reads before writing styles, and collect animations once using container.getAnimations({ subtree: true }), grouped by effect target while retaining per-element completion/interruption cleanup? This is a suggested optimization direction; the reproduction leaves the published renderer unchanged and does not validate a complete fix.
Validations
- Follow the project's Code of Conduct.
- Read the Contributing Guide.
- Searched existing issues for the same bug.
Source: shikijs/shiki