@@ -44,6 +44,7 @@ interface CompiledComponent {
4444 methods : Record < string , string >
4545 methodDeclarations : Record < string , string [ ] >
4646 declarationTypes : string
47+ declarationImports : string
4748 eventTypes : string [ ]
4849 handlers : string [ ]
4950 scopeMethods : string [ ]
@@ -251,7 +252,7 @@ export function methodDeclaration(method: ClientMethod): string {
251252 return `${ method . name } ${ method . generic } (${ parameters } ): ${ method . returns || ( method . async ? 'Promise<unknown>' : 'unknown' ) } `
252253}
253254
254- function extractClientMethods ( source : string , file : string ) : { methods : Record < string , string > , declarations : Record < string , string [ ] > , types : string } {
255+ function extractClientMethods ( source : string , file : string ) : { methods : Record < string , string > , declarations : Record < string , string [ ] > , types : string , typeImports : string } {
255256 const methods : Record < string , string > = { } , declarations : Record < string , string [ ] > = { } , types : string [ ] = [ ] , imports : Array < { binding : string , module : string } > = [ ]
256257 const scripts = scanHtmlScriptElements ( componentScriptSource ( source ) ) . map ( script => ( {
257258 code : source . slice ( script . bodyStart , script . bodyEnd ) ,
@@ -328,7 +329,7 @@ function extractClientMethods(source: string, file: string): { methods: Record<s
328329 if ( imported . module . startsWith ( '.' ) || path . isAbsolute ( imported . module ) ) throw new Error ( `${ file } : public method types import ${ JSON . stringify ( imported . module ) } outside the generated package; declare these types in the component or import them from a published dependency` )
329330 }
330331 const typeImports = [ ...new Set ( usedImports . map ( imported => `import type ${ imported . binding . replace ( / \b t y p e \s + / g, '' ) } from ${ JSON . stringify ( imported . module ) } ` ) ) ]
331- return { methods, declarations, types : [ ... typeImports , ... types ] . join ( '\n\n' ) }
332+ return { methods, declarations, types : types . join ( '\n\n' ) , typeImports : typeImports . join ( ' \n') }
332333}
333334
334335function extractTemplate ( source : string ) : string {
@@ -451,7 +452,7 @@ export function parseNativeComponentLoop(expression: string): { iterable: string
451452 return pair ? { iterable : pair [ 1 ] ! , key : pair [ 2 ] , value : pair [ 3 ] ! } : undefined
452453}
453454
454- function compileTemplateToRender ( template : string ) : string {
455+ function compileTemplateToRender ( template : string , handlers : string [ ] ) : string {
455456 const parts : string [ ] = [ ]
456457 let cursor = 0
457458
@@ -482,15 +483,17 @@ function compileTemplateToRender(template: string): string {
482483 // Interpolation splits the literal, so the pieces around it are emitted as
483484 // string constants and the expressions as real JavaScript.
484485 let index = 0
485- const interpolation = / \{ ! ! \s * ( [ \s \S ] * ?) \s * ! ! \} | \{ \{ \s * ( [ \s \S ] * ?) \s * \} \} / g
486+ const interpolation = / \{ ! ! \s * ( [ \s \S ] * ?) \s * ! ! \} | \{ \{ \s * ( [ \s \S ] * ?) \s * \} \} | ( d a t a - s t x - o n - [ \w : - ] + = " ) ( \d + ) ( " ) / g
486487 let hit : RegExpExecArray | null
487488 while ( ( hit = interpolation . exec ( raw ) ) ) {
488489 if ( hit . index > index ) parts . push ( `out += ${ JSON . stringify ( raw . slice ( index , hit . index ) ) } ;` )
489490 // `{!! !!}` is the author saying "this is markup". `{{ }}` is the author
490491 // saying "this is a value", and a value is escaped, always.
491- parts . push ( hit [ 1 ] !== undefined
492- ? `out += __raw(${ hit [ 1 ] } );`
493- : `out += __escape(${ hit [ 2 ] } );` )
492+ parts . push ( hit [ 3 ] !== undefined
493+ ? `out += ${ JSON . stringify ( hit [ 3 ] ) } + (this._handlers.push(${ handlers [ Number ( hit [ 4 ] ) ] } ) - 1) + ${ JSON . stringify ( hit [ 5 ] ) } ;`
494+ : hit [ 1 ] !== undefined
495+ ? `out += __raw(${ hit [ 1 ] } );`
496+ : `out += __escape(${ hit [ 2 ] } );` )
494497 index = hit . index + hit [ 0 ] . length
495498 }
496499 if ( index < raw . length ) parts . push ( `out += ${ JSON . stringify ( raw . slice ( index ) ) } ;` )
@@ -787,7 +790,7 @@ async function compileComponent(
787790 scope . imports . push ( ...hoistScriptImports ( source . slice ( script . bodyStart , script . bodyEnd ) ) . imports )
788791 }
789792 scope . imports = [ ...new Set ( scope . imports ) ]
790- const render = compileTemplateToRender ( bindingCompilation . template )
793+ const render = compileTemplateToRender ( bindingCompilation . template , eventCompilation . handlers )
791794
792795 // A prop declared with defineProps is part of the element's public surface,
793796 // so it becomes a real property here. Without this the component compiles
@@ -823,6 +826,7 @@ async function compileComponent(
823826 methods : { ...metadataMethods , ...configuredMethods , ...extractedMethods . methods } ,
824827 methodDeclarations : extractedMethods . declarations ,
825828 declarationTypes : extractedMethods . types ,
829+ declarationImports : extractedMethods . typeImports ,
826830 eventTypes : eventCompilation . eventTypes ,
827831 handlers : eventCompilation . handlers ,
828832 scopeMethods : Object . keys ( extractedMethods . methods ) ,
@@ -905,6 +909,7 @@ export class StxElement extends HTMLElementBase {
905909 this._scopeSubscriptions = [];
906910 this._scopeMounts = [];
907911 this._scopeDestroys = [];
912+ this._scopeConnectionDestroys = [];
908913 this._scopeMountCleanups = [];
909914 const definition = this.constructor.definition;
910915 for (const [name, property] of Object.entries(definition.properties || {})) {
@@ -963,8 +968,10 @@ export class StxElement extends HTMLElementBase {
963968 catch (error) { console.error('[stx] onDisconnect failed', error); }
964969 finally {
965970 this._disposeScopeEffects();
966- for (const dispose of this._scopeSubscriptions.splice(0)) dispose?.();
967- for (const cleanup of [...this._scopeDestroys, ...this._scopeMountCleanups.splice(0)]) {
971+ for (const dispose of this._scopeSubscriptions.splice(0)) {
972+ try { dispose?.(); } catch (error) { console.error('[stx] scope subscription cleanup failed', error); }
973+ }
974+ for (const cleanup of [...this._scopeDestroys.splice(0), ...this._scopeConnectionDestroys.splice(0), ...this._scopeMountCleanups.splice(0)]) {
968975 try { cleanup(); } catch (error) { console.error('[stx] scope cleanup failed', error); }
969976 }
970977 for (const [event, listener] of this._listeners) this.renderRoot.removeEventListener(event, listener);
@@ -1016,7 +1023,9 @@ export class StxElement extends HTMLElementBase {
10161023 }
10171024
10181025 _disposeScopeEffects() {
1019- for (const dispose of this._scopeEffects.splice(0)) dispose();
1026+ for (const dispose of this._scopeEffects.splice(0)) {
1027+ try { dispose(); } catch (error) { console.error('[stx] effect cleanup failed', error); }
1028+ }
10201029 }
10211030
10221031 _runScopeMounts() {
@@ -1031,7 +1040,9 @@ ${signals ? ` _beginScope() {
10311040 this._scopeMounts = [];
10321041 this._scopeDestroys = [];
10331042 let stateIndex = 0, derivedIndex = 0;
1043+ let rendering = true;
10341044 return {
1045+ finish: () => { rendering = false; },
10351046 state: (initial) => {
10361047 const index = stateIndex++;
10371048 const value = this._scopeStates[index] ||= nativeState(initial);
@@ -1058,7 +1069,7 @@ ${signals ? ` _beginScope() {
10581069 },
10591070 batch: nativeBatch,
10601071 onMount: callback => this._scopeMounts.push(callback),
1061- onDestroy: callback => this._scopeDestroys.push(callback),
1072+ onDestroy: callback => (rendering ? this._scopeDestroys : this._scopeConnectionDestroys) .push(callback),
10621073 };
10631074 }
10641075
@@ -1253,6 +1264,9 @@ function componentModule(component: CompiledComponent, runtimeImport = './runtim
12531264 } ) . join ( '\n\n' )
12541265
12551266 const scope = component . scope
1267+ const localNames = new Set ( new Tokenizer ( [ scope . body , ...scope . imports , ...Object . keys ( component . properties ) , ...Object . values ( scope . propBindings ) ] . join ( '\n' ) ) . tokenize ( ) . map ( token => token . value ) )
1268+ let scopeOwner = '__stxScope'
1269+ while ( localNames . has ( scopeOwner ) ) scopeOwner += '$'
12561270
12571271 // Every declared property, not only the ones defineProps named. A component
12581272 // may declare its surface in script metadata instead, and those templates
@@ -1275,13 +1289,14 @@ function componentModule(component: CompiledComponent, runtimeImport = './runtim
12751289
12761290 if ( component . render . trim ( ) !== '' || scope . body !== '' || destructured !== '' ) {
12771291 const inner = [
1278- scope . apis . length ? `const { ${ scope . apis . join ( ', ' ) } } = this._beginScope() ;` : '' ,
1292+ scope . apis . length ? `const ${ scopeOwner } = this._beginScope();\nconst { ${ scope . apis . join ( ', ' ) } } = ${ scopeOwner } ;` : '' ,
12791293 destructured ? `const { ${ destructured } } = this._props();` : '' ,
12801294 scope . body ,
12811295 component . scopeMethods . length ? `this._scopeMethods = { ${ component . scopeMethods . join ( ', ' ) } };` : '' ,
1282- component . handlers . length ? ` this._handlers = [${ component . handlers . join ( ', ' ) } ];` : '' ,
1296+ component . handlers . length ? ' this._handlers = [];' : '' ,
12831297 `let out = '';` ,
12841298 component . render ,
1299+ scope . apis . length ? `${ scopeOwner } .finish();` : '' ,
12851300 'return out;' ,
12861301 ] . filter ( Boolean ) . join ( '\n' )
12871302
@@ -1336,25 +1351,41 @@ export function typeForProperty(property: ComponentLibraryProperty): string {
13361351}
13371352
13381353function componentDeclaration ( component : CompiledComponent ) : string {
1354+ // Authored support types keep their names in a private scope. In particular,
1355+ // a tuple map named FooEventMap must never merge with the DOM FooEventMap.
1356+ const authoredTypes = [ component . declarationTypes , component . declarationImports , component . name , ...Object . values ( component . methodDeclarations ) . flat ( ) , ...Object . values ( component . events ) . map ( event => event . detailType ) ] . join ( '\n' )
1357+ const identifiers = new Set ( new Tokenizer ( authoredTypes ) . tokenize ( ) . map ( token => token . value ) )
1358+ const unique = ( base : string ) : string => { let name = base ; while ( identifiers . has ( name ) ) name += '$' ; identifiers . add ( name ) ; return name }
1359+ const support = unique ( '__StxSupport' ) , publicMethods = unique ( '__Methods' ) , eventDetails = unique ( '__Details' )
1360+ const scoped = ! ! component . declarationTypes
13391361 const props = Object . entries ( component . properties ) . map ( ( [ name , property ] ) =>
13401362 ` ${ name } ${ property . required ? '' : '?' } : ${ typeForProperty ( property ) } ` ,
13411363 ) . join ( '\n' )
13421364 const events = Object . entries ( component . events ) . map ( ( [ name , event ] ) =>
1343- ` ${ JSON . stringify ( name ) } : CustomEvent<${ event . detailType || 'unknown' } >` ) . join ( '\n' )
1365+ ` ${ JSON . stringify ( name ) } : CustomEvent<${ scoped ? ` ${ support } . ${ eventDetails } [ ${ JSON . stringify ( name ) } ]` : event . detailType || 'unknown' } >` ) . join ( '\n' )
13441366 const methods = Object . keys ( component . methods ) . flatMap ( name => component . methodDeclarations [ name ] || [ `${ name } ($event?: Event): unknown` ] ) . map ( signature => ` ${ signature } ` ) . join ( '\n' )
13451367
1346- return `${ component . declarationTypes ? `${ component . declarationTypes } \n\n` : '' } export interface ${ component . name } Props {
1368+ const supportDeclaration = scoped ? `declare namespace ${ support } {
1369+ ${ component . declarationTypes }
1370+ export interface ${ publicMethods } {
1371+ ${ methods }
1372+ }
1373+ export interface ${ eventDetails } {
1374+ ${ Object . entries ( component . events ) . map ( ( [ name , event ] ) => ` ${ JSON . stringify ( name ) } : ${ event . detailType || 'unknown' } ` ) . join ( '\n' ) }
1375+ }
1376+ }\n\n` : ''
1377+ return `${ component . declarationImports ? component . declarationImports + '\n\n' : '' } ${ supportDeclaration } export interface ${ component . name } Props {
13471378${ props }
13481379}
13491380
13501381export interface ${ component . name } EventMap {
13511382${ events }
13521383}
13531384
1354- export interface ${ component . name } extends Required<Pick<${ component . name } Props, Exclude<keyof ${ component . name } Props, keyof HTMLElement>>> {}
1385+ export interface ${ component . name } extends Required<Pick<${ component . name } Props, Exclude<keyof ${ component . name } Props, keyof HTMLElement>>>${ scoped ? `, ${ support } . ${ publicMethods } ` : '' } {}
13551386
13561387export declare class ${ component . name } extends HTMLElement {
1357- ${ methods }
1388+ ${ scoped ? '' : methods }
13581389 addEventListener<K extends keyof ${ component . name } EventMap>(type: K, listener: (this: ${ component . name } , event: ${ component . name } EventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void
13591390 addEventListener<K extends keyof HTMLElementEventMap>(type: K, listener: (this: HTMLElement, event: HTMLElementEventMap[K]) => unknown, options?: boolean | AddEventListenerOptions): void
13601391 addEventListener(type: string, listener: EventListenerOrEventListenerObject | null, options?: boolean | AddEventListenerOptions): void
@@ -1539,15 +1570,9 @@ export async function buildComponentLibrary(config: ComponentLibraryConfig): Pro
15391570 }
15401571
15411572 const signals = components . some ( component => component . scope . apis . length )
1542- if ( signals ) {
1543- // Bundle the native primitives once. Generated elements need no ambient
1544- // runtime and share the same dependency tracker as their emitted helpers.
1545- const extension = import . meta. url . endsWith ( '.ts' ) ? '.ts' : '.js'
1546- const nativeSignals = new URL ( `./signals-api${ extension } ` , import . meta. url ) . pathname
1547- const built = await Bun . build ( { entrypoints : [ nativeSignals ] , target : 'browser' , format : 'esm' , minify : true } )
1548- if ( ! built . success ) throw new Error ( `Failed to bundle native component signals: ${ built . logs . map ( String ) . join ( '\n' ) } ` )
1549- await emit ( 'signals.js' , await built . outputs [ 0 ] ! . text ( ) )
1550- }
1573+ const extension = import . meta. url . endsWith ( '.ts' ) ? '.ts' : '.js'
1574+ const nativeSignals = new URL ( `./signals-api${ extension } ` , import . meta. url ) . pathname
1575+ if ( signals ) await emit ( 'signals.js' , `export * from ${ JSON . stringify ( nativeSignals ) } ;` )
15511576 await emit ( 'runtime.js' , runtimeModule ( signals ) )
15521577 for ( const component of components ) {
15531578 await emit ( `${ component . tag } .js` , componentModule ( component ) )
@@ -1562,7 +1587,7 @@ export async function buildComponentLibrary(config: ComponentLibraryConfig): Pro
15621587 // when the component has no static declarations. Scan compiled browser code
15631588 // so server scripts, type syntax and import-looking text cannot activate it.
15641589 const importScanner = new Bun . Transpiler ( { loader : 'ts' } )
1565- if ( components . some ( component => component . scope . imports . length || importScanner . scanImports ( componentModule ( component ) ) . some ( imported => imported . kind === 'dynamic-import' ) ) ) {
1590+ if ( signals || components . some ( component => component . scope . imports . length || importScanner . scanImports ( componentModule ( component ) ) . some ( imported => imported . kind === 'dynamic-import' ) ) ) {
15661591 // Resolve TS/npm/transitive browser dependencies before distribution. A
15671592 // single split build preserves shared module identity between elements;
15681593 // separately bundling each entry would duplicate imported stores/effects.
@@ -1571,6 +1596,8 @@ export async function buildComponentLibrary(config: ComponentLibraryConfig): Pro
15711596 for ( const [ index , file ] of entrypoints . entries ( ) ) canonicalEntries . set ( file , components [ index ] ! )
15721597 const sourceEntries = new Map ( components . map ( component => [ path . join ( path . dirname ( component . sourcePath ) , `.stx-library-${ component . tag } .ts` ) , component ] ) )
15731598 const runtimePath = path . join ( outputDir , 'runtime.js' )
1599+ entrypoints . push ( runtimePath )
1600+ if ( signals ) entrypoints . push ( path . join ( outputDir , 'signals.js' ) )
15741601 // Bun resolves from its module identity (onLoad has no resolveDir option).
15751602 // Give each generated entry an identity beside its authored component,
15761603 // without writing there. Bun then applies the source package's browser
@@ -1585,7 +1612,10 @@ export async function buildComponentLibrary(config: ComponentLibraryConfig): Pro
15851612 if ( args . path === 'bun' || args . path . startsWith ( 'bun:' ) || args . path . startsWith ( 'node:' ) || SERVER_MODULES . has ( args . path ) ) throw new Error ( `Server-only import ${ JSON . stringify ( args . path ) } is not available in a browser component (${ args . importer } )` )
15861613 const entry = canonicalEntries . get ( args . path )
15871614 if ( ! args . importer && entry ) return { path : path . join ( path . dirname ( entry . sourcePath ) , `.stx-library-${ entry . tag } .ts` ) }
1588- if ( args . path === runtimePath && sourceEntries . has ( args . importer ) ) return { path : './runtime.js' , external : true }
1615+ if ( args . path === runtimePath && sourceEntries . has ( args . importer ) ) return { path : runtimePath }
1616+ // Authored module helpers and element scope primitives participate in
1617+ // this same split build, using the owner's canonical signals module.
1618+ if ( / ^ (?: @ s t a c k s j s \/ s t x | s t x ) \/ s i g n a l s - a p i (?: \. [ j t ] s ) ? $ / . test ( args . path ) ) return { path : nativeSignals }
15891619 } )
15901620 builder . onLoad ( { filter : / \. t s $ / } , args => {
15911621 const component = sourceEntries . get ( args . path )
0 commit comments