@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 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 delegated events 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.
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 are delegated after normal event bubbling and run from the target toward matching Kudzu ancestors in deterministic order. Delegated handlers cannot call or reference `preventDefault`, `stopPropagation`, or `stopImmediatePropagation`; the compiler rejects those methods because external ESM cannot apply them with correct synchronous DOM semantics.
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 | **373 ms** |
253
- | Astro | Yes | **158 B** | **365 B** | 877 ms |
254
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 837 ms |
255
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 626 ms |
256
- | Vue CSR | No | 24.0 KB | 60.3 KB | 778 ms |
257
- | React CSR | No | 59.2 KB | 189.0 KB | 1049 ms |
258
- | Next.js | Yes | 182.1 KB | 652.2 KB | 3099 ms |
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 | **478 ms** |
269
- | Astro | Yes | **0 B** | **3.0 KB** | 1253 ms |
270
- | Svelte CSR | No | 10.2 KB | 27.2 KB | 1018 ms |
271
- | Qwik CSR | No | 20.2 KB | 59.6 KB | 784 ms |
272
- | Vue CSR | No | 24.2 KB | 62.3 KB | 960 ms |
273
- | React CSR | No | 59.8 KB | 192.3 KB | 1253 ms |
274
- | Next.js | Yes | 182.6 KB | 663.6 KB | 3880 ms |
275
-
276
- Benchmark snapshot collected on July 21, 2026 with Node 24.14.0 on an Intel i5-9500. 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. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
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
 
@@ -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"), specializeEvents(nativeRuntime, nativeEvents))
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
  }
@@ -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
- return `<${tag}${attributes}>${await renderNode(props.children, childNamespace, childSelectValue)}</${tag}>`
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, new Set())
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
- for (const { item, key, token } of entries) {
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
- const fragment = list.start.content.cloneNode(true)
78
- node = fragment.querySelector(`[data-k-list-root="${list.descriptor.id}"]`)
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
- ;(list.container ?? list.start.parentNode).insertBefore(fragment, list.boundary)
84
- list.container ??= node.parentNode
85
- mountDom(node)
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
- const parent = list.container ?? list.start.parentNode
97
- for (const [, node] of next) parent.insertBefore(node, list.boundary)
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
- for (const marker of matching(root, "template[data-k-list-text]")) {
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 matching(root, "[data-k-list-attrs]")) {
108
- for (const [target, field] of JSON.parse(node.dataset.kListAttrs)) patchBinding(node, target, item?.[field])
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 matching(root, "[data-k-list-events]")) {
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 matching(root, "template[data-k-list-expression]")) {
117
- evaluate(JSON.parse(marker.dataset.kListExpression), item).then(value => {
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 matching(root, "[data-k-list-expression-attrs]")) {
122
- for (const [target, module, handler] of JSON.parse(node.dataset.kListExpressionAttrs)) {
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(value == null ? "" : String(value)))
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
- const prototype = Object.getPrototypeOf(value)
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 descriptors = Object.getOwnPropertyDescriptors(value)
201
- if (Array.isArray(value) && Object.keys(descriptors).some(key => key !== "length" && !/^(0|[1-9]\d*)$/.test(key))) throw new Error("Keyed list arrays must not contain custom properties")
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 [key, descriptor] of Object.entries(descriptors)) {
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
- for (const eventName of eventNames) {
41
- document.addEventListener(eventName, event => {
42
- try {
43
- dispatchNative(event, snapshotNativeTargets(event, eventName), modules).catch(error => console.error(error))
44
- } catch (error) {
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 snapshotNativeTargets(event, eventName) {
52
- const selector = `[data-k-native-${eventName}]`
53
- const targets = []
54
- for (let target = event.target.closest(selector); target; target = target.parentElement?.closest(selector)) {
55
- targets.push({ target, native: JSON.parse(target.dataset[`kNative${capitalize(eventName)}`]) })
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
- async function dispatchNative(event, targets, modules) {
61
- for (const { target, native } of targets) {
62
- let modulePromise = modules.get(native.module)
63
- if (!modulePromise) {
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 delegatedEvent(event, currentTarget) {
78
- return new Proxy(event, {
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",