Item 5 of the ranked plan in #1773. Items 1–4 landed in 5a80a17; this is the one left, and it needs its own change rather than being folded into that commit.
What the issue asked for
Single preservation gate: compute preserve-vs-interpolate once, stamp it, have all seven sites consume it.
What's actually there now
The premise needs updating: there is already one predicate. usesSignalsInScript() lives in expressions.ts:449 and is the only implementation — includes.ts:1176, process.ts:1228 and expressions.ts:719 all call it. There aren't seven copies of the heuristic.
What is spread out is the override each caller passes, because each knows something the predicate structurally cannot see. processExpressions merges four inputs (expressions.ts:719):
const hasSignals = options?.forceSignals === true
|| context?.__stx_force_signals === true
|| Array.isArray(context?.__stx_client_signal_names)
|| usesSignalsInScript(template)Each override exists for a different, documented reason:
forceSignals— a partial's<script>is stripped byincludes.tsbefore expressions run, so the predicate can't see its ownstate()/derived(). The caller computes the gate on the partial's full content and passes it down (#1758).__stx_force_signals— slot content.<Alert></Alert>is authored by the caller, so judging it by the component's own script rendered it as an empty styled box.__stx_client_signal_names— an explicit name list from the bundler.
Why it isn't a mechanical refactor
Collapsing those into one stamp means deciding, in one place, what each currently expresses separately: whose scope an expression belongs to, at a point in the pipeline where the template has already been transformed differently for each caller. That changes which expressions get preserved in exactly the edge cases that took eight fixes to get right (c571492f80, 4c5fb057f3, d411bf8e44, 409a2e08f5, bcd6f2ce53, da91c34484, 8ebb172b2a, 20509daf21).
Memoising the predicate doesn't help either: the callers pass different template strings (pre- and post-strip), which is the actual source of divergence.
Suggested shape
- Compute the gate once, on the original template, before any script stripping or slot substitution, and stamp it on the context (
__stx_signals_gate). - Have
processExpressionsprefer the stamp and treat the three existing overrides as fallbacks, so behaviour is unchanged until each is removed deliberately. - Retire the overrides one at a time, each with a regression test naming the case it covers (
#1758partial, slot content, bundler name list).
Staged that way each step is separately revertible, which the current all-at-once shape is not.
Mitigation already in place
The post-hydration invariant sweep from #1773 item 1 means a miss here is now a loud, self-healing console.error naming the scope id rather than a screenshot of literal . That removes the urgency, not the duplication.