Skip to content

Commit 78dcb63

Browse files
lifeartclaude
andcommitted
fix(gxt): route classic {{#each}} through $_eachSync for DOM-node identity
Async $_each updates the DOM on a microtask, which lands AFTER the synchronous __gxtSyncDomNow → __gxtForceEmberRerender morph fallback runs. The morph then diffs the new full-template fragment against the pre-mutation live DOM and clobbers Text node content position-by-position, destroying the keyed-row identity guarded by assertPartialInvariants in the each-test "it maintains DOM stability for stable keys when list is updated" cases. SyncListComponent is identity-preserving and runs inline with __gxtSyncDomNow, so morph then sees identical DOM and is a no-op for the keyed rows. Smoke 333/333; previously failing 3 each-stability tests now pass with no regressions in toggling-each (39/39), curly components (120/120), or {{get}} (21/21). Vitest 3785/3785. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
1 parent f72b48a commit 78dcb63

1 file changed

Lines changed: 26 additions & 2 deletions

File tree

packages/@ember/-internals/gxt-backend/compile.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12171,8 +12171,32 @@ export function precompileTemplate(
1217112171
// 3. Inject $a alias for @named args
1217212172
if (compilationResult.code) {
1217312173
let modifiedCode = compilationResult.code;
12174-
// NOTE: $_each -> $_eachSync replacement is now handled in the GXT serializer
12175-
// (control.ts emits $_eachSync directly in IS_GLIMMER_COMPAT_MODE)
12174+
// Force every {{#each}} block in classic Ember templates onto the
12175+
// synchronous list path (`$_eachSync` / `SyncListComponent`).
12176+
//
12177+
// GXT's serializer emits async `$_each` by default — `AsyncListComponent`
12178+
// applies its DOM mutations on a microtask. After a runTask
12179+
// mutation that fires `notifyPropertyChange(arr, '[]')`, the
12180+
// `__gxtSyncDomNow` pipeline runs synchronously, so the async
12181+
// syncList opcode hasn't yet updated the live DOM by the time
12182+
// `__gxtForceEmberRerender`'s morph fallback fires. The morph then
12183+
// diffs the new full-template fragment against the *pre-mutation*
12184+
// live DOM (3 children) position-by-position, clobbering the
12185+
// existing Text nodes' content with whatever happens to land at the
12186+
// same index in the new fragment. That destroys the DOM-node
12187+
// identity that the `assertPartialInvariants` invariant in the
12188+
// `Syntax test: {{#each}} ... it maintains DOM stability for
12189+
// stable keys when list is updated` test guards. The synchronous
12190+
// SyncListComponent path moves item markers (and the rows behind
12191+
// them) BEFORE the morph runs, so morph then sees identical content
12192+
// on both sides and is a no-op for keyed rows — preserving identity.
12193+
//
12194+
// Async element destructors (the original reason GXT removed the
12195+
// forced-sync path) only matter for animations attached to
12196+
// `{{#each}}` rows; classic Ember templates compiled via
12197+
// `precompileTemplate` never set them up, so the sync path is
12198+
// strictly safe here.
12199+
modifiedCode = modifiedCode.replace(/\$_each\(/g, '$_eachSync(');
1217612200
// NOTE: $__log site ID wrapping is now handled in the GXT serializer
1217712201
// (value.ts emits comma expression with site ID directly in IS_GLIMMER_COMPAT_MODE)
1217812202
// Post-process: replace per-compilation __logSite:N with globally unique IDs

0 commit comments

Comments
 (0)