ReviewOS

stacks/stx

x-data getters are evaluated once and never re-run, silently rendering stale values

#2053
Closed glennmichael123 opened this 9 days ago · {{ comments.length }} {{ comments.length === 1 ? 'comment' : 'comments' }}
9 days ago

A getter declared in an x-data object literal is evaluated once while the scope is built and never re-evaluated, so it renders its initial answer forever. Plain property reads in the same template update correctly, and a method with the same body updates correctly.

Nothing is logged. The page renders, every other binding works, and the stale value sits next to fresh ones — which is what makes it expensive to find.

Reproduction

<div id='probe-scope' x-data="{
  n: 1,
  obj: { v: 'initial' },
  get doubled() { return this.n * 2; },
  get fromObj() { return 'got:' + this.obj.v; },
  methodDoubled() { return this.n * 2; },
  methodFromObj() { return 'got:' + this.obj.v; }
}">
  <div id='r-direct'>{{ n }}</div>
  <div id='r-objdirect'>{{ obj.v }}</div>
  <div id='r-getter'>{{ doubled }}</div>
  <div id='r-getterobj'>{{ fromObj }}</div>
  <div id='r-method'>{{ methodDoubled() }}</div>
  <div id='r-methodobj'>{{ methodFromObj() }}</div>
</div>

<script data-stx-scoped>
setTimeout(function () {
  document.getElementById('probe-scope')
    .__stx_execute("n = 5; obj = { v: 'updated' }");
}, 300);
</script>

After the assignment:

BindingRenderedExpected
55
updatedupdated
— getter210
— gettergot:initialgot:updated
1010
got:updatedgot:updated

Both getters keep the value they had at scope construction. Both methods, with identical bodies, re-evaluate.

How it showed up

A status panel whose x-data had:

get coreLabel() {
  if (!this.core.available) return 'Not installed';
  return this.core.healthy ? 'Healthy' : 'Unhealthy';
}

After a fetch assigned core, the card still read Not installed while , bound two lines below it, showed the version from the same response. The data was right, one binding was stale, and nothing indicated which.

Either outcome would help

  1. Make getters reactive — read them through the same proxy that tracks plain properties, so a getter is a derived value rather than a snapshot; or
  2. Reject them loudly — warn (or throw) when an x-data literal declares an accessor, the way a template compiler can, and document that derived values belong in methods.

Silent staleness is the worst of the three, because the failure looks like a data problem rather than a reactivity one.

Related: x-data literals already have two sharp edges that fail silently in a similar way — a // comment, and a " inside the attribute. We guard all three with a test over every template now, but they would be better caught by the framework.

Environment

  • @stacksjs/stx 0.2.382
  • macOS 26.3.1 arm64, Bun 1.4.3
9 days ago

Fixed in e766e52400. A getter in an x-data literal is a derived value now, not a snapshot.

The mechanism

The bridge built its scope with for (var key in state) and then read state[key]. Reading an accessor calls it, and the value it returns is not a function — so it went down the data branch and was wrapped as a plain state(): the answer the getter gave at construction time, frozen for the life of the page.

That is also why a method with an identical body worked. typeof state[key] === 'function' is true for a method, so it took the method branch and was re-invoked per read. The two lines in your table differ only in which branch a property-read fell into.

What it does now

Outcome 1 of the two you offered, because the framework already has the primitive and a getter is a derived value.

Accessors are collected via Object.getOwnPropertyDescriptors without being read, then registered as derived(() => descriptor.get.call(ctxProxy)) once the proxy exists for them to read through. derived is lazy, so nothing evaluates until a binding asks for it. They are marked rawState as well, which is what makes the proxy unwrap them and what exposes the signal itself to the signals runtime — that is where dependency tracking comes from, so a getter needs both, exactly like a data property.

Your reproduction, driven through the real bridge:

Bindingbeforeafter
210
got:initialgot:updated
1010
, 5, updatedunchanged

The "before" column is measured, not quoted from the report: I ran the same probe against a clean worktree at the previous commit, where went 2 → 2 after n = 5.

Assignment, which the report did not reach

A getter had no write story, and the naive version of this fix leaves a new silent failure: a derived has no .value setter, so doubled = 99 would be a no-op that reports nothing.

  • a getter/setter pair writes through its setter
  • a getter alone warns: cannot assign to doubled: it is a getter in x-data, so its value comes from what it reads. Give it a setter, or make it a plain property.

Why this survived

The existing bridge test asserts on the generated runtime as text — it greps for initScope. It could not have caught this, and nothing else executed the bridge. The new tests do, which took two harness corrections worth writing down for anyone testing x-data behaviour:

  • in serve mode the signals runtime is fetched from a URL rather than inlined, so a harness that runs only the page's inline scripts has no runtime and every binding stays literal text
  • the page's scripts declare top-level vars that only a real <script> makes global; new Function(code)() confines them, so the bridge's own __stx_reactive was invisible to the script that calls it. Indirect eval is what a script tag does.

9 tests, full suite 15,133 pass.

You mention a // comment and a " inside an x-data attribute failing the same way, guarded app-side by a test over every template. Those are worth their own issue if you have the repros — the x-data literal is parsed with new Function('return (' + literal + ')'), so both are plausible as the same class: input the parser accepts or mangles rather than rejects. I would rather fix them than have every app carry a template sweep for them.

Sign in to comment on this issue.