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| expression | source of the array | rows | |
|---|---|---|---|
| A | plainItems | plain const in <script client> | 3 |
| B | monitors() | signal call | 3 |
| C | monitors | bare signal | 3 |
| D | (monitors() ?? []) | defensive call | 3 |
| E | rows() | server array wrapped in state() in the client block | 3 |
| F | serverRows | the <script server> const directly | 0 |
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:
| runs | can see server bindings | can see client bindings | |
|---|---|---|---|
@foreach | server | yes | no |
<template :for> | client | no | yes |
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
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.Consider making the two constructs agree. Server bindings are already bridged into
<script client>, which is what makes E work, so:fornot seeing them is the surprising half. Either bridge them into the:forscope too, or server-expand<template :for>when the expression resolves server-side the way@foreachdoes.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).