jq79 0.4.2 → 0.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "jq79",
3
- "version": "0.4.2",
3
+ "version": "0.4.4",
4
4
  "description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
5
5
  "keywords": [
6
6
  "reactive",
package/src/dom.ts CHANGED
@@ -65,12 +65,77 @@ export function isSafeUrl(value: string): boolean {
65
65
  }
66
66
  }
67
67
 
68
+ // la política de destinos: decide si un href/src ya seguro por protocolo
69
+ // puede apuntar a donde apunta. Restringe *sobre* el chequeo de protocolo,
70
+ // nunca en su lugar
71
+ export type AllowUrl = (url: URL, tag: string, attr: string) => boolean;
72
+
73
+ export type SanitizeOptions = { allowUrl?: AllowUrl };
74
+
75
+ const DEFAULT_PORTS: Record<string, string> = { 'https:': '443', 'http:': '80' };
76
+
77
+ type HostPattern = { host: RegExp; port: string | null };
78
+
79
+ // "host[:puerto]": `*` casa exactamente UNA etiqueta dns - la regla de los
80
+ // certificados TLS, no la de CSP: *.germade.dev casa a.germade.dev, pero ni
81
+ // germade.dev (escribe los dos para incluir el apex) ni a.b.germade.dev.
82
+ // Sin puerto casa cualquiera; un patrón inválido devuelve null y no casa
83
+ // nada - una política rota cierra, no abre
84
+ function compileHostPattern(pattern: string): HostPattern | null {
85
+ const match = pattern.trim().toLowerCase().match(/^([a-z\d*][a-z\d.*-]*?)(?::(\d{1,5}|\*))?$/);
86
+ if (!match) return null;
87
+ const [, host, port] = match;
88
+ const labels = host.split('.');
89
+ if (labels.some(label => label !== '*' && !/^[a-z\d-]+$/.test(label))) return null;
90
+ // las etiquetas validadas no llevan metacaracteres de regex, así que no
91
+ // hay nada que escapar; los puntos los pone el join
92
+ const re = new RegExp(`^${labels.map(label => (label === '*' ? '[^.]+' : label)).join('\\.')}$`);
93
+ return { host: re, port: !port || port === '*' ? null : port };
94
+ }
95
+
96
+ // compila una lista de patrones (string separado por comas, o array) en un
97
+ // predicado AllowUrl. El puerto comparado es el *efectivo* de la URL (el
98
+ // explícito, o el del esquema), así "germade.dev:443" casa https://germade.dev.
99
+ // Una URL sin host (mailto:) no casa ningún patrón: los patrones hablan de
100
+ // hosts - la forma función de la política puede admitirla si quiere
101
+ export const allowedHosts = (patterns: string | string[]): AllowUrl => {
102
+ const compiled = (Array.isArray(patterns) ? patterns : patterns.split(','))
103
+ .map(compileHostPattern)
104
+ .filter((p): p is HostPattern => p !== null);
105
+ return url => {
106
+ const host = url.hostname.toLowerCase();
107
+ const port = url.port || DEFAULT_PORTS[url.protocol] || '';
108
+ return compiled.some(p => p.host.test(host) && (p.port === null || p.port === port));
109
+ };
110
+ };
111
+
112
+ // consulta la política con la URL resuelta contra la página, para que una
113
+ // URL relativa se juzgue como el destino same-origin que realmente es. Un
114
+ // predicado que lanza, o una URL que no parsea, es un no
115
+ function consultAllowUrl(allowUrl: AllowUrl, value: string, tag: string, attr: string): boolean {
116
+ try {
117
+ return !!allowUrl(new URL(value, document.baseURI), tag, attr);
118
+ } catch {
119
+ return false;
120
+ }
121
+ }
122
+
123
+ // el saneado es recursivo, así que la profundidad del input es profundidad
124
+ // de pila. 512 es lo que toleran los parsers de los navegadores antes de
125
+ // aplanar el anidamiento, con lo que ningún documento legítimo pierde nada -
126
+ // y superar el límite lanza un RangeError con nombre, en vez de reventar la
127
+ // pila en algún punto indeterminado más arriba
128
+ const MAX_SANITIZE_DEPTH = 512;
129
+
68
130
  // copia los hijos de `source` en `target`, saneando los elementos y clonando
