@kudzujs/core 0.4.3 → 0.4.5
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/README.md +34 -18
- package/framework/build.mjs +11 -33
- package/framework/core.mjs +4 -1
- package/framework/list-runtime.js +92 -27
- package/framework/native-runtime.js +32 -40
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -166,7 +166,7 @@ const [items, setItems] = useState([
|
|
|
166
166
|
)}</ul>
|
|
167
167
|
```
|
|
168
168
|
|
|
169
|
-
Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Item-local handlers use
|
|
169
|
+
Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Item-local handlers use direct DOM listeners and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
|
|
170
170
|
|
|
171
171
|
Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags, fragments, and reactive `style`, `ref`, or `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
|
|
172
172
|
|
|
@@ -190,7 +190,7 @@ async function load() {
|
|
|
190
190
|
|
|
191
191
|
Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, class instances, and imported helper functions are not yet supported as captures.
|
|
192
192
|
|
|
193
|
-
Native handlers
|
|
193
|
+
Native handlers use direct DOM listeners with normal `currentTarget`, bubbling, default-action, and propagation semantics. Handler modules load before listener registration, so `preventDefault`, `stopPropagation`, and `stopImmediatePropagation` work synchronously as expected.
|
|
194
194
|
|
|
195
195
|
## Rendering
|
|
196
196
|
|
|
@@ -249,13 +249,13 @@ Same counter with initial value `7` and increment/decrement buttons:
|
|
|
249
249
|
|
|
250
250
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
251
251
|
|---|---:|---:|---:|---:|
|
|
252
|
-
| Kudzu | Yes | 487 B | 1.4 KB | **
|
|
253
|
-
| Astro | Yes | **158 B** | **365 B** |
|
|
254
|
-
| Svelte CSR | No | 10.5 KB | 26.9 KB |
|
|
255
|
-
| Qwik CSR | No | 20.6 KB | 57.8 KB |
|
|
256
|
-
| Vue CSR | No | 24.0 KB | 60.3 KB |
|
|
257
|
-
| React CSR | No | 59.2 KB | 189.0 KB |
|
|
258
|
-
| Next.js | Yes | 182.1 KB | 652.2 KB |
|
|
252
|
+
| Kudzu | Yes | 487 B | 1.4 KB | **363 ms** |
|
|
253
|
+
| Astro | Yes | **158 B** | **365 B** | 851 ms |
|
|
254
|
+
| Svelte CSR | No | 10.5 KB | 26.9 KB | 827 ms |
|
|
255
|
+
| Qwik CSR | No | 20.6 KB | 57.8 KB | 598 ms |
|
|
256
|
+
| Vue CSR | No | 24.0 KB | 60.3 KB | 762 ms |
|
|
257
|
+
| React CSR | No | 59.2 KB | 189.0 KB | 1018 ms |
|
|
258
|
+
| Next.js | Yes | 182.1 KB | 652.2 KB | 2989 ms |
|
|
259
259
|
|
|
260
260
|
Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
|
|
261
261
|
|
|
@@ -265,15 +265,31 @@ Same content and CSS across every fixture:
|
|
|
265
265
|
|
|
266
266
|
| Framework | Initial content | Initial JS gzip | Total output | Clean build |
|
|
267
267
|
|---|---:|---:|---:|---:|
|
|
268
|
-
| Kudzu | Yes | **0 B** | 3.2 KB | **
|
|
269
|
-
| Astro | Yes | **0 B** | **3.0 KB** |
|
|
270
|
-
| Svelte CSR | No | 10.2 KB | 27.2 KB |
|
|
271
|
-
| Qwik CSR | No | 20.2 KB | 59.6 KB |
|
|
272
|
-
| Vue CSR | No | 24.2 KB | 62.3 KB |
|
|
273
|
-
| React CSR | No | 59.8 KB | 192.3 KB |
|
|
274
|
-
| Next.js | Yes | 182.6 KB | 663.6 KB |
|
|
275
|
-
|
|
276
|
-
|
|
268
|
+
| Kudzu | Yes | **0 B** | 3.2 KB | **380 ms** |
|
|
269
|
+
| Astro | Yes | **0 B** | **3.0 KB** | 965 ms |
|
|
270
|
+
| Svelte CSR | No | 10.2 KB | 27.2 KB | 824 ms |
|
|
271
|
+
| Qwik CSR | No | 20.2 KB | 59.6 KB | 594 ms |
|
|
272
|
+
| Vue CSR | No | 24.2 KB | 62.3 KB | 755 ms |
|
|
273
|
+
| React CSR | No | 59.8 KB | 192.3 KB | 1041 ms |
|
|
274
|
+
| Next.js | Yes | 182.6 KB | 663.6 KB | 2975 ms |
|
|
275
|
+
|
|
276
|
+
### 1,000-item Keyed List
|
|
277
|
+
|
|
278
|
+
The list starts with 1,000 keyed items, then updates every label, reverses the order, removes odd IDs, and adds 500 items. Browser timings are medians from seven fresh headless Chrome runs.
|
|
279
|
+
|
|
280
|
+
| Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
|
|
281
|
+
|---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
|
|
282
|
+
| Astro | Yes | **324 B** | **43.6 KB** | 867 ms | **3.5 ms** | **25.4 ms** | 10.7 ms | **18.0 ms** | **57.6 ms** |
|
|
283
|
+
| Kudzu | Yes | 5.7 KB | 180.8 KB | **417 ms** | 8.3 ms | 28.5 ms | **10.2 ms** | 20.5 ms | **67.5 ms** |
|
|
284
|
+
| Vue CSR | No | 24.3 KB | 61.3 KB | 771 ms | 9.7 ms | 31.3 ms | 9.4 ms | 18.1 ms | 68.5 ms |
|
|
285
|
+
| React CSR | No | 59.3 KB | 189.4 KB | 1024 ms | 8.7 ms | 32.3 ms | 12.4 ms | 18.1 ms | 71.5 ms |
|
|
286
|
+
| Next.js | Yes | 182.2 KB | 695.2 KB | 3049 ms | 6.7 ms | 35.0 ms | 16.3 ms | 19.4 ms | 77.4 ms |
|
|
287
|
+
| Qwik CSR | No | 22.2 KB | 64.1 KB | 602 ms | 14.5 ms | 19.9 ms | 31.3 ms | 18.9 ms | 84.6 ms |
|
|
288
|
+
| Svelte CSR | No | 12.9 KB | 33.1 KB | 859 ms | 5.2 ms | 65.4 ms | 10.7 ms | 19.9 ms | 101.2 ms |
|
|
289
|
+
|
|
290
|
+
Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu's keyed-list operations total 67.5 ms, ahead of the React fixture's 71.5 ms and within 9.9 ms of Astro across all four operations.
|
|
291
|
+
|
|
292
|
+
Benchmark snapshot collected on July 22, 2026 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
|
|
277
293
|
|
|
278
294
|
## Development
|
|
279
295
|
|
package/framework/build.mjs
CHANGED
|
@@ -64,12 +64,13 @@ export async function build({ quiet = false } = {}) {
|
|
|
64
64
|
await mkdir(assetsDirectory, { recursive: true })
|
|
65
65
|
const commandEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.commands).map(event => event.event)))].sort()
|
|
66
66
|
const nativeEvents = [...new Set(plans.flatMap(plan => plan.events.filter(event => event.native).map(event => event.event)))].sort()
|
|
67
|
+
const nativeModules = handlerModules.filter(module => module.hasNativeHandlers).map(module => `/assets/${module.path}`)
|
|
68
|
+
const hasNativeHandlers = nativeModules.length > 0
|
|
67
69
|
if (behaviorCount) {
|
|
68
|
-
const runtimeFile = bindingCount || listCount ? "./shared-runtime.js" : "./runtime.js"
|
|
70
|
+
const runtimeFile = bindingCount || listCount || hasNativeHandlers ? "./shared-runtime.js" : "./runtime.js"
|
|
69
71
|
const runtime = specializeRuntime(await readFile(new URL(runtimeFile, import.meta.url), "utf8"), commandEvents, stateSeedCount > 0)
|
|
70
72
|
await writeFile(join(assetsDirectory, "kudzu.js"), runtime)
|
|
71
73
|
}
|
|
72
|
-
const hasNativeHandlers = handlerModules.some(module => module.hasNativeHandlers)
|
|
73
74
|
if (bindingCount || hasNativeHandlers) await cp(new URL("./serialization.js", import.meta.url), join(assetsDirectory, "kudzu-serialization.js"))
|
|
74
75
|
if (bindingCount) {
|
|
75
76
|
const bindingRuntime = (await readFile(new URL("./binding-runtime.js", import.meta.url), "utf8"))
|
|
@@ -84,9 +85,9 @@ export async function build({ quiet = false } = {}) {
|
|
|
84
85
|
}
|
|
85
86
|
if (hasNativeHandlers) {
|
|
86
87
|
const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
|
|
87
|
-
.replace('"./runtime.js"', '"./kudzu.js"')
|
|
88
|
+
.replace('"./shared-runtime.js"', '"./kudzu.js"')
|
|
88
89
|
.replace('"./serialization.js"', '"./kudzu-serialization.js"')
|
|
89
|
-
await writeFile(join(assetsDirectory, "kudzu-native.js"),
|
|
90
|
+
await writeFile(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules))
|
|
90
91
|
}
|
|
91
92
|
for (const handlerModule of handlerModules) {
|
|
92
93
|
const output = join(assetsDirectory, handlerModule.path)
|
|
@@ -104,6 +105,12 @@ function specializeEvents(source, events) {
|
|
|
104
105
|
return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
|
|
105
106
|
}
|
|
106
107
|
|
|
108
|
+
function specializeNativeRuntime(source, events, modules) {
|
|
109
|
+
const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
|
|
110
|
+
const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
|
|
111
|
+
return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
|
|
112
|
+
}
|
|
113
|
+
|
|
107
114
|
function specializeRuntime(source, events, hasStateSeed) {
|
|
108
115
|
const specialized = specializeEvents(source, events)
|
|
109
116
|
if (hasStateSeed) return specialized
|
|
@@ -661,7 +668,6 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
|
|
|
661
668
|
if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
|
|
662
669
|
if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
|
|
663
670
|
|
|
664
|
-
rejectNativeEventControls(expression)
|
|
665
671
|
const optimized = compileOptimizedEvent(expression, setters, factory)
|
|
666
672
|
if (optimized) return optimized
|
|
667
673
|
|
|
@@ -684,34 +690,6 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
|
|
|
684
690
|
])
|
|
685
691
|
}
|
|
686
692
|
|
|
687
|
-
function rejectNativeEventControls(expression) {
|
|
688
|
-
const controls = new Set(["preventDefault", "stopPropagation", "stopImmediatePropagation"])
|
|
689
|
-
const found = new Set()
|
|
690
|
-
const eventAliases = new Set()
|
|
691
|
-
const parameter = expression.parameters[0]?.name
|
|
692
|
-
if (parameter && ts.isIdentifier(parameter)) eventAliases.add(parameter.text)
|
|
693
|
-
const visit = node => {
|
|
694
|
-
if (ts.isIdentifier(node) && controls.has(node.text)) found.add(node.text)
|
|
695
|
-
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isIdentifier(unwrapEventAlias(node.initializer)) && eventAliases.has(unwrapEventAlias(node.initializer).text)) {
|
|
696
|
-
eventAliases.add(node.name.text)
|
|
697
|
-
}
|
|
698
|
-
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left) && ts.isIdentifier(unwrapEventAlias(node.right)) && eventAliases.has(unwrapEventAlias(node.right).text)) eventAliases.add(node.left.text)
|
|
699
|
-
if (ts.isElementAccessExpression(node) && ts.isIdentifier(unwrapEventAlias(node.expression)) && eventAliases.has(unwrapEventAlias(node.expression).text)) {
|
|
700
|
-
if (ts.isStringLiteral(node.argumentExpression) && controls.has(node.argumentExpression.text)) found.add(node.argumentExpression.text)
|
|
701
|
-
else if (!ts.isStringLiteral(node.argumentExpression)) for (const control of controls) found.add(control)
|
|
702
|
-
}
|
|
703
|
-
ts.forEachChild(node, visit)
|
|
704
|
-
}
|
|
705
|
-
for (const parameter of expression.parameters) visit(parameter)
|
|
706
|
-
visit(expression.body)
|
|
707
|
-
if (found.size) throw new Error(`Delegated native handlers do not support event control methods: ${[...found].sort().join(", ")}`)
|
|
708
|
-
}
|
|
709
|
-
|
|
710
|
-
function unwrapEventAlias(node) {
|
|
711
|
-
if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node)) return unwrapEventAlias(node.expression)
|
|
712
|
-
return node
|
|
713
|
-
}
|
|
714
|
-
|
|
715
693
|
function nativeStateNames(expression, setters) {
|
|
716
694
|
return referencedStateNames(expression.body, setters, expression)
|
|
717
695
|
}
|
package/framework/core.mjs
CHANGED
|
@@ -320,6 +320,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
320
320
|
|
|
321
321
|
const tag = node.type
|
|
322
322
|
const props = node.props ?? {}
|
|
323
|
+
const directListText = props.children?.[listFieldMarker] ? props.children : undefined
|
|
323
324
|
const childSelectValue = tag === "select"
|
|
324
325
|
? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
|
|
325
326
|
: selectValue
|
|
@@ -414,12 +415,14 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
|
|
|
414
415
|
if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
|
|
415
416
|
if (listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
|
|
416
417
|
if (listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
|
|
418
|
+
if (directListText) attributes += ` data-k-list-text="${escapeAttribute(directListText.field)}"`
|
|
417
419
|
|
|
418
420
|
if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
|
|
419
421
|
|
|
420
422
|
const voidElements = new Set(["area", "base", "br", "col", "embed", "hr", "img", "input", "link", "meta", "source", "track", "wbr"])
|
|
421
423
|
if (voidElements.has(tag)) return `<${tag}${attributes}>`
|
|
422
|
-
|
|
424
|
+
const children = directListText ? escapeHtml(directListText.value ?? "") : await renderNode(props.children, childNamespace, childSelectValue)
|
|
425
|
+
return `<${tag}${attributes}>${children}</${tag}>`
|
|
423
426
|
}
|
|
424
427
|
|
|
425
428
|
async function renderList(node, namespace, selectValue) {
|
|
@@ -5,6 +5,8 @@ const listRegistrations = new WeakMap()
|
|
|
5
5
|
const mountedLists = new WeakSet()
|
|
6
6
|
const imports = new Map()
|
|
7
7
|
const revisions = new WeakMap()
|
|
8
|
+
const itemParts = new WeakMap()
|
|
9
|
+
const itemPartsSelector = "[data-k-list-text],[data-k-list-attrs],[data-k-list-events],[data-k-list-expression],[data-k-list-expression-attrs]"
|
|
8
10
|
|
|
9
11
|
function commitLists(id) {
|
|
10
12
|
const lists = listTargets.get(id)
|
|
@@ -27,10 +29,13 @@ function mountLists(root) {
|
|
|
27
29
|
mountedLists.add(start)
|
|
28
30
|
const descriptor = JSON.parse(start.dataset.kList)
|
|
29
31
|
const roots = [...start.ownerDocument.querySelectorAll("[data-k-list-item]")].filter(node => JSON.parse(node.dataset.kListItem)[0] === descriptor.id)
|
|
32
|
+
const items = browserState.get(descriptor.state)
|
|
33
|
+
for (const root of roots) listItemParts(root)
|
|
30
34
|
const list = {
|
|
31
35
|
start,
|
|
32
36
|
descriptor,
|
|
33
37
|
roots: new Map(roots.map(node => [keyToken(JSON.parse(node.dataset.kListItem)[1]), node])),
|
|
38
|
+
values: new Map(items.map(item => [keyToken(item[descriptor.key]), JSON.stringify(item)])),
|
|
34
39
|
container: roots[0]?.parentNode,
|
|
35
40
|
boundary: roots.length ? roots.at(-1).nextSibling : findEnd(start, descriptor.id)
|
|
36
41
|
}
|
|
@@ -60,66 +65,105 @@ function updateList(list) {
|
|
|
60
65
|
if (!Array.isArray(items)) throw new Error("Keyed list state must remain an array")
|
|
61
66
|
const entries = []
|
|
62
67
|
const keys = new Set()
|
|
68
|
+
const seen = new Set()
|
|
63
69
|
for (const item of items) {
|
|
64
70
|
const key = item?.[list.descriptor.key]
|
|
65
71
|
if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
|
|
66
72
|
assertListItem(item)
|
|
67
|
-
assertListValue(item,
|
|
73
|
+
assertListValue(item, seen, true)
|
|
68
74
|
const token = keyToken(key)
|
|
69
75
|
if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
|
|
70
76
|
keys.add(token)
|
|
71
|
-
entries.push({ item, key, token })
|
|
77
|
+
entries.push({ item, key, token, value: JSON.stringify(item) })
|
|
72
78
|
}
|
|
73
79
|
const next = []
|
|
74
|
-
|
|
80
|
+
const values = new Map()
|
|
81
|
+
const parent = list.container ?? list.start.parentNode
|
|
82
|
+
const additions = parent.ownerDocument.createDocumentFragment()
|
|
83
|
+
let added = false
|
|
84
|
+
for (const { item, key, token, value } of entries) {
|
|
75
85
|
let node = list.roots.get(token)
|
|
76
86
|
if (!node) {
|
|
77
|
-
|
|
78
|
-
node
|
|
87
|
+
node = list.start.content.firstElementChild?.cloneNode(true)
|
|
88
|
+
if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
|
|
79
89
|
if (!node) throw new Error("Keyed list template has no root element")
|
|
80
90
|
node.removeAttribute("data-k-list-root")
|
|
81
91
|
node.dataset.kListItem = JSON.stringify([list.descriptor.id, key])
|
|
82
92
|
fillListItem(node, item)
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
} else {
|
|
93
|
+
additions.append(node)
|
|
94
|
+
added = true
|
|
95
|
+
} else if (list.values.get(token) !== value) {
|
|
87
96
|
fillListItem(node, item)
|
|
88
97
|
}
|
|
89
98
|
next.push([token, node])
|
|
99
|
+
values.set(token, value)
|
|
90
100
|
}
|
|
91
101
|
for (const [token, node] of list.roots) {
|
|
92
102
|
if (keys.has(token)) continue
|
|
93
103
|
unmountDom(node)
|
|
94
104
|
node.remove()
|
|
95
105
|
}
|
|
96
|
-
|
|
97
|
-
|
|
106
|
+
if (added) {
|
|
107
|
+
mountDom(additions)
|
|
108
|
+
parent.insertBefore(additions, list.boundary)
|
|
109
|
+
list.container ??= parent
|
|
110
|
+
}
|
|
111
|
+
let anchor = list.boundary
|
|
112
|
+
let misplaced = 0
|
|
113
|
+
for (let index = next.length - 1; index >= 0; index--) {
|
|
114
|
+
const node = next[index][1]
|
|
115
|
+
if (node.nextSibling !== anchor) misplaced++
|
|
116
|
+
anchor = node
|
|
117
|
+
}
|
|
118
|
+
if (misplaced > next.length / 2) {
|
|
119
|
+
const reordered = parent.ownerDocument.createDocumentFragment()
|
|
120
|
+
reordered.append(...next.map(([, node]) => node))
|
|
121
|
+
parent.insertBefore(reordered, list.boundary)
|
|
122
|
+
} else if (misplaced) {
|
|
123
|
+
anchor = list.boundary
|
|
124
|
+
for (let index = next.length - 1; index >= 0; index--) {
|
|
125
|
+
const node = next[index][1]
|
|
126
|
+
if (node.nextSibling !== anchor) parent.insertBefore(node, anchor)
|
|
127
|
+
anchor = node
|
|
128
|
+
}
|
|
129
|
+
}
|
|
98
130
|
list.roots = new Map(next)
|
|
131
|
+
list.values = values
|
|
99
132
|
}
|
|
100
133
|
|
|
101
134
|
function fillListItem(root, item) {
|
|
102
135
|
const revision = (revisions.get(root) ?? 0) + 1
|
|
103
136
|
revisions.set(root, revision)
|
|
104
|
-
|
|
137
|
+
const parts = listItemParts(root)
|
|
138
|
+
for (const node of parts.directTexts) {
|
|
139
|
+
const text = item?.[node.dataset.kListText]
|
|
140
|
+
const value = text == null ? "" : String(text)
|
|
141
|
+
const current = node.firstChild
|
|
142
|
+
if (current?.nodeType === Node.TEXT_NODE && !current.nextSibling) {
|
|
143
|
+
if (current.data !== value) current.data = value
|
|
144
|
+
} else {
|
|
145
|
+
node.textContent = value
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
for (const marker of parts.texts) {
|
|
105
149
|
patchListText(marker, "template[data-k-list-text-end]", item?.[marker.dataset.kListText])
|
|
106
150
|
}
|
|
107
|
-
for (const node of
|
|
108
|
-
for (const [target, field] of
|
|
151
|
+
for (const [node, attributes] of parts.attributes) {
|
|
152
|
+
for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
|
|
109
153
|
}
|
|
110
|
-
for (const node of
|
|
154
|
+
for (const node of parts.events) {
|
|
111
155
|
for (const [event, native] of JSON.parse(node.dataset.kListEvents)) {
|
|
112
156
|
native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
|
|
113
157
|
node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
|
|
114
158
|
}
|
|
115
159
|
}
|
|
116
|
-
for (const marker of
|
|
117
|
-
evaluate(
|
|
160
|
+
for (const [marker, descriptor] of parts.expressions) {
|
|
161
|
+
evaluate(descriptor, item).then(value => {
|
|
118
162
|
if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
|
|
119
163
|
}).catch(error => console.error(error))
|
|
120
164
|
}
|
|
121
|
-
for (const node of
|
|
122
|
-
for (const [target, module, handler] of
|
|
165
|
+
for (const [node, attributes] of parts.expressionAttributes) {
|
|
166
|
+
for (const [target, module, handler] of attributes) {
|
|
123
167
|
evaluate({ module, handler }, item).then(value => {
|
|
124
168
|
if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
|
|
125
169
|
}).catch(error => console.error(error))
|
|
@@ -127,15 +171,36 @@ function fillListItem(root, item) {
|
|
|
127
171
|
}
|
|
128
172
|
}
|
|
129
173
|
|
|
174
|
+
function listItemParts(root) {
|
|
175
|
+
let parts = itemParts.get(root)
|
|
176
|
+
if (parts) return parts
|
|
177
|
+
parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [] }
|
|
178
|
+
for (const node of matching(root, itemPartsSelector)) {
|
|
179
|
+
if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push(node)
|
|
180
|
+
if (node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
|
|
181
|
+
if (node.hasAttribute("data-k-list-events")) parts.events.push(node)
|
|
182
|
+
if (node.hasAttribute("data-k-list-expression")) parts.expressions.push([node, JSON.parse(node.dataset.kListExpression)])
|
|
183
|
+
if (node.hasAttribute("data-k-list-expression-attrs")) parts.expressionAttributes.push([node, JSON.parse(node.dataset.kListExpressionAttrs)])
|
|
184
|
+
}
|
|
185
|
+
itemParts.set(root, parts)
|
|
186
|
+
return parts
|
|
187
|
+
}
|
|
188
|
+
|
|
130
189
|
function patchListText(marker, endSelector, value) {
|
|
131
190
|
let end = marker.nextSibling
|
|
132
191
|
while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches(endSelector))) end = end.nextSibling
|
|
133
192
|
if (!end) throw new Error("Keyed list text marker has no end")
|
|
193
|
+
const text = value == null ? "" : String(value)
|
|
194
|
+
const current = marker.nextSibling
|
|
195
|
+
if (current?.nodeType === Node.TEXT_NODE && current.nextSibling === end) {
|
|
196
|
+
if (current.data !== text) current.data = text
|
|
197
|
+
return
|
|
198
|
+
}
|
|
134
199
|
const range = marker.ownerDocument.createRange()
|
|
135
200
|
range.setStartAfter(marker)
|
|
136
201
|
range.setEndBefore(end)
|
|
137
202
|
range.deleteContents()
|
|
138
|
-
end.before(marker.ownerDocument.createTextNode(
|
|
203
|
+
end.before(marker.ownerDocument.createTextNode(text))
|
|
139
204
|
}
|
|
140
205
|
|
|
141
206
|
function evaluate(descriptor, item) {
|
|
@@ -189,19 +254,19 @@ function assertListItem(item) {
|
|
|
189
254
|
if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
|
|
190
255
|
}
|
|
191
256
|
|
|
192
|
-
function assertListValue(value, seen) {
|
|
257
|
+
function assertListValue(value, seen, root = false) {
|
|
193
258
|
if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)) return
|
|
194
259
|
if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
|
|
195
260
|
if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
|
|
196
|
-
|
|
197
|
-
if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
|
|
198
|
-
if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
|
|
261
|
+
if (!root && !Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
|
|
199
262
|
seen.add(value)
|
|
200
|
-
const
|
|
201
|
-
if (
|
|
263
|
+
const keys = Reflect.ownKeys(value)
|
|
264
|
+
if (keys.some(key => typeof key === "symbol")) throw new Error("Keyed list items must not contain symbols")
|
|
265
|
+
if (Array.isArray(value) && keys.some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) throw new Error("Keyed list arrays must not contain custom properties")
|
|
202
266
|
if (Array.isArray(value) && Object.keys(value).length !== value.length) throw new Error("Keyed list arrays must not contain holes")
|
|
203
|
-
for (const
|
|
267
|
+
for (const key of keys) {
|
|
204
268
|
if (Array.isArray(value) && key === "length") continue
|
|
269
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
205
270
|
if (!descriptor.enumerable) throw new Error("Keyed list items must not contain non-enumerable properties")
|
|
206
271
|
if (!("value" in descriptor)) throw new Error("Keyed list items must not contain accessors")
|
|
207
272
|
assertListValue(descriptor.value, seen)
|
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { browserState, commitDom } from "./runtime.js"
|
|
1
|
+
import { browserState, commitDom, registerMountHook, registerUnmountHook } from "./shared-runtime.js"
|
|
2
2
|
import { deserialize } from "./serialization.js"
|
|
3
3
|
|
|
4
|
+
const registrations = new WeakMap()
|
|
5
|
+
|
|
4
6
|
export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
|
|
5
7
|
const changed = new Set()
|
|
6
8
|
let scheduled = false
|
|
@@ -34,54 +36,44 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
|
|
|
34
36
|
}
|
|
35
37
|
|
|
36
38
|
if (typeof document !== "undefined") {
|
|
37
|
-
const modules = new Map()
|
|
38
|
-
|
|
39
39
|
const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
console.error(error)
|
|
46
|
-
}
|
|
47
|
-
})
|
|
48
|
-
}
|
|
40
|
+
const modules = new Map([])
|
|
41
|
+
const mount = root => mountNative(root, eventNames, modules)
|
|
42
|
+
registerMountHook(mount)
|
|
43
|
+
registerUnmountHook(unmountNative)
|
|
44
|
+
mount(document)
|
|
49
45
|
}
|
|
50
46
|
|
|
51
|
-
function
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
47
|
+
function mountNative(root, eventNames, modules) {
|
|
48
|
+
for (const eventName of eventNames) {
|
|
49
|
+
for (const node of matching(root, `[data-k-native-${eventName}]`)) {
|
|
50
|
+
const listeners = registrations.get(node) ?? new Map()
|
|
51
|
+
if (listeners.has(eventName)) continue
|
|
52
|
+
const listener = event => {
|
|
53
|
+
try {
|
|
54
|
+
const native = JSON.parse(node.dataset[`kNative${capitalize(eventName)}`])
|
|
55
|
+
const result = modules.get(native.module)[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), event)
|
|
56
|
+
if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error(error)
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
node.addEventListener(eventName, listener)
|
|
62
|
+
listeners.set(eventName, listener)
|
|
63
|
+
registrations.set(node, listeners)
|
|
64
|
+
}
|
|
56
65
|
}
|
|
57
|
-
return targets
|
|
58
66
|
}
|
|
59
67
|
|
|
60
|
-
|
|
61
|
-
for (const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
modulePromise = import(native.module)
|
|
65
|
-
modules.set(native.module, modulePromise)
|
|
66
|
-
}
|
|
67
|
-
try {
|
|
68
|
-
const module = await modulePromise
|
|
69
|
-
const result = module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target))
|
|
70
|
-
if (result && typeof result.then === "function") result.catch(error => console.error(error))
|
|
71
|
-
} catch (error) {
|
|
72
|
-
console.error(error)
|
|
73
|
-
}
|
|
68
|
+
function unmountNative(root) {
|
|
69
|
+
for (const node of matching(root, "*")) {
|
|
70
|
+
for (const [eventName, listener] of registrations.get(node) ?? []) node.removeEventListener(eventName, listener)
|
|
71
|
+
registrations.delete(node)
|
|
74
72
|
}
|
|
75
73
|
}
|
|
76
74
|
|
|
77
|
-
function
|
|
78
|
-
return
|
|
79
|
-
get(source, property) {
|
|
80
|
-
if (property === "currentTarget") return currentTarget
|
|
81
|
-
const value = Reflect.get(source, property, source)
|
|
82
|
-
return typeof value === "function" ? value.bind(source) : value
|
|
83
|
-
}
|
|
84
|
-
})
|
|
75
|
+
function matching(root, selector) {
|
|
76
|
+
return [...(root.matches?.(selector) ? [root] : []), ...(root.querySelectorAll?.(selector) ?? [])]
|
|
85
77
|
}
|
|
86
78
|
|
|
87
79
|
function capitalize(value) {
|