ReviewOS

also looking at this

stacks/stx

fix(ssr): respect client signal ownership before evaluation

#2100
Merged chris wants to merge codex/client-signal-ssr-ownership into main
2 files +46 -29
packages/stx/src/expressions.tsmodified+22-28
Changes to packages/stx/src/expressions.ts
@@ -35,7 +35,7 @@ import fs from 'node:fs'
3535import { isDevelopment } from './env'
3636import { stripCommentsAndLiterals } from './strip-literals'
3737import { createSafeFunction, getExpressionSafetyRule, safeEvaluate } from './safe-evaluator'
38import { createDetailedErrorMessage } from './utils'
38import { componentClientSignalNames, createDetailedErrorMessage } from './utils'
3939import { createPlaceholder } from './placeholder'
4040import { neutralizeTemplateSyntax } from './template-syntax-escape'
4141import { classifyHtmlInterpolation, UnsafeInterpolationContextError } from './interpolation-context'
@@ -717,15 +717,19 @@ function escapeInterpolatedHtmlValue(
717717 * recognised too. Without it the scan is text-only, which is what every caller
718718 * did before and is still correct for a script that declares its own signals.
719719 */
720/** Bodies of inline client scripts already found at genuine element positions. */
721function clientScriptBodies(scripts: string[]): string[] {
722 return scripts.flatMap((script) => {
723 const match = script.match(/^<script\b([^>]*)>([\s\S]*?)<\/script\s*>$/i)
724 return !match || /\bserver\b|\bsrc\s*=/.test(match[1]) ? [] : [match[2]]
725 })
726}
727
720728export function usesSignalsInScript(template: string, filePath?: string): boolean {
721729 // Check genuine script elements only. Tag-like text inside comments,
722730 // attributes, or style bodies is not executable script.
723731 const { scripts } = stashScriptElements(template)
724 for (const script of scripts) {
725 const match = script.match(/^<script\b([^>]*)>([\s\S]*?)<\/script\s*>$/i)
726 if (!match || /\bserver\b|\bsrc\s*=/.test(match[1]))
727 continue
728 const content = match[2]
732 for (const content of clientScriptBodies(scripts)) {
729733 if (/\b(state|derived|effect|ref|reactive|computed|watch|watchEffect|useModel)\s*(?:<[^>]*>)?\s*\(/.test(content)) {
730734 return true
731735 }
@@ -1216,11 +1220,10 @@ export function processExpressions(template: string, context: Record<string, any
12161220 // static prop shadows the signal. The Set stays.
12171221 const hasSignals = context?.__stx_signals_gate === true
12181222 || usesSignalsInScript(template)
1219 const clientSignalNames = new Set<string>(
1220 Array.isArray(context?.__stx_client_signal_names)
1221 ? context.__stx_client_signal_names
1222 : [],
1223 )
1223 const clientSignalNames = new Set<string>([
1224 ...(Array.isArray(context?.__stx_client_signal_names) ? context.__stx_client_signal_names : []),
1225 ...componentClientSignalNames(clientScriptBodies(scriptBlocks)),
1226 ])
12241227 const preservedClientExpressions = new Set<string>(
12251228 Array.isArray(context?.__stx_preserved_client_expressions)
12261229 ? context.__stx_preserved_client_expressions
@@ -1345,6 +1348,14 @@ export function processExpressions(template: string, context: Record<string, any
13451348 return ''
13461349 }
13471350
1351 // An authored client signal owns its binding before evaluation. A server
1352 // prop or auto-import can have the same name (e.g. the POST helper `post`);
1353 // calling that helper first can submit a request even when its result is
1354 // discarded. Component scripts have already been lifted out, so inherit
1355 // their recorded names alongside declarations in this page's own scripts.
1356 if (firstVarIsClientSignal)
1357 return match
1358
13481359 // Try evaluation first - this allows user-defined helper functions to work
13491360 // Only preserve for client-side if evaluation fails AND it looks like a signal
13501361 let rejection: ExpressionRejection | undefined
@@ -1353,20 +1364,6 @@ export function processExpressions(template: string, context: Record<string, any
13531364 rejection = r
13541365 })
13551366
1356 // A component prop can have a static server value and then be shadowed
1357 // by a client signal with the same name:
1358 //
1359 // const title = useReactiveProp('title', '')
1360 // <h2></h2>
1361 //
1362 // The server context contains `title: "Hello"`, so context-only
1363 // detection cannot distinguish this from a server helper and used to
1364 // evaluate the expression away. The component renderer records locally
1365 // declared client signals before stripping its script, allowing the
1366 // interpolation to survive for the scoped runtime.
1367 if (firstVarIsClientSignal)
1368 return match
1369
13701367 // For signal-based templates: if the expression references variables NOT in the
13711368 // server-side context (i.e. client-only signals), preserve it for client-side
13721369 // processing REGARDLESS of the server-evaluated value. Gating on `value === undefined`
@@ -1422,9 +1419,6 @@ export function processExpressions(template: string, context: Record<string, any
14221419 catch (error: unknown) {
14231420 if (error instanceof UnsafeInterpolationContextError)
14241421 throw error
1425 if (firstVarIsClientSignal)
1426 return match
1427
14281422 // For signal-based templates, preserve expressions that reference runtime variables
14291423 // This includes loop variables (page, event, etc.) and component-defined functions (fmt, etc.)
14301424 if (hasSignals && !expressionUsesOnlyContextVars(trimmedExpr, context)) {