ReviewOS

stacks/stx

:for cannot see a <script server> binding while <script client> can, and the warning blames signals instead

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

Verified on 0.2.399 and against current main: the only change to signals.ts since v0.2.399 is the respectExisting work from #2050, and the :for scope handling is untouched.

<template :for> cannot see a <script server> binding, while <script client> in the same file can. The list silently renders zero rows, and the warning that fires points at a cause that is usually not the real one.

Repro

Six variants of the same list on one page, each printing how many rows it rendered.

<script server>
const serverRows = [
  { id: 1, name: 'alpha', status: 'up' },
  { id: 2, name: 'beta', status: 'down' },
  { id: 3, name: 'gamma', status: 'up' },
]
</script>

<ul data-probe="a"><template :for="item in plainItems"><li>{{ item }}</li></template></ul>
<ul data-probe="b"><template :for="item in monitors()"><li>{{ item }}</li></template></ul>
<ul data-probe="c"><template :for="item in monitors"><li>{{ item }}</li></template></ul>
<ul data-probe="d"><template :for="item in (monitors() ?? [])"><li>{{ item }}</li></template></ul>
<ul data-probe="e"><template :for="row in rows()"><li>{{ row.name }} / {{ row.status }}</li></template></ul>
<ul data-probe="f"><template :for="row in serverRows"><li>{{ row.name }} / {{ row.status }}</li></template></ul>

<script client>
const plainItems = ['alpha', 'beta', 'gamma']
const monitors = state(['alpha', 'beta', 'gamma'])
const rows = state(serverRows)
</script>

Result:

A=3  B=3  C=3  D=3  E=3  F=0
expressionsource of the arrayrows
AplainItemsplain const in <script client>3
Bmonitors()signal call3
Cmonitorsbare signal3
D(monitors() ?? [])defensive call3
Erows()server array wrapped in state() in the client block3
FserverRowsthe <script server> const directly0

Only F fails:

[STX] :for expected an array; got empty/error for expression "serverRows".
[root=serverRows inScope=false type=string isStxStore=false keys=-]
If this is a signal call, try the bare reference (signal instead of signal()).

A is the control that matters: a plain, non-signal array declared in the client block iterates fine. So this is not about signals or about plain arrays. It is about which block the name came from.

E is the other control: the same server array, wrapped in state() inside the client block, iterates fine. So the value does reach client scope. Only the :for expression evaluator cannot see it.

What is actually happening

<template :for> is not server-expanded at all. In the served HTML every one of the six carries only the template's prototype <li>, with nothing expanded:

<ul data-probe="f">
  <template :for="row in serverRows">
    <li x-cloak>{{ row.name }} / {{ row.status }}</li>
  </template>
</ul>

So expansion is entirely the client runtime's job, and the runtime's scope holds <script client> bindings but not <script server> ones. inScope=false says exactly that, and type=string is what the unresolved expression degrades to.

Meanwhile @foreach (serverRows as row) over the same binding works, because that one is server-side.

That leaves two iteration constructs with different scopes:

runscan see server bindingscan see client bindings
@foreachserveryesno
<template :for>clientnoyes

Nothing in the names, or in the warning, says which is which. Reaching for :for with server data is a natural thing to try and it fails quietly: one console warning and an empty list.

The hint is the expensive part

If this is a signal call, try the bare reference (signal instead of signal()).

In this failure mode the identifier is usually not a signal at all, so the hint describes a fix that cannot apply. It also fires after inScope=false has already established that the name was not resolvable, which is strictly more useful information than the signal guess.

This is not hypothetical. One of my apps hit exactly this, iterating monitors fetched in a <script server> block, read the hint, concluded the signal/:for reactive pattern was unreliable, and built the entire app on server-rendered plain <script> blocks with hand-written document.getElementById wiring instead. It carries a comment to that effect in two files and a dedicated four-variant probe page that was built to chase the signal theory. 15 of that app's strict-mode DOM findings trace back to that one decision, and the decision was based on a misread the warning encouraged.

Suggestions

  1. Make the warning scope-aware. When inScope=false, lead with that and name the likely cause rather than guessing at signals:

    :for expected an array for expression "serverRows", but "serverRows" is not in the client scope. A <script server> binding is not available to :for, which is expanded on the client. Wrap it in state() in a <script client> block, or iterate it with @foreach, which runs on the server.

    Keep the signal hint for when the resolved value really is a function with _isSignal / _isDerived, which the code already detects a few lines earlier.

  2. Consider making the two constructs agree. Server bindings are already bridged into <script client>, which is what makes E work, so :for not seeing them is the surprising half. Either bridge them into the :for scope too, or server-expand <template :for> when the expression resolves server-side the way @foreach does.

  3. Failing either, document the split, since "iterate a list" currently has two spellings with different visibility and no signpost.

(1) alone would have saved the app above its architecture, so it is worth having even if (2) is a larger change. Happy to send a PR for (1).

10 days ago

Follow-up, because the obvious objection to the repro above is that it wraps :for in a <template>, which is separately known to be a bad shape (stx strips <template> during SFC extraction, so the guidance is to put :for directly on the element).

That is not what this is. Re-probed with :for on the element, plus an @foreach control over the same binding:

