@@ -669,6 +669,74 @@ export function injectRouterScript(template: string): string {
669669 return template . slice ( 0 , bodyCloseIdx ) + routerScript + '\n</body>' + template . slice ( bodyCloseIdx + 7 )
670670}
671671
672+ /**
673+ * Add x-cloak to HTML elements that contain unresolved {{ }} expressions.
674+ * These expressions were preserved for client-side evaluation (signals, loop vars, etc.).
675+ * x-cloak hides them until the JS runtime processes and reveals them — prevents FOUC.
676+ */
677+ function addCloakToUnresolvedExpressions ( html : string ) : string {
678+ // Add x-cloak to elements whose text content contains {{ }} expressions.
679+ // We parse tag boundaries carefully to avoid breaking attributes that contain ">".
680+ const tagNames = 'div|span|p|h[1-6]|td|th|li|a|button|label|section|article|header|footer|main|nav|aside|dd|dt|figcaption|summary|caption|blockquote|pre|code|em|strong|small|sub|sup|time|mark|abbr|cite|q|s|u|b|i'
681+ const tagStartRe = new RegExp ( `<(${ tagNames } )\\b` , 'gi' )
682+ let result = ''
683+ let lastIndex = 0
684+ let tagMatch : RegExpExecArray | null
685+
686+ // eslint-disable-next-line no-cond-assign
687+ while ( ( tagMatch = tagStartRe . exec ( html ) ) !== null ) {
688+ const tagOpenStart = tagMatch . index
689+
690+ // Find the real closing ">" of this opening tag, skipping ">" inside quoted attributes
691+ let i = tagOpenStart + tagMatch [ 0 ] . length
692+ let inSingleQuote = false
693+ let inDoubleQuote = false
694+ let tagCloseIdx = - 1
695+
696+ while ( i < html . length ) {
697+ const ch = html [ i ]
698+ if ( inSingleQuote ) {
699+ if ( ch === '\'' ) inSingleQuote = false
700+ }
701+ else if ( inDoubleQuote ) {
702+ if ( ch === '"' ) inDoubleQuote = false
703+ }
704+ else if ( ch === '\'' ) {
705+ inSingleQuote = true
706+ }
707+ else if ( ch === '"' ) {
708+ inDoubleQuote = true
709+ }
710+ else if ( ch === '>' ) {
711+ tagCloseIdx = i
712+ break
713+ }
714+ i ++
715+ }
716+
717+ if ( tagCloseIdx === - 1 ) continue
718+
719+ const fullTag = html . slice ( tagOpenStart , tagCloseIdx + 1 )
720+
721+ // Skip if already has x-cloak
722+ if ( fullTag . includes ( 'x-cloak' ) ) continue
723+
724+ // Look at text content after this tag until the next "</" or "<" tag
725+ const afterTag = html . slice ( tagCloseIdx + 1 )
726+ const closingIdx = afterTag . indexOf ( '</' )
727+ const textContent = closingIdx >= 0 ? afterTag . slice ( 0 , closingIdx ) : afterTag . slice ( 0 , 200 )
728+
729+ if ( / \{ \{ [ \s \S ] * ?\} \} / . test ( textContent ) ) {
730+ // Insert x-cloak before the closing >
731+ result += html . slice ( lastIndex , tagCloseIdx ) + ' x-cloak>'
732+ lastIndex = tagCloseIdx + 1
733+ }
734+ }
735+
736+ result += html . slice ( lastIndex )
737+ return result
738+ }
739+
672740/**
673741 * Process STX signals in template.
674742 * Handles script setup, runtime injection, and transforms.
@@ -1911,6 +1979,11 @@ async function processOtherDirectives(
19111979 // Process expressions now (delayed to allow other directives to generate expressions)
19121980 output = await processExpressions ( output , context , filePath )
19131981
1982+ // Add x-cloak to elements containing unresolved {{ }} expressions (preserved for client-side).
1983+ // This prevents FOUC — raw mustache syntax flashing before the JS runtime processes them.
1984+ // The signals runtime removes x-cloak after binding, identical to Vue's v-cloak approach.
1985+ output = addCloakToUnresolvedExpressions ( output )
1986+
19141987 // Process reactive bindings (:class, :text, stx-class, stx-bind:class, etc.)
19151988 // This generates client-side JS for store-aware reactive updates
19161989 output = processTemplateBindings ( output )
0 commit comments