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

Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.

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)) {
packages/stx/test/expressions/signals-gate-stamp.test.tsmodified+24-1
Changes to packages/stx/test/expressions/signals-gate-stamp.test.ts
@@ -27,7 +27,7 @@
2727 * probed already preserved correctly. These tests therefore pin the stamp's own
2828 * contract, which is what the next steps will build on.
2929 */
30import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
30import { afterAll, beforeAll, describe, expect, it, mock } from 'bun:test'
3131import fs from 'node:fs'
3232import os from 'node:os'
3333import path from 'node:path'
@@ -54,6 +54,10 @@ const label = derived(() => 'live')
5454 fs.writeFileSync(path.join(dir, 'partials', 'plain.stx'), '<span></span>')
5555 fs.mkdirSync(path.join(dir, 'components'), { recursive: true })
5656 fs.writeFileSync(path.join(dir, 'components', 'Alert.stx'), '<div class="alert"><slot /></div>')
57 fs.writeFileSync(path.join(dir, 'components', 'PostEditor.stx'), `<script client>
58const post = useReactiveProp('post', null)
59</script>
60<button>{{ post() ? 'Save changes' : 'Create post' }}</button>`)
5761})
5862
5963afterAll(() => {
@@ -136,6 +140,25 @@ const n = state(0)
136140 * "untested". These are those tests.
137141 */
138142describe('cases the per-caller overrides used to carry', () => {
143 it.each([
144 `<script client>const post = state(null)</script><button>Save changes</button>`,
145 '<PostEditor />',
146 ])('never invokes a server helper shadowed by an authored client signal', async (template) => {
147 // Stacks auto-imports its POST helper as `post`. Evaluating the client
148 // editor's signal before preserving it used to submit an undefined URL.
149 const post = mock(() => Promise.resolve({ server: true }))
150 const out = await processDirectives(template, { post }, path.join(dir, 'page.stx'), options(), new Set<string>())
151 expect(post).not.toHaveBeenCalled()
152 expect(out).toContain("Save changes")
153 })
154
155 it('still evaluates a server helper when no client signal owns its name', async () => {
156 const title = mock(() => 'Server title')
157 const out = await processDirectives('<h1></h1>', { title }, path.join(dir, 'page.stx'), options(), new Set<string>())
158 expect(out).toContain('<h1>Server title</h1>')
159 expect(title).toHaveBeenCalledTimes(1)
160 })
161
139162 it('preserves slot content authored by the CALLER', async () => {
140163 // options.__stx_force_signals existed for this: `<Alert></Alert>`
141164 // is written by the caller, so judging it by the COMPONENT's own script