镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit e2300dc

Browse files
committed
fix: preserve native component scope ownership
1 parent 69a2805 commit e2300dc

6 files changed

Lines changed: 360 additions & 32 deletions

File tree

‎packages/stx/src/component-library.ts‎

Lines changed: 59 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -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(/\btype\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

334335
function 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*\}\}|(data-stx-on-[\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

13381353
function 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
13501381
export 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
13561387
export 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 (/^(?:@stacksjs\/stx|stx)\/signals-api(?:\.[jt]s)?$/.test(args.path)) return { path: nativeSignals }
15891619
})
15901620
builder.onLoad({ filter: /\.ts$/ }, args => {
15911621
const component = sourceEntries.get(args.path)

‎packages/stx/src/emits-declaration.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,12 @@ function detailFromTuple(tuple: string): string {
1818
const member = members[0]!
1919
if (member.startsWith('...')) return tuple.trim()
2020
const labelled = /^(?:[A-Za-z_$][\w$]*)(\?)?\s*:\s*([\s\S]+)$/.exec(member)
21-
return labelled ? `${labelled[2]}${labelled[1] ? ' | undefined' : ''}` : member
21+
// A tuple member's ? is tuple syntax, not a standalone type operator.
22+
// Native emit dispatches [] for zero arguments and the scalar for one,
23+
// including an explicitly supplied undefined.
24+
const optional = labelled ? !!labelled[1] : member.endsWith('?')
25+
const type = labelled ? labelled[2]! : optional ? member.slice(0, -1).trim() : member
26+
return optional ? `(${type}) | undefined | []` : type
2227
}
2328

2429
export interface EmitsDeclaration {

‎packages/stx/test/types/define-emits.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,13 @@ const payload = defineEmits<{ select: { id: string }; delete: string }>()
2424
payload('select', { id: 'one' }); payload('delete', 'one')
2525
// @ts-expect-error Payload maps retain object shapes.
2626
payload('select', { id: 1 })
27+
const optional = defineEmits<{ change: [string?]; named: [value?: string] }>()
28+
optional('change'); optional('change', undefined); optional('change', 'value')
29+
optional('named'); optional('named', undefined); optional('named', 'value')
30+
// @ts-expect-error Optional argument remains typed.
31+
optional('change', 7)
32+
// @ts-expect-error Labeled optional argument remains typed.
33+
optional('named', 7)
2734
const runtime = defineEmits(['change', 'close'])
2835
runtime('change', 'value'); runtime('close')
2936
// @ts-expect-error Runtime event names are checked.

0 commit comments

Comments
 (0)