jq79 0.4.3 → 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.3",
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/jq79.ts CHANGED
@@ -67,7 +67,11 @@ const compileExpr = (expr: string, params: string[]): Function | null => {
67
67
  let fn = compiled.get(key)
68
68
  if (fn === undefined) {
69
69
  try {
70
- 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); }`)
71
75
  } catch {
72
76
  fn = null // a syntax error: it will never compile, so don't try again
73
77
  }
@@ -121,6 +125,39 @@ const bindEvent = (el: Element, attr: string, expr: string, scope: Record<string
121
125
  }, { once: mods.has("once"), capture: mods.has("capture") })
122
126
  }
123
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
+
124
161
  const kebabToCamel = (name: string) => name.replace(/-(\w)/g, (_, c: string) => c.toUpperCase())
125
162
 
126
163
  // the stable boundaries of a rendered chunk. An element is its own handle, but
@@ -196,11 +233,22 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
196
233
  wrapper.append(anchor, endAnchor)
197
234
 
198
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
199
238
  Object.entries(node.attrs).forEach(([attr, value]) => {
200
239
  // the parent's scope stamp is stamped on every template element, this tag
201
240
  // included - it's not a prop, and the child renders under its own scope
202
- if (attr === SCOPE_ATTR || CONTROL_ATTRS.has(attr) || attr.startsWith("@")) return
203
- 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(":")) {
204
252
  const name = kebabToCamel(attr.slice(1))
205
253
  props[name] = value || name
206
254
  } else {
@@ -208,6 +256,31 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
208
256
  }
209
257
  })
210
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
+
211
284
  let current: Component79 | null = null
212
285
  let currentDef: Component79 | null = null
213
286
  let childFx: EffectScope | null = null
@@ -233,6 +306,46 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
233
306
  modules: nextDef.modules,
234
307
  filename: nextDef.filename,
235
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
+
236
349
  const seed = untracked(() =>
237
350
  Object.fromEntries(Object.entries(props).map(([name, expr]) => [name, evalExpr(expr, scope)]))
238
351
  )
@@ -370,7 +483,15 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
370
483
 
371
484
  Object.entries(node.attrs).forEach(([key, value]) => {
372
485
  if (key.startsWith("@")) bindEvent(el, key, value, scope)
373
- 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)
374
495
  })
375
496
 
376
497
  const bindExpr = node.attrs[":attrs"]
@@ -1250,15 +1371,22 @@ export class Component79 {
1250
1371
  // listen on any ancestor (or with @event-name on a wrapping element).
1251
1372
  // Captures the marker rather than `this` so a later re-render's scripts
1252
1373
  // can't dispatch from the wrong generation - the same guard keeps stale
1253
- // 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"
1254
1380
  const marker = this.startMarker
1255
1381
  const $emit = (eventName: string, payload?: any): boolean => {
1256
- const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true })
1257
- const result = marker.dispatchEvent(event)
1382
+ const event = new CustomEvent(eventName, { detail: payload, bubbles: true, composed: true, cancelable: true })
1258
1383
  if (marker === this.startMarker) {
1259
1384
  this.emitListeners.get(eventName)?.forEach(listener => listener(event, payload))
1260
1385
  }
1261
- 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
1262
1390
  }
1263
1391
 
1264
1392
  // `await $mounted()` suspends a setup script until mount() attaches the
@@ -1324,7 +1452,18 @@ export class Component79 {
1324
1452
  })
1325
1453
 
1326
1454
  const content = document.createDocumentFragment()
1327
- 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)
1328
1467
  this.content = content
1329
1468
 
1330
1469
  if (shadow) {