69
131
  // el texto; cualquier otra cosa (comentarios, etc.) se descarta
70
- function appendSanitizedChildren(source: ParentNode, target: HTMLElement): void {
132
+ function appendSanitizedChildren(source: ParentNode, target: HTMLElement, depth: number, allowUrl?: AllowUrl): void {
133
+ if (depth > MAX_SANITIZE_DEPTH) {
134
+ throw new RangeError(`jq79: sanitizeHTML input nests deeper than ${MAX_SANITIZE_DEPTH} elements`);
135
+ }
71
136
  for (const child of Array.from(source.childNodes)) {
72
137
  if (child.nodeType === Node.ELEMENT_NODE) {
73
- const sanitizedChild = sanitizeNode(child as HTMLElement);
138
+ const sanitizedChild = sanitizeNode(child as HTMLElement, depth, allowUrl);
74
139
  if (sanitizedChild) target.appendChild(sanitizedChild);
75
140
  } else if (child.nodeType === Node.TEXT_NODE) {
76
141
  target.appendChild(child.cloneNode());
@@ -79,7 +144,7 @@ function appendSanitizedChildren(source: ParentNode, target: HTMLElement): void
79
144
  }
80
145
 
81
146
  // sanea un elemento (los llamadores solo pasan nodos ELEMENT_NODE)
82
- function sanitizeNode(node: HTMLElement): HTMLElement | null {
147
+ function sanitizeNode(node: HTMLElement, depth: number, allowUrl?: AllowUrl): HTMLElement | null {
83
148
  const tag = node.tagName.toLowerCase();
84
149
  if (!ALLOWED_TAGS.has(tag)) return null; // tag no permitido → se descarta el nodo entero
85
150
 
@@ -91,7 +156,10 @@ function sanitizeNode(node: HTMLElement): HTMLElement | null {
91
156
  const allowedGlobal = ALLOWED_ATTR['*']?.has(name);
92
157
  if (!allowedForTag && !allowedGlobal) continue;
93
158
 
94
- if ((name === 'href' || name === 'src') && !isSafeUrl(attr.value)) continue;
159
+ if (name === 'href' || name === 'src') {
160
+ if (!isSafeUrl(attr.value)) continue;
161
+ if (allowUrl && !consultAllowUrl(allowUrl, attr.value, tag, name)) continue;
162
+ }
95
163
 
96
164
  clean.setAttribute(name, attr.value);
97
165
  }
@@ -99,16 +167,16 @@ function sanitizeNode(node: HTMLElement): HTMLElement | null {
99
167
  // fuerza rel seguro en enlaces (target nunca se copia: no está permitido)
100
168
  if (tag === 'a') clean.setAttribute('rel', 'noopener noreferrer');
101
169
 
102
- appendSanitizedChildren(node, clean);
170
+ appendSanitizedChildren(node, clean, depth + 1, allowUrl);
103
171
 
104
172
  return clean;
105
173
  }
106
174
 
107
- export function sanitizeHTML(html: string): string {
175
+ export function sanitizeHTML(html: string, options?: SanitizeOptions): string {
108
176
  const doc = new DOMParser().parseFromString(html, 'text/html');
109
177
  const container = document.createElement('div');
110
178
 
111
- appendSanitizedChildren(doc.body, container);
179
+ appendSanitizedChildren(doc.body, container, 0, options?.allowUrl);
112
180
 
113
181
  return container.innerHTML;
114
182
  }
package/src/jq79.ts CHANGED
@@ -1,5 +1,6 @@
1
1
 
2
- import { $, $$, $create, sanitizeHTML } from "./dom"
2
+ import { $, $$, $create, sanitizeHTML, allowedHosts } from "./dom"
3
+ import type { AllowUrl } from "./dom"
3
4
  import { $reactive, untracked, createEffectScope } from "./reactive"
4
5
  import type { ReactiveDeepData, EffectScope } from "./reactive"
5
6
  import { transformSetupScript, transformFactoryScript, parsePropsPattern, parseFactoryProps, type PropDecl } from "./transform"
@@ -66,7 +67,11 @@ const compileExpr = (expr: string, params: string[]): Function | null => {
66
67
  let fn = compiled.get(key)
67
68
  if (fn === undefined) {
68
69
  try {
69
- fn = new Function("$scope", ...params, `with ($scope) { return (${expr}); }`)
70
+ // the newline before `)` ends a trailing line comment in the
71
+ // expression ({{ msg // greeting }}); ASI doesn't apply inside parens,
72
+ // so everything else is untouched. Without it the comment eats the
73
+ // rest of this single-line body and the expression never compiles
74
+ fn = new Function("$scope", ...params, `with ($scope) { return (${expr}\n); }`)
70
75
  } catch {
71
76
  fn = null // a syntax error: it will never compile, so don't try again
72
77
  }
@@ -91,7 +96,7 @@ const interpolate = (template: string, scope: Record<string, any>): string =>
91
96
  template.replace(/{{\s*([\s\S]+?)\s*}}/g, (_, expr) => evalExpr(expr, scope) ?? "")
92
97
 
93
98
 
94
- const CONTROL_ATTRS = new Set([":attrs", ":class", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html"])
99
+ const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed"])
95
100
  // `item in items`, `item, i in items`, `(value, key) in props` - the second
96
101
  // binding is the array index or the object key, parens optional (Vue-style).
97
102
  // The list expression can span lines, so it matches [\s\S] rather than `.`
@@ -120,6 +125,39 @@ const bindEvent = (el: Element, attr: string, expr: string, scope: Record<string
120
125
  }, { once: mods.has("once"), capture: mods.has("capture") })
121
126
  }
122
127
 
128
+ // @event on a component tag: the tag renders as comment anchors, so there is
129
+ // no element to listen on - the attribute subscribes to the child instance's
130
+ // $emit channel (instance.on) instead, which survives the child's re-renders
131
+ // and works detached. Native DOM events from the child's inner DOM never
132
+ // arrive here: they bubble past the anchors to shared ancestors (a listener
133
+ // on a wrapping element hears those); a child that wants its native event
134
+ // heard on its tag re-emits it. .prevent flips the child's $emit() return to
135
+ // false, .stop keeps the emit off the DOM dispatch, .once unsubscribes after
136
+ // one call; .self and .capture have no meaning on this channel and are ignored
137
+ const wireTagEvent = (instance: Component79, attr: string, expr: string, scope: Record<string, any>) => {
138
+ const [name, ...modifiers] = attr.slice(1).split(".")
139
+ const mods = new Set(modifiers)
140
+
141
+ const listener = (event: CustomEvent) => {
142
+ if (mods.has("prevent")) event.preventDefault()
143
+ if (mods.has("stop")) event.stopPropagation()
144
+ if (mods.has("once")) instance.off(name, listener)
145
+
146
+ // untracked, so a tag handler behaves like an element handler no matter
147
+ // when the emit fires: an element handler never runs inside an effect,
148
+ // but $emit can (a $: that emits, a setup-script emit inside the parent's
149
+ // creation effect), and the handler's reads would land in that effect's
150
+ // deps. Today that is contained - cross-store deps are never notified,
151
+ // and the creation effect's definition guard no-ops a spurious wake - but
152
+ // "what a handler reads is nobody's dependency" shouldn't hinge on either
153
+ untracked(() => {
154
+ const handler = evalExpr(expr, scope, { $event: event })
155
+ if (typeof handler === "function") handler(event)
156
+ })
157
+ }
158
+ instance.on(name, listener)
159
+ }
160
+
123
161
  const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
124
162
 
125
163
  // the stable boundaries of a rendered chunk. An element is its own handle, but
@@ -195,11 +233,22 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
195
233
  wrapper.append(anchor, endAnchor)
196
234
 
197
235
  const props: Record<string, string> = {} // prop name -> expression in parent scope
236
+ const models: Record<string, string> = {} // model name -> assignable expression in parent scope
237
+ const events: Array<[string, string]> = [] // @attr (modifiers included) -> handler expression
198
238
  Object.entries(node.attrs).forEach(([attr, value]) => {
199
239
  // the parent's scope stamp is stamped on every template element, this tag
200
240
  // included - it's not a prop, and the child renders under its own scope
201
- if (attr === SCOPE_ATTR || CONTROL_ATTRS.has(attr) || attr.startsWith("@")) return
202
- if (attr.startsWith(":")) {
241
+ if (attr === SCOPE_ATTR || CONTROL_ATTRS.has(attr)) return
242
+ if (attr.startsWith("@")) {
243
+ events.push([attr, value])
244
+ } else if (attr === ":model" || attr.startsWith(":model.")) {
245
+ // :model[.name]="expr" - two-way: a prop down plus a writeback listener
246
+ // (wired below, once the instance exists). The modifier arrives
247
+ // lowercased from the HTML parser, so names are declared kebab-case;
248
+ // the bare :model binds the name "default"
249
+ const name = attr === ":model" ? "default" : kebabToCamel(attr.slice(":model.".length))
250
+ models[name] = value || (attr === ":model" ? "model" : name)
251
+ } else if (attr.startsWith(":")) {
203
252
  const name = kebabToCamel(attr.slice(1))
204
253
  props[name] = value || name
205
254
  } else {
@@ -207,6 +256,31 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
207
256
  }
208
257
  })
209
258
 
259
+ // each model is also a prop down: the child reads the value under the
260
+ // model's name - `model` for the default, because a prop named `default`
261
+ // could never be read from a child expression (reserved word). Without the
262
+ // prop this isn't two-way, it's upward collection: a parent reset or an
263
+ // initial value would never reach the child
264
+ const modelAttr = (name: string) => (name === "default" ? ":model" : `:model.${name}`)
265
+ const modelProp = (name: string) => (name === "default" ? "model" : name)
266
+ // the newline keeps `= $value` out of a trailing line comment in the
267
+ // expression (:model="uname // the username") - glued on the same line,
268
+ // the assignment would vanish into the comment and compile as a bare read,
269
+ // dropping every update without a word
270
+ const assignment = (expr: string) => `${expr}\n= $value`
271
+ Object.entries(models).forEach(([name, expr]) => {
272
+ const prop = modelProp(name)
273
+ if (props[prop] !== undefined) {
274
+ console.warn(`jq79: <${node.tag}> binds prop "${prop}" through both :${prop} and ${modelAttr(name)} - ${modelAttr(name)} wins`)
275
+ }
276
+ props[prop] = expr
277
+ // an expression that can't be an assignment target is a wiring mistake -
278
+ // say so now, not on the first update that silently goes nowhere
279
+ if (compileExpr(assignment(expr), ["$value"]) === null) {
280
+ console.warn(`jq79: ${modelAttr(name)}="${expr}" is not assignable - updates from <${node.tag}> will be dropped`)
281
+ }
282
+ })
283
+
210
284
  let current: Component79 | null = null
211
285
  let currentDef: Component79 | null = null
212
286
  let childFx: EffectScope | null = null
@@ -232,6 +306,46 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
232
306
  modules: nextDef.modules,
233
307
  filename: nextDef.filename,
234
308
  })
309
+ // the writeback half of :model - one event, one contract. The name is
310
+ // normalized like the attribute was (kebab->camel; absent means default),
311
+ // and everything off-contract warns and does nothing: an event protocol's
312
+ // failure mode has to be loud, or a typo'd name is an input that types
313
+ // into the void
314
+ if (Object.keys(models).length) {
315
+ // each mistake is warned once per instance, not once per keystroke: an
316
+ // input emitting a typo'd name would otherwise flood the console on
317
+ // every character typed into it
318
+ const warned = new Set<string>()
319
+ const warnOnce = (key: string, message: string) => {
320
+ if (warned.has(key)) return
321
+ warned.add(key)
322
+ console.warn(message)
323
+ }
324
+ instance.on("model:update", (_event, payload) => {
325
+ if (payload === null || typeof payload !== "object") {
326
+ warnOnce("payload", `jq79: model:update expects a { name?, value } payload, got ${payload === null ? "null" : typeof payload}`)
327
+ return
328
+ }
329
+ const name = payload.name == null ? "default" : kebabToCamel(String(payload.name))
330
+ const expr = models[name]
331
+ if (expr === undefined) {
332
+ warnOnce(name, `jq79: <${node.tag}> has no ${modelAttr(name)} - bound: ${Object.keys(models).map(modelAttr).join(", ")}`)
333
+ return
334
+ }
335
+ // untracked, like the tag handlers: a child emitting from its setup
336
+ // script runs inside the parent's *creation* effect, and the reads a
337
+ // path assignment makes (`user` in `user.name = $value`) would land
338
+ // in its deps - donating that effect one wasted (guard-stopped) wake
339
+ // per later write. An imperative writeback is nobody's dependency
340
+ untracked(() => evalExpr(assignment(expr), scope, { $value: payload.value }))
341
+ })
342
+ }
343
+
344
+ // @event on the tag listens to this instance's $emit channel (and only
345
+ // this instance's - a grandchild's emit arrives here solely as an
346
+ // explicit re-emit)
347
+ events.forEach(([attr, expr]) => wireTagEvent(instance, attr, expr, scope))
348
+
235
349
  const seed = untracked(() =>
236
350
  Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
237
351
  )
@@ -308,6 +422,26 @@ const classNames = (value: any): string[] => {
308
422
  return []
309
423
  }
310
424
 
425
+ // what :html.allowed accepts, normalized to an AllowUrl predicate: host
426
+ // patterns (a comma-separated string or an array - see allowedHosts in
427
+ // ./dom) or a function (url: URL, tag, attr) => boolean. Anything else -
428
+ // including a policy expression that evaluates to undefined - denies every
429
+ // destination: the attribute declares the intent to restrict, so a broken
430
+ // policy fails closed, and so does a predicate that throws
431
+ const normalizeAllowUrl = (policy: any): AllowUrl => {
432
+ if (typeof policy === "function") {
433
+ return (url, tag, attr) => {
434
+ try {
435
+ return !!policy(url, tag, attr)
436
+ } catch {
437
+ return false
438
+ }
439
+ }
440
+ }
441
+ if (typeof policy === "string" || Array.isArray(policy)) return allowedHosts(policy)
442
+ return () => false
443
+ }
444
+
311
445
  // renders a single element node: static attrs, @event listeners, a reactive
312
446
  // :attrs object, and its content - :text/:html override the element's own
313
447
  // children with a reactive textContent/innerHTML, otherwise children render
@@ -349,7 +483,15 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
349
483
 
350
484
  Object.entries(node.attrs).forEach(([key, value]) => {
351
485
  if (key.startsWith("@")) bindEvent(el, key, value, scope)
352
- else if (!CONTROL_ATTRS.has(key)) el.setAttribute(key, value)
486
+ else if (key === ":model" || key.startsWith(":model.")) {
487
+ // :model binds component tags only (see TODOS/2026-07-15.model-directive.md;
488
+ // the native-element form is parked there). Warn on a real element, but
489
+ // not on a tag that may still upgrade into a component - the upgrade
490
+ // re-renders through renderNestedComponent, models and all
491
+ if (!(el instanceof HTMLUnknownElement || node.tag.includes("-"))) {
492
+ console.warn(`jq79: ${key} on <${node.tag}> does nothing - :model binds component tags only (for now)`)
493
+ }
494
+ } else if (!CONTROL_ATTRS.has(key)) el.setAttribute(key, value)
353
495
  })
354
496
 
355
497
  const bindExpr = node.attrs[":attrs"]
@@ -389,17 +531,49 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
389
531
  // :text="expr" sets textContent reactively, replacing any children.
390
532
  // :html="expr" sets innerHTML reactively, sanitizing the value first so
391
533
  // untrusted content can't inject scripts/attributes (see sanitizeHTML in
392
- // ./dom). Both skip rendering the element's own children/interpolation
534
+ // ./dom). Both skip rendering the element's own children/interpolation.
535
+ // :html.allowed="expr" adds a destination policy for the content's
536
+ // href/src URLs - evaluated in the same effect, so a policy held in the
537
+ // store is as reactive as the content itself
393
538
  const textExpr = node.attrs[":text"]
394
539
  const htmlExpr = node.attrs[":html"]
540
+ const allowedExpr = node.attrs[":html.allowed"]
541
+ if (allowedExpr !== undefined && htmlExpr === undefined) {
542
+ console.warn("jq79: :html.allowed without :html on the same element does nothing")
543
+ }
395
544
  if (textExpr !== undefined) {
396
545
  fx.effect(() => { el.textContent = String(evalExpr(textExpr, scope) ?? "") })
397
546
  } else if (htmlExpr !== undefined) {
398
- fx.effect(() => { el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? "")) })
547
+ fx.effect(() => {
548
+ const options = allowedExpr !== undefined ? { allowUrl: normalizeAllowUrl(evalExpr(allowedExpr, scope)) } : undefined
549
+ el.innerHTML = sanitizeHTML(String(evalExpr(htmlExpr, scope) ?? ""), options)
550
+ })
399
551
  } else {
400
552
  el.appendChild(renderNodes(node.children, scope, fx, shadow))
401
553
  }
402
554
 
555
+ // :value / :checked / :selected write the DOM *property*, not the
556
+ // attribute - the attribute is only a form control's default, and detaches
557
+ // the moment the user interacts (which is why :attrs="{ value }" stops
558
+ // driving a typed-in input). One-way, store -> DOM: the way back stays an
559
+ // explicit @input/@change. :value skips the write when the property
560
+ // already holds the string, so an unrelated re-run can't move the caret of
561
+ // the input the user is typing into. Registered after the children render:
562
+ // :value on a <select> can only pick an <option> that already exists
563
+ const valueExpr = node.attrs[":value"]
564
+ if (valueExpr !== undefined) {
565
+ fx.effect(() => {
566
+ const value = String(evalExpr(valueExpr, scope) ?? "")
567
+ if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
568
+ })
569
+ }
570
+ ;([":checked", ":selected"] as const).forEach(attr => {
571
+ const expr = node.attrs[attr]
572
+ if (expr === undefined) return
573
+ const prop = attr.slice(1) as "checked" | "selected"
574
+ fx.effect(() => { (el as any)[prop] = !!evalExpr(expr, scope) })
575
+ })
576
+
403
577
  return el
404
578
  }
405
579
 
@@ -1197,15 +1371,22 @@ export class Component79 {
1197
1371
  // listen on any ancestor (or with @event-name on a wrapping element).
1198
1372
  // Captures the marker rather than `this` so a later re-render's scripts
1199
1373
  // can't dispatch from the wrong generation - the same guard keeps stale
1200
- // generations from reaching the instance's on() listeners
1374
+ // generations from reaching the instance's on() listeners.
1375
+ // The on() channel runs *first* (it's where @event on a component tag is
1376
+ // wired - see wireTagEvent) so its listeners can shape the DOM dispatch:
1377
+ // stopPropagation() there keeps the event off the DOM entirely, and the
1378
+ // event is cancelable so preventDefault() - from either channel - flips
1379
+ // the return to false, telling the emitting child "the parent vetoed"
1201
1380
  const marker = this.startMarker
1202
1381
  const $emit = (eventName: string, payload?: any): boolean => {
1203
- const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true })
1204
- const result = marker.dispatchEvent(event)
1382
+ const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true, cancelable: true })
1205
1383
  if (marker === this.startMarker) {
1206
1384
  this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
1207
1385
  }
1208
- return result
1386
+ // cancelBubble is the spec's legacy name, but it's the only *readable*
1387
+ // accessor for the stop-propagation flag - hence the deprecation hint
1388
+ if (!event.cancelBubble) marker.dispatchEvent(event)
1389
+ return !event.defaultPrevented
1209
1390
  }
1210
1391
 
1211
1392
  // `await $mounted()` suspends a setup script until mount() attaches the
@@ -1271,7 +1452,18 @@ export class Component79 {
1271
1452
  })
1272
1453
 
1273
1454
  const content = document.createDocumentFragment()
1274
- content.append(this.startMarker, renderNodes(this.template, store, fx, shadow), this.endMarker)
1455
+ // the template renders under a scope that also answers $emit (unless the
1456
+ // store shadows the name), so an inline handler can emit without routing
1457
+ // through a setup function: @input="$emit('update', $event.target.value)".
1458
+ // has/get only - never an own key - so Object.keys, snapshot spreads and
1459
+ // the component-key scan don't see it, and every read still forwards
1460
+ // through the reactive store, keeping dependency tracking intact
1461
+ const templateScope = new Proxy(store as Record<string, any>, {
1462
+ has: (target, key) => key === "$emit" || Reflect.has(target, key),
1463
+ get: (target, key, receiver) =>
1464
+ key === "$emit" && !Reflect.has(target, key) ? $emit : Reflect.get(target, key, receiver),
1465
+ })
1466
+ content.append(this.startMarker, renderNodes(this.template, templateScope, fx, shadow), this.endMarker)
1275
1467
  this.content = content
1276
1468
 
1277
1469
  if (shadow) {