<script server>
const srvRows = [{ id: 1, name: 'alpha' }, { id: 2, name: 'beta' }, { id: 3, name: 'gamma' }]
</script>

<ul data-probe="g"><li :for="row in srvRows">{{ row.name }}</li></ul>
<ul data-probe="h"><template :for="row in srvRows"><li>{{ row.name }}</li></template></ul>
<ul data-probe="i"><li :for="row in cli()">{{ row.name }}</li></ul>
<ul data-probe="j">@foreach (srvRows as row)<li>{{ row.name }}</li>@endforeach</ul>

<script client>
const cli = state([{ id: 1, name: 'alpha' }, { id: 2, name: 'beta' }, { id: 3, name: 'gamma' }])
</script>
G=0  H=0  I=3  J=3
shapebindingrows
G:for on the elementserver block0
H:for on a <template> wrapperserver block0
I:for on the elementclient state()3
J@foreachserver block3

G is the documented-correct shape and it fails identically:

[STX] :for expected an array; got empty/error for expression "srvRows".
[root=srvRows inScope=false type=string isStxStore=false keys=-]

So the <template> wrapper is irrelevant to this one. G vs I isolates it to the binding's origin, and G vs J shows the same array iterating fine through the server-side construct. The variable that decides it is which block declared the name, nothing about the element or the directive placement.

Worth noting this makes the diagnostic text the only thing standing between a reader and the answer: inScope=false is already correct and already printed. It is the sentence after it that points the wrong way.

10 days ago

Version correction on my own measurements here, since I have just found that the app I probed in was not running the version I said it was.

Both probes went through buddy serve, and that path turned out to be resolving a nested stx inside @stacksjs/buddy/node_modules at 0.2.343, not the app's top-level 0.2.401 (details in #2052). I proved the copy by byte-comparing generated output against the served page. So where I wrote "verified on 0.2.399" and "on stx 0.2.399", the honest statement is measured on 0.2.343.

That does not change the finding, and I am not retracting anything:

  • The behaviour is reproducible in the first place, which is what the report is about.
  • Your independent reproduction lands on the same conclusion from your side, on current code: both spellings render zero rows over a server-block array while @foreach renders fine, so template stripping is not the driver.
  • The failing shape, a server-block binding in a client-expanded :for, is structural rather than version-specific.

But the version attribution in the original report and in my follow-up comment was wrong, and anyone reading this later should not take "0.2.399" as having been checked. If it matters for whatever fix lands, the clean re-measurement would be on a path whose resolved stx has been confirmed rather than assumed, which is a lesson I am taking rather than offering.

10 days ago

Both suggestions are in. :for over a <script server> binding now runs on the server, so the two spellings of "iterate a list" agree.

Suggestion 2 — the constructs now agree

22ab1901f7. Your table, with the new row:

runssees <script server>sees <script client>
@foreach(rows as row)serveryesno
:for="row in rows", iterable is a signalclientnoyes
:for="row in rows", iterable is server dataserveryesn/a

All three of these now render identically:

<ul><li :for="row in serverRows">{{ row.name }}</li></ul>
<ul><template :for="row in serverRows"><li>{{ row.name }}</li></template></ul>
<ul>@foreach(serverRows as row)<li>{{ row.name }}</li>@endforeach</ul>

The framework already made this exact decision in the other direction, which is what made it the right fix rather than a new rule: convertSignalLoopsToAttributes leaves an @foreach server-side when its iterable is server data, and converts it to a client :for when it is a signal. The missing half was only ever the author who writes :for directly. So the data decides where the loop runs, not the keyword — same as @if.

Narrow on purpose. Only an iterable whose ROOT identifier is a server context key converts, because that is the case that cannot work any other way:

  • a literal (:for="n in [1,2,3]") stays on the client — it already worked, and moving it gains nothing
  • a signal stays, because reactivity is the point of :for
  • an unknown name stays, so a typo still reaches the runtime warning instead of vanishing into a server loop
  • a signal shadowing a server name of the same spelling stays: the signal is what you are iterating

Index bindings ((row, i) in rows) and nested loops over an outer loop's own binding both work. <template> is unwrapped rather than emitted, since a server loop repeats real markup and a template element renders nothing. 13 tests, and the full suite is 14,988 pass / 0 fail — which is the number that mattered, since this changes a documented rule.

Suggestion 1 shipped earlier

be0f6c2536. The warning leads with scope and names the two fixes, and keeps the signal advice only for when the value really is a signal. A value of the wrong type now says which type.

Worth noting for this issue's record: with suggestion 2 in, the server-binding case no longer produces a warning at all — it renders. The scope-aware message now fires for a name that is genuinely in neither scope, which is the case where "not in the client scope" is the whole truth.

One correction to the report

The <template> wrapper was never the variable. Measured against the real pipeline before changing anything: <template :for> and <li :for> over a server array were byte-for-byte equally unexpanded. So suggestion 3 — "document the split" — would have documented a split that did not exist along the line it appeared to, and "use the element form instead" would not have helped either. That is now in docs/guide/prefix-convention.md, rewritten to describe the behaviour this issue produced rather than the one it found.

Your inScope=false type=string read was exactly right, and it was the part of the report that pointed at the real mechanism.

Sign in to comment on this issue.