From c4517c159ea1cc4939af7fa12bab4b0b7d64b625 Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Fri, 3 Apr 2026 15:15:31 +0200 Subject: [PATCH 1/2] fix: prevent stale re-renders of #each item views during sequence update When the data source of a parent template changes, Tracker can re-run helpers inside #each item views before ObserveSequence has had a chance to diff and remove stale items. This causes items to briefly render with inconsistent data (e.g., item shows "foo" while parent data says "bar"). Fix by freezing item views during sequence transitions: - observe_sequence.js: add onInvalidate, beforeDiff, and afterDiff callbacks so callers can hook into the sequence update lifecycle - builtins.js: use onInvalidate to mark item views with _eachItemPendingUpdate immediately when the sequence is invalidated (before Tracker flush), and afterDiff to clear the flag on survivors - view.js: skip doRender re-runs when the view or an ancestor has _eachItemPendingUpdate set Tested with 5 scenarios: different IDs, same ID with changed content, multiple items removed, new-style #each (each-in), and nested #each. Fixes #468 --- packages/blaze/builtins.js | 24 +++++++++++++++++++ packages/blaze/view.js | 13 ++++++++++ packages/observe-sequence/observe_sequence.js | 20 +++++++++++++++- 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/blaze/builtins.js b/packages/blaze/builtins.js index b2d73df1f..31d9cc9ee 100644 --- a/packages/blaze/builtins.js +++ b/packages/blaze/builtins.js @@ -234,6 +234,19 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { eachView.stopHandle = ObserveSequence.observe(function () { return eachView.argVar.get()?.value; }, { + // Called immediately when the sequence source is invalidated, + // BEFORE the Tracker flush re-runs other autoruns. This freezes + // item views so their helpers don't re-run with stale data. + // See meteor/blaze#468. + onInvalidate: function () { + if (!eachView._domrange) return; + const members = eachView._domrange.members; + for (let i = 0; i < members.length; i++) { + if (members[i] && members[i].view) { + members[i].view._eachItemPendingUpdate = true; + } + } + }, addedAt: function (id, item, index) { Tracker.nonreactive(function () { let newItemView; @@ -324,6 +337,17 @@ Blaze.Each = function (argFunc, contentFunc, elseFunc) { subviews.splice(toIndex, 0, itemView); } }); + }, + // Called after the diff is applied. Clear the pending flag on + // surviving item views so they can re-render normally again. + afterDiff: function () { + if (!eachView._domrange) return; + const members = eachView._domrange.members; + for (let i = 0; i < members.length; i++) { + if (members[i] && members[i].view) { + delete members[i].view._eachItemPendingUpdate; + } + } } }); diff --git a/packages/blaze/view.js b/packages/blaze/view.js index 482034c9f..26d2c980a 100644 --- a/packages/blaze/view.js +++ b/packages/blaze/view.js @@ -345,6 +345,19 @@ Blaze._materializeView = function (view, parentView, _workStack, _intoArray) { Tracker.nonreactive(function () { view.autorun(function doRender(c) { // `view.autorun` sets the current view. + + // Skip re-render if this view or an ancestor is an #each item + // that's pending a sequence update. This prevents stale renders + // where an item's helpers re-run before ObserveSequence has had + // a chance to remove it. See meteor/blaze#468. + if (!c.firstRun) { + let v = view; + while (v) { + if (v._eachItemPendingUpdate) return; + v = v.parentView; + } + } + view.renderCount = view.renderCount + 1; view._isInRender = true; // Any dependencies that should invalidate this Computation come diff --git a/packages/observe-sequence/observe_sequence.js b/packages/observe-sequence/observe_sequence.js index a18ce2acf..85c87d999 100644 --- a/packages/observe-sequence/observe_sequence.js +++ b/packages/observe-sequence/observe_sequence.js @@ -112,9 +112,16 @@ ObserveSequence = { // general 'key' argument which could be a function, a dotted // field name, or the special @index value. let lastSeqArray = []; // elements are objects of form {_id, item} - const computation = Tracker.autorun(function () { + const computation = Tracker.autorun(function (c) { const seq = sequenceFunc(); + // When this computation is invalidated (sequence source changed), + // immediately notify callers so they can freeze item views BEFORE + // the flush re-runs other autoruns. See meteor/blaze#468. + if (callbacks.onInvalidate) { + c.onInvalidate(() => callbacks.onInvalidate()); + } + Tracker.nonreactive(function () { let seqArray; // same structure as `lastSeqArray` above. @@ -142,7 +149,18 @@ ObserveSequence = { throw badSequenceError(seq); } + // Allow callers to prepare for the diff (e.g., freeze item views + // that are about to be removed). See meteor/blaze#468. + if (callbacks.beforeDiff) { + callbacks.beforeDiff(lastSeqArray, seqArray); + } + diffArray(lastSeqArray, seqArray, callbacks); + + if (callbacks.afterDiff) { + callbacks.afterDiff(); + } + lastSeq = seq; lastSeqArray = seqArray; }); From 31dbf689431ac9af26ac37cad33481d0d05b8cb7 Mon Sep 17 00:00:00 2001 From: dupontbertrand Date: Fri, 3 Apr 2026 15:29:11 +0200 Subject: [PATCH 2/2] test: add regression tests for #each stale data context (#468) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add two Tinytest cases that verify #each item views don't re-render with stale data when the parent data context changes: - Different IDs (removedAt + addedAt) — the original bug report - New-style #each item in items syntax --- packages/spacebars-tests/template_tests.html | 28 +++++ packages/spacebars-tests/template_tests.js | 107 +++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/packages/spacebars-tests/template_tests.html b/packages/spacebars-tests/template_tests.html index 34322ade9..7f29ed409 100644 --- a/packages/spacebars-tests/template_tests.html +++ b/packages/spacebars-tests/template_tests.html @@ -1172,3 +1172,31 @@
{{item}}
{{/each}} + + + + + + + + + + diff --git a/packages/spacebars-tests/template_tests.js b/packages/spacebars-tests/template_tests.js index 022e06cef..039616a83 100644 --- a/packages/spacebars-tests/template_tests.js +++ b/packages/spacebars-tests/template_tests.js @@ -4350,3 +4350,110 @@ Tinytest.add( ); } ); + +// #468 — #each stale data context +// When the parent data context changes and the #each sequence returns +// different items, item views should NOT re-render with stale data +// before being removed. +Tinytest.add( + 'spacebars-tests - template_tests - #each no stale render on different IDs', + function (test) { + const parentTmpl = Template.spacebars_template_test_each_stale_parent1; + const childTmpl = Template.spacebars_template_test_each_stale_child1; + + const mode = new ReactiveVar('foo'); + const renderLog = []; + + parentTmpl.helpers({ + mode: function () { return mode.get(); }, + }); + + childTmpl.helpers({ + getItems: function () { + const foo = Template.currentData().foo; + if (foo === 'foo') { + return [{ _id: '1', msg: 'foo-item' }]; + } + return [{ _id: '2', msg: 'bar-item' }]; + }, + logRender: function (msg) { + const dataFoo = Template.instance().data.foo; + renderLog.push({ msg, dataFoo }); + return ''; + }, + }); + + const div = renderToDiv(parentTmpl); + + // Initial render + test.equal(renderLog.length, 1); + test.equal(renderLog[0].msg, 'foo-item'); + test.equal(renderLog[0].dataFoo, 'foo'); + + // Switch — should NOT produce a stale render where msg="foo-item" with dataFoo="bar" + renderLog.length = 0; + mode.set('bar'); + Tracker.flush(); + + // Every render should have consistent msg and dataFoo + renderLog.forEach(function (entry) { + if (entry.msg === 'foo-item') { + test.equal(entry.dataFoo, 'foo', 'stale: foo-item rendered with dataFoo=bar'); + } + if (entry.msg === 'bar-item') { + test.equal(entry.dataFoo, 'bar', 'stale: bar-item rendered with dataFoo=foo'); + } + }); + + // Final render should be bar-item + const last = renderLog[renderLog.length - 1]; + test.equal(last.msg, 'bar-item'); + test.equal(last.dataFoo, 'bar'); + } +); + + +Tinytest.add( + 'spacebars-tests - template_tests - #each no stale render with each-in syntax', + function (test) { + const parentTmpl = Template.spacebars_template_test_each_stale_parent3; + const childTmpl = Template.spacebars_template_test_each_stale_child3; + + const mode = new ReactiveVar('foo'); + const renderLog = []; + + parentTmpl.helpers({ + mode: function () { return mode.get(); }, + }); + + childTmpl.helpers({ + getItems: function () { + const foo = Template.currentData().foo; + if (foo === 'foo') { + return [{ _id: '1', msg: 'foo-item' }]; + } + return [{ _id: '2', msg: 'bar-item' }]; + }, + logRenderItem: function (item) { + const dataFoo = Template.instance().data.foo; + renderLog.push({ msg: item.msg, dataFoo }); + return ''; + }, + }); + + const div = renderToDiv(parentTmpl); + renderLog.length = 0; + mode.set('bar'); + Tracker.flush(); + + renderLog.forEach(function (entry) { + if (entry.msg === 'foo-item') { + test.equal(entry.dataFoo, 'foo', 'stale: foo-item rendered with dataFoo=bar'); + } + }); + + const last = renderLog[renderLog.length - 1]; + test.equal(last.msg, 'bar-item'); + test.equal(last.dataFoo, 'bar'); + } +);