Skip to content

Commit bb81afc

Browse files
committed
fix(slots): keep caller signals reactive inside slot content
Slot content belongs to the caller, so {{ }} inside it names the CALLER's reactive state, which the component's own script knows nothing about. Judged by that script alone the expressions looked unresolvable and not signal-backed, so they were evaluated server-side against a context that never had them and rendered as empty. The symptom was a styled, empty box: <Alert>{{ error }}</Alert> produced the alert with no message inside, and the only clue anything was wrong was that the box appeared at all. Reuses the gate the @include path already needed for the same reason: a component invoked with expression-bearing slot content is treated as signal-bearing, so the existing "unresolvable here, preserve for the client" rule applies.
1 parent a5d2982 commit bb81afc

3 files changed

Lines changed: 141 additions & 1 deletion

File tree

packages/stx/src/expressions.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -683,7 +683,17 @@ export function processExpressions(template: string, context: Record<string, any
683683
// own state()/derived(). The caller computes the gate on the partial's FULL
684684
// content and passes it here, so {{ }} over the partial's own signals are
685685
// preserved for the runtime — exactly like a top-level page. See stacksjs/stx#1758.
686-
const hasSignals = options?.forceSignals === true || usesSignalsInScript(template)
686+
//
687+
// `__stx_force_signals` is the same gate for SLOT CONTENT. A component
688+
// rendering `<slot />` receives markup authored by its caller, and any
689+
// {{ }} in it refers to the CALLER's scope - which the component's own
690+
// script knows nothing about. Judging by the component's script alone,
691+
// those expressions looked unresolvable-and-not-signal-backed, so they were
692+
// evaluated server-side against a context that never had them and rendered
693+
// as empty. `<Alert>{{ error }}</Alert>` produced a styled, empty box.
694+
const hasSignals = options?.forceSignals === true
695+
|| context?.__stx_force_signals === true
696+
|| usesSignalsInScript(template)
687697

688698
// Replace triple curly braces with unescaped expressions {{{ expr }}} - similar to {!! expr !!}
689699
output = output.replace(/\{\{\{([\s\S]*?)\}\}\}/g, (match, expr, offset) => {

packages/stx/src/utils.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -526,6 +526,29 @@ export function isHydrateTrigger(trigger: string): boolean {
526526
|| trigger.startsWith('media:')
527527
}
528528

529+
/**
530+
* Does this slot content carry template expressions?
531+
*
532+
* Slot content belongs to the caller, so `{{ ... }}` inside it names the
533+
* caller's variables. When any are present the component has to be treated as
534+
* signal-bearing, or the expression pipeline resolves them against the
535+
* component's context (where they do not exist) and renders them as empty.
536+
*
537+
* `<script>` and `<style>` regions are ignored: a regex or a CSS rule can
538+
* contain brace pairs that are not template expressions, and matching them
539+
* would flag components that have no slot expressions at all.
540+
*/
541+
export function slotContentHasExpressions(slotContent: string): boolean {
542+
if (!slotContent)
543+
return false
544+
545+
const withoutScripts = slotContent
546+
.replace(/<script[\s\S]*?<\/script>/gi, '')
547+
.replace(/<style[\s\S]*?<\/style>/gi, '')
548+
549+
return /\{\{[\s\S]*?\}\}|\{!![\s\S]*?!!\}/.test(withoutScripts)
550+
}
551+
529552
export async function renderComponentWithSlot(
530553
componentPath: string,
531554
props: Record<string, unknown>,
@@ -821,6 +844,16 @@ export async function renderComponentWithSlot(
821844
// expression (using the `slot` variable above) rather than being replaced by
822845
// the parent page's @section('content') content
823846
__sections: {},
847+
// Slot content is authored by the CALLER, so any {{ }} in it refers to
848+
// the caller's scope - reactive state the component's own script has
849+
// never heard of. Without this flag the expression pipeline judged them
850+
// by the component's script alone, found no signals, and evaluated them
851+
// server-side against a context that never had them: they rendered as
852+
// empty. `<Alert>{{ error }}</Alert>` produced a styled, empty box, and
853+
// the only clue was that the box appeared at all. Flagging the component
854+
// as signal-bearing lets the existing "unresolvable here, preserve for
855+
// the client" rule do its job.
856+
...(slotContentHasExpressions(slotContent) && { __stx_force_signals: true }),
824857
}
825858

826859
// Fill in `defineProps` destructuring defaults for any prop the caller
Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
2+
import { mkdtemp, rm } from 'node:fs/promises'
3+
import { tmpdir } from 'node:os'
4+
import path from 'node:path'
5+
import { slotContentHasExpressions } from '../src/utils'
6+
7+
/**
8+
* Slot content belongs to the caller, so `{{ ... }}` inside it names the
9+
* CALLER's reactive state - which the component's own script knows nothing
10+
* about. Judged by that script alone the expressions looked unresolvable and
11+
* not signal-backed, so they were evaluated server-side against a context that
12+
* never had them and rendered as empty.
13+
*
14+
* The visible symptom was a styled, empty box: `<Alert>{{ error }}</Alert>`
15+
* produced the alert with no message in it, and the only clue that anything
16+
* was wrong was that the box appeared at all.
17+
*/
18+
19+
let dir: string
20+
21+
beforeAll(async () => {
22+
dir = await mkdtemp(path.join(tmpdir(), 'stx-slot-signals-'))
23+
24+
await Bun.write(path.join(dir, 'components', 'Callout.stx'), `<template>
25+
<div class="callout"><slot /></div>
26+
</template>
27+
`)
28+
29+
// A page whose message lives in a signal, handed to the component as slot
30+
// content. This is the shape that silently rendered nothing.
31+
await Bun.write(path.join(dir, 'page.stx'), `<script>
32+
const message = state('')
33+
</script>
34+
<Callout>{{ message }}</Callout>
35+
`)
36+
37+
// The same page, but the component resolves the expression itself.
38+
await Bun.write(path.join(dir, 'resolved.stx'), `<script>
39+
const message = state('')
40+
</script>
41+
<Callout>{{ slotOwnedByComponent }}</Callout>
42+
`)
43+
})
44+
45+
afterAll(async () => {
46+
if (dir)
47+
await rm(dir, { recursive: true, force: true })
48+
})
49+
50+
async function render(file: string): Promise<string> {
51+
const { processDirectives } = await import('../src/index')
52+
const source = await Bun.file(path.join(dir, file)).text()
53+
return await processDirectives(
54+
source,
55+
{},
56+
path.join(dir, file),
57+
{ componentsDir: path.join(dir, 'components') } as any,
58+
new Set(),
59+
)
60+
}
61+
62+
describe('slot content over caller signals', () => {
63+
it('preserves the expression for the client instead of rendering an empty box', async () => {
64+
const html = await render('page.stx')
65+
66+
// The component rendered, and the caller's expression survived to the
67+
// client runtime rather than being resolved to nothing server-side.
68+
expect(html).toContain('callout')
69+
expect(html).toContain('{{ message }}')
70+
})
71+
72+
it('leaves an expression the component cannot resolve intact rather than blanking it', async () => {
73+
const html = await render('resolved.stx')
74+
75+
expect(html).toContain('{{ slotOwnedByComponent }}')
76+
})
77+
})
78+
79+
describe('slotContentHasExpressions', () => {
80+
it('detects the expression forms slot content can carry', () => {
81+
expect(slotContentHasExpressions('{{ error }}')).toBe(true)
82+
expect(slotContentHasExpressions('<span>{{ error }}</span>')).toBe(true)
83+
expect(slotContentHasExpressions('{!! rawHtml !!}')).toBe(true)
84+
})
85+
86+
it('is false for plain content, so unrelated components are unaffected', () => {
87+
expect(slotContentHasExpressions('Save changes')).toBe(false)
88+
expect(slotContentHasExpressions('<strong>Save</strong>')).toBe(false)
89+
expect(slotContentHasExpressions('')).toBe(false)
90+
})
91+
92+
it('ignores braces inside script and style, which are not template expressions', () => {
93+
// A regex in a script and a CSS rule both contain brace pairs.
94+
expect(slotContentHasExpressions('<script>const re = /\\{\\{x\\}\\}/g</script>')).toBe(false)
95+
expect(slotContentHasExpressions('<style>.a { color: red }</style>')).toBe(false)
96+
})
97+
})

0 commit comments

Comments
 (0)