jq79 0.5.14 → 0.6.1
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 +1 -1
- package/src/jq79.ts +361 -72
- 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.1",
|
|
4
4
|
"description": "Mini reactive component library: single-file components, Svelte-style setup scripts, fine-grained proxy reactivity. Single-file build, zero dependencies.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"reactive",
|
package/src/jq79.ts
CHANGED
|
@@ -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
|
|
@@ -1288,12 +1389,59 @@ const renderConditional = (branches: ConditionalBranch[], scope: Record<string,
|
|
|
1288
1389
|
// reactive proxy's `set` trap: it would wrap `value` as if it were a genuine
|
|
1289
1390
|
// store mutation and fire a bogus notify() under a name (e.g. "item") shared
|
|
1290
1391
|
// by every unrelated item in every :each on the page. defineProperty always
|
|
1291
|
-
// writes to `scope` itself, never delegating, so this can't happen
|
|
1392
|
+
// writes to `scope` itself, never delegating, so this can't happen.
|
|
1393
|
+
//
|
|
1394
|
+
// Only while the key is *missing*, though: once this has run, the property is
|
|
1395
|
+
// own and writable, and a plain assignment finds it there and writes it in
|
|
1396
|
+
// place. That is what renderEach's reuse paths do, and why they may - a
|
|
1397
|
+
// defineProperty costs several times an assignment, and a list that never
|
|
1398
|
+
// reorders would otherwise pay one per row per pass to rewrite the value it
|
|
1399
|
+
// already held
|
|
1292
1400
|
const defineScopeVar = (scope: Record<string, any>, key: string, value: any) => {
|
|
1293
1401
|
Object.defineProperty(scope, key, { value, writable: true, enumerable: true, configurable: true })
|
|
1294
1402
|
}
|
|
1295
1403
|
|
|
1296
|
-
|
|
1404
|
+
// `pos` is where the entry sat in the previous pass, refreshed as the buckets
|
|
1405
|
+
// are built - the positioning pass needs the old order to work out which rows
|
|
1406
|
+
// are already where they belong (see longestIncreasingRun)
|
|
1407
|
+
type EachEntry = { key: any; item: any; scope: Record<string, any>; range: NodeRange; fx: EffectScope; pos: number }
|
|
1408
|
+
|
|
1409
|
+
// the indices of one longest strictly increasing subsequence of `positions`,
|
|
1410
|
+
// as a flag per index. Fed the old position of every entry in the new order
|
|
1411
|
+
// (-1 for one rendered this pass), it names the rows that are ALREADY in the
|
|
1412
|
+
// right order relative to each other: move everything else and the pass issues
|
|
1413
|
+
// the fewest insertions it can. The walk this serves used to demand only that
|
|
1414
|
+
// each entry follow the one before it, which is minimal for an append and
|
|
1415
|
+
// quadratic for a reorder - one row out of place cascaded into a move for
|
|
1416
|
+
// every row after it, so swapping rows 1 and 998 of a 1,000-row table issued
|
|
1417
|
+
// 997 insertBefore calls where two would do.
|
|
1418
|
+
// Patience sorting, O(n log n): `tails[l]` is the index of the smallest value
|
|
1419
|
+
// that can end an increasing run of length l+1, and `before` remembers what
|
|
1420
|
+
// each index was appended to, which is what makes the run reconstructible
|
|
1421
|
+
const longestIncreasingRun = (positions: number[]): Uint8Array => {
|
|
1422
|
+
const inRun = new Uint8Array(positions.length)
|
|
1423
|
+
const tails: number[] = []
|
|
1424
|
+
const before = new Int32Array(positions.length).fill(-1)
|
|
1425
|
+
|
|
1426
|
+
for (let index = 0; index < positions.length; index++) {
|
|
1427
|
+
const position = positions[index]
|
|
1428
|
+
if (position < 0) continue // rendered this pass: it has no old position to be in order with
|
|
1429
|
+
let low = 0
|
|
1430
|
+
let high = tails.length
|
|
1431
|
+
while (low < high) {
|
|
1432
|
+
const mid = (low + high) >> 1
|
|
1433
|
+
if (positions[tails[mid]] < position) low = mid + 1
|
|
1434
|
+
else high = mid
|
|
1435
|
+
}
|
|
1436
|
+
if (low > 0) before[index] = tails[low - 1]
|
|
1437
|
+
tails[low] = index
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
for (let index = tails.length ? tails[tails.length - 1] : -1; index !== -1; index = before[index]) {
|
|
1441
|
+
inRun[index] = 1
|
|
1442
|
+
}
|
|
1443
|
+
return inRun
|
|
1444
|
+
}
|
|
1297
1445
|
|
|
1298
1446
|
// what :each iterates besides arrays: dictionaries, as their entries. Class
|
|
1299
1447
|
// instances, Maps and the rest stay out - the store doesn't wrap them
|
|
@@ -1314,15 +1462,76 @@ const isPlainObject = (value: any): value is Record<string, any> => {
|
|
|
1314
1462
|
// property key, which is already the stable identity. Each item gets its own
|
|
1315
1463
|
// scope via Object.create(scope), so the bindings and `$index` shadow
|
|
1316
1464
|
// same-named outer names without copying the parent scope's keys
|
|
1465
|
+
// does anything in this subtree name one of `names` as an identifier? Attribute
|
|
1466
|
+
// values and text alike, since either can hold an expression - a prop handing a
|
|
1467
|
+
// position to a nested component (`<Row :n="$index">`) is an attribute on a node
|
|
1468
|
+
// inside the item, which is why the walk has to cover children's attrs too.
|
|
1469
|
+
//
|
|
1470
|
+
// Over-approximating is the safe direction and the intended one: a name that
|
|
1471
|
+
// appears in a string literal costs a refresh that wasn't needed, which is
|
|
1472
|
+
// exactly what happens today for every list. Missing one would leave a binding
|
|
1473
|
+
// stale, and the walk cannot - a template expression is source text
|
|
1474
|
+
const mentionsAny = (node: TemplateNode | string, names: string[]): boolean => {
|
|
1475
|
+
if (typeof node === "string") return names.some(name => identifierIn(node, name))
|
|
1476
|
+
return Object.values(node.attrs).some(value => names.some(name => identifierIn(value, name))) ||
|
|
1477
|
+
node.children.some(child => mentionsAny(child, names))
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
// `name` as a whole word: `$index` must not match inside `$indexes`, and `i`
|
|
1481
|
+
// must not match inside `items`. `$` counts as a word character here, which is
|
|
1482
|
+
// why the boundaries are checked by hand rather than with \b - \b treats `$`
|
|
1483
|
+
// as a boundary and would find the `i` of `$index` when looking for `i`
|
|
1484
|
+
const IDENTIFIER_CHAR = /[A-Za-z0-9_$]/
|
|
1485
|
+
|
|
1486
|
+
// `a.b` and nothing else: two plain identifiers, one dot. A deeper path could
|
|
1487
|
+
// walk a null halfway down, which is a diagnostic evalExpr owns
|
|
1488
|
+
const KEY_MEMBER = /^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$/
|
|
1489
|
+
|
|
1490
|
+
const identifierIn = (text: string, name: string): boolean => {
|
|
1491
|
+
for (let at = text.indexOf(name); at !== -1; at = text.indexOf(name, at + 1)) {
|
|
1492
|
+
const before = at === 0 ? "" : text[at - 1]
|
|
1493
|
+
const after = text[at + name.length] ?? ""
|
|
1494
|
+
if (!IDENTIFIER_CHAR.test(before) && !IDENTIFIER_CHAR.test(after)) return true
|
|
1495
|
+
}
|
|
1496
|
+
return false
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1317
1499
|
const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectScope, shadow: boolean): Node => {
|
|
1318
1500
|
const match = node.attrs[":each"].match(EACH_PATTERN)
|
|
1319
1501
|
if (!match) return document.createComment(`invalid :each expression "${node.attrs[":each"]}"`)
|
|
1320
1502
|
|
|
1321
1503
|
const [, itemName, atName, listExpr] = match
|
|
1322
1504
|
const keyExpr = node.attrs[":key"]
|
|
1505
|
+
|
|
1506
|
+
// `:key="row.id"`, or the loop variable itself, is what a key almost always
|
|
1507
|
+
// is - and reading one needs neither a scope to resolve names against nor a
|
|
1508
|
+
// compiled expression, because the item is already in hand. A pass evaluates
|
|
1509
|
+
// one key per row, so a 1,000-row list paid 1,000 `with`-scoped calls through
|
|
1510
|
+
// the store proxy to discover that nothing had changed: most of the 37% of a
|
|
1511
|
+
// pass that goes on evaluating expressions
|
|
1512
|
+
// (TODOS/2026-08-23.where-the-list-operations-go.md). Anything else - a call,
|
|
1513
|
+
// an index, a deeper path, a name from the outer scope - still goes through
|
|
1514
|
+
// evalExpr, and so does a non-object item, which keeps every diagnostic a
|
|
1515
|
+
// property read of a null row would have raised
|
|
1516
|
+
const keyIsItem = keyExpr === itemName
|
|
1517
|
+
const keyProp = keyExpr !== undefined && !keyIsItem && KEY_MEMBER.test(keyExpr) && keyExpr.startsWith(`${itemName}.`)
|
|
1518
|
+
? keyExpr.slice(itemName.length + 1)
|
|
1519
|
+
: undefined
|
|
1323
1520
|
const { [":each"]: _each, [":key"]: _key, ...itemAttrs } = node.attrs
|
|
1324
1521
|
const itemNode: TemplateNode = { ...node, attrs: itemAttrs }
|
|
1325
1522
|
|
|
1523
|
+
// An entry that changed position needs its position-only bindings re-run -
|
|
1524
|
+
// `$index` and the `, at` name are plain scope vars, untracked by design, so
|
|
1525
|
+
// nothing can wake them (see EffectScope.refresh). But refresh re-runs *every*
|
|
1526
|
+
// effect on the entry, and in a list whose template names no position at all
|
|
1527
|
+
// - the common one - all of that recomputes strings that cannot have changed:
|
|
1528
|
+
// it was 9ms of removeRow's 25ms. Decided once, from the template, rather
|
|
1529
|
+
// than per row per render. The item name is deliberately not in this list:
|
|
1530
|
+
// nearly every binding reads it, and it is not what goes stale.
|
|
1531
|
+
// See TODOS/2026-08-23.positional-refresh.md
|
|
1532
|
+
const positionalNames = ["$index", ...(atName ? [atName] : [])]
|
|
1533
|
+
const readsPosition = mentionsAny(itemNode, positionalNames)
|
|
1534
|
+
|
|
1326
1535
|
const anchor = document.createComment("each")
|
|
1327
1536
|
const wrapper = document.createDocumentFragment()
|
|
1328
1537
|
wrapper.appendChild(anchor)
|
|
@@ -1336,79 +1545,146 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1336
1545
|
let entries: EachEntry[] = []
|
|
1337
1546
|
let warnedDuplicates = false
|
|
1338
1547
|
|
|
1548
|
+
// one memo for the whole diff: every row resolves its tags to the same scope
|
|
1549
|
+
// keys, so the scan that used to run per element per row now runs once per
|
|
1550
|
+
// distinct tag (see findComponentKey)
|
|
1339
1551
|
fx.effect(() => {
|
|
1340
|
-
const
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1552
|
+
const pass = openRenderPass(scope)
|
|
1553
|
+
try {
|
|
1554
|
+
const list = evalExpr(listExpr, scope)
|
|
1555
|
+
// read the source in place rather than normalizing it to [at, item] pairs
|
|
1556
|
+
// first: a list of 1,000 rows built 1,000 two-element arrays per pass,
|
|
1557
|
+
// every one of them garbage by the end of it. `keys` carries a plain
|
|
1558
|
+
// object's property list (insertion order, which is also its identity)
|
|
1559
|
+
// and is null for an array, where the index is the key. Either way the
|
|
1560
|
+
// items are read off the store proxy one at a time, so each stays tracked
|
|
1561
|
+
// under its own key - adds, deletes and changes all wake this effect
|
|
1562
|
+
const array = Array.isArray(list)
|
|
1563
|
+
const keys: string[] | null = array ? null : isPlainObject(list) ? Object.keys(list) : []
|
|
1564
|
+
const length = array ? list.length : keys!.length
|
|
1565
|
+
// buckets rather than a key->entry map: duplicate keys (a user error, but
|
|
1566
|
+
// one that must degrade instead of corrupt) consume entries in order of
|
|
1567
|
+
// appearance, so no entry is ever matched twice - matching one twice is
|
|
1568
|
+
// how a reused row got disposed and a removed one resurrected
|
|
1569
|
+
const previous = new Map<any, EachEntry[]>()
|
|
1570
|
+
entries.forEach((entry, index) => {
|
|
1571
|
+
entry.pos = index
|
|
1572
|
+
const bucket = previous.get(entry.key)
|
|
1573
|
+
if (bucket) bucket.push(entry)
|
|
1574
|
+
else previous.set(entry.key, [entry])
|
|
1575
|
+
})
|
|
1358
1576
|
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
1370
|
-
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1577
|
+
const seen = new Set<any>()
|
|
1578
|
+
const moved: EachEntry[] = []
|
|
1579
|
+
const nextEntries: EachEntry[] = []
|
|
1580
|
+
// each entry's position in the previous pass, in the new order, for the
|
|
1581
|
+
// positioning walk below - -1 for one rendered here, which has none
|
|
1582
|
+
const positions: number[] = []
|
|
1583
|
+
// one scratch scope for the whole pass, not one per item. A :key
|
|
1584
|
+
// expression reads the item by name (`row.id`), so it needs a scope to
|
|
1585
|
+
// read it from - but which entry that key names, and so whether anything
|
|
1586
|
+
// has to be rendered at all, is only known once it has been evaluated.
|
|
1587
|
+
// Building the entry's real scope up front meant every reused row - the
|
|
1588
|
+
// common case, and nearly all of removeRow, selectRow and swapRows - paid
|
|
1589
|
+
// for an object and three defineProperty calls that were then dropped on
|
|
1590
|
+
// the floor. Nothing outlives the evaluation, which is synchronous, so one
|
|
1591
|
+
// scratch serves the whole list; a row that really is rendered gets a
|
|
1592
|
+
// scope of its own, below
|
|
1593
|
+
let scratch: Record<string, any> | undefined
|
|
1594
|
+
|
|
1595
|
+
for (let index = 0; index < length; index++) {
|
|
1596
|
+
const at = array ? index : keys![index]
|
|
1597
|
+
const item = array ? list[index] : list[at]
|
|
1598
|
+
|
|
1599
|
+
let key: any = at
|
|
1600
|
+
if (keyIsItem) key = item
|
|
1601
|
+
else if (keyProp !== undefined && item !== null && typeof item === "object") key = item[keyProp]
|
|
1602
|
+
else if (keyExpr !== undefined) {
|
|
1603
|
+
if (scratch === undefined) {
|
|
1604
|
+
scratch = Object.create(scope) as Record<string, any>
|
|
1605
|
+
defineScopeVar(scratch, itemName, item)
|
|
1606
|
+
if (atName) defineScopeVar(scratch, atName, at)
|
|
1607
|
+
defineScopeVar(scratch, "$index", index)
|
|
1608
|
+
} else {
|
|
1609
|
+
// own and writable already, so a plain assignment writes to
|
|
1610
|
+
// `scratch` itself - the delegation defineScopeVar exists to
|
|
1611
|
+
// prevent can only happen while the key is missing from it
|
|
1612
|
+
scratch[itemName] = item
|
|
1613
|
+
if (atName) scratch[atName] = at
|
|
1614
|
+
scratch.$index = index
|
|
1615
|
+
}
|
|
1616
|
+
key = evalExpr(keyExpr, scratch)
|
|
1617
|
+
}
|
|
1380
1618
|
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1619
|
+
if (seen.has(key) && !warnedDuplicates) {
|
|
1620
|
+
warnedDuplicates = true
|
|
1621
|
+
console.warn(`jq79: duplicate :key in :each "${node.attrs[":each"]}"; duplicates pair up by position`)
|
|
1622
|
+
}
|
|
1623
|
+
seen.add(key)
|
|
1624
|
+
const existing = previous.get(key)?.shift()
|
|
1625
|
+
|
|
1626
|
+
if (existing && Object.is(existing.item, item)) {
|
|
1627
|
+
// same reason as `scratch`: these are own writable properties of the
|
|
1628
|
+
// entry's scope from the moment it was rendered, and writing only
|
|
1629
|
+
// what moved keeps a list that never reorders from paying anything
|
|
1630
|
+
const entryScope = existing.scope
|
|
1631
|
+
if (entryScope.$index !== index) {
|
|
1632
|
+
if (readsPosition) moved.push(existing)
|
|
1633
|
+
entryScope.$index = index
|
|
1634
|
+
}
|
|
1635
|
+
if (atName && entryScope[atName] !== at) entryScope[atName] = at
|
|
1636
|
+
nextEntries.push(existing)
|
|
1637
|
+
positions.push(existing.pos)
|
|
1638
|
+
continue
|
|
1639
|
+
}
|
|
1385
1640
|
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
return { key, item, scope: itemScope, fx: itemFx, range }
|
|
1391
|
-
})
|
|
1641
|
+
if (existing) {
|
|
1642
|
+
existing.fx.dispose()
|
|
1643
|
+
removeRange(existing.range)
|
|
1644
|
+
}
|
|
1392
1645
|
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1646
|
+
const itemScope = Object.create(scope)
|
|
1647
|
+
defineScopeVar(itemScope, itemName, item)
|
|
1648
|
+
if (atName) defineScopeVar(itemScope, atName, at)
|
|
1649
|
+
defineScopeVar(itemScope, "$index", index)
|
|
1650
|
+
const itemFx = createEffectScope(scope)
|
|
1651
|
+
// bounds captured before the positioning pass inserts the entry: a
|
|
1652
|
+
// component entry is a fragment, which empties on insertion (see boundsOf)
|
|
1653
|
+
const range = boundsOf(renderNode(itemNode, itemScope, itemFx, shadow))
|
|
1654
|
+
nextEntries.push({ key, item, scope: itemScope, fx: itemFx, range, pos: index })
|
|
1655
|
+
positions.push(-1)
|
|
1656
|
+
}
|
|
1398
1657
|
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1658
|
+
// whatever no new item consumed is gone. Effects are torn down one by one
|
|
1659
|
+
// as always; the DOM goes in runs of neighbours, which is what makes
|
|
1660
|
+
// clearing a long list one mutation instead of one per row
|
|
1661
|
+
const dead = new Set<EachEntry>()
|
|
1662
|
+
previous.forEach(bucket => bucket.forEach(entry => dead.add(entry)))
|
|
1663
|
+
if (dead.size) {
|
|
1664
|
+
dead.forEach(entry => entry.fx.dispose())
|
|
1665
|
+
removeRuns(contiguousRuns(entries, entry => dead.has(entry)))
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
let prevNode: Node = anchor
|
|
1669
|
+
// rows already in order relative to each other stay where they are; the
|
|
1670
|
+
// rest are placed after the row that precedes them in the new order,
|
|
1671
|
+
// which is where the pass has just left `prevNode`
|
|
1672
|
+
const inPlace = longestIncreasingRun(positions)
|
|
1673
|
+
nextEntries.forEach((entry, index) => {
|
|
1674
|
+
if (!inPlace[index] && prevNode.nextSibling !== entry.range.first) moveRangeAfter(entry.range, prevNode)
|
|
1675
|
+
prevNode = entry.range.last
|
|
1676
|
+
})
|
|
1404
1677
|
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1408
|
-
|
|
1409
|
-
|
|
1678
|
+
// reused entries that changed position: their tracked bindings re-run off
|
|
1679
|
+
// the list notification anyway, but a binding that reads only `$index` or
|
|
1680
|
+
// the named key tracked nothing - refresh them so the move reaches those
|
|
1681
|
+
// too. Untracked, so these runs don't feed this list effect's own deps
|
|
1682
|
+
moved.forEach(entry => untracked(() => entry.fx.refresh()))
|
|
1410
1683
|
|
|
1411
|
-
|
|
1684
|
+
entries = nextEntries
|
|
1685
|
+
} finally {
|
|
1686
|
+
closeRenderPass(pass)
|
|
1687
|
+
}
|
|
1412
1688
|
})
|
|
1413
1689
|
|
|
1414
1690
|
return wrapper
|
|
@@ -1416,8 +1692,21 @@ const renderEach = (node: TemplateNode, scope: Record<string, any>, fx: EffectSc
|
|
|
1416
1692
|
|
|
1417
1693
|
// renders a list of sibling template nodes (text + elements), grouping
|
|
1418
1694
|
// consecutive :if/:elseif/:else nodes into a single conditional block
|
|
1419
|
-
|
|
1420
|
-
|
|
1695
|
+
// `into` renders straight into an element that is not in the document yet -
|
|
1696
|
+
// what renderElement does for an element's own children. Every element used to
|
|
1697
|
+
// get a DocumentFragment of its own, filled and then emptied into it: for a
|
|
1698
|
+
// 10,000-row table that is 70,000 fragments and a second pass over every node,
|
|
1699
|
+
// and the intermediate is invisible either way because the element is still
|
|
1700
|
+
// detached. Callers that need a standalone chunk (a component's content, an
|
|
1701
|
+
// :if branch) omit it and get the fragment
|
|
1702
|
+
const renderNodes = <T extends ParentNode>(
|
|
1703
|
+
nodes: (TemplateNode | string)[],
|
|
1704
|
+
scope: Record<string, any>,
|
|
1705
|
+
fx: EffectScope,
|
|
1706
|
+
shadow = false,
|
|
1707
|
+
into?: T
|
|
1708
|
+
): T | DocumentFragment => {
|
|
1709
|
+
const fragment = into ?? document.createDocumentFragment()
|
|
1421
1710
|
let i = 0
|
|
1422
1711
|
|
|
1423
1712
|
while (i < nodes.length) {
|