ReviewOS

also looking at this

stacks/stx

fix(conditionals): don't match directive tokens inside HTML tags

#1779
Closed glennmichael123 wants to merge fix/nested-if-endif-matching into main
2 files +90 -0
(client signal) inside a server @if corrupts the rendered template — content after the inner block and the outer boundaries.

Fix

Directive tokens only ever occur in text content, never inside a <...> tag. Add isInsideTag() and skip tag-interior @if/ in the branch scanner so the signal and conditional passes agree on what is a directive.

Tests

Adds regression tests (two-pass signal→conditionals) for both outer branches, an inner reactive @elseif chain, plus guards that normal server nesting and a directive @else right after a closing tag still work. Existing conditionals + parser + signal suites all green (871 pass / 0 fail); lint + typecheck clean.

Found while building nested owner-vs-share-mode conditionals in a downstream app (ghostanalytics dashboard).

2 changed files on the files tab, with 0 review threads.

Sign in to comment on this pull request.

@endif
packages/stx/src/parser/directive-parser.tsmodified+30-0
Changes to packages/stx/src/parser/directive-parser.ts
@@ -87,6 +87,27 @@ export function extractParenthesizedExpressionDetailed(source: string, startPos:
8787// Balanced Tag Matching
8888// =============================================================================
8989
90/**
91 * Whether `pos` in `source` falls INSIDE an HTML tag (`<...>`) — i.e. the nearest
92 * angle bracket looking backwards is an unclosed `<`.
93 *
94 * Directive tokens (`@if`/``) only ever occur in text
95 * content, never inside a tag. The reactive-signal pass, however, rewrites a
96 * NESTED reactive conditional into HTML-attribute markers BEFORE this server pass
97 * runs — e.g. ` B` becomes
98 * `<template @if="isPro()">A</template><template @else>B</template>`. Without this
99 * guard the branch scanner matches that `@else` ATTRIBUTE as the enclosing server
100 * `@if`'s top-level `@else`, mismatching branch/`@endif` boundaries and corrupting
101 * the rest of the template. Skipping tag-interior tokens keeps the two passes from
102 * disagreeing on what is a directive.
103 */
104export function isInsideTag(source: string, pos: number): boolean {
105 const lastOpen = source.lastIndexOf('<', pos)
106 if (lastOpen === -1)
107 return false
108 return lastOpen > source.lastIndexOf('>', pos)
109}
110
90111/**
91112 * Find the matching end tag for a directive, handling nested instances
92113 *
@@ -185,6 +206,15 @@ export function parseConditionalBlock(source: string, startPos: number): ParsedC
185206 currentPos = atIdx
186207 const remaining = fullContent.substring(atIdx)
187208
209 // Ignore directive-looking tokens that are actually HTML attributes (inside a
210 // `<...>` tag), e.g. the `<template @else>` markers the reactive-signal pass
211 // emits for a nested reactive conditional. Matching them here would mismatch
212 // this block's branch/@endif boundaries. See isInsideTag().
213 if (isInsideTag(fullContent, atIdx)) {
214 currentPos++
215 continue
216 }
217
188218 // Track nested @if depth
189219 if (/^@if\s*\(/.test(remaining)) {
190220 nestedDepth++
packages/stx/test/directives/conditionals.test.tsmodified+60-0
Changes to packages/stx/test/directives/conditionals.test.ts
@@ -1,5 +1,6 @@
11import { afterAll, beforeAll, describe, expect, it } from 'bun:test'
22import { processConditionals, processAuthDirectives, processEnvDirective, processIssetEmptyDirectives, processSwitchStatements } from '../../src/conditionals'
3import { convertSignalDirectivesToAttributes } from '../../src/signal-processing'
34
45describe('Conditionals Comprehensive', () => {
56 const filePath = 'test.stx'
@@ -1563,3 +1564,62 @@ describe('Conditionals Comprehensive', () => {
15631564 })
15641565 })
15651566})
1567
1568// Regression: a NESTED reactive @if/@else lives inside a server @if. The signal
1569// pass rewrites the inner reactive conditional into `<template @else>` HTML-
1570// attribute markers BEFORE processConditionals runs; the server branch scanner
1571// must NOT mistake that `@else` attribute for the enclosing @if's top-level
1572// @else, or it mismatches @endif boundaries and corrupts the template.
1573describe('@if — nested reactive conditional inside a server @if', () => {
1574 const filePath = 'test.stx'
1575 // Mirrors the app case: outer `>FREE</template>')
1590 // the OUTER else-branch is not shown, and nothing is truncated mid-tag
1591 expect(out).not.toContain('LOCKED')
1592 expect(out).not.toMatch(/<template\s*<\/div>/)
1593 })
1594
1595 it('shows the outer else-branch (and no reactive markers) when the outer @if is false', () => {
1596 const out = twoPass(false)
1597 expect(out).toContain('LOCKED')
1598 expect(out).not.toContain('<template')
1599 expect(out).not.toContain('BEFORE')
1600 expect(out).not.toContain('AFTER')
1601 })
1602
1603 it('handles an inner reactive