also looking at this
fix(conditionals): don't match directive tokens inside HTML tags
#1779@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.
@endifReview 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.
| @@ -87,6 +87,27 @@ export function extractParenthesizedExpressionDetailed(source: string, startPos: | ||
| 87 | 87 | // Balanced Tag Matching |
| 88 | 88 | // ============================================================================= |
| 89 | 89 | |
| 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 | */ | |
| 104 | export 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 | ||
| 90 | 111 | /** |
| 91 | 112 | * Find the matching end tag for a directive, handling nested instances |
| 92 | 113 | * |
| @@ -185,6 +206,15 @@ export function parseConditionalBlock(source: string, startPos: number): ParsedC | ||
| 185 | 206 | currentPos = atIdx |
| 186 | 207 | const remaining = fullContent.substring(atIdx) |
| 187 | 208 | |
| 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 | ||
| 188 | 218 | // Track nested @if depth |
| 189 | 219 | if (/^@if\s*\(/.test(remaining)) { |
| 190 | 220 | nestedDepth++ |
| @@ -1,5 +1,6 @@ | ||
| 1 | 1 | import { afterAll, beforeAll, describe, expect, it } from 'bun:test' |
| 2 | 2 | import { processConditionals, processAuthDirectives, processEnvDirective, processIssetEmptyDirectives, processSwitchStatements } from '../../src/conditionals' |
| 3 | import { convertSignalDirectivesToAttributes } from '../../src/signal-processing' | |
| 3 | 4 | |
| 4 | 5 | describe('Conditionals Comprehensive', () => { |
| 5 | 6 | const filePath = 'test.stx' |
| @@ -1563,3 +1564,62 @@ describe('Conditionals Comprehensive', () => { | ||
| 1563 | 1564 | }) |
| 1564 | 1565 | }) |
| 1565 | 1566 | }) |
| 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. | |
| 1573 | describe('@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 | |