jq79 0.6.1 → 0.6.3
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 +13 -13
- package/dist/jq79.cjs.map +1 -1
- package/dist/jq79.d.ts +4 -0
- package/dist/jq79.global.js +13 -13
- package/dist/jq79.global.js.map +1 -1
- package/dist/jq79.js +13 -13
- package/dist/jq79.js.map +1 -1
- package/package.json +2 -1
- package/src/jq79.ts +574 -59
- package/src/reactive.ts +169 -38
package/src/jq79.ts
CHANGED
|
@@ -267,8 +267,49 @@ const evalHandler = (expr: string, scope: Record<string, any>, extras: Record<st
|
|
|
267
267
|
|
|
268
268
|
// [\s\S] rather than `.` so an expression can span lines, like the ones in
|
|
269
269
|
// directive attributes (which reach evalExpr wrapped in parens either way)
|
|
270
|
-
const
|
|
271
|
-
|
|
270
|
+
const INTERPOLATION_RE = /{{\s*([\s\S]+?)\s*}}/g
|
|
271
|
+
|
|
272
|
+
// A text template split once into its literal and expression parts. The split
|
|
273
|
+
// used to happen on every run of every instance - `String.replace` over the
|
|
274
|
+
// whole text, a fresh match object and a callback per expression - and a
|
|
275
|
+
// :each over 1,000 rows runs it 1,000 times per text node to reach the same
|
|
276
|
+
// answer about the same string. Keyed by the template text, like compileExpr's
|
|
277
|
+
// cache and bounded the same way: by how many distinct texts the source holds
|
|
278
|
+
//
|
|
279
|
+
// An expression part is boxed so a literal `"x"` and an expression `x` stay
|
|
280
|
+
// distinguishable without a second array
|
|
281
|
+
type TextPart = string | { expr: string }
|
|
282
|
+
|
|
283
|
+
const textParts = new Map<string, TextPart[]>()
|
|
284
|
+
|
|
285
|
+
const splitText = (template: string): TextPart[] => {
|
|
286
|
+
const cached = textParts.get(template)
|
|
287
|
+
if (cached) return cached
|
|
288
|
+
const parts: TextPart[] = []
|
|
289
|
+
let at = 0
|
|
290
|
+
INTERPOLATION_RE.lastIndex = 0
|
|
291
|
+
for (let match = INTERPOLATION_RE.exec(template); match; match = INTERPOLATION_RE.exec(template)) {
|
|
292
|
+
if (match.index > at) parts.push(template.slice(at, match.index))
|
|
293
|
+
parts.push({ expr: match[1] })
|
|
294
|
+
at = match.index + match[0].length
|
|
295
|
+
}
|
|
296
|
+
if (at < template.length) parts.push(template.slice(at))
|
|
297
|
+
textParts.set(template, parts)
|
|
298
|
+
return parts
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// what an interpolated text node renders to, from the parts. `?? ""` on each
|
|
302
|
+
// expression, and String() over the join, is what template.replace did: a
|
|
303
|
+
// nullish value contributes nothing and everything else is coerced
|
|
304
|
+
const renderText = (parts: TextPart[], scope: Record<string, any>): string => {
|
|
305
|
+
if (parts.length === 1) {
|
|
306
|
+
const only = parts[0]
|
|
307
|
+
return typeof only === "string" ? only : String(evalExpr(only.expr, scope) ?? "")
|
|
308
|
+
}
|
|
309
|
+
let out = ""
|
|
310
|
+
for (const part of parts) out += typeof part === "string" ? part : String(evalExpr(part.expr, scope) ?? "")
|
|
311
|
+
return out
|
|
312
|
+
}
|
|
272
313
|
|
|
273
314
|
|
|
274
315
|
const CONTROL_ATTRS = new Set([":attrs", ":class", ":value", ":checked", ":selected", ":if", ":elseif", ":else", ":each", ":key", ":with", ":text", ":html", ":html.allowed", ":props"])
|
|
@@ -378,16 +419,16 @@ const removeRange = ({ first, last }: NodeRange) => {
|
|
|
378
419
|
// single span: unlinking 10,000 rows one at a time is 40% of that operation,
|
|
379
420
|
// profiled - see TODOS/2026-08-23.batch-range-removal.md. Runs are built by the
|
|
380
421
|
// caller, which is the only place that knows what else is going
|
|
381
|
-
const removeRuns = (runs: NodeRange[]
|
|
422
|
+
const removeRuns = (runs: NodeRange[]) => {
|
|
382
423
|
runs.forEach(run => {
|
|
383
|
-
if (run.
|
|
384
|
-
const parent = run
|
|
424
|
+
if (run.first === run.last) return removeRange(run)
|
|
425
|
+
const parent = run.first.parentNode
|
|
385
426
|
if (!parent) return
|
|
386
427
|
// both ends sit between nodes, so nothing is partially selected and whole
|
|
387
428
|
// nodes are what gets unlinked
|
|
388
429
|
const range = document.createRange()
|
|
389
|
-
range.setStartBefore(run
|
|
390
|
-
range.setEndAfter(run
|
|
430
|
+
range.setStartBefore(run.first)
|
|
431
|
+
range.setEndAfter(run.last)
|
|
391
432
|
range.deleteContents()
|
|
392
433
|
})
|
|
393
434
|
}
|
|
@@ -396,16 +437,19 @@ const removeRuns = (runs: NodeRange[][]) => {
|
|
|
396
437
|
// `ordered` (which is in DOM order). Adjacency is confirmed rather than assumed:
|
|
397
438
|
// a gap - an entry removed earlier in the same pass - starts a new run, so a
|
|
398
439
|
// live entry can never end up inside one
|
|
399
|
-
const contiguousRuns = <T extends { range: NodeRange }>(ordered: T[], isDead: (entry: T) => boolean): NodeRange[]
|
|
400
|
-
const runs: NodeRange[]
|
|
401
|
-
let run: NodeRange
|
|
440
|
+
const contiguousRuns = <T extends { range: NodeRange }>(ordered: T[], isDead: (entry: T) => boolean): NodeRange[] => {
|
|
441
|
+
const runs: NodeRange[] = []
|
|
442
|
+
let run: NodeRange | null = null
|
|
402
443
|
ordered.forEach(entry => {
|
|
403
444
|
if (!isDead(entry)) {
|
|
404
445
|
run = null
|
|
405
446
|
return
|
|
406
447
|
}
|
|
407
|
-
|
|
408
|
-
|
|
448
|
+
// a run is a span, not the list of ranges inside it: only its two ends are
|
|
449
|
+
// ever read, and a list dropping 10,000 rows was building an array of
|
|
450
|
+
// 10,000 entries to hand over two of them
|
|
451
|
+
if (run && run.last.nextSibling === entry.range.first) run.last = entry.range.last
|
|
452
|
+
else runs.push((run = { first: entry.range.first, last: entry.range.last }))
|
|
409
453
|
})
|
|
410
454
|
return runs
|
|
411
455
|
}
|
|
@@ -458,6 +502,18 @@ const scanComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
458
502
|
let tagMemo: Map<string, string | null> | null = null
|
|
459
503
|
let memoBase: object | null = null
|
|
460
504
|
|
|
505
|
+
// Scope objects known to declare no PascalCase key of their own, so the walk
|
|
506
|
+
// below can skip them without calling Object.keys - which allocates an array
|
|
507
|
+
// and scans it, per element, per row. An :each item scope holds `item`,
|
|
508
|
+
// `$index` and maybe the `, at` name, and whether any of those can be a
|
|
509
|
+
// component name is decided by the template, once (see EachPlan).
|
|
510
|
+
//
|
|
511
|
+
// Only scopes jq79 creates and never adds a key to go in here. A store must
|
|
512
|
+
// never: a setup script's `const Row = await $import(...)` arrives as a new key
|
|
513
|
+
// after the template has already rendered, which is the whole reason the tag
|
|
514
|
+
// memo lives for exactly one pass
|
|
515
|
+
const plainScopes = new WeakSet<object>()
|
|
516
|
+
|
|
461
517
|
// opened and closed by hand rather than by a wrapper taking a callback: a
|
|
462
518
|
// component that renders itself through :each stacks one renderEach per level,
|
|
463
519
|
// and a callback would add a frame to each of them. The cyclic-component test
|
|
@@ -488,6 +544,7 @@ const findComponentKey = (scope: Record<string, any>, tag: string): string | nul
|
|
|
488
544
|
tagMemo.set(tag, key)
|
|
489
545
|
return key
|
|
490
546
|
}
|
|
547
|
+
if (plainScopes.has(obj)) continue
|
|
491
548
|
for (const key of Object.keys(obj)) {
|
|
492
549
|
if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
|
|
493
550
|
}
|
|
@@ -1149,6 +1206,202 @@ const applyAttr = (el: Element, name: string, value: any) => {
|
|
|
1149
1206
|
// normally. :if/:elseif/:else/:each are handled by renderNodes, which decides
|
|
1150
1207
|
// *whether*/*how many times* a node is rendered before calling this. Tags
|
|
1151
1208
|
// matching a PascalCase scope variable render as nested components instead
|
|
1209
|
+
|
|
1210
|
+
// ---------------------------------------------------------------------------
|
|
1211
|
+
// Cloning a fixed shape instead of deriving it per instance.
|
|
1212
|
+
//
|
|
1213
|
+
// renderNode asks the same questions of the same AST node for every instance of
|
|
1214
|
+
// it: is this a slot, a component, an unknown tag; which of these attributes is
|
|
1215
|
+
// a directive; split this text on `{{`. For a :each of 1,000 rows that is ~25
|
|
1216
|
+
// questions per element per row whose answers were fixed by the source text.
|
|
1217
|
+
// Where a subtree's *shape* is fixed - the elements, their static attributes and
|
|
1218
|
+
// their nesting never vary, only the values bound into them - the shape is built
|
|
1219
|
+
// once per definition into a detached skeleton, and each instance is one
|
|
1220
|
+
// cloneNode plus a walk to each binding point.
|
|
1221
|
+
//
|
|
1222
|
+
// Worth -20 to -49% of create1k depending on how much fixed structure a row
|
|
1223
|
+
// has, and nothing at all on a row that has none. Measured, with the method and
|
|
1224
|
+
// the caveats, in TODOS/2026-08-24.clone-skeletons-measured.md.
|
|
1225
|
+
//
|
|
1226
|
+
// Two rules keep this from becoming the bug it could be:
|
|
1227
|
+
//
|
|
1228
|
+
// 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.
|
|
1233
|
+
// 2. **The interpreted path stays the fallback for everything else**, including
|
|
1234
|
+
// every tag that could still turn into a component. The upgrade watch and
|
|
1235
|
+
// the unresolved-component throw are not reimplemented here; they are never
|
|
1236
|
+
// reached from here.
|
|
1237
|
+
//
|
|
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.
|
|
1240
|
+
// ---------------------------------------------------------------------------
|
|
1241
|
+
|
|
1242
|
+
// Flipping this must never change what renders, only how - which is what
|
|
1243
|
+
// tests/skeleton.test.ts exists to keep true. It is on, and switchable through
|
|
1244
|
+
// `Component79.debug({ cloneSkeletons: false })`, because a second render path
|
|
1245
|
+
// is the kind of change that wants an off switch a user can reach without a
|
|
1246
|
+
// rebuild: a page that renders wrong is a bug report either way, but one whose
|
|
1247
|
+
// reporter can say "it goes away with cloning off" is a bug report that names
|
|
1248
|
+
// the file
|
|
1249
|
+
const debugFlags: DebugFlags = { cloneSkeletons: true }
|
|
1250
|
+
|
|
1251
|
+
// What `Component79.debug()` can switch. One flag today; the shape is an object
|
|
1252
|
+
// so the next one does not change the call
|
|
1253
|
+
export type DebugFlags = {
|
|
1254
|
+
// build a fixed-shape subtree by cloning a skeleton made once per definition,
|
|
1255
|
+
// instead of walking the AST for every instance of it. Off means every
|
|
1256
|
+
// element goes through renderNode, exactly as before this existed
|
|
1257
|
+
cloneSkeletons: boolean
|
|
1258
|
+
}
|
|
1259
|
+
|
|
1260
|
+
// The four things a hole can be, in the order renderNode registers them.
|
|
1261
|
+
// A `:` attribute with a dot in it is rejected wholesale except `:class.`:
|
|
1262
|
+
// `:model.`, `:props.`, `:slot.` and `:html.allowed` all live in that shape, and
|
|
1263
|
+
// so would the next directive family somebody invents
|
|
1264
|
+
const plannableAttr = (name: string): boolean => {
|
|
1265
|
+
if (name.startsWith("@")) return true
|
|
1266
|
+
if (name === ":class") return true
|
|
1267
|
+
if (name.startsWith(":class.")) return true
|
|
1268
|
+
if (!name.startsWith(":")) return name !== COMPONENT_TAG_ATTR // a static attribute
|
|
1269
|
+
return !isControlAttr(name) && !name.includes(".")
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
const plannableNode = (node: TemplateNode): boolean => {
|
|
1273
|
+
if (node.component || node.tag.includes("-")) return false
|
|
1274
|
+
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
|
|
1278
|
+
for (const key in node.attrs) if (!plannableAttr(key)) return false
|
|
1279
|
+
return node.children.every(child => typeof child === "string" || plannableNode(child))
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// A hole, and the path from the skeleton root to the node it fills: child
|
|
1283
|
+
// indices rather than a query, resolved by walking childNodes. The AST keeps
|
|
1284
|
+
// whitespace text nodes on purpose, and the skeleton keeps them too, so the
|
|
1285
|
+
// indices line up on both sides by construction
|
|
1286
|
+
type SkeletonOp =
|
|
1287
|
+
| { kind: "text"; path: number[]; parts: TextPart[] }
|
|
1288
|
+
| { kind: "event"; path: number[]; attr: string; expr: string }
|
|
1289
|
+
| { kind: "attr"; path: number[]; name: string; expr: string }
|
|
1290
|
+
| { kind: "class"; path: number[]; classExpr?: string; toggles: [string, string][] | null; staticClasses: Set<string> }
|
|
1291
|
+
|
|
1292
|
+
type SkeletonPlan = { skeleton: Element; ops: SkeletonOp[]; tags: string[] }
|
|
1293
|
+
|
|
1294
|
+
// mirrors renderNode's own order: the attribute walk (events and attribute
|
|
1295
|
+
// bindings as they appear), then :class, then the children. Effects run in
|
|
1296
|
+
// registration order, so this is not cosmetic
|
|
1297
|
+
const buildSkeleton = (node: TemplateNode, path: number[], ops: SkeletonOp[], tags: Set<string>): Element => {
|
|
1298
|
+
tags.add(node.tag)
|
|
1299
|
+
const el = document.createElement(node.tag)
|
|
1300
|
+
|
|
1301
|
+
let classExpr: string | undefined
|
|
1302
|
+
let toggles: [string, string][] | null = null
|
|
1303
|
+
for (const key in node.attrs) {
|
|
1304
|
+
const value = node.attrs[key]
|
|
1305
|
+
if (key.startsWith("@")) ops.push({ kind: "event", path, attr: key, expr: value })
|
|
1306
|
+
else if (key === ":class") classExpr = value
|
|
1307
|
+
else if (key.startsWith(":class.")) (toggles ??= []).push([key.slice(":class.".length), value])
|
|
1308
|
+
else if (key.startsWith(":")) {
|
|
1309
|
+
const name = key.slice(1)
|
|
1310
|
+
ops.push({ kind: "attr", path, name, expr: value || kebabToCamel(name) })
|
|
1311
|
+
} else el.setAttribute(key, value)
|
|
1312
|
+
}
|
|
1313
|
+
if (classExpr !== undefined || toggles) {
|
|
1314
|
+
ops.push({ kind: "class", path, classExpr, toggles, staticClasses: new Set(classNames(node.attrs.class ?? "")) })
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
node.children.forEach((child, index) => {
|
|
1318
|
+
if (typeof child === "string") {
|
|
1319
|
+
// an interpolated text node is a hole; the skeleton holds the empty node
|
|
1320
|
+
// it will be written into, so the child indices match either way
|
|
1321
|
+
if (child.includes("{{")) {
|
|
1322
|
+
ops.push({ kind: "text", path: [...path, index], parts: splitText(child) })
|
|
1323
|
+
el.appendChild(document.createTextNode(""))
|
|
1324
|
+
} else el.appendChild(document.createTextNode(child))
|
|
1325
|
+
return
|
|
1326
|
+
}
|
|
1327
|
+
el.appendChild(buildSkeleton(child, [...path, index], ops, tags))
|
|
1328
|
+
})
|
|
1329
|
+
|
|
1330
|
+
return el
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
// how many elements a subtree is worth cloning for. Below this the fixed cost
|
|
1334
|
+
// of the plan - the lookup, the tag check, the path walks - is the whole
|
|
1335
|
+
// saving: planning fragments of one or two elements measured as a wash at best
|
|
1336
|
+
// and a regression on a row whose only fragments are that small
|
|
1337
|
+
const MIN_SKELETON_ELEMENTS = 3
|
|
1338
|
+
|
|
1339
|
+
const countElements = (node: TemplateNode): number =>
|
|
1340
|
+
1 + node.children.reduce((total, child) => total + (typeof child === "string" ? 0 : countElements(child)), 0)
|
|
1341
|
+
|
|
1342
|
+
const skeletonPlans = new WeakMap<TemplateNode, SkeletonPlan | null>()
|
|
1343
|
+
|
|
1344
|
+
const planOf = (node: TemplateNode): SkeletonPlan | null => {
|
|
1345
|
+
const cached = skeletonPlans.get(node)
|
|
1346
|
+
if (cached !== undefined) return cached
|
|
1347
|
+
|
|
1348
|
+
let plan: SkeletonPlan | null = null
|
|
1349
|
+
if (plannableNode(node) && countElements(node) >= MIN_SKELETON_ELEMENTS) {
|
|
1350
|
+
const ops: SkeletonOp[] = []
|
|
1351
|
+
const tags = new Set<string>()
|
|
1352
|
+
const skeleton = buildSkeleton(node, [], ops, tags)
|
|
1353
|
+
plan = { skeleton, ops, tags: Array.from(tags) }
|
|
1354
|
+
}
|
|
1355
|
+
skeletonPlans.set(node, plan)
|
|
1356
|
+
return plan
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
const atPath = (root: Node, path: number[]): Node => {
|
|
1360
|
+
let at = root
|
|
1361
|
+
for (let i = 0; i < path.length; i++) at = at.childNodes[path[i]]
|
|
1362
|
+
return at
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
const renderFromSkeleton = (plan: SkeletonPlan, scope: Record<string, any>, fx: EffectScope): Node => {
|
|
1366
|
+
const root = plan.skeleton.cloneNode(true) as Element
|
|
1367
|
+
|
|
1368
|
+
for (const op of plan.ops) {
|
|
1369
|
+
const target = op.path.length === 0 ? root : atPath(root, op.path)
|
|
1370
|
+
|
|
1371
|
+
if (op.kind === "text") {
|
|
1372
|
+
const textNode = target as Text
|
|
1373
|
+
const parts = op.parts
|
|
1374
|
+
fx.effect(() => {
|
|
1375
|
+
const text = renderText(parts, scope)
|
|
1376
|
+
if (textNode.textContent !== text) textNode.textContent = text
|
|
1377
|
+
})
|
|
1378
|
+
} else if (op.kind === "event") {
|
|
1379
|
+
bindEvent(target as Element, op.attr, op.expr, scope)
|
|
1380
|
+
} else if (op.kind === "attr") {
|
|
1381
|
+
const el = target as Element
|
|
1382
|
+
const { name, expr } = op
|
|
1383
|
+
fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
|
|
1384
|
+
} else {
|
|
1385
|
+
const el = target as Element
|
|
1386
|
+
const { classExpr, toggles, staticClasses } = op
|
|
1387
|
+
let bound: string[] = []
|
|
1388
|
+
fx.effect(() => {
|
|
1389
|
+
const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
|
|
1390
|
+
toggles?.forEach(([name, expr]) => {
|
|
1391
|
+
if (evalExpr(expr, scope)) next.push(...classNames(name))
|
|
1392
|
+
})
|
|
1393
|
+
bound.forEach(name => {
|
|
1394
|
+
if (!next.includes(name) && !staticClasses.has(name)) el.classList.remove(name)
|
|
1395
|
+
})
|
|
1396
|
+
el.classList.add(...next)
|
|
1397
|
+
bound = next
|
|
1398
|
+
})
|
|
1399
|
+
}
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
return root
|
|
1403
|
+
}
|
|
1404
|
+
|
|
1152
1405
|
const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
1153
1406
|
// :with applies to the element's own bindings (@events, :attrs) and its
|
|
1154
1407
|
// whole subtree. On a :each element the item scope is already in place, so
|
|
@@ -1165,6 +1418,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1165
1418
|
const componentKey = findComponentKey(scope, node.tag)
|
|
1166
1419
|
if (componentKey) return renderNestedComponent(componentKey, node, scope, fx, shadow)
|
|
1167
1420
|
|
|
1421
|
+
// A planned subtree is cloned - unless a scope key captures one of its tags.
|
|
1422
|
+
// findComponentKey strips dashes and lowercases, and every PascalCase scope
|
|
1423
|
+
// key participates, so a variable named `Td` makes every <td> under it a
|
|
1424
|
+
// component and `Map`, `Data`, `Table`, `Form` and `Label` are all HTML tags
|
|
1425
|
+
// somebody might name a component after. "It is a known HTML tag" is not on
|
|
1426
|
+
// its own an answer; this is. It costs what the interpreted path already
|
|
1427
|
+
// pays - one findComponentKey per distinct tag, memoized per render pass
|
|
1428
|
+
if (debugFlags.cloneSkeletons) {
|
|
1429
|
+
const plan = planOf(node)
|
|
1430
|
+
if (plan && !plan.tags.some(tag => findComponentKey(scope, tag))) return renderFromSkeleton(plan, scope, fx)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1168
1433
|
const el = document.createElement(node.tag)
|
|
1169
1434
|
|
|
1170
1435
|
// <UserCrad /> - written as a component (node.component), resolving to no
|
|
@@ -1218,7 +1483,14 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1218
1483
|
})
|
|
1219
1484
|
}
|
|
1220
1485
|
|
|
1221
|
-
Object.entries(
|
|
1486
|
+
// walked with `for...in` rather than Object.entries().forEach: a 1,000-row
|
|
1487
|
+
// :each renders every element of its template a thousand times, and the
|
|
1488
|
+
// entries form allocates one array of pairs plus one two-element array per
|
|
1489
|
+
// attribute *per instance*. Nothing here reads the pairs as pairs, so the
|
|
1490
|
+
// allocation buys nothing and the garbage it makes is measurable - see
|
|
1491
|
+
// TODOS/2026-08-23.where-the-create-time-goes.md
|
|
1492
|
+
for (const key in node.attrs) {
|
|
1493
|
+
const value = node.attrs[key]
|
|
1222
1494
|
if (key.startsWith("@")) bindEvent(el, key, value, scope)
|
|
1223
1495
|
else if (key === ":model" || key.startsWith(":model.")) {
|
|
1224
1496
|
// :model binds component tags only (see TODOS/2026-07-15.model-directive.md;
|
|
@@ -1249,7 +1521,7 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1249
1521
|
fx.effect(() => applyAttr(el, name, evalExpr(expr, scope)))
|
|
1250
1522
|
}
|
|
1251
1523
|
} else el.setAttribute(key, value)
|
|
1252
|
-
}
|
|
1524
|
+
}
|
|
1253
1525
|
|
|
1254
1526
|
const bindExpr = node.attrs[":attrs"]
|
|
1255
1527
|
if (bindExpr !== undefined) {
|
|
@@ -1271,17 +1543,23 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1271
1543
|
// static list survives every re-run, even when the expression names one of
|
|
1272
1544
|
// its classes and then drops it (class="btn" :class="{ btn: cond }" keeps
|
|
1273
1545
|
// btn on false)
|
|
1546
|
+
//
|
|
1547
|
+
// The toggle list stays null until a `:class.` attribute is actually found,
|
|
1548
|
+
// for the reason the attribute walk above is a `for...in`: entries + filter +
|
|
1549
|
+
// map allocated three arrays for every element rendered, and the
|
|
1550
|
+
// overwhelming majority of elements carry no `:class.` at all
|
|
1274
1551
|
const classExpr = node.attrs[":class"]
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
.
|
|
1278
|
-
|
|
1552
|
+
let classToggles: [string, string][] | null = null
|
|
1553
|
+
for (const key in node.attrs) {
|
|
1554
|
+
if (key.startsWith(":class.")) (classToggles ??= []).push([key.slice(":class.".length), node.attrs[key]])
|
|
1555
|
+
}
|
|
1556
|
+
if (classExpr !== undefined || classToggles) {
|
|
1279
1557
|
const staticClasses = new Set(classNames(node.attrs.class ?? ""))
|
|
1280
1558
|
let bound: string[] = []
|
|
1281
1559
|
|
|
1282
1560
|
fx.effect(() => {
|
|
1283
1561
|
const next = classExpr !== undefined ? classNames(evalExpr(classExpr, scope)) : []
|
|
1284
|
-
classToggles
|
|
1562
|
+
classToggles?.forEach(([name, expr]) => {
|
|
1285
1563
|
if (evalExpr(expr, scope)) next.push(...classNames(name))
|
|
1286
1564
|
})
|
|
1287
1565
|
bound.forEach(name => {
|
|
@@ -1338,12 +1616,18 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1338
1616
|
if ((el as HTMLInputElement).value !== value) (el as HTMLInputElement).value = value
|
|
1339
1617
|
})
|
|
1340
1618
|
}
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1619
|
+
// written out rather than looped over a literal array: the loop allocated the
|
|
1620
|
+
// array *and* its closure for every element rendered - 8,000 of each per
|
|
1621
|
+
// create1k, almost all of them to find nothing. Same reason the attribute
|
|
1622
|
+
// walk above is a `for...in` (TODOS/2026-08-23.where-the-create-time-goes.md)
|
|
1623
|
+
const checkedExpr = node.attrs[":checked"]
|
|
1624
|
+
if (checkedExpr !== undefined) {
|
|
1625
|
+
fx.effect(() => { (el as HTMLInputElement).checked = !!evalExpr(checkedExpr, scope) })
|
|
1626
|
+
}
|
|
1627
|
+
const selectedExpr = node.attrs[":selected"]
|
|
1628
|
+
if (selectedExpr !== undefined) {
|
|
1629
|
+
fx.effect(() => { (el as HTMLOptionElement).selected = !!evalExpr(selectedExpr, scope) })
|
|
1630
|
+
}
|
|
1347
1631
|
|
|
1348
1632
|
return el
|
|
1349
1633
|
}
|
|
@@ -1404,7 +1688,10 @@ const defineScopeVar = (scope: Record<string, any>, key: string, value: any) =>
|
|
|
1404
1688
|
// `pos` is where the entry sat in the previous pass, refreshed as the buckets
|
|
1405
1689
|
// are built - the positioning pass needs the old order to work out which rows
|
|
1406
1690
|
// are already where they belong (see longestIncreasingRun)
|
|
1407
|
-
|
|
1691
|
+
// `dead` is set by the pass that disposes the entry, and read by the run walk
|
|
1692
|
+
// right after: a Set membership test per row was the alternative, and a list
|
|
1693
|
+
// dropping 10,000 rows does 10,000 of them
|
|
1694
|
+
type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope; pos: number; dead?: boolean }
|
|
1408
1695
|
|
|
1409
1696
|
// the indices of one longest strictly increasing subsequence of `positions`,
|
|
1410
1697
|
// as a flag per index. Fed the old position of every entry in the new order
|
|
@@ -1496,10 +1783,37 @@ const identifierIn = (text: string, name: string): boolean => {
|
|
|
1496
1783
|
return false
|
|
1497
1784
|
}
|
|
1498
1785
|
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1786
|
+
// Everything renderEach reads off the template and nothing else: the parsed
|
|
1787
|
+
// clause, how the key is read, the item node, and whether anything in the
|
|
1788
|
+
// subtree names a position. All of it is fixed by the source, and none of it
|
|
1789
|
+
// was cached - renderEach runs once per render of its parent, which for a
|
|
1790
|
+
// :each nested inside another is once per row of the outer one. `mentionsAny`
|
|
1791
|
+
// walks the whole item subtree, so that was a subtree walk per row per pass
|
|
1792
|
+
type EachPlan = {
|
|
1793
|
+
itemName: string
|
|
1794
|
+
atName: string | undefined
|
|
1795
|
+
listExpr: string
|
|
1796
|
+
keyExpr: string | undefined
|
|
1797
|
+
keyIsItem: boolean
|
|
1798
|
+
keyProp: string | undefined
|
|
1799
|
+
itemNode: TemplateNode
|
|
1800
|
+
readsPosition: boolean
|
|
1801
|
+
// can either loop name be mistaken for a component? Decided from the
|
|
1802
|
+
// template, so the item scope can be marked plain without being scanned
|
|
1803
|
+
namesComponent: boolean
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1806
|
+
const eachPlans = new WeakMap<TemplateNode, EachPlan | null>()
|
|
1807
|
+
|
|
1808
|
+
const eachPlanOf = (node: TemplateNode): EachPlan | null => {
|
|
1809
|
+
const cached = eachPlans.get(node)
|
|
1810
|
+
if (cached !== undefined) return cached
|
|
1502
1811
|
|
|
1812
|
+
const match = node.attrs[":each"].match(EACH_PATTERN)
|
|
1813
|
+
if (!match) {
|
|
1814
|
+
eachPlans.set(node, null)
|
|
1815
|
+
return null
|
|
1816
|
+
}
|
|
1503
1817
|
const [, itemName, atName, listExpr] = match
|
|
1504
1818
|
const keyExpr = node.attrs[":key"]
|
|
1505
1819
|
|
|
@@ -1532,6 +1846,28 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1532
1846
|
const positionalNames = ["$index", ...(atName ? [atName] : [])]
|
|
1533
1847
|
const readsPosition = mentionsAny(itemNode, positionalNames)
|
|
1534
1848
|
|
|
1849
|
+
const namesComponent = /^[A-Z]/.test(itemName) || (atName !== undefined && /^[A-Z]/.test(atName))
|
|
1850
|
+
const plan: EachPlan = { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent }
|
|
1851
|
+
eachPlans.set(node, plan)
|
|
1852
|
+
return plan
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
1856
|
+
const plan = eachPlanOf(node)
|
|
1857
|
+
if (!plan) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
|
|
1858
|
+
|
|
1859
|
+
const { itemName, atName, listExpr, keyExpr, keyIsItem, keyProp, itemNode, readsPosition, namesComponent } = plan
|
|
1860
|
+
|
|
1861
|
+
// `:key="row.id"`, or the loop variable itself, is what a key almost always
|
|
1862
|
+
// is - and reading one needs neither a scope to resolve names against nor a
|
|
1863
|
+
// compiled expression, because the item is already in hand. A pass evaluates
|
|
1864
|
+
// one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
|
|
1865
|
+
// the store proxy to discover that nothing had changed: most of the 37% of a
|
|
1866
|
+
// pass that goes on evaluating expressions
|
|
1867
|
+
// (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
|
|
1868
|
+
// an index, a deeper path, a name from the outer scope - still goes through
|
|
1869
|
+
// evalExpr, and so does a non-object item, which keeps every diagnostic a
|
|
1870
|
+
// property read of a null row would have raised
|
|
1535
1871
|
const anchor = document.createComment("each")
|
|
1536
1872
|
const wrapper = document.createDocumentFragment()
|
|
1537
1873
|
wrapper.appendChild(anchor)
|
|
@@ -1647,6 +1983,8 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1647
1983
|
defineScopeVar(itemScope, itemName, item)
|
|
1648
1984
|
if (atName) defineScopeVar(itemScope, atName, at)
|
|
1649
1985
|
defineScopeVar(itemScope, "$index", index)
|
|
1986
|
+
// one WeakSet write per row against one Object.keys per element in it
|
|
1987
|
+
if (!namesComponent) plainScopes.add(itemScope)
|
|
1650
1988
|
const itemFx = createEffectScope(scope)
|
|
1651
1989
|
// bounds captured before the positioning pass inserts the entry: a
|
|
1652
1990
|
// component entry is a fragment, which empties on insertion (see boundsOf)
|
|
@@ -1661,8 +1999,11 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1661
1999
|
const dead = new Set<EachEntry>()
|
|
1662
2000
|
previous.forEach(bucket => bucket.forEach(entry => dead.add(entry)))
|
|
1663
2001
|
if (dead.size) {
|
|
1664
|
-
dead.forEach(entry =>
|
|
1665
|
-
|
|
2002
|
+
dead.forEach(entry => {
|
|
2003
|
+
entry.dead = true
|
|
2004
|
+
entry.fx.dispose()
|
|
2005
|
+
})
|
|
2006
|
+
removeRuns(contiguousRuns(entries, entry => entry.dead === true))
|
|
1666
2007
|
}
|
|
1667
2008
|
|
|
1668
2009
|
let prevNode: Node = anchor
|
|
@@ -1690,6 +2031,168 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1690
2031
|
return wrapper
|
|
1691
2032
|
}
|
|
1692
2033
|
|
|
2034
|
+
// the first sibling from `from` that is not indentation. The branches of a
|
|
2035
|
+
// chain are written on their own lines, so whitespace-only text sits between
|
|
2036
|
+
// them in the AST and must not break the chain up. One copy of the rule, used
|
|
2037
|
+
// by the renderer that groups a chain and by the validator that checks its
|
|
2038
|
+
// grammar - the two can never disagree about what "adjacent" means
|
|
2039
|
+
const nextSiblingAt = (nodes: (TemplateNode | string)[], from: number): number => {
|
|
2040
|
+
let at = from
|
|
2041
|
+
while (at < nodes.length && typeof nodes[at] === "string" && !(nodes[at] as string).trim()) at++
|
|
2042
|
+
return at
|
|
2043
|
+
}
|
|
2044
|
+
|
|
2045
|
+
// The grammar of a conditional chain: `:if`, then any number of `:elseif`,
|
|
2046
|
+
// then an optional `:else`, on adjacent sibling elements. Both ways of getting
|
|
2047
|
+
// it wrong render *something*, which is why they need saying out loud:
|
|
2048
|
+
//
|
|
2049
|
+
// - two of the three on one element: the first in precedence order applies and
|
|
2050
|
+
// the rest are control attrs, so they are silently dropped
|
|
2051
|
+
// - a branch no chain claimed - no `:if` before it, or one separated from it by
|
|
2052
|
+
// an element (a `:each` row, a component tag, any sibling that isn't
|
|
2053
|
+
// whitespace): it falls through to renderNode, where `:elseif`/`:else` are
|
|
2054
|
+
// control attrs skipped by the attribute walk, and the element renders
|
|
2055
|
+
// **unconditionally**. That one had no diagnostic at all
|
|
2056
|
+
//
|
|
2057
|
+
// Reported once per definition, from the parse-time walk below
|
|
2058
|
+
const warnChainAttrs = (node: TemplateNode) => {
|
|
2059
|
+
const hasIf = ":if" in node.attrs
|
|
2060
|
+
const hasElseif = ":elseif" in node.attrs
|
|
2061
|
+
const hasElse = ":else" in node.attrs
|
|
2062
|
+
if ((hasIf ? 1 : 0) + (hasElseif ? 1 : 0) + (hasElse ? 1 : 0) < 2) return
|
|
2063
|
+
// allocated only on the way to a warning, never on the path that finds none
|
|
2064
|
+
const present = [hasIf ? ":if" : null, hasElseif ? ":elseif" : null, hasElse ? ":else" : null].filter(Boolean)
|
|
2065
|
+
console.warn(
|
|
2066
|
+
`jq79: ${present.join(" and ")} on the same <${node.tag}> - only ${present[0]} applies; ` +
|
|
2067
|
+
"the branches of a chain are sibling elements, one directive each"
|
|
2068
|
+
)
|
|
2069
|
+
}
|
|
2070
|
+
|
|
2071
|
+
// `afterClosedChain`: the branch chain immediately before this node ended with
|
|
2072
|
+
// an `:else`, so this is a *second* one rather than a stray - which is the
|
|
2073
|
+
// difference between a useful message and a puzzling one ("continues no :if"
|
|
2074
|
+
// reads as nonsense when there is an :if two lines up)
|
|
2075
|
+
const warnOrphanBranch = (node: TemplateNode, afterClosedChain: boolean) => {
|
|
2076
|
+
const attr = ":elseif" in node.attrs ? ":elseif" : ":else"
|
|
2077
|
+
console.warn(
|
|
2078
|
+
afterClosedChain
|
|
2079
|
+
? `jq79: a second ${attr} on <${node.tag}> - the chain before it already ended with :else, ` +
|
|
2080
|
+
"which closes it. One :if, any number of :elseif, at most one :else"
|
|
2081
|
+
: `jq79: ${attr} on <${node.tag}> continues no :if - it renders unconditionally. ` +
|
|
2082
|
+
"A chain is :if, then :elseif, then :else, on adjacent siblings: anything but whitespace between them breaks it"
|
|
2083
|
+
)
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
// Checks one node list's chains, and every list below it, against that grammar.
|
|
2087
|
+
// Run once per definition from componentPartsFrom, not per render: a template
|
|
2088
|
+
// says what it says before any data exists, so a stray :else is reported when
|
|
2089
|
+
// the component is defined - once, whatever the list it sits in later renders
|
|
2090
|
+
// a thousand rows of, and even if it sits in a branch that never becomes
|
|
2091
|
+
// active. Rendering is left alone entirely; nothing below costs an instance
|
|
2092
|
+
// anything.
|
|
2093
|
+
//
|
|
2094
|
+
// The dispatch mirrors renderNodes' loop, because that is what decides which
|
|
2095
|
+
// node ends up a branch of what: a :each node is claimed before the chain
|
|
2096
|
+
// grouping ever sees it, which is exactly why it breaks a chain
|
|
2097
|
+
const validateChains = (nodes: (TemplateNode | string)[]) => {
|
|
2098
|
+
nodes.forEach(node => {
|
|
2099
|
+
if (typeof node !== "string") validateChains(node.children)
|
|
2100
|
+
})
|
|
2101
|
+
|
|
2102
|
+
// the chain that ended immediately before this point closed itself with an
|
|
2103
|
+
// :else, so a further branch here is a second one rather than a stray
|
|
2104
|
+
let afterClosedChain = false
|
|
2105
|
+
|
|
2106
|
+
for (let i = 0; i < nodes.length; ) {
|
|
2107
|
+
const node = nodes[i]
|
|
2108
|
+
|
|
2109
|
+
if (typeof node === "string") {
|
|
2110
|
+
if (node.trim()) afterClosedChain = false
|
|
2111
|
+
i++
|
|
2112
|
+
continue
|
|
2113
|
+
}
|
|
2114
|
+
|
|
2115
|
+
// renderEach speaks for a :each element carrying a branch attribute of its
|
|
2116
|
+
// own, and says something more useful than the grammar would
|
|
2117
|
+
if (":each" in node.attrs) {
|
|
2118
|
+
afterClosedChain = false
|
|
2119
|
+
i++
|
|
2120
|
+
continue
|
|
2121
|
+
}
|
|
2122
|
+
|
|
2123
|
+
warnChainAttrs(node)
|
|
2124
|
+
|
|
2125
|
+
if (":if" in node.attrs) {
|
|
2126
|
+
i++
|
|
2127
|
+
// the same walk renderNodes does, so the nodes claimed here are the ones
|
|
2128
|
+
// it will claim: any number of :elseif, then at most one :else
|
|
2129
|
+
const claim = (attr: string): TemplateNode | undefined => {
|
|
2130
|
+
const next = nextSiblingAt(nodes, i)
|
|
2131
|
+
const candidate = nodes[next]
|
|
2132
|
+
if (typeof candidate === "object" && attr in candidate.attrs) {
|
|
2133
|
+
i = next + 1
|
|
2134
|
+
return candidate
|
|
2135
|
+
}
|
|
2136
|
+
return undefined
|
|
2137
|
+
}
|
|
2138
|
+
// a claimed branch never reaches the check above - `:elseif :else` on one
|
|
2139
|
+
// element is claimed as an :elseif and its :else dropped, in silence
|
|
2140
|
+
for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) warnChainAttrs(elseif)
|
|
2141
|
+
const elseNode = claim(":else")
|
|
2142
|
+
if (elseNode) warnChainAttrs(elseNode)
|
|
2143
|
+
// an :else closes the chain. A chain that ended without one cannot be
|
|
2144
|
+
// followed by a stray at all - claim() would have taken it
|
|
2145
|
+
afterClosedChain = elseNode !== undefined
|
|
2146
|
+
continue
|
|
2147
|
+
}
|
|
2148
|
+
|
|
2149
|
+
// no chain claimed this node, so a branch attribute on it is an orphan and
|
|
2150
|
+
// the element renders unconditionally
|
|
2151
|
+
if (":elseif" in node.attrs || ":else" in node.attrs) warnOrphanBranch(node, afterClosedChain)
|
|
2152
|
+
afterClosedChain = false
|
|
2153
|
+
i++
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
|
|
2157
|
+
// The chain a `:if` node heads, and the index the sibling walk resumes at.
|
|
2158
|
+
// Both are fixed by the template - the node list is the same array on every
|
|
2159
|
+
// render - and renderNodes runs per instance, so a chain inside a :each row was
|
|
2160
|
+
// re-grouped, and its two arrays re-allocated, once per row per pass.
|
|
2161
|
+
// renderConditional only reads the branches, so one array serves every instance
|
|
2162
|
+
type Chain = { branches: ConditionalBranch[]; next: number }
|
|
2163
|
+
|
|
2164
|
+
const chains = new WeakMap<TemplateNode, Chain>()
|
|
2165
|
+
|
|
2166
|
+
const chainOf = (nodes: (TemplateNode | string)[], node: TemplateNode, from: number): Chain => {
|
|
2167
|
+
const cached = chains.get(node)
|
|
2168
|
+
if (cached) return cached
|
|
2169
|
+
|
|
2170
|
+
const branches: ConditionalBranch[] = [{ expr: node.attrs[":if"], node }]
|
|
2171
|
+
let at = from + 1
|
|
2172
|
+
// the whitespace between the branches is indentation and nothing else, so it
|
|
2173
|
+
// is skipped rather than rendered (nextSiblingAt): only one branch is ever in
|
|
2174
|
+
// the DOM, so there is nothing for it to be a space *between*
|
|
2175
|
+
const claim = (attr: string): TemplateNode | undefined => {
|
|
2176
|
+
const next = nextSiblingAt(nodes, at)
|
|
2177
|
+
const candidate = nodes[next]
|
|
2178
|
+
if (typeof candidate === "object" && attr in candidate.attrs) {
|
|
2179
|
+
at = next + 1
|
|
2180
|
+
return candidate
|
|
2181
|
+
}
|
|
2182
|
+
return undefined
|
|
2183
|
+
}
|
|
2184
|
+
|
|
2185
|
+
for (let elseif = claim(":elseif"); elseif; elseif = claim(":elseif")) {
|
|
2186
|
+
branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
|
|
2187
|
+
}
|
|
2188
|
+
const elseNode = claim(":else")
|
|
2189
|
+
if (elseNode) branches.push({ node: elseNode })
|
|
2190
|
+
|
|
2191
|
+
const chain: Chain = { branches, next: at }
|
|
2192
|
+
chains.set(node, chain)
|
|
2193
|
+
return chain
|
|
2194
|
+
}
|
|
2195
|
+
|
|
1693
2196
|
// renders a list of sibling template nodes (text + elements), grouping
|
|
1694
2197
|
// consecutive :if/:elseif/:else nodes into a single conditional block
|
|
1695
2198
|
// `into` renders straight into an element that is not in the document yet -
|
|
@@ -1716,7 +2219,17 @@ const renderNodes = <T extends ParentNode>(
|
|
|
1716
2219
|
const textNode = document.createTextNode(node)
|
|
1717
2220
|
// static text is most of a template (all of its indentation, for a start):
|
|
1718
2221
|
// only text with a {{ expression }} in it needs an effect to stay in sync
|
|
1719
|
-
|
|
2222
|
+
// the write is guarded: an effect woken by a sibling's change (one entry
|
|
2223
|
+
// of a :each refreshed on a move, a grouped notification) recomputes the
|
|
2224
|
+
// same string it already wrote, and assigning it back is a DOM mutation
|
|
2225
|
+
// the browser has to take seriously
|
|
2226
|
+
if (node.includes("{{")) {
|
|
2227
|
+
const parts = splitText(node)
|
|
2228
|
+
fx.effect(() => {
|
|
2229
|
+
const text = renderText(parts, scope)
|
|
2230
|
+
if (textNode.textContent !== text) textNode.textContent = text
|
|
2231
|
+
})
|
|
2232
|
+
}
|
|
1720
2233
|
fragment.appendChild(textNode)
|
|
1721
2234
|
i++
|
|
1722
2235
|
continue
|
|
@@ -1729,31 +2242,9 @@ const renderNodes = <T extends ParentNode>(
|
|
|
1729
2242
|
}
|
|
1730
2243
|
|
|
1731
2244
|
if (":if" in node.attrs) {
|
|
1732
|
-
const
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
// the branches of a chain are siblings in the AST, but the template writes
|
|
1736
|
-
// them on their own lines - so the whitespace between them is indentation
|
|
1737
|
-
// and nothing else, and it's dropped rather than rendered: only one branch
|
|
1738
|
-
// is ever in the DOM, so there is nothing for it to be a space *between*
|
|
1739
|
-
const nextBranch = (attr: string): TemplateNode | undefined => {
|
|
1740
|
-
let next = i
|
|
1741
|
-
while (next < nodes.length && typeof nodes[next] === "string" && !(nodes[next] as string).trim()) next++
|
|
1742
|
-
const candidate = nodes[next]
|
|
1743
|
-
if (typeof candidate === "object" && attr in candidate.attrs) {
|
|
1744
|
-
i = next + 1
|
|
1745
|
-
return candidate
|
|
1746
|
-
}
|
|
1747
|
-
return undefined
|
|
1748
|
-
}
|
|
1749
|
-
|
|
1750
|
-
for (let elseif = nextBranch(":elseif"); elseif; elseif = nextBranch(":elseif")) {
|
|
1751
|
-
branches.push({ expr: elseif.attrs[":elseif"], node: elseif })
|
|
1752
|
-
}
|
|
1753
|
-
const elseNode = nextBranch(":else")
|
|
1754
|
-
if (elseNode) branches.push({ node: elseNode })
|
|
1755
|
-
|
|
1756
|
-
fragment.appendChild(renderConditional(branches, scope, fx, shadow))
|
|
2245
|
+
const chain = chainOf(nodes, node, i)
|
|
2246
|
+
fragment.appendChild(renderConditional(chain.branches, scope, fx, shadow))
|
|
2247
|
+
i = chain.next
|
|
1757
2248
|
continue
|
|
1758
2249
|
}
|
|
1759
2250
|
|
|
@@ -2121,6 +2612,10 @@ const componentPartsFrom = (elements: Element[], hashSource: string): ComponentP
|
|
|
2121
2612
|
})
|
|
2122
2613
|
}
|
|
2123
2614
|
|
|
2615
|
+
// the template says what it says before any data exists, so its conditional
|
|
2616
|
+
// chains are checked here, once per definition
|
|
2617
|
+
validateChains(template)
|
|
2618
|
+
|
|
2124
2619
|
return { template, scripts, styles }
|
|
2125
2620
|
}
|
|
2126
2621
|
|
|
@@ -2811,6 +3306,26 @@ export class Component79 {
|
|
|
2811
3306
|
//
|
|
2812
3307
|
// Component79.fetch("./app.html").mount("main")
|
|
2813
3308
|
// const app = await Component79.fetch("./app.html")
|
|
3309
|
+
// Reads the debug flags, and sets the ones it is given:
|
|
3310
|
+
//
|
|
3311
|
+
// Component79.debug() // what is on right now
|
|
3312
|
+
// Component79.debug({ cloneSkeletons: false }) // turn one off
|
|
3313
|
+
//
|
|
3314
|
+
// Returns the flags as they stand after the call, so a caller can put them
|
|
3315
|
+
// back. Global to the module, not per component: these switch how the
|
|
3316
|
+
// renderer works, and a page rendering two ways at once is the one state
|
|
3317
|
+
// nobody could debug
|
|
3318
|
+
static debug(options?: Partial<DebugFlags>): DebugFlags {
|
|
3319
|
+
if (options) {
|
|
3320
|
+
for (const key in options) {
|
|
3321
|
+
const value = options[key as keyof DebugFlags]
|
|
3322
|
+
if (typeof value === "boolean") debugFlags[key as keyof DebugFlags] = value
|
|
3323
|
+
else console.warn(`jq79: Component79.debug ignored "${key}" - the flags are booleans, and the ones it knows are: ${Object.keys(debugFlags).join(", ")}`)
|
|
3324
|
+
}
|
|
3325
|
+
}
|
|
3326
|
+
return { ...debugFlags }
|
|
3327
|
+
}
|
|
3328
|
+
|
|
2814
3329
|
static fetch(url: string): PendingComponent79 {
|
|
2815
3330
|
if (Array.isArray(url)) throw new TypeError("Component79.fetch takes one URL; use fetchAll for an array")
|
|
2816
3331
|
return new PendingComponent79(fetchComponent(url))
|
|
@@ -3050,7 +3565,7 @@ export class Component79 {
|
|
|
3050
3565
|
}
|
|
3051
3566
|
declareProps(store, parseFactoryProps(script.content))
|
|
3052
3567
|
const body = deferred ? defer(factoryCode) : factoryCode
|
|
3053
|
-
return runFactoryScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
3568
|
+
return runFactoryScript(body, store, run => fx.effect(run), instanceHelpers, $import, at)
|
|
3054
3569
|
}
|
|
3055
3570
|
const { vars, code } = transformSetupScript(script.content)
|
|
3056
3571
|
declareProps(store, setupSignature(script))
|
|
@@ -3058,7 +3573,7 @@ export class Component79 {
|
|
|
3058
3573
|
// to them (and reads of them) through the reactive proxy
|
|
3059
3574
|
vars.forEach(name => { if (!(name in store)) (store as any)[name] = undefined })
|
|
3060
3575
|
const body = deferred ? defer(code) : code
|
|
3061
|
-
return runSetupScript(body, store, fx.effect, instanceHelpers, $import, at)
|
|
3576
|
+
return runSetupScript(body, store, run => fx.effect(run), instanceHelpers, $import, at)
|
|
3062
3577
|
})()
|
|
3063
3578
|
// a script that threw has nothing left to contribute, so its rejection
|
|
3064
3579
|
// releases the gate exactly as completion does - the error is already
|