meteor / meteor/blaze

Async helper binding keeps a stale result (out-of-order promise clobbers the latest value)

Open Beginner friendly
#514 0 comments 0 reactions 0 assignees View on GitHub
Dominant language
JavaScript
Stars
543
Forks
117
Avg merge
6h 18m
Merged PRs (30d)
2

Description

_Encountered while building a Blaze 3.1 regression bench; written and opened by Claude Code after review by the author (@dupontbertrand)._

## Summary

A reactive helper that returns a `Promise` (consumed by an async binding — `{{#let}}`, `{{#if}}`, `{{#with}}`, `{{#each … in …}}`, or a mustache) does **not** guard against out-of-order resolution. When the binding re-runs because a reactive dependency changed, the **previous, still-pending** promise's `.then` later calls `reactiveVar.set` and overwrites the newer result. The view ends up displaying a **stale** value: the last trigger does not win.

**Affected package / version:** `blaze@3.1.0-alpha.0` (published on Atmosphere; verified against its installed sources). The async-binding code path is specific to the 3.1 alpha line.

## Root cause (exact source)

`packages/blaze/builtins.js`:

- `_setBindingValue` (**lines 65–74**) — the async branch (**lines 67–70**) schedules the promise's `.then` unconditionally, with no generation/sequence guard:

```js
function _setBindingValue(reactiveVar, value, mapper = _identity) {
if (value && typeof value.then === 'function') {
value.then(
value => reactiveVar.set({ value: mapper(value) }), // ← no "is this still the latest?" check
error => reactiveVar.set({ error }),
);
} else {
reactiveVar.set({ value: mapper(value) });
}
}
```

- `_createBinding` (**lines 85–98**) re-invokes `_setBindingValue` inside `view.autorun` on every reactive change (**lines 88–92**):

```js
view.autorun(
() => _setBindingValue(reactiveVar, binding(), mapper),
view.parentView,
displayName,
);
```

Each `.then` closes over the same `reactiveVar` with no notion of which invocation is current, so a slow earlier promise resolving after a fast later one wins.

## Minimal reproduction

```js
Template.x.onCreated(function () { this.trig = new ReactiveVar(1); });
Template.x.helpers({
out() {
const v = Template.instance().trig.get();
// trig=1 → SLOW (120 ms) resolving 'v1'; trig=2 → FAST (10 ms) resolving 'v2'
return new Promise(res => setTimeout(() => res('v' + v), v === 1 ? 120 : 10));
},
});
// {{#let o=out}}{{o}}{{/let}}
```

Steps: render `x`; then, synchronously right after the first render, `inst.trig.set(2)` (re-triggers the binding while the first, slow promise is still in flight). Wait > 120 ms.

- **Expected** final text: `v2` — the latest trigger's result.
- **Actual** on `3.1.0-alpha.0`: `v1` — the stale 120 ms promise resolves last and overwrites `v2`.

Deterministic: the 120 ms vs 10 ms gap makes the resolution order (v2 then stale v1) stable; no timing flake.

## Runnable one-command reproduction

A minimal standalone Meteor app (Meteor 3.5.2, `blaze-html-templates@3.1.0-alpha.0`, no jQuery). Self-verifying, headless — the failing assertion **is** the reproduction:

```bash
TEST_BROWSER_DRIVER=puppeteer meteor test --once --driver-package meteortesting:mocha --port 5599
```

Resolved versions: Meteor `3.5.2`; `.meteor/versions` → `blaze@3.1.0-alpha.0`, `blaze-html-templates@3.1.0-alpha.0`, `templating@1.5.0-alpha.0`, `reactive-var@1.0.13`, `tracker@1.3.4`.

Observed output:

```
1) blaze async binding stale result
last trigger wins: v2 (fast) must not be clobbered by a stale v1 (slow):
AssertionError [ERR_ASSERTION]: expected 'v2' (last-trigger-wins) but observed 'v1'
-- the stale slow 'v1' promise clobbered the newer fast 'v2' result
+ expected - actual
-v1
+v2
CLIENT FAILURES: 1
```

