jq79 0.6.3 → 0.6.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.6.3",
3
+ "version": "0.6.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",
@@ -67,6 +67,7 @@
67
67
  "benchmark": "node scripts/run-benchmark.mjs",
68
68
  "benchmark:ab": "node scripts/run-ab.mjs",
69
69
  "benchmark:comparison": "node scripts/run-comparison.mjs",
70
+ "benchmark:once": "node scripts/run-once-render.mjs",
70
71
  "prepublishOnly": "npm test && npm run build"
71
72
  },
72
73
  "peerDependencies": {
package/src/jq79.ts CHANGED
@@ -25,6 +25,13 @@ type TemplateNode = {
25
25
  // parse (see stampComponentTag) and lifted off attrs here, where it stops
26
26
  // looking like an attribute to every loop downstream
27
27
  component?: string
28
+ // the element's namespace, present only when it is NOT HTML - an <svg>
29
+ // subtree, or MathML. Read straight off the parsed tree, because the HTML
30
+ // parser has already run the foreign-content algorithm over it and knows
31
+ // things a tag name cannot say: whether this <title> is SVG's or HTML's, and
32
+ // where a <foreignObject> hands the namespace back. Absent is the common
33
+ // case and means HTML, so an ordinary node is exactly the shape it was
34
+ ns?: string
28
35
  }
29
36
 
30
37
  type TagBlock = {
@@ -51,17 +58,25 @@ const elementAttrs = (el: Element): Record<string, string> =>
51
58
  // they are not in the AST at all - which is where slot content is written
52
59
  // (<template :slot.name>), and why a nested <template> used to render as an
53
60
  // empty element whatever was inside it
61
+ const HTML_NS = "http://www.w3.org/1999/xhtml"
62
+
54
63
  const elementToAST = (el: Element): TemplateNode => {
55
64
  const attrs = elementAttrs(el)
65
+ // `tagName` is uppercase for HTML and as-authored for everything else, so the
66
+ // lowercasing that normalizes <DIV> would destroy <clipPath>, <linearGradient>
67
+ // and <feGaussianBlur>, whose names are case-sensitive
68
+ const ns = el.namespaceURI
56
69
  // the pre-parse stamp becomes a field and leaves attrs entirely: it is not a
57
70
  // prop, not a directive and not an attribute, and every loop that walks attrs
58
71
  // would otherwise need to know its name
59
72
  const component = attrs[COMPONENT_TAG_ATTR]
60
73
  delete attrs[COMPONENT_TAG_ATTR]
74
+ const foreign = ns !== null && ns !== HTML_NS
61
75
  return {
62
- tag: el.tagName.toLowerCase(),
76
+ tag: foreign ? el.tagName : el.tagName.toLowerCase(),
63
77
  attrs,
64
78
  ...(component === undefined ? {} : { component }),
79
+ ...(foreign ? { ns } : {}),
65
80
  children: Array.from((el instanceof HTMLTemplateElement ? el.content : el).childNodes).flatMap((node): (TemplateNode | string)[] => {
66
81
  if (node.nodeType === Node.TEXT_NODE) {
67
82
  const text = node.textContent ?? ""
@@ -1194,6 +1209,14 @@ const BOOLEAN_ATTRS = new Set([
1194
1209
  // browser doesn't have - and `readonly`/`novalidate`/`ismap` reflect under
1195
1210
  // camelCase property names no kebab->camel pass can produce, failing toward
1196
1211
  // `readonly="false"`, which is read-only
1212
+ // the one place an element is built, so the interpreted path and the cloner
1213
+ // cannot disagree about what a tag means. `ns` is set only for a foreign
1214
+ // element (see TemplateNode) - and creating one in its own namespace is what
1215
+ // makes `viewBox` keep its case, because setAttribute only lowercases a
1216
+ // qualified name on an HTML element
1217
+ const createFor = (node: TemplateNode): Element =>
1218
+ node.ns === undefined ? document.createElement(node.tag) : document.createElementNS(node.ns, node.tag)
1219
+
1197
1220
  const applyAttr = (el: Element, name: string, value: any) => {
1198
1221
  const boolean = BOOLEAN_ATTRS.has(name)
1199
1222
  if (boolean ? !value : value == null) el.removeAttribute(name)
@@ -1226,17 +1249,26 @@ const applyAttr = (el: Element, name: string, value: any) => {
1226
1249
  // Two rules keep this from becoming the bug it could be:
1227
1250
  //
1228
1251
  // 1. **The holes are an allowlist, never a denylist.** `plannableAttr` names
1229
- // the four things a skeleton knows how to fill; every other attribute makes
1230
- // the subtree unplannable. So a directive added to renderNode later is
1231
- // *slower* until somebody teaches it here - never silently mis-rendered,
1232
- // which is the failure a second render path invites.
1252
+ // what a skeleton knows how to fill; every other attribute makes the subtree
1253
+ // unplannable. So a directive added to renderNode later is *slower* until
1254
+ // somebody teaches it here - never silently mis-rendered, which is the
1255
+ // failure a second render path invites.
1256
+ //
1257
+ // The allowlist is where the one divergence found so far came from, and it
1258
+ // came from *widening* rather than from renderNode growing: `:model` is the
1259
+ // single directive renderNode treats specially that CONTROL_ATTRS does not
1260
+ // name, so it slipped through the generic `:<name>` clause. Adding to this
1261
+ // list is the dangerous edit in this file - see
1262
+ // TODOS/2026-08-24.more-holes-in-the-cloner.md.
1233
1263
  // 2. **The interpreted path stays the fallback for everything else**, including
1234
1264
  // every tag that could still turn into a component. The upgrade watch and
1235
1265
  // the unresolved-component throw are not reimplemented here; they are never
1236
1266
  // reached from here.
1237
1267
  //
1238
- // tests/skeleton.test.ts renders a corpus both ways and diffs the DOM, which is
1239
- // what makes rule 1 enforceable rather than a promise.
1268
+ // tests/skeleton.test.ts renders a corpus both ways and diffs the DOM *and the
1269
+ // order the bindings register in*, which is what makes rule 1 enforceable
1270
+ // rather than a promise. The order axis is not decoration: :value on a <select>
1271
+ // has to run after its <option>s are bound, and no DOM diff can see that.
1240
1272
  // ---------------------------------------------------------------------------
1241
1273
 
1242
1274
  // Flipping this must never change what renders, only how - which is what
@@ -1257,7 +1289,16 @@ export type DebugFlags = {
1257
1289
  cloneSkeletons: boolean
1258
1290
  }
1259
1291
 
1260
- // The four things a hole can be, in the order renderNode registers them.
1292
+ // the control attributes a skeleton knows how to fill. The rest of
1293
+ // CONTROL_ATTRS stays rejected on purpose: :if/:elseif/:else/:each/:key change
1294
+ // the shape rather than filling a hole, :with changes the scope its subtree
1295
+ // evaluates in, :props belongs to a component tag, and :html carries a
1296
+ // sanitizer plus a second attribute (:html.allowed) whose "without :html"
1297
+ // warning fires once per render interpreted and would fire once per definition
1298
+ // here - see TODOS/2026-08-24.more-holes-in-the-cloner.md
1299
+ const PLANNABLE_CONTROL_ATTRS = new Set([":text", ":attrs", ":value", ":checked", ":selected"])
1300
+
1301
+ // What a hole can be, in the order renderNode registers them.
1261
1302
  // A `:` attribute with a dot in it is rejected wholesale except `:class.`:
1262
1303
  // `:model.`, `:props.`, `:slot.` and `:html.allowed` all live in that shape, and
1263
1304
  // so would the next directive family somebody invents
@@ -1265,17 +1306,33 @@ const plannableAttr = (name: string): boolean => {
1265
1306
  if (name.startsWith("@")) return true
1266
1307
  if (name === ":class") return true
1267
1308
  if (name.startsWith(":class.")) return true
1309
+ if (PLANNABLE_CONTROL_ATTRS.has(name)) return true
1268
1310
  if (!name.startsWith(":")) return name !== COMPONENT_TAG_ATTR // a static attribute
1311
+ // `:model` is the one directive renderNode treats specially that CONTROL_ATTRS
1312
+ // does not name, so the clause below would let it through as a generic
1313
+ // `:<name>` binding and the skeleton would write `model="..."` where the
1314
+ // interpreted path warns and writes nothing (`:model` binds component tags
1315
+ // only). `:model.<name>` is caught by the dot; the bare form needs saying
1316
+ if (name === ":model") return false
1269
1317
  return !isControlAttr(name) && !name.includes(".")
1270
1318
  }
1271
1319
 
1272
1320
  const plannableNode = (node: TemplateNode): boolean => {
1273
1321
  if (node.component || node.tag.includes("-")) return false
1274
1322
  if (isSlotTag(node.tag) || node.tag === "template") return false
1275
- // an unknown tag may still become a component, and <svg> is one of them:
1276
- // createElement builds SVG names in the HTML namespace (see renderNode)
1277
- if (document.createElement(node.tag) instanceof HTMLUnknownElement) return false
1323
+ // an unknown tag may still become a component, so it stays interpreted - the
1324
+ // upgrade watch lives there and a clone cannot carry it. A *foreign* element
1325
+ // skips the test rather than failing it: <circle> is an HTMLUnknownElement
1326
+ // when built with createElement, which is exactly the mistake this used to
1327
+ // make, and nothing in an <svg> subtree can ever become a component (no SVG
1328
+ // tag is uppercase-initial, so none is ever stamped as one)
1329
+ if (node.ns === undefined && document.createElement(node.tag) instanceof HTMLUnknownElement) return false
1278
1330
  for (const key in node.attrs) if (!plannableAttr(key)) return false
1331
+ // an element with :text has no children on either path (see buildSkeleton), so
1332
+ // what the source wrote inside it cannot make the subtree unplannable - a
1333
+ // component tag under a :text is markup nobody renders, not markup the clone
1334
+ // path would get wrong
1335
+ if (node.attrs[":text"] !== undefined) return true
1279
1336
  return node.children.every(child => typeof child === "string" || plannableNode(child))
1280
1337
  }
1281
1338
 
@@ -1287,7 +1344,12 @@ type SkeletonOp =
1287
1344
  | { kind: "text"; path: number[]; parts: TextPart[] }
1288
1345
  | { kind: "event"; path: number[]; attr: string; expr: string }
1289
1346
  | { kind: "attr"; path: number[]; name: string; expr: string }
1347
+ | { kind: "attrs"; path: number[]; expr: string }
1290
1348
  | { kind: "class"; path: number[]; classExpr?: string; toggles: [string, string][] | null; staticClasses: Set<string> }
1349
+ | { kind: "textContent"; path: number[]; expr: string }
1350
+ | { kind: "value"; path: number[]; expr: string }
1351
+ | { kind: "checked"; path: number[]; expr: string }
1352
+ | { kind: "selected"; path: number[]; expr: string }
1291
1353
 
1292
1354
  type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
1293
1355
 
@@ -1296,7 +1358,7 @@ type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
1296
1358
  // registration order, so this is not cosmetic
1297
1359
  const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], tags: Set<string>): Element => {
1298
1360
  tags.add(node.tag)
1299
- const el = document.createElement(node.tag)
1361
+ const el = createFor(node)
1300
1362
 
1301
1363
  let classExpr: string | undefined
1302
1364
  let toggles: [string, string][] | null = null
@@ -1305,16 +1367,29 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1305
1367
  if (key.startsWith("@")) ops.push({ kind: "event", path, attr: key, expr: value })
1306
1368
  else if (key === ":class") classExpr = value
1307
1369
  else if (key.startsWith(":class.")) (toggles ??= []).push([key.slice(":class.".length), value])
1370
+ // a directive of its own, bound below - the same skip renderNode's walk
1371
+ // makes, and for the same reason: without it :text would be written out as
1372
+ // an attribute named `text`. :class/:class. are control attrs too and are
1373
+ // already caught above
1374
+ else if (isControlAttr(key)) { /* handled after the walk */ }
1308
1375
  else if (key.startsWith(":")) {
1309
1376
  const name = key.slice(1)
1310
1377
  ops.push({ kind: "attr", path, name, expr: value || kebabToCamel(name) })
1311
1378
  } else el.setAttribute(key, value)
1312
1379
  }
1380
+ const attrsExpr = node.attrs[":attrs"]
1381
+ if (attrsExpr !== undefined) ops.push({ kind: "attrs", path, expr: attrsExpr })
1382
+
1313
1383
  if (classExpr !== undefined || toggles) {
1314
1384
  ops.push({ kind: "class", path, classExpr, toggles, staticClasses: new Set(classNames(node.attrs.class ?? "")) })
1315
1385
  }
1316
1386
 
1317
- node.children.forEach((child, index) => {
1387
+ // :text replaces the element's content, and renderNode never renders the
1388
+ // children of an element carrying one. So the skeleton gives it none either:
1389
+ // a :text node is a leaf on both paths, whatever the source wrote inside it
1390
+ const textExpr = node.attrs[":text"]
1391
+ if (textExpr !== undefined) ops.push({ kind: "textContent", path, expr: textExpr })
1392
+ else node.children.forEach((child, index) => {
1318
1393
  if (typeof child === "string") {
1319
1394
  // an interpolated text node is a hole; the skeleton holds the empty node
1320
1395
  // it will be written into, so the child indices match either way
@@ -1327,6 +1402,18 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1327
1402
  el.appendChild(buildSkeleton(child, [...path, index], ops, tags))
1328
1403
  })
1329
1404
 
1405
+ // after the children, because renderNode registers them there and for its
1406
+ // reason: :value on a <select> can only pick an <option> that already exists.
1407
+ // `ops` is flat and in registration order, and this is the recursive call's
1408
+ // tail, so a parent's form-state ops land after every op of every descendant -
1409
+ // which is exactly what renderNode's own recursion does
1410
+ const valueExpr = node.attrs[":value"]
1411
+ if (valueExpr !== undefined) ops.push({ kind: "value", path, expr: valueExpr })
1412
+ const checkedExpr = node.attrs[":checked"]
1413
+ if (checkedExpr !== undefined) ops.push({ kind: "checked", path, expr: checkedExpr })
1414
+ const selectedExpr = node.attrs[":selected"]
1415
+ if (selectedExpr !== undefined) ops.push({ kind: "selected", path, expr: selectedExpr })
1416
+
1330
1417
  return el
1331
1418
  }
1332
1419
 
@@ -1336,15 +1423,46 @@ const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], ta
1336
1423
  // and a regression on a row whose only fragments are that small
1337
1424
  const MIN_SKELETON_ELEMENTS = 3
1338
1425
 
1426
+ // children under a :text are not built by either path, so they are not elements
1427
+ // this threshold should be counting - a <p :text="v"> with two <span>s written
1428
+ // inside it is one element's worth of cloning, not three
1339
1429
  const countElements = (node: TemplateNode): number =>
1340
- 1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
1430
+ node.attrs[":text"] !== undefined
1431
+ ? 1
1432
+ : 1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
1341
1433
 
1342
1434
  const skeletonPlans = new WeakMap<TemplateNode, SkeletonPlan | null>()
1343
1435
 
1436
+ // A definition rendered ONCE pays for a plan it never reuses: +23% at
1437
+ // MIN_SKELETON_ELEMENTS, +11.5% at six elements, measured in
1438
+ // TODOS/2026-08-24.one-shot-render-measured.md. So the plan is built on the
1439
+ // SECOND render, not the first - a one-shot definition never builds one at all,
1440
+ // and a :each of 1,000 rows interprets row 1 and clones the other 999.
1441
+ //
1442
+ // The element count could not answer this. It is a proxy for "will this be
1443
+ // rendered again", and raising it to protect the one-shot case would take a
1444
+ // 9-element list row - which amortizes beautifully - off the clone path. This
1445
+ // keys on the thing itself.
1446
+ //
1447
+ // renderEach calls renderNode per row and renderNode calls planOf, so the list
1448
+ // case needs no special handling; it falls out.
1449
+ //
1450
+ // The third state is a WeakSet rather than a sentinel in the map, so the map's
1451
+ // type keeps saying what it means: absent is "never seen", null is "examined,
1452
+ // not plannable"
1453
+ const seenOnce = new WeakSet<TemplateNode>()
1454
+
1344
1455
  const planOf = (node: TemplateNode): SkeletonPlan | null => {
1345
1456
  const cached = skeletonPlans.get(node)
1346
1457
  if (cached !== undefined) return cached
1347
1458
 
1459
+ // first sighting: interpret it, and decide nothing. Examining it here is the
1460
+ // cost the one-shot case was paying
1461
+ if (!seenOnce.has(node)) {
1462
+ seenOnce.add(node)
1463
+ return null
1464
+ }
1465
+
1348
1466
  let plan: SkeletonPlan | null = null
1349
1467
  if (plannableNode(node) && countElements(node) >= MIN_SKELETON_ELEMENTS) {
1350
1468
  const ops: SkeletonOp[] = []
@@ -1365,6 +1483,9 @@ const atPath = (root: Node, path: number[]): Node => {
1365
1483
  const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx: EffectScope): Node => {
1366
1484
  const root = plan.skeleton.cloneNode(true) as Element
1367
1485
 
1486
+ // ordered by how often a row actually carries the kind, not by when it was
1487
+ // added: this chain runs once per op per instance, so the four holes a
1488
+ // benchmark row is made of are matched before the five a form is
1368
1489
  for (const op of plan.ops) {
1369
1490
  const target = op.path.length === 0 ? root : atPath(root, op.path)
1370
1491
 
@@ -1381,7 +1502,7 @@ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx:
1381
1502
  const el = target as Element
1382
1503
  const { name, expr } = op
1383
1504
  fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
1384
- } else {
1505
+ } else if (op.kind === "class") {
1385
1506
  const el = target as Element
1386
1507
  const { classExpr, toggles, staticClasses } = op
1387
1508
  let bound: string[] = []
@@ -1396,6 +1517,44 @@ const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx:
1396
1517
  el.classList.add(...next)
1397
1518
  bound = next
1398
1519
  })
1520
+ } else if (op.kind === "attrs") {
1521
+ const el = target as Element
1522
+ const { expr } = op
1523
+ let boundKeys: string[] = []
1524
+ fx.effect(() => {
1525
+ boundKeys.forEach(key => el.removeAttribute(key))
1526
+ const bound = evalExpr(expr, scope)
1527
+ boundKeys = bound && typeof bound === "object" ? Object.keys(bound) : []
1528
+ boundKeys.forEach(key => applyAttr(el, key, bound[key]))
1529
+ })
1530
+ } else if (op.kind === "textContent") {
1531
+ const el = target as Element
1532
+ const { expr } = op
1533
+ fx.effect(() => { el.textContent = String(evalExpr(expr, scope) ?? "") })
1534
+ } else if (op.kind === "value") {
1535
+ // the property, not the attribute, and skipping a write that would not
1536
+ // change it - renderNode's reasons apply here unchanged
1537
+ const el = target as HTMLInputElement
1538
+ const { expr } = op
1539
+ fx.effect(() => {
1540
+ const value = String(evalExpr(expr, scope) ?? "")
1541
+ if (el.value !== value) el.value = value
1542
+ })
1543
+ } else if (op.kind === "checked") {
1544
+ const el = target as HTMLInputElement
1545
+ const { expr } = op
1546
+ fx.effect(() => { el.checked = !!evalExpr(expr, scope) })
1547
+ } else if (op.kind === "selected") {
1548
+ const el = target as HTMLOptionElement
1549
+ const { expr } = op
1550
+ fx.effect(() => { el.selected = !!evalExpr(expr, scope) })
1551
+ } else {
1552
+ // every kind is named above, so this is unreachable - and the assignment
1553
+ // is what makes the compiler say so. A kind added to SkeletonOp and
1554
+ // forgotten here is the exact failure this whole file is arranged to
1555
+ // prevent, and it is cheaper to catch it in tsc than in the corpus
1556
+ const unhandled: never = op
1557
+ void unhandled
1399
1558
  }
1400
1559
  }
1401
1560
 
@@ -1430,7 +1589,7 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
1430
1589
  if (plan && !plan.tags.some(tag => findComponentKey(scope, tag))) return renderFromSkeleton(plan, scope, fx)
1431
1590
  }
1432
1591
 
1433
- const el = document.createElement(node.tag)
1592
+ const el = createFor(node)
1434
1593
 
1435
1594
  // <UserCrad /> - written as a component (node.component), resolving to no
1436
1595
  // component, and not an element either. Nothing else on the page can supply