jq79 0.5.13 → 0.6.0
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.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/dist/reactive.d.ts +6 -2
- package/package.json +2 -1
- package/src/jq79.ts +238 -70
- package/src/reactive.ts +467 -27
package/dist/reactive.d.ts
CHANGED
|
@@ -4,10 +4,14 @@ type ListenerOptions = {
|
|
|
4
4
|
immediate?: boolean;
|
|
5
5
|
};
|
|
6
6
|
type Unsubscribe = () => void;
|
|
7
|
+
export type EffectOptions = {
|
|
8
|
+
deep?: boolean;
|
|
9
|
+
alsoWakenBy?: Record<string, any>[];
|
|
10
|
+
};
|
|
7
11
|
export type ReactiveDeepData<T> = T & {
|
|
8
12
|
$on: (dotKey: string, listener: ChangeListener, options?: ListenerOptions) => Unsubscribe;
|
|
9
13
|
$onAny: (listener: AnyChangeListener, options?: ListenerOptions) => Unsubscribe;
|
|
10
|
-
$effect: (run: () => void,
|
|
14
|
+
$effect: (run: () => void, options?: EffectOptions) => Unsubscribe;
|
|
11
15
|
$dispose: () => void;
|
|
12
16
|
};
|
|
13
17
|
export declare const $toRaw: <T>(value: T) => T;
|
|
@@ -20,5 +24,5 @@ export type EffectScope = {
|
|
|
20
24
|
refresh: () => void;
|
|
21
25
|
dispose: () => void;
|
|
22
26
|
};
|
|
23
|
-
export declare const createEffectScope: (scope: Record<string, any
|
|
27
|
+
export declare const createEffectScope: (scope: Record<string, any>, deep?: boolean) => EffectScope;
|
|
24
28
|
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "jq79",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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",
|
|
@@ -65,6 +65,7 @@
|
|
|
65
65
|
"site": "node scripts/build-site.mjs",
|
|
66
66
|
"site.dev": "node scripts/site-dev.mjs",
|
|
67
67
|
"benchmark": "node scripts/run-benchmark.mjs",
|
|
68
|
+
"benchmark:comparison": "node scripts/run-comparison.mjs",
|
|
68
69
|
"prepublishOnly": "npm test && npm run build"
|
|
69
70
|
},
|
|
70
71
|
"peerDependencies": {
|
package/src/jq79.ts
CHANGED
|
@@ -373,6 +373,43 @@ const removeRange = ({ first, last }: NodeRange) => {
|
|
|
373
373
|
}
|
|
374
374
|
}
|
|
375
375
|
|
|
376
|
+
// removes several ranges that sit next to each other, in one DOM call each run
|
|
377
|
+
// rather than one per node. A list dropping all its rows hands them over as a
|
|
378
|
+
// single span: unlinking 10,000 rows one at a time is 40% of that operation,
|
|
379
|
+
// profiled - see TODOS/2026-08-23.batch-range-removal.md. Runs are built by the
|
|
380
|
+
// caller, which is the only place that knows what else is going
|
|
381
|
+
const removeRuns = (runs: NodeRange[][]) => {
|
|
382
|
+
runs.forEach(run => {
|
|
383
|
+
if (run.length === 1) return removeRange(run[0])
|
|
384
|
+
const parent = run[0].first.parentNode
|
|
385
|
+
if (!parent) return
|
|
386
|
+
// both ends sit between nodes, so nothing is partially selected and whole
|
|
387
|
+
// nodes are what gets unlinked
|
|
388
|
+
const range = document.createRange()
|
|
389
|
+
range.setStartBefore(run[0].first)
|
|
390
|
+
range.setEndAfter(run[run.length - 1].last)
|
|
391
|
+
range.deleteContents()
|
|
392
|
+
})
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// groups the entries `isDead` selects into runs of DOM neighbours, walking
|
|
396
|
+
// `ordered` (which is in DOM order). Adjacency is confirmed rather than assumed:
|
|
397
|
+
// a gap - an entry removed earlier in the same pass - starts a new run, so a
|
|
398
|
+
// 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[] | null = null
|
|
402
|
+
ordered.forEach(entry => {
|
|
403
|
+
if (!isDead(entry)) {
|
|
404
|
+
run = null
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if (run && run[run.length - 1].last.nextSibling === entry.range.first) run.push(entry.range)
|
|
408
|
+
else runs.push((run = [entry.range]))
|
|
409
|
+
})
|
|
410
|
+
return runs
|
|
411
|
+
}
|
|
412
|
+
|
|
376
413
|
// moves [first..last] inclusive so the range starts right after `prev`
|
|
377
414
|
const moveRangeAfter = ({ first, last }: NodeRange, prev: Node) => {
|
|
378
415
|
const ref = prev.nextSibling
|
|
@@ -388,9 +425,69 @@ const moveRangeAfter = ({ first, last }: NodeRange, prev: Node) => {
|
|
|
388
425
|
// case-insensitive with dashes stripped (<nested-component> works too). Only
|
|
389
426
|
// PascalCase scope keys participate, so ordinary variables named like real
|
|
390
427
|
// elements (title, code, ...) never hijack them
|
|
428
|
+
const scanComponentKey = (scope: Record<string, any>, tag: string): string | null => {
|
|
429
|
+
const normalized = tag.replace(/-/g, "").toLowerCase()
|
|
430
|
+
for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
|
|
431
|
+
for (const key of Object.keys(obj)) {
|
|
432
|
+
if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
return null
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// The scan above is run for every element rendered, and it walks the whole
|
|
439
|
+
// scope chain calling Object.keys at each level - which profiling puts at 13.7%
|
|
440
|
+
// of the create path, four times the next attributable frame, almost all of it
|
|
441
|
+
// answering "no" for tags like <td>. Within one render pass the answer can't
|
|
442
|
+
// change: it is a *key*, not the value behind it, so every row of a :each
|
|
443
|
+
// resolves a tag identically, and a store write mid-pass restarts the pass
|
|
444
|
+
// rather than continuing it (the reentrancy guard in reactive.ts).
|
|
445
|
+
//
|
|
446
|
+
// So it is memoized for exactly one pass and no longer. It has to be no longer:
|
|
447
|
+
// a template renders before its setup script settles, so `const Row = await
|
|
448
|
+
// $import(...)` arrives as a new store key *after* elements are on the page -
|
|
449
|
+
// and a cached "no component called Row" that outlived the pass would never be
|
|
450
|
+
// revisited. See TODOS/2026-08-23.component-key-scan.md
|
|
451
|
+
// The memo answers for one *base* scope - the one the pass was opened with -
|
|
452
|
+
// and nothing below it. A lookup walks from wherever it starts up to that base,
|
|
453
|
+
// checking own keys as it goes (an :each item scope has two or three, a :with
|
|
454
|
+
// a handful), and only then consults the memo. So a name introduced under the
|
|
455
|
+
// base still shadows correctly, and a scope that never reaches the base at all
|
|
456
|
+
// - a nested component renders against its own store - has simply been fully
|
|
457
|
+
// scanned by the time the walk ends, which is the answer anyway
|
|
458
|
+
let tagMemo: Map<string, string | null> | null = null
|
|
459
|
+
let memoBase: object | null = null
|
|
460
|
+
|
|
461
|
+
// opened and closed by hand rather than by a wrapper taking a callback: a
|
|
462
|
+
// component that renders itself through :each stacks one renderEach per level,
|
|
463
|
+
// and a callback would add a frame to each of them. The cyclic-component test
|
|
464
|
+
// cuts off at 200 levels, and on a CI runner that extra frame per level was the
|
|
465
|
+
// difference between cutting off and a RangeError - the same reason $effect
|
|
466
|
+
// keeps its own shape (see reactive.ts)
|
|
467
|
+
type RenderPass = { memo: Map<string, string | null> | null; base: object | null }
|
|
468
|
+
|
|
469
|
+
const openRenderPass = (base: Record<string, any>): RenderPass => {
|
|
470
|
+
const outer: RenderPass = { memo: tagMemo, base: memoBase }
|
|
471
|
+
tagMemo = new Map()
|
|
472
|
+
memoBase = base
|
|
473
|
+
return outer
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
const closeRenderPass = (outer: RenderPass) => {
|
|
477
|
+
tagMemo = outer.memo
|
|
478
|
+
memoBase = outer.base
|
|
479
|
+
}
|
|
480
|
+
|
|
391
481
|
const findComponentKey = (scope: Record<string, any>, tag: string): string | null => {
|
|
482
|
+
if (!tagMemo) return scanComponentKey(scope, tag)
|
|
392
483
|
const normalized = tag.replace(/-/g, "").toLowerCase()
|
|
393
484
|
for (let obj: any = scope; obj && obj !== Object.prototype; obj = Object.getPrototypeOf(obj)) {
|
|
485
|
+
if (obj === memoBase) {
|
|
486
|
+
if (tagMemo.has(tag)) return tagMemo.get(tag)!
|
|
487
|
+
const key = scanComponentKey(obj, tag)
|
|
488
|
+
tagMemo.set(tag, key)
|
|
489
|
+
return key
|
|
490
|
+
}
|
|
394
491
|
for (const key of Object.keys(obj)) {
|
|
395
492
|
if (/^[A-Z]/.test(key) && key.replace(/-/g, "").toLowerCase() === normalized) return key
|
|
396
493
|
}
|
|
@@ -902,7 +999,11 @@ const renderNestedComponent = (key: string, node: TemplateNode, scope: Record<st
|
|
|
902
999
|
}
|
|
903
1000
|
endAnchor.parentNode!.insertBefore(holder, endAnchor)
|
|
904
1001
|
|
|
905
|
-
|
|
1002
|
+
// deep: a prop sync forwards whatever the expression evaluates to, whole,
|
|
1003
|
+
// into the child's store - it reads `user`, never `user.name`, so it can't
|
|
1004
|
+
// track what it passes on. A parent's deep mutation reaches the child
|
|
1005
|
+
// through this effect or not at all (see $effect's `deep`)
|
|
1006
|
+
const syncFx = createEffectScope(scope, true)
|
|
906
1007
|
// without a spread the prop set is fixed and known: one effect per prop, so
|
|
907
1008
|
// a change to one prop re-syncs only that prop. A spread's key set is
|
|
908
1009
|
// dynamic and its precedence is positional, so it can't be resolved a key at
|
|
@@ -1217,9 +1318,9 @@ const renderNode = (node: TemplateNode, outerScope: Record<string, any>, fx: Eff
|
|
|
1217
1318
|
// for them. They render (bindings and all) and go there - appended as
|
|
1218
1319
|
// childNodes they would be in the DOM but in no document fragment, seen by
|
|
1219
1320
|
// nothing and rendered by nobody
|
|
1220
|
-
|
|
1321
|
+
renderNodes(node.children, scope, fx, shadow, el.content)
|
|
1221
1322
|
} else {
|
|
1222
|
-
|
|
1323
|
+
renderNodes(node.children, scope, fx, shadow, el)
|
|
1223
1324
|
}
|
|
1224
1325
|
|
|
1225
1326
|
// :value / :checked / :selected write the DOM *property*, not the
|
|
@@ -1314,6 +1415,36 @@ const isPlainObject = (value: any): value is Record<string, any> => {
|
|
|
1314
1415
|
// property key, which is already the stable identity. Each item gets its own
|
|
1315
1416
|
// scope via Object.create(scope), so the bindings and `$index` shadow
|
|
1316
1417
|
// same-named outer names without copying the parent scope's keys
|
|
1418
|
+
// does anything in this subtree name one of `names` as an identifier? Attribute
|
|
1419
|
+
// values and text alike, since either can hold an expression - a prop handing a
|
|
1420
|
+
// position to a nested component (`<Row :n="$index">`) is an attribute on a node
|
|
1421
|
+
// inside the item, which is why the walk has to cover children's attrs too.
|
|
1422
|
+
//
|
|
1423
|
+
// Over-approximating is the safe direction and the intended one: a name that
|
|
1424
|
+
// appears in a string literal costs a refresh that wasn't needed, which is
|
|
1425
|
+
// exactly what happens today for every list. Missing one would leave a binding
|
|
1426
|
+
// stale, and the walk cannot - a template expression is source text
|
|
1427
|
+
const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
|
|
1428
|
+
if (typeof node === "string") return names.some(name => identifierIn(node, name))
|
|
1429
|
+
return Object.values(node.attrs).some(value => names.some(name => identifierIn(value, name))) ||
|
|
1430
|
+
node.children.some(child => mentionsAny(child, names))
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
// `name` as a whole word: `$index` must not match inside `$indexes`, and `i`
|
|
1434
|
+
// must not match inside `items`. `$` counts as a word character here, which is
|
|
1435
|
+
// why the boundaries are checked by hand rather than with \b - \b treats `$`
|
|
1436
|
+
// as a boundary and would find the `i` of `$index` when looking for `i`
|
|
1437
|
+
const IDENTIFIER_CHAR = /[A-Za-z0-9_$]/
|
|
1438
|
+
|
|
1439
|
+
const identifierIn = (text: string, name: string): boolean => {
|
|
1440
|
+
for (let at = text.indexOf(name); at !== -1; at = text.indexOf(name, at + 1)) {
|
|
1441
|
+
const before = at === 0 ? "" : text[at - 1]
|
|
1442
|
+
const after = text[at + name.length] ?? ""
|
|
1443
|
+
if (!IDENTIFIER_CHAR.test(before) && !IDENTIFIER_CHAR.test(after)) return true
|
|
1444
|
+
}
|
|
1445
|
+
return false
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1317
1448
|
const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
1318
1449
|
const match = node.attrs[":each"].match(EACH_PATTERN)
|
|
1319
1450
|
if (!match) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
|
|
@@ -1323,6 +1454,18 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1323
1454
|
const { [":each"]: _each, [":key"]: _key, ...itemAttrs } = node.attrs
|
|
1324
1455
|
const itemNode: TemplateNode = { ...node, attrs: itemAttrs }
|
|
1325
1456
|
|
|
1457
|
+
// An entry that changed position needs its position-only bindings re-run -
|
|
1458
|
+
// `$index` and the `, at` name are plain scope vars, untracked by design, so
|
|
1459
|
+
// nothing can wake them (see EffectScope.refresh). But refresh re-runs *every*
|
|
1460
|
+
// effect on the entry, and in a list whose template names no position at all
|
|
1461
|
+
// - the common one - all of that recomputes strings that cannot have changed:
|
|
1462
|
+
// it was 9ms of removeRow's 25ms. Decided once, from the template, rather
|
|
1463
|
+
// than per row per render. The item name is deliberately not in this list:
|
|
1464
|
+
// nearly every binding reads it, and it is not what goes stale.
|
|
1465
|
+
// See TODOS/2026-08-23.positional-refresh.md
|
|
1466
|
+
const positionalNames = ["$index", ...(atName ? [atName] : [])]
|
|
1467
|
+
const readsPosition = mentionsAny(itemNode, positionalNames)
|
|
1468
|
+
|
|
1326
1469
|
const anchor = document.createComment("each")
|
|
1327
1470
|
const wrapper = document.createDocumentFragment()
|
|
1328
1471
|
wrapper.appendChild(anchor)
|
|
@@ -1336,79 +1479,91 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1336
1479
|
let entries: EachEntry[] = []
|
|
1337
1480
|
let warnedDuplicates = false
|
|
1338
1481
|
|
|
1482
|
+
// one memo for the whole diff: every row resolves its tags to the same scope
|
|
1483
|
+
// keys, so the scan that used to run per element per row now runs once per
|
|
1484
|
+
// distinct tag (see findComponentKey)
|
|
1339
1485
|
fx.effect(() => {
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
:
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
const
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1486
|
+
const pass = openRenderPass(scope)
|
|
1487
|
+
try {
|
|
1488
|
+
const list = evalExpr(listExpr, scope)
|
|
1489
|
+
// both sources normalize to [at, item] pairs: the index for an array, the
|
|
1490
|
+
// property key for a plain object (insertion order). Object entries are
|
|
1491
|
+
// read off the store proxy, so each value is tracked under its own key -
|
|
1492
|
+
// adds, deletes and changes all wake this effect
|
|
1493
|
+
const pairs: [any, any][] = Array.isArray(list)
|
|
1494
|
+
? list.map((item, index): [any, any] => [index, item])
|
|
1495
|
+
: isPlainObject(list) ? Object.entries(list) : []
|
|
1496
|
+
// buckets rather than a key->entry map: duplicate keys (a user error, but
|
|
1497
|
+
// one that must degrade instead of corrupt) consume entries in order of
|
|
1498
|
+
// appearance, so no entry is ever matched twice - matching one twice is
|
|
1499
|
+
// how a reused row got disposed and a removed one resurrected
|
|
1500
|
+
const previous = new Map<any, EachEntry[]>()
|
|
1501
|
+
entries.forEach(entry => {
|
|
1502
|
+
const bucket = previous.get(entry.key)
|
|
1503
|
+
if (bucket) bucket.push(entry)
|
|
1504
|
+
else previous.set(entry.key, [entry])
|
|
1505
|
+
})
|
|
1358
1506
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1507
|
+
const seen = new Set<any>()
|
|
1508
|
+
const moved: EachEntry[] = []
|
|
1509
|
+
const nextEntries = pairs.map(([at, item], index): EachEntry => {
|
|
1510
|
+
const itemScope = Object.create(scope)
|
|
1511
|
+
defineScopeVar(itemScope, itemName, item)
|
|
1512
|
+
if (atName) defineScopeVar(itemScope, atName, at)
|
|
1513
|
+
defineScopeVar(itemScope, "$index", index)
|
|
1514
|
+
const key = keyExpr !== undefined ? evalExpr(keyExpr, itemScope) : at
|
|
1515
|
+
if (seen.has(key) && !warnedDuplicates) {
|
|
1516
|
+
warnedDuplicates = true
|
|
1517
|
+
console.warn(`jq79: duplicate :key in :each "${node.attrs[":each"]}"; duplicates pair up by position`)
|
|
1518
|
+
}
|
|
1519
|
+
seen.add(key)
|
|
1520
|
+
const existing = previous.get(key)?.shift()
|
|
1521
|
+
|
|
1522
|
+
if (existing && Object.is(existing.item, item)) {
|
|
1523
|
+
if (readsPosition && existing.scope.$index !== index) moved.push(existing)
|
|
1524
|
+
defineScopeVar(existing.scope, "$index", index)
|
|
1525
|
+
if (atName) defineScopeVar(existing.scope, atName, at)
|
|
1526
|
+
return existing
|
|
1527
|
+
}
|
|
1380
1528
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1529
|
+
if (existing) {
|
|
1530
|
+
existing.fx.dispose()
|
|
1531
|
+
removeRange(existing.range)
|
|
1532
|
+
}
|
|
1385
1533
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1534
|
+
const itemFx = createEffectScope(scope)
|
|
1535
|
+
// bounds captured before the positioning pass inserts the entry: a
|
|
1536
|
+
// component entry is a fragment, which empties on insertion (see boundsOf)
|
|
1537
|
+
const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
|
|
1538
|
+
return { key, item, scope: itemScope, fx: itemFx, range }
|
|
1539
|
+
})
|
|
1392
1540
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1541
|
+
// whatever no new item consumed is gone. Effects are torn down one by one
|
|
1542
|
+
// as always; the DOM goes in runs of neighbours, which is what makes
|
|
1543
|
+
// clearing a long list one mutation instead of one per row
|
|
1544
|
+
const dead = new Set<EachEntry>()
|
|
1545
|
+
previous.forEach(bucket => bucket.forEach(entry => dead.add(entry)))
|
|
1546
|
+
if (dead.size) {
|
|
1547
|
+
dead.forEach(entry => entry.fx.dispose())
|
|
1548
|
+
removeRuns(contiguousRuns(entries, entry => dead.has(entry)))
|
|
1549
|
+
}
|
|
1398
1550
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1551
|
+
let prevNode: Node = anchor
|
|
1552
|
+
nextEntries.forEach(entry => {
|
|
1553
|
+
if (prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
|
|
1554
|
+
prevNode = entry.range.last
|
|
1555
|
+
})
|
|
1404
1556
|
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1557
|
+
// reused entries that changed position: their tracked bindings re-run off
|
|
1558
|
+
// the list notification anyway, but a binding that reads only `$index` or
|
|
1559
|
+
// the named key tracked nothing - refresh them so the move reaches those
|
|
1560
|
+
// too. Untracked, so these runs don't feed this list effect's own deps
|
|
1561
|
+
moved.forEach(entry => untracked(() => entry.fx.refresh()))
|
|
1410
1562
|
|
|
1411
|
-
|
|
1563
|
+
entries = nextEntries
|
|
1564
|
+
} finally {
|
|
1565
|
+
closeRenderPass(pass)
|
|
1566
|
+
}
|
|
1412
1567
|
})
|
|
1413
1568
|
|
|
1414
1569
|
return wrapper
|
|
@@ -1416,8 +1571,21 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1416
1571
|
|
|
1417
1572
|
// renders a list of sibling template nodes (text + elements), grouping
|
|
1418
1573
|
// consecutive :if/:elseif/:else nodes into a single conditional block
|
|
1419
|
-
|
|
1420
|
-
|
|
1574
|
+
// `into` renders straight into an element that is not in the document yet -
|
|
1575
|
+
// what renderElement does for an element's own children. Every element used to
|
|
1576
|
+
// get a DocumentFragment of its own, filled and then emptied into it: for a
|
|
1577
|
+
// 10,000-row table that is 70,000 fragments and a second pass over every node,
|
|
1578
|
+
// and the intermediate is invisible either way because the element is still
|
|
1579
|
+
// detached. Callers that need a standalone chunk (a component's content, an
|
|
1580
|
+
// :if branch) omit it and get the fragment
|
|
1581
|
+
const renderNodes = <T extends ParentNode>(
|
|
1582
|
+
nodes: (TemplateNode | string)[],
|
|
1583
|
+
scope: Record<string, any>,
|
|
1584
|
+
fx: EffectScope,
|
|
1585
|
+
shadow = false,
|
|
1586
|
+
into?: T
|
|
1587
|
+
): T | DocumentFragment => {
|
|
1588
|
+
const fragment = into ?? document.createDocumentFragment()
|
|
1421
1589
|
let i = 0
|
|
1422
1590
|
|
|
1423
1591
|
while (i < nodes.length) {
|