Confirmed: the stale, slow-resolving `v1` (first trigger) overwrites the newer, fast-resolving `v2` (second trigger).

> **Note:** pin `puppeteer@^24` for the headless driver — puppeteer 25.x pulls `yargs@18`, whose `import.meta` syntax crashes Meteor 3.5.2's classic-script server bundler; unrelated to this bug.

Reproducer files (drop into a fresh meteor create app with blaze-html-templates@3.1.0-alpha.0)

`client/main.html`
```html
blaze-async-stale-result-repro

{{#let o=out}}{{o}}{{/let}}

```

`client/main.js`
```js
import { Template } from 'meteor/templating';
import { ReactiveVar } from 'meteor/reactive-var';
import './main.html';

Template.x.onCreated(function () {
this.trig = new ReactiveVar(1);
Template.x.__lastInstance = this; // test handle; this is a repro, not an app
});

Template.x.helpers({
out() {
const v = Template.instance().trig.get();
// trig=1 -> SLOW (120ms) 'v1'; trig=2 -> FAST (10ms) 'v2'
return new Promise((resolve) => setTimeout(() => resolve('v' + v), v === 1 ? 120 : 10));
},
});
```

`tests/main.js`
```js
import assert from 'assert';

if (Meteor.isClient) {
const { Blaze } = require('meteor/blaze');
const { Template } = require('meteor/templating');
require('../client/main.html');
require('../client/main.js');

describe('blaze async binding stale result', function () {
it('last trigger wins: v2 (fast) must not be clobbered by a stale v1 (slow)', async function () {
this.timeout(5000);
const container = document.createElement('div');
document.body.appendChild(container);
const view = Blaze.render(Template.x, container);
try {
const inst = Template.x.__lastInstance;
assert.strictEqual(inst.trig.get(), 1, 'initial trig should be 1 (slow v1 in flight)');
inst.trig.set(2); // re-trigger (fast v2) while slow v1 is still pending
await new Promise((resolve) => setTimeout(resolve, 300));
const observed = container.querySelector('.out') && container.querySelector('.out').textContent;
assert.strictEqual(observed, 'v2',
`expected 'v2' (last-trigger-wins) but observed '${observed}' -- the stale slow 'v1' promise clobbered the newer fast 'v2' result`);
} finally {
Blaze.remove(view);
container.remove();
}
});
});
}
```

`package.json` (excerpt)
```json
{
"meteor": { "mainModule": { "client": "client/main.js", "server": "server/main.js" }, "testModule": "tests/main.js" },
"devDependencies": { "puppeteer": "^24.43.1" }
}
```

## Expected behaviour (not prescribing an implementation)

An async binding should be **last-trigger-wins**: once the binding re-runs, results from a superseded invocation must not be applied. The natural direction is a per-binding generation marker captured when a promise is scheduled and re-checked in its `.then` before `reactiveVar.set` — but the exact shape (generation counter, aborting the stale continuation, etc.) is for the maintainers to decide. Sync bindings (the `else` branch) are unaffected.

## Relationship to other Blaze issues

- **Distinct from #468** (`{{#each}}` data-context staleness after update/reorder) — fixed by **#501**; verified green on this same alpha.
- **Distinct from #512 / #513** (native no-jQuery event-scope drop via the `view.name` heuristic in `dombackend.js`) — an event-delegation bug, unrelated to async binding resolution.
- This is the **async-binding family**: an ordering/generation defect in `_setBindingValue`.

Contributor guide

Open the contributing guide

Research direction

Start in packages/blaze/builtins.js, especially _setBindingValue and _createBinding, then run the standalone reproduction with the stated meteor test command. Add coverage for the slow v1 and fast v2 resolution order; done means the binding retains v2 while synchronous bindings remain unaffected.

Written by the indexing model from the issue text.

Assessment

Tech stack
javascript
Domain
frontend
Issue type
Bug
Difficulty
2/5
Estimated time
1-3 hours
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
84/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.