ReviewOS

also looking at this

stacks/stx

fix(includes): preserve {⁠{ }} in @include partials that define their own signals (#1758)

#1759
Merged glennmichael123 wants to merge fix/1758-mustache-in-include-partials into main
3 files +76 -6

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+8-3
Changes to packages/stx/src/expressions.ts
@@ -640,7 +640,7 @@ export function interpolateScriptsInTemplate(
640640/**
641641 * Process template expressions including variables, filters, and operations
642642 */
643export function processExpressions(template: string, context: Record<string, any>, filePath: string): string {
643export function processExpressions(template: string, context: Record<string, any>, filePath: string, options?: { forceSignals?: boolean }): string {
644644 let output = template
645645
646646 // Protect <style> and <script> blocks that contain NO stx template
@@ -677,8 +677,13 @@ export function processExpressions(template: string, context: Record<string, any
677677 output = maskInto(output, matchScriptElement, scriptBlocks, n => `<!--__STX_SCRIPT_EXPR_${n}__-->`)
678678
679679 // Check if this template uses signals - if so, we need to preserve expressions
680 // that reference runtime variables (like loop variables or signal calls)
681 const hasSignals = usesSignalsInScript(template)
680 // that reference runtime variables (like loop variables or signal calls).
681 // `forceSignals` is set by the @include path: a partial's <script> is stripped
682 // (includes.ts) before this runs, so usesSignalsInScript(template) can't see its
683 // own state()/derived(). The caller computes the gate on the partial's FULL
684 // content and passes it here, so over the partial's own signals are
685 // preserved for the runtime — exactly like a top-level page. See stacksjs/stx#1758.
686 const hasSignals = options?.forceSignals === true || usesSignalsInScript(template)
682687
683688 // Replace triple curly braces with unescaped expressions [object Object] - similar to
684689 output = output.replace(/\{\{\{([\s\S]*?)\}\}\}/g, (match, expr, offset) => {
packages/stx/src/includes.tsmodified+12-3
Changes to packages/stx/src/includes.ts
@@ -69,7 +69,7 @@
6969import type { StxOptions } from './types'
7070import path from 'node:path'
7171import { processConditionals } from './conditionals'
72import { processExpressions } from './expressions'
72import { processExpressions, usesSignalsInScript } from './expressions'
7373import { LRUCache } from './performance-utils'
7474import { createSafeFunction, isExpressionSafe, safeEvaluate, safeEvaluateObject } from './safe-evaluator'
7575import { transformStoreImports } from './store-imports'
@@ -1103,8 +1103,17 @@ catch (e) {
11031103 // Process conditionals
11041104 processedContent = processConditionals(processedContent, includeContext, includeFilePath)
11051105
1106 // Process expressions
1107 processedContent = processExpressions(processedContent, includeContext, includeFilePath)
1106 // Process expressions.
1107 // The partial's <script> was stripped above (line ~925) so the style/script
1108 // extractors and the parent's client-script pipeline don't double-process it.
1109 // That also hides the partial's own state()/derived() from processExpressions'
1110 // usesSignalsInScript gate, which would then evaluate ``
1111 // server-side (undefined → emptied) and consume the marker, leaving nothing
1112 // for the runtime to bind — the partial renders permanently blank. Recover the
1113 // gate from the FULL partial content so a partial with a client signal script
1114 // preserves just like a top-level page. See stacksjs/stx#1758.
1115 const partialHasSignals = usesSignalsInScript(partialContent)
1116 processedContent = processExpressions(processedContent, includeContext, includeFilePath, { forceSignals: partialHasSignals })
11081117
11091118 // Append preserved style and script for SFC support
11101119 if (preservedStyle) {
packages/stx/test/includes/signal-partial-mustache.test.tsadded+56-0
Changes to packages/stx/test/includes/signal-partial-mustache.test.ts
@@ -0,0 +1,56 @@
1import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
2import { processDirectives } from '../../src/process'
3import { cleanupTestDirs, createPartialFile, PARTIALS_DIR, setupTestDirs } from '../utils'
4
5// Regression: stacksjs/stx#1758
6//
7// An @include partial's <script> is stripped (includes.ts) BEFORE its body
8// expressions are processed, so processExpressions' usesSignalsInScript gate
9// can't see the partial's own state()/derived(). Without recovering that gate
10// from the partial's full content, `` is evaluated
11// server-side (undefined → emptied) and the marker is consumed — the partial
12// renders permanently blank instead of binding on the client.
13//
14// The fix computes usesSignalsInScript on the FULL partial content and passes
15// it to processExpressions as { forceSignals }. These tests pin both the fix
16// (signal partials preserve ) and that it does NOT over-preserve
17// (server-var / signal-free partials still evaluate server-side).
18//
19// Rendered via src `processDirectives` rather than Bun.build+stxPlugin so the
20// assertions exercise src, not a possibly-stale dist (see test-realm memory).
21describe('stx#1758: in @include partials with their own signals', () => {
22 beforeAll(setupTestDirs)
23 afterAll(cleanupTestDirs)
24
25 const opts = { debug: false, partialsDir: PARTIALS_DIR, componentsDir: PARTIALS_DIR } as any
26 const render = (tmpl: string): Promise<string> =>
27 processDirectives(tmpl, {}, 'page.stx', opts, new Set())
28
29 it('preserves when the partial defines its own signals', async () => {
30 await createPartialFile(
31 'signal-counter.stx',
32 `<script client>\n const n = derived(() => 42)\n</script>\n<p class="counter"></p>`,
33 )
34 const out = await render(`<div> /var/www/reviewos-reviewos/releases/3e057f9/resources/views/layouts/app.stx:3879:20117: include error: Error loading include file signal-counter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/signal-counter.stx' ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx (line 3879, col 20117) Message: Error loading include file signal-counter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/signal-counter.stx' Context:  3877:
packages/stx/src/includes.tsmodified+12-3
Changes to packages/stx/src/includes.ts
@@ -69,7 +69,7 @@
6969import type { StxOptions } from './types'
7070import path from 'node:path'
7171import { processConditionals } from './conditionals'
72import { processExpressions } from './expressions'
72import { processExpressions, usesSignalsInScript } from './expressions'
7373import { LRUCache } from './performance-utils'
7474import { createSafeFunction, isExpressionSafe, safeEvaluate, safeEvaluateObject } from './safe-evaluator'
7575import { transformStoreImports } from './store-imports'
@@ -1103,8 +1103,17 @@ catch (e) {
11031103 // Process conditionals
11041104 processedContent = processConditionals(processedContent, includeContext, includeFilePath)
11051105
1106 // Process expressions
1107 processedContent = processExpressions(processedContent, includeContext, includeFilePath)
1106 // Process expressions.
1107 // The partial's <script> was stripped above (line ~925) so the style/script
1108 // extractors and the parent's client-script pipeline don't double-process it.
1109 // That also hides the partial's own state()/derived() from processExpressions'
1110 // usesSignalsInScript gate, which would then evaluate ``
1111 // server-side (undefined → emptied) and consume the marker, leaving nothing
1112 // for the runtime to bind — the partial renders permanently blank. Recover the
1113 // gate from the FULL partial content so a partial with a client signal script
1114 // preserves just like a top-level page. See stacksjs/stx#1758.
1115 const partialHasSignals = usesSignalsInScript(partialContent)
1116 processedContent = processExpressions(processedContent, includeContext, includeFilePath, { forceSignals: partialHasSignals })
11081117
11091118 // Append preserved style and script for SFC support
11101119 if (preservedStyle) {
3878: > 3879:
packages/stx/test/includes/signal-partial-mustache.test.tsadded+56-0
Changes to packages/stx/test/includes/signal-partial-mustache.test.ts
@@ -0,0 +1,56 @@
1import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
2import { processDirectives } from '../../src/process'
3import { cleanupTestDirs, createPartialFile, PARTIALS_DIR, setupTestDirs } from '../utils'
4
5// Regression: stacksjs/stx#1758
6//
7// An @include partial's <script> is stripped (includes.ts) BEFORE its body
8// expressions are processed, so processExpressions' usesSignalsInScript gate
9// can't see the partial's own state()/derived(). Without recovering that gate
10// from the partial's full content, `` is evaluated
11// server-side (undefined → emptied) and the marker is consumed — the partial
12// renders permanently blank instead of binding on the client.
13//
14// The fix computes usesSignalsInScript on the FULL partial content and passes
15// it to processExpressions as { forceSignals }. These tests pin both the fix
16// (signal partials preserve ) and that it does NOT over-preserve
17// (server-var / signal-free partials still evaluate server-side).
18//
19// Rendered via src `processDirectives` rather than Bun.build+stxPlugin so the
20// assertions exercise src, not a possibly-stale dist (see test-realm memory).
21describe('stx#1758: in @include partials with their own signals', () => {
22 beforeAll(setupTestDirs)
23 afterAll(cleanupTestDirs)
24
25 const opts = { debug: false, partialsDir: PARTIALS_DIR, componentsDir: PARTIALS_DIR } as any
26 const render = (tmpl: string): Promise<string> =>
27 processDirectives(tmpl, {}, 'page.stx', opts, new Set())
28
29 it('preserves when the partial defines its own signals', async () => {
30 await createPartialFile(
31 'signal-counter.stx',
32 `<script client>\n const n = derived(() => 42)\n</script>\n<p class="counter"></p>`,
33 )
34 const out = await render(`<div> ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx Message: Error loading include file signal-counter: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/signal-counter.stx' </div>`)
35 // Must survive as a runtime marker, not be emptied server-side.
36 expect(out).toMatch(/\{\{\s*n\(\)\s*\}\}/)
37 })
38
39 it('still evaluates <script server> vars in a partial (no over-preservation)', async () => {
40 await createPartialFile(
41 'server-card.stx',
42 `<script server>\n const who = 'Alice'\n</script>\n<p class="server"></p>`,
43 )
44 const out = await render(`<div> ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx Message: Error loading include file server-card: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/server-card.stx' </div>`)
45 expect(out).toContain('Alice')
46 expect(out).not.toMatch(/\{\{\s*who\s*\}\}/)
47 })
48
49 it('does not over-preserve in a signal-free partial', async () => {
50 await createPartialFile('dumb.stx', `<p class="dumb"></p>`)
51 const out = await render(`<div> ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx Message: Error loading include file dumb: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/dumb.stx' </div>`)
52 // No signals anywhere → evaluated server-side (undefined → empty), not shipped
53 // to the client as a literal it cannot resolve.
54 expect(out).not.toMatch(/\{\{\s*mystery\s*\}\}/)
55 })
56})
3880: 3881:  </div>`
)
35 // Must survive as a runtime marker, not be emptied server-side.
36 expect(out).toMatch(/\{\{\s*n\(\)\s*\}\}/)
37 })
38
39 it('still evaluates <script server> vars in a partial (no over-preservation)', async () => {
40 await createPartialFile(
41 'server-card.stx',
42 `<script server>\n const who = 'Alice'\n</script>\n<p class="server"></p>`,
43 )
44 const out = await render(`<div> ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx Message: Error loading include file server-card: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/server-card.stx' </div>`)
45 expect(out).toContain('Alice')
46 expect(out).not.toMatch(/\{\{\s*who\s*\}\}/)
47 })
48
49 it('does not over-preserve in a signal-free partial', async () => {
50 await createPartialFile('dumb.stx', `<p class="dumb"></p>`)
51 const out = await render(`<div> ╭──────────────────────────────────────────────────────────────╮ │  ERROR  Include Error │ ╰──────────────────────────────────────────────────────────────╯ File: app.stx Message: Error loading include file dumb: ENOENT: no such file or directory, open '/var/www/reviewos-reviewos/releases/3e057f9/resources/views/partials/dumb.stx' </div>`)
52 // No signals anywhere → evaluated server-side (undefined → empty), not shipped
53 // to the client as a literal it cannot resolve.
54 expect(out).not.toMatch(/\{\{\s*mystery\s*\}\}/)
55 })
56})
@endif