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/dist/jq79.cjs +12 -10
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.global.js +12 -10
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +12 -10
- package/dist/jq79.js.map +1 -1
- package/package.json +1 -1
- package/src/jq79.ts +148 -9
package/package.json
CHANGED
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
|
-
|
|
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)
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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) {
|