@kudzujs/core 0.6.19 → 0.6.21

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
@@ -79,7 +79,7 @@ export default function HomePage() {
79
79
  npm run dev
80
80
  ```
81
81
 
82
- Pages live in `src/pages`; `index.tsx` maps to `/`. `npm run dev` serves locally on `127.0.0.1`, reloads the browser after successful rebuilds, and shows build failures in an error overlay. Across that full-page reload, compatible Kudzu logical state is briefly preserved by route-unique state variable name for the current pathname, query, and hash, including controlled properties, conditions, and keyed-list arrays. Renamed, removed, and duplicate-named state is skipped. Uncontrolled DOM state, focus, selection, and imperative DOM mutations are not preserved. Set `PORT` to change the default port of `3000`. The development client and state snapshot are dev-only; production output in `dist/` is unaffected.
82
+ Pages live in `src/pages`; `index.tsx` maps to `/`. `npm run dev` serves locally on `127.0.0.1`, reloads the browser after successful rebuilds, and shows build failures in an error overlay. Across that full-page reload, compatible Kudzu logical state is briefly preserved by route-unique state variable name for the current pathname, query, and hash, including controlled properties, conditions, and keyed-list arrays. Renamed, removed, and duplicate-named state is skipped. Uncontrolled DOM state, focus, selection, and imperative DOM mutations are not preserved. The server starts at `PORT` or `3000` and increments until it finds an available port. Set `HOST=0.0.0.0` when a local reverse proxy or container must reach the server. The development client and state snapshot are dev-only; production output in `dist/` is unaffected.
83
83
 
84
84
  Dynamic static pages use bracket parameters and `getStaticPaths()`:
85
85
 
@@ -388,7 +388,18 @@ return <ItemList items={items} />
388
388
 
389
389
  The original row component remains reusable across multiple lists and ordinary JSX. State-backed list wrappers and row components are specialized to intrinsic JSX at build time; no component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX, in one top-level immutable `const` rendered once as a JSX child, or in one synchronous wrapper receiving the state identifier as a direct prop. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Single-level item-local `&&` and ternary JSX conditions patch only their bounded branch and mount or unmount its handlers. Item-local handlers and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected and clean up when it is removed. A direct primitive item dependency such as `[item.name]`, optionally mixed with state as `[version, item.name]`, reruns only rows whose selected value changed; the replacement setup receives the complete latest item. Unrelated fields and reorder do not rerun it, while a key change removes and mounts the row. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
390
390
 
391
- 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 local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. State-backed list wrappers use one destructured props parameter, an intrinsic return root, no effects, and a direct local-state prop. Same-file wrappers must be unexported and state-backed at every call; relative default, named/aliased, and direct named re-export wrappers are specialized per qualifying call. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. A list alias may only be rendered once and cannot be read by other JavaScript. 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, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package, namespace, and star-export list wrappers, package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions, lists, or component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
391
+ One nested keyed map may read a direct array property of its parent item. This supports category/item data populated after mount while preserving both parent and child DOM identity across updates and reorder:
392
+
393
+ ```tsx
394
+ {categories.map(category => <section key={category.id}>
395
+ <h2>{category.title}</h2>
396
+ <ul>{category.items.map(item => <li key={item.id}>{item.title}</li>)}</ul>
397
+ </section>)}
398
+ ```
399
+
400
+ The nested collection must be `parent.<field>`, the child row must have one intrinsic root, and child handlers may capture the child item. A second child list, third nesting level, computed collection, parent-item capture from the child row, child conditions, child effects, child components, and child row-local state remain unsupported.
401
+
402
+ 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 local-state `.map`, one identifier callback parameter, one intrinsic JSX root or top-level local or relative-imported row component, and `key={item.<field>}`. State-backed list wrappers use one destructured props parameter, an intrinsic return root, no effects, and a direct local-state prop. Same-file wrappers must be unexported and state-backed at every call; relative default, named/aliased, and direct named re-export wrappers are specialized per qualifying call. Row components accept destructured projected props, top-level single-`const` calculations and inline effects before one intrinsic return. Effect dependencies inside a row may be empty, direct primitive Kudzu state identifiers, or direct `item.<field>` properties whose selected values remain JSON-safe primitives. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` dependencies are rejected. A list alias may only be rendered once and cannot be read by other JavaScript. 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, imported helpers used inside calculations, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Package, namespace, and star-export list wrappers, package or namespace row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, nested item conditions beyond the direct one-level form above, component tags, refs, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
392
403
 
393
404
  The focused wrapper fixture emits 1,393 B raw / 500 B gzip HTML and 10,719 B raw / 4,665 B gzip JavaScript across its route capabilities. After one warm-up, seven clean builds measured 314.1, 325.3, 322.3, 327.2, 336.1, 322.4, and 315.0 ms, with a 322.4 ms median.
394
405
 
@@ -65,6 +65,7 @@ export async function build({ quiet = false, minify = true } = {}) {
65
65
  const plans = []
66
66
  const pageEntries = []
67
67
  const effectEntries = []
68
+ const nativeEntries = []
68
69
  const paramEntries = []
69
70
  const rewrites = []
70
71
  const emittedRoutes = new Set()
@@ -103,6 +104,7 @@ export async function build({ quiet = false, minify = true } = {}) {
103
104
  const navigationGroup = navigationByRoute.get(applicationRoute)
104
105
  const navigable = Boolean(navigationGroup)
105
106
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
107
+ const nativePath = `native/${route ? `${route}/index` : "index"}.js`
106
108
  const paramPath = `params/${route}/index.js`
107
109
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
108
110
  emittedRoutes.add(routePath)
@@ -126,6 +128,7 @@ export async function build({ quiet = false, minify = true } = {}) {
126
128
  base,
127
129
  runtimeAsset: runtimePlaceholder,
128
130
  effectAsset: assetPath(base, `assets/${effectPath}`),
131
+ nativeAsset: assetPath(base, `assets/${nativePath}`),
129
132
  paramAsset: assetPath(base, `assets/${paramPath}`),
130
133
  runtimeParams: runtimeSchema?.params,
131
134
  ...(navigable ? { navigationAsset: navigationGroup.assetPath, applicationId: navigationGroup.applicationId, layoutId: navigationGroup.layoutId, routeId: applicationRoute } : {})
@@ -141,6 +144,10 @@ export async function build({ quiet = false, minify = true } = {}) {
141
144
  plans.push({ route: routePath, ...result.plan })
142
145
  if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime, navigable })
143
146
  if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
147
+ if (result.plan.events.some(event => event.native)) nativeEntries.push({
148
+ path: nativePath,
149
+ modules: [...new Set(result.plan.events.filter(event => event.native).map(event => event.native.module))]
150
+ })
144
151
  if (result.hasBehaviors) {
145
152
  behaviorCount++
146
153
  if (!usesDependencyRuntime) regularBehaviorCount++
@@ -190,14 +197,14 @@ export async function build({ quiet = false, minify = true } = {}) {
190
197
  const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
191
198
  const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
192
199
  const hasListRowStates = plans.some(plan => plan.lists.some(list => list.rowStates))
200
+ const hasNestedLists = plans.some(plan => plan.lists.some(list => list.ownerField))
193
201
  const hasItemDependencies = plans.some(plan => plan.effects.some(effect => effect.itemDependencies?.length))
194
202
  const hasListAsyncParts = hasListExpressions || hasListExpressionAttributes || hasListConditions
195
- const hasListMounts = hasListConditions || plans.some(plan => plan.lists.some(list => list.mount))
203
+ const hasListMounts = hasListConditions || hasNestedLists || plans.some(plan => plan.lists.some(list => list.mount))
196
204
  const hasNestedStateCaptures = hasNestedCaptureState(plans)
197
205
  const hasSetterCaptures = hasCaptureType(plans, "setter")
198
206
  const hasEffectCaptures = plans.some(plan => plan.effects.some(effect => Object.keys(effect.scope).length))
199
- const nativeModules = emittedHandlerModules.filter(module => module.hasNativeHandlers).map(module => assetPath(base, `assets/${module.path}`))
200
- const hasNativeHandlers = nativeModules.length > 0
207
+ const hasNativeHandlers = nativeEntries.length > 0
201
208
  const hasEffects = effectEntries.length > 0
202
209
  const hasNavigableEffects = effectEntries.some(entry => entry.navigable)
203
210
  const hasNavigableOwners = effectEntries.some(entry => entry.navigable && entry.effects.some(effect => effect.owner))
@@ -272,16 +279,18 @@ export async function build({ quiet = false, minify = true } = {}) {
272
279
  __KUDZU_LIST_ASYNC_PARTS__: String(hasListAsyncParts),
273
280
  __KUDZU_LIST_MOUNTS__: String(hasListMounts),
274
281
  __KUDZU_LIST_ITEM_HOOKS__: String(hasItemDependencies),
275
- __KUDZU_LIST_ROW_STATES__: String(hasListRowStates)
282
+ __KUDZU_LIST_ROW_STATES__: String(hasListRowStates),
283
+ __KUDZU_NESTED_LISTS__: String(hasNestedLists)
276
284
  })
277
285
  }
278
286
  if (hasNativeHandlers) {
279
287
  const nativeRuntime = (await readFile(new URL("./native-runtime.js", import.meta.url), "utf8"))
280
288
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
281
289
  .replace('"./serialization.js"', '"./kudzu-serialization.js"')
282
- await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), specializeNativeRuntime(nativeRuntime, nativeEvents, nativeModules), minify, {
290
+ await writeJavaScript(join(assetsDirectory, "kudzu-native.js"), specializeEvents(nativeRuntime, nativeEvents), minify, {
283
291
  "globalThis.__KUDZU_CAPTURE_SETTER__": String(hasSetterCaptures)
284
292
  })
293
+ for (const entry of nativeEntries) await printNativeEntry(entry, assetsDirectory, base, minify)
285
294
  }
286
295
  if (navigationGroups.length) {
287
296
  const navigationSource = await readFile(new URL("./navigation-runtime.js", import.meta.url), "utf8")
@@ -411,10 +420,13 @@ function specializeNavigationPatterns(source, enabled) {
411
420
  function fallback`)
412
421
  }
413
422
 
414
- function specializeNativeRuntime(source, events, modules) {
415
- const imports = modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
416
- const entries = modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
417
- return `${imports}\n${specializeEvents(source, events).replace(/const modules = new Map\(\[[^\n]*\]\)/, `const modules = new Map([${entries}])`)}`
423
+ async function printNativeEntry(entry, assetsDirectory, base, minify) {
424
+ const output = join(assetsDirectory, entry.path)
425
+ await mkdir(dirname(output), { recursive: true })
426
+ const imports = entry.modules.map((module, index) => `import * as __kNativeModule${index} from ${JSON.stringify(module)}`).join("\n")
427
+ const registrations = entry.modules.map((module, index) => `[${JSON.stringify(module)}, __kNativeModule${index}]`).join(",")
428
+ const runtime = assetPath(base, "assets/kudzu-native.js")
429
+ await writeJavaScript(output, `import { registerNativeModules } from ${JSON.stringify(runtime)}\n${imports}\nregisterNativeModules([${registrations}])`, minify)
418
430
  }
419
431
 
420
432
  function printEffectEntry(effects, output, handlerModules, assetsDirectory, base, paramPath, runtimeName) {
@@ -1360,8 +1372,13 @@ export function parseDevPort(value) {
1360
1372
  return port
1361
1373
  }
1362
1374
 
1363
- export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
1375
+ export function parseDevHost(value) {
1376
+ return value?.trim() || "127.0.0.1"
1377
+ }
1378
+
1379
+ export async function dev({ port = parseDevPort(process.env.PORT), host = parseDevHost(process.env.HOST) } = {}) {
1364
1380
  if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid dev server port: ${port}`)
1381
+ if (typeof host !== "string" || !host.trim()) throw new Error(`Invalid dev server host: ${host}`)
1365
1382
  const base = normalizeBase((await loadConfig()).base)
1366
1383
 
1367
1384
  let buildError
@@ -1431,7 +1448,8 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
1431
1448
  }
1432
1449
  })
1433
1450
 
1434
- server.listen(port, "127.0.0.1", () => console.log(`Kudzu dev server: http://127.0.0.1:${server.address().port}`))
1451
+ const listeningPort = await listenDevServer(server, port, host)
1452
+ console.log(`Kudzu dev server: http://${host}:${listeningPort}`)
1435
1453
 
1436
1454
  let timer
1437
1455
  let rebuilding = false
@@ -1467,6 +1485,32 @@ export async function dev({ port = parseDevPort(process.env.PORT) } = {}) {
1467
1485
  }
1468
1486
  }
1469
1487
 
1488
+ async function listenDevServer(server, port, host) {
1489
+ let candidate = port
1490
+ while (true) {
1491
+ try {
1492
+ await new Promise((resolve, reject) => {
1493
+ const onError = error => {
1494
+ server.off("listening", onListening)
1495
+ reject(error)
1496
+ }
1497
+ const onListening = () => {
1498
+ server.off("error", onError)
1499
+ resolve()
1500
+ }
1501
+ server.once("error", onError)
1502
+ server.once("listening", onListening)
1503
+ server.listen(candidate, host)
1504
+ })
1505
+ return server.address().port
1506
+ } catch (error) {
1507
+ if (error.code !== "EADDRINUSE" || candidate === 0 || candidate === 65535) throw error
1508
+ console.log(`Port ${candidate} is in use, trying ${candidate + 1}`)
1509
+ candidate++
1510
+ }
1511
+ }
1512
+ }
1513
+
1470
1514
  function injectDevClient(html, session, revision, schema) {
1471
1515
  return `${html}${devClient(session, revision, schema).replace("binding|list|native", "binding|deps|list|native")}`
1472
1516
  }
@@ -1624,6 +1668,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1624
1668
  const listValues = new WeakMap()
1625
1669
  const listEventItems = new WeakMap()
1626
1670
  const listConditions = new WeakMap()
1671
+ const nestedLists = new WeakMap()
1627
1672
  const listEffectEntries = new WeakMap()
1628
1673
  let usesBehavior = false
1629
1674
  let usesBinding = false
@@ -1976,7 +2021,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1976
2021
  calculation.parent = callback
1977
2022
  validateListExpression(calculation, parts.item, originalParts.root, fail)
1978
2023
  }
1979
- validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, settersForNode(originalParts.root, settersByFunction), specialization?.rowState)
2024
+ validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, settersForNode(originalParts.root, settersByFunction), specialization?.rowState, nestedLists)
1980
2025
  if (specialization?.effects.length) {
1981
2026
  usesListEffects = true
1982
2027
  const statements = specialization.effects.map(entry => {
@@ -2143,14 +2188,16 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2143
2188
  }
2144
2189
 
2145
2190
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
2146
- const listParts = renderedLists.get(node)
2191
+ const nestedParts = nestedLists.get(unwrapExpression(node.expression))
2192
+ const listParts = renderedLists.get(node) ?? nestedParts
2147
2193
  if (listParts) {
2148
2194
  usesBehavior = true
2149
2195
  usesList = true
2150
2196
  return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
2151
2197
  listParts.state,
2152
2198
  factory.createStringLiteral(listParts.keyField),
2153
- ts.visitNode(listParts.callback, visitor)
2199
+ ts.visitNode(listParts.callback, visitor),
2200
+ ...(nestedParts ? [factory.createStringLiteral(listParts.ownerField)] : [])
2154
2201
  ]))
2155
2202
  }
2156
2203
  const conditional = conditionalParts(node.expression)
@@ -2353,6 +2400,25 @@ function keyedListParts(expression, setters) {
2353
2400
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
2354
2401
  }
2355
2402
 
2403
+ function nestedKeyedListParts(expression, parentItem) {
2404
+ const value = unwrapExpression(expression)
2405
+ if (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map") return undefined
2406
+ const collection = value.expression.expression
2407
+ if (!ts.isPropertyAccessExpression(collection) || !ts.isIdentifier(collection.expression) || collection.expression.text !== parentItem) return undefined
2408
+ const callback = value.arguments[0]
2409
+ if (!ts.isArrowFunction(callback) || callback.parameters.length !== 1 || !ts.isIdentifier(callback.parameters[0].name)) {
2410
+ throw new Error("Nested keyed list map callback must be an arrow function with one identifier parameter")
2411
+ }
2412
+ const root = unwrapExpression(callback.body)
2413
+ if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) throw new Error("Nested keyed list map callback must return one JSX element")
2414
+ const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
2415
+ const key = attributes.properties.find(attribute => ts.isJsxAttribute(attribute) && ts.isIdentifier(attribute.name) && attribute.name.text === "key")
2416
+ const item = callback.parameters[0].name.text
2417
+ const keyField = key && ts.isJsxAttribute(key) && key.initializer && ts.isJsxExpression(key.initializer) && key.initializer.expression && directProperty(key.initializer.expression, item)
2418
+ if (!keyField) throw new Error(`Nested keyed list root must have key={${item}.<field>}`)
2419
+ return { callback, root, item, keyField, ownerField: collection.name.text }
2420
+ }
2421
+
2356
2422
  function isStateBackedListComponentCall(call, component, setters) {
2357
2423
  if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) return false
2358
2424
  const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
@@ -2437,7 +2503,7 @@ function insideJsxEventHandler(node, root) {
2437
2503
  return false
2438
2504
  }
2439
2505
 
2440
- function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, setters, rowState) {
2506
+ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions, setters, rowState, nestedLists) {
2441
2507
  const fail = (node, message) => {
2442
2508
  throw sourceNodeError(node, sourceFile, message)
2443
2509
  }
@@ -2448,10 +2514,11 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2448
2514
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
2449
2515
  }
2450
2516
  let conditionDepth = 0
2517
+ let nestedList
2451
2518
  const visit = node => {
2452
2519
  if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
2453
2520
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
2454
- if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
2521
+ if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, parts.nested ? "Keyed lists support at most one nested level" : "Nested keyed list collections must be a direct property of the parent item")
2455
2522
  if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, item)) fail(node, "Keyed list item spreads are not supported")
2456
2523
  if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.text)) {
2457
2524
  listEventItems.set(node, item)
@@ -2459,8 +2526,21 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
2459
2526
  }
2460
2527
  if (ts.isJsxExpression(node) && node.expression) {
2461
2528
  const expression = unwrapExpression(node.expression)
2529
+ if (containsJsx(expression) && ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && expression.expression.name.text === "map") {
2530
+ const nested = nestedKeyedListParts(expression, item)
2531
+ if (!nested) fail(expression, parts.nested ? "Keyed lists support at most one nested level" : "Nested keyed list collections must be a direct property of the parent item")
2532
+ if (parts.nested) fail(expression, "Keyed lists support at most one nested level")
2533
+ if (nestedList) fail(expression, "Keyed list rows support one nested keyed list")
2534
+ if (referenceIdentifiers(nested.callback, item).length) fail(nested.root, "Nested keyed list rows cannot capture the parent item")
2535
+ nestedList = nested
2536
+ const nestedParts = { ...nested, state: parts.state, nested: true }
2537
+ nestedLists.set(expression, nestedParts)
2538
+ validateKeyedList(nestedParts, sourceFile, listValues, listEventItems, listConditions, setters, undefined, nestedLists)
2539
+ return
2540
+ }
2462
2541
  const condition = conditionalParts(expression)
2463
2542
  if (condition && containsJsx(expression)) {
2543
+ if (parts.nested) fail(node, "Nested keyed list item conditions are not supported")
2464
2544
  if (conditionDepth) fail(node, "Nested item conditions are not supported in keyed lists")
2465
2545
  if (rowState && referencedStateNames(condition.condition, setters).has(rowState.state)) {
2466
2546
  conditionDepth++
@@ -27,7 +27,7 @@ export function nativeBehavior(module: string, handler: string, states: Array<[s
27
27
  export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
28
28
  export function bindingValue(value: unknown): unknown
29
29
  export function conditional(kind: "and" | "ternary", value: unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
30
- export function list(items: unknown, keyField: string, render: (item: unknown) => unknown): unknown
30
+ export function list(items: unknown, keyField: string, render: (item: unknown) => unknown, ownerField?: string): unknown
31
31
  export function listField(read: () => unknown, field: string): unknown
32
32
  export function listExpression(read: () => unknown, module: string, handler: string): unknown
33
33
  export function listItem(): unknown
@@ -55,6 +55,7 @@ export function renderPage<Props = Record<string, never>>(
55
55
  base?: string
56
56
  runtimeAsset?: string
57
57
  effectAsset?: string
58
+ nativeAsset?: string
58
59
  paramAsset?: string
59
60
  runtimeParams?: string[]
60
61
  navigationAsset?: string
@@ -214,10 +214,18 @@ export function stateConditional(kind, state, truthy, falsy) {
214
214
  return { [conditionalMarker]: true, kind, value: state.value, truthy, falsy, state: state.id }
215
215
  }
216
216
 
217
- export function list(items, keyField, render) {
217
+ export function list(items, keyField, render, ownerField) {
218
218
  if (!items?.[signalMarker] || !Array.isArray(items.value)) throw new Error("A keyed list must use local array state")
219
+ let values = items.value
220
+ if (ownerField) {
221
+ const owner = renderContext?.listRoot ?? renderContext?.listRowRoot
222
+ if (!owner) throw new Error("A nested keyed list must be rendered inside a keyed row")
223
+ renderContext.listFields?.add(ownerField)
224
+ values = renderContext.listTemplate ? [] : owner.item?.[ownerField]
225
+ if (!Array.isArray(values)) throw new Error(`Nested keyed list property "${ownerField}" must remain an array`)
226
+ }
219
227
  const keys = new Set()
220
- for (const item of items.value) {
228
+ for (const item of values) {
221
229
  const key = item?.[keyField]
222
230
  if (!validListKey(key)) throw new Error(`Keyed list key "${keyField}" must be a string or finite number`)
223
231
  assertListItem(item)
@@ -226,7 +234,7 @@ export function list(items, keyField, render) {
226
234
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
227
235
  keys.add(token)
228
236
  }
229
- return { [listMarker]: true, items, keyField, render }
237
+ return { [listMarker]: true, items, values, keyField, render, ownerField }
230
238
  }
231
239
 
232
240
  export function listField(read, field) {
@@ -347,7 +355,7 @@ function serializeCapture(name, value, seen) {
347
355
  }
348
356
 
349
357
  export async function renderPage(component, metadata = {}, props = {}, layout) {
350
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowConditions: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
358
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
351
359
 
352
360
  try {
353
361
  const page = { [routeScopeMarker]: true, component, props }
@@ -381,7 +389,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
381
389
  ? `<script type="module"${capability} src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
382
390
  : ""
383
391
  const nativeRuntime = renderContext.hasNativeBehaviors
384
- ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
392
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.nativeAsset ?? assetPath(metadata.base, "assets/kudzu-native.js"))}"></script>`
385
393
  : ""
386
394
  const paramRuntime = renderContext.hasParams
387
395
  ? `<script type="module"${capability} src="${escapeAttribute(metadata.paramAsset)}"></script>`
@@ -735,23 +743,33 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
735
743
 
736
744
  async function renderList(node, namespace, selectValue) {
737
745
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
738
- const id = nextRenderId("l")
739
- const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.items.value.map(item => item[node.keyField]), ...(node.items[reducerStateMarker] ? { reducer: true } : {}) }
746
+ const ownerRoot = node.ownerField ? renderContext.listRoot ?? renderContext.listRowRoot : undefined
747
+ const ownerTemplate = Boolean(node.ownerField && renderContext.listTemplate)
748
+ const id = node.ownerField ? nextRowListId() : nextRenderId("l")
749
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map(item => item[node.keyField]), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.items[reducerStateMarker] ? { reducer: true } : {}) }
750
+ if (ownerTemplate) Object.assign(ownerRoot.descriptor, { child: { field: node.ownerField, key: node.keyField }, mount: true, nested: true })
740
751
  renderContext.listDepth++
752
+ const previousListRoot = renderContext.listRoot
753
+ const previousListRowRoot = renderContext.listRowRoot
754
+ const previousListTemplate = renderContext.listTemplate
755
+ const previousListInitialMarkers = renderContext.listInitialMarkers
741
756
  const previousListFields = renderContext.listFields
742
757
  const previousListEffectOwners = renderContext.listEffectOwners
743
758
  const previousListRowStates = renderContext.listRowStates
744
759
  const previousListRowConditions = renderContext.listRowConditions
760
+ const previousListRowLists = renderContext.listRowLists
745
761
  try {
746
762
  renderContext.listTemplate = true
747
763
  renderContext.listEffectOwners = []
748
764
  renderContext.listRowStates = []
749
765
  renderContext.listRowConditions = []
766
+ renderContext.listRowLists = []
750
767
  renderContext.listFields = new Set([node.keyField])
751
- renderContext.listRoot = { id, state: node.items.id, template: true, effects: [], item: {}, rowIndexes: { s: 0, c: 0 } }
768
+ renderContext.listRoot = { id, state: node.items.id, descriptor, template: true, effects: [], item: {}, rowIndexes: { s: 0, c: 0, l: 0 } }
752
769
  renderContext.listRowRoot = renderContext.listRoot
753
770
  const template = await renderNode(node.render({}), namespace, selectValue)
754
- if (template.includes("data-k-native-") || template.includes("data-k-effects=")) descriptor.mount = true
771
+ if (template.includes("data-k-native-") || template.includes("data-k-effects=") || template.includes("data-k-list=")) descriptor.mount = true
772
+ if (template.includes("data-k-list=")) descriptor.nested = true
755
773
  if (template.includes("data-k-effects=")) descriptor.effects = true
756
774
  if (template.includes("data-k-list-condition")) descriptor.conditions = true
757
775
  if (template.includes("data-k-list-text-end")) descriptor.textRanges = true
@@ -764,33 +782,47 @@ async function renderList(node, namespace, selectValue) {
764
782
  if (renderContext.listRowConditions.length) descriptor.rowConditions = renderContext.listRowConditions.map(({ id }) => id)
765
783
  descriptor.mount = true
766
784
  }
767
- const seed = listSeed(node.items.value, renderContext.listFields)
785
+ const seed = node.ownerField ? undefined : listSeed(node.values, renderContext.listFields)
768
786
  if (seed) descriptor.seed = seed
769
787
  let current = ""
770
788
  renderContext.listTemplate = false
771
789
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
772
- for (const item of node.items.value) {
773
- renderContext.listRoot = { id, state: node.items.id, key: item[node.keyField], template: false, effects: [], item, rowIndexes: { s: 0, c: 0 } }
790
+ for (const item of node.values) {
791
+ renderContext.listRoot = { id, state: node.items.id, descriptor, key: item[node.keyField], template: false, effects: [], item, rowIndexes: { s: 0, c: 0, l: 0 } }
774
792
  renderContext.listRowRoot = renderContext.listRoot
775
793
  current += await renderNode(node.render(item), namespace, selectValue)
776
794
  }
777
- renderContext.lists.push(descriptor)
795
+ if (!node.ownerField || ownerTemplate) renderContext.lists.push(descriptor)
778
796
  renderContext.hasBehaviors = true
779
797
  renderContext.hasLists = true
780
798
  return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${template}</template>${current}<template data-k-list-end="${id}"></template>`
781
799
  } finally {
782
- renderContext.listRoot = undefined
783
- renderContext.listRowRoot = undefined
784
- renderContext.listTemplate = false
785
- renderContext.listInitialMarkers = false
800
+ renderContext.listRoot = previousListRoot
801
+ renderContext.listRowRoot = previousListRowRoot
802
+ renderContext.listTemplate = previousListTemplate
803
+ renderContext.listInitialMarkers = previousListInitialMarkers
786
804
  renderContext.listFields = previousListFields
787
805
  renderContext.listEffectOwners = previousListEffectOwners
788
806
  renderContext.listRowStates = previousListRowStates
789
807
  renderContext.listRowConditions = previousListRowConditions
808
+ renderContext.listRowLists = previousListRowLists
790
809
  renderContext.listDepth--
791
810
  }
792
811
  }
793
812
 
813
+ function nextRowListId() {
814
+ const root = renderContext.listRoot ?? renderContext.listRowRoot
815
+ const index = root.rowIndexes.l++
816
+ if (renderContext.listTemplate) {
817
+ const id = nextRenderId("l")
818
+ renderContext.listRowLists[index] = { id }
819
+ return id
820
+ }
821
+ const entry = renderContext.listRowLists[index]
822
+ if (!entry) throw new Error("Nested keyed lists must have the same order for every parent item")
823
+ return entry.id
824
+ }
825
+
794
826
  function nextRenderId(kind) {
795
827
  if (renderContext.scoped) return `${renderContext.renderScope === "layout" ? "l" : "r"}${kind}${renderContext.counters[renderContext.renderScope][kind]++}`
796
828
  const counters = { s: "nextState", r: "nextRef", c: "nextCondition", l: "nextList", e: "nextEffect", p: "nextParam" }
@@ -7,6 +7,7 @@ const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
7
7
  const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
8
8
  const itemParts = new WeakMap()
9
9
  const listItems = new WeakMap()
10
+ const ownedLists = __KUDZU_NESTED_LISTS__ ? new WeakMap() : undefined
10
11
  const conditionOwners = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
11
12
  const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}${__KUDZU_LIST_EFFECTS__ ? ",[data-k-effects]" : ""}`
12
13
 
@@ -37,8 +38,8 @@ function mountLists(root) {
37
38
  const roots = listRoots(start, end)
38
39
  if (__KUDZU_LIST_ROW_STATES__ && descriptor.rowStates) for (let index = 0; index < roots.length; index++) initializeRowStates(descriptor, descriptor.keys[index])
39
40
  const templateRoot = start.content.firstElementChild
40
- const parts = listItemPartPlan(templateRoot)
41
- for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root) : mapListItemParts(parts, root)
41
+ const parts = listItemPartPlan(templateRoot, descriptor.nested)
42
+ for (const root of roots) __KUDZU_LIST_CONDITIONS__ && descriptor.conditions ? listItemParts(root, descriptor.nested) : mapListItemParts(parts, root, descriptor.nested)
42
43
  if (__KUDZU_LIST_SEEDS__ && descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
43
44
  const items = browserState.get(descriptor.state)
44
45
  const list = {
@@ -50,10 +51,18 @@ function mountLists(root) {
50
51
  values: new Map(),
51
52
  items: undefined,
52
53
  container: roots[0]?.parentNode,
53
- boundary: end
54
+ boundary: end,
55
+ ...(__KUDZU_NESTED_LISTS__ && descriptor.ownerField ? { owner: listOwner(start) } : {})
56
+ }
57
+ if (__KUDZU_NESTED_LISTS__ && descriptor.ownerField) {
58
+ if (!list.owner) throw new Error("Nested keyed list has no parent row")
59
+ if (ownedLists.has(list.owner)) throw new Error("Keyed list rows support one nested keyed list")
60
+ ownedLists.set(list.owner, list)
61
+ listRegistrations.set(start, { list, owner: list.owner })
62
+ } else {
63
+ register(listTargets, descriptor.state, list)
64
+ listRegistrations.set(start, { state: descriptor.state, list })
54
65
  }
55
- register(listTargets, descriptor.state, list)
56
- listRegistrations.set(start, { state: descriptor.state, list })
57
66
  updateList(list)
58
67
  }
59
68
  }
@@ -65,9 +74,13 @@ function unmountLists(root) {
65
74
  function unregisterList(start) {
66
75
  const registration = listRegistrations.get(start)
67
76
  if (registration) {
68
- const lists = listTargets.get(registration.state)
69
- lists?.delete(registration.list)
70
- if (!lists?.size) listTargets.delete(registration.state)
77
+ if (__KUDZU_NESTED_LISTS__ && registration.owner) {
78
+ if (ownedLists.get(registration.owner) === registration.list) ownedLists.delete(registration.owner)
79
+ } else {
80
+ const lists = listTargets.get(registration.state)
81
+ lists?.delete(registration.list)
82
+ if (!lists?.size) listTargets.delete(registration.state)
83
+ }
71
84
  if (__KUDZU_LIST_ROW_STATES__ && registration.list.descriptor.rowStates) for (const token of registration.list.roots.keys()) deleteRowStates(registration.list.descriptor, token)
72
85
  }
73
86
  listRegistrations.delete(start)
@@ -75,8 +88,12 @@ function unregisterList(start) {
75
88
  }
76
89
 
77
90
  function updateList(list) {
78
- const items = browserState.get(list.descriptor.state)
79
- if (!Array.isArray(items)) throw new Error("Keyed list state must remain an array")
91
+ const items = __KUDZU_NESTED_LISTS__ && list.descriptor.ownerField
92
+ ? listItems.get(list.owner)?.[list.descriptor.ownerField]
93
+ : browserState.get(list.descriptor.state)
94
+ if (!Array.isArray(items)) throw new Error(list.descriptor.ownerField ? `Nested keyed list property "${list.descriptor.ownerField}" must remain an array` : "Keyed list state must remain an array")
95
+ if (__KUDZU_NESTED_LISTS__ && (list.descriptor.child || list.descriptor.ownerField) && list.items && updateNestedList(list, items)) return
96
+ if (__KUDZU_NESTED_LISTS__ && list.descriptor.child) validateChildLists(items, list.descriptor.child)
80
97
  if (list.descriptor.reducer && list.items && updateReducerList(list, items)) return
81
98
  const entries = []
82
99
  const keys = new Set()
@@ -132,15 +149,14 @@ function updateList(list) {
132
149
  if (!node) throw new Error("Keyed list template has no root element")
133
150
  node.removeAttribute("data-k-list-root")
134
151
  if (__KUDZU_LIST_ROW_STATES__ && list.descriptor.rowStates) initializeRowStates(list.descriptor, key, node)
135
- mapListItemParts(list.parts, node)
136
- fillListItem(node, item)
152
+ mapListItemParts(list.parts, node, list.descriptor.nested)
153
+ fillListItem(node, item, list.descriptor.nested)
137
154
  additions.append(node)
138
155
  added = true
139
156
  } else if (list.values.get(token) !== value) {
140
- fillListItem(node, item)
157
+ fillListItem(node, item, list.descriptor.nested)
141
158
  if (__KUDZU_LIST_ITEM_HOOKS__) notifyListItem(list.descriptor.state, node)
142
159
  }
143
- listItems.set(node, item)
144
160
  next.push([token, node])
145
161
  values.set(token, value)
146
162
  }
@@ -184,6 +200,75 @@ function updateList(list) {
184
200
  list.items = items
185
201
  }
186
202
 
203
+ function updateNestedList(list, items) {
204
+ const previous = list.items
205
+ if (items === previous) return false
206
+ if (items.length === previous.length && items.every((item, index) => item === previous[index])) {
207
+ list.items = items
208
+ return true
209
+ }
210
+ if (items.length === previous.length && items.every((item, index) => item === previous[previous.length - index - 1])) {
211
+ const tokens = [...list.roots.keys()].reverse()
212
+ const parent = list.container ?? list.start.parentNode
213
+ const reordered = parent.ownerDocument.createDocumentFragment()
214
+ reordered.append(...tokens.map(token => list.roots.get(token)))
215
+ parent.insertBefore(reordered, list.boundary)
216
+ list.roots = new Map(tokens.map(token => [token, list.roots.get(token)]))
217
+ list.items = items
218
+ list.container ??= parent
219
+ return true
220
+ }
221
+ if (items.length === previous.length - 1) {
222
+ let removed = 0
223
+ while (removed < items.length && items[removed] === previous[removed]) removed++
224
+ if (items.every((item, index) => item === previous[index >= removed ? index + 1 : index])) {
225
+ removeListRoot(list, keyToken(previous[removed]?.[list.descriptor.key]))
226
+ list.roots = new Map(items.map(item => {
227
+ const token = keyToken(item[list.descriptor.key])
228
+ return [token, list.roots.get(token)]
229
+ }))
230
+ list.items = items
231
+ return true
232
+ }
233
+ }
234
+ if (items.length === previous.length + 1 && previous.every((item, index) => item === items[index])) {
235
+ const entry = nestedListEntry(list, items.at(-1))
236
+ if (list.roots.has(entry.token)) throw new Error(`Duplicate keyed list key: ${String(entry.key)}`)
237
+ addListRoot(list, entry)
238
+ list.items = items
239
+ return true
240
+ }
241
+ if (items.length !== previous.length) return false
242
+ let changed = -1
243
+ for (let index = 0; index < items.length; index++) {
244
+ if (items[index] === previous[index]) continue
245
+ if (changed !== -1) return false
246
+ changed = index
247
+ }
248
+ if (changed === -1) return false
249
+ const item = items[changed]
250
+ if (item?.[list.descriptor.key] !== previous[changed]?.[list.descriptor.key]) return false
251
+ const entry = nestedListEntry(list, item)
252
+ const node = list.roots.get(entry.token)
253
+ if (!node) return false
254
+ if (list.values.get(entry.token) !== entry.value) {
255
+ fillListItem(node, item, list.descriptor.nested)
256
+ if (__KUDZU_LIST_ITEM_HOOKS__) notifyListItem(list.descriptor.state, node)
257
+ list.values.set(entry.token, entry.value)
258
+ }
259
+ list.items = items
260
+ return true
261
+ }
262
+
263
+ function nestedListEntry(list, item) {
264
+ const key = item?.[list.descriptor.key]
265
+ if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
266
+ assertListItem(item)
267
+ if (list.descriptor.child) validateChildLists([item], list.descriptor.child)
268
+ assertListValue(item, new Set(), true)
269
+ return { item, key, token: keyToken(key), value: JSON.stringify(item) }
270
+ }
271
+
187
272
  function updateReducerList(list, items) {
188
273
  const previous = list.items
189
274
  if (items.length === previous.length && items.every((item, index) => item === previous[index])) {
@@ -237,9 +322,8 @@ function addListRoot(list, { item, key, token, value }) {
237
322
  if (!node) throw new Error("Keyed list template has no root element")
238
323
  node.removeAttribute("data-k-list-root")
239
324
  if (__KUDZU_LIST_ROW_STATES__ && list.descriptor.rowStates) initializeRowStates(list.descriptor, key, node)
240
- mapListItemParts(list.parts, node)
241
- fillListItem(node, item)
242
- listItems.set(node, item)
325
+ mapListItemParts(list.parts, node, list.descriptor.nested)
326
+ fillListItem(node, item, list.descriptor.nested)
243
327
  const parent = list.container ?? list.start.parentNode
244
328
  parent.insertBefore(node, list.boundary)
245
329
  if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(node)
@@ -257,11 +341,16 @@ function removeListRoot(list, token) {
257
341
  list.values.delete(token)
258
342
  }
259
343
 
260
- function fillListItem(root, item) {
344
+ function fillListItem(root, item, nested = false) {
345
+ listItems.set(root, item)
261
346
  const revision = __KUDZU_LIST_ASYNC_PARTS__ ? (revisions.get(root) ?? 0) + 1 : 0
262
347
  if (__KUDZU_LIST_ASYNC_PARTS__) revisions.set(root, revision)
263
- const parts = listItemParts(root)
348
+ const parts = listItemParts(root, nested)
264
349
  fillListParts(root, parts, item, revision)
350
+ if (__KUDZU_NESTED_LISTS__) {
351
+ const child = ownedLists.get(root)
352
+ if (child) updateList(child)
353
+ }
265
354
  }
266
355
 
267
356
  function fillListParts(root, parts, item, revision) {
@@ -317,11 +406,11 @@ function fillListParts(root, parts, item, revision) {
317
406
  }
318
407
  }
319
408
 
320
- function listItemParts(root) {
409
+ function listItemParts(root, nested = false) {
321
410
  let parts = itemParts.get(root)
322
411
  if (parts) return parts
323
412
  parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [], effects: [] }
324
- for (const node of matching(root, itemPartsSelector)) {
413
+ for (const node of nested ? ownedElements(root).filter(node => node.matches(itemPartsSelector)) : matching(root, itemPartsSelector)) {
325
414
  if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
326
415
  if (__KUDZU_LIST_ATTRIBUTES__ && node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
327
416
  if (__KUDZU_LIST_EVENTS__ && node.hasAttribute("data-k-list-events")) parts.events.push([node, node.dataset.kListEvents])
@@ -337,8 +426,8 @@ function listItemParts(root) {
337
426
  return parts
338
427
  }
339
428
 
340
- function listItemPartPlan(template) {
341
- const source = [template, ...template.querySelectorAll("*")]
429
+ function listItemPartPlan(template, nested = false) {
430
+ const source = nested ? ownedElements(template) : [template, ...template.querySelectorAll("*")]
342
431
  const indexes = new Map(source.map((node, index) => [node, index]))
343
432
  const parts = listItemParts(template)
344
433
  return {
@@ -353,8 +442,8 @@ function listItemPartPlan(template) {
353
442
  }
354
443
  }
355
444
 
356
- function mapListItemParts(parts, root) {
357
- const target = [root, ...root.querySelectorAll("*")]
445
+ function mapListItemParts(parts, root, nested = false) {
446
+ const target = nested ? ownedElements(root) : [root, ...root.querySelectorAll("*")]
358
447
  itemParts.set(root, {
359
448
  directTexts: parts.directTexts.map(([index, field]) => [target[index], field]),
360
449
  texts: __KUDZU_LIST_TEXT_RANGES__ ? parts.texts.map(([index, field]) => [target[index], field]) : [],
@@ -406,6 +495,49 @@ function renderFalsy(value) {
406
495
  return value === false || value == null || value === true ? "" : String(value)
407
496
  }
408
497
 
498
+ function validateChildLists(items, child) {
499
+ for (const item of items) {
500
+ const children = item?.[child.field]
501
+ if (!Array.isArray(children)) throw new Error(`Nested keyed list property "${child.field}" must remain an array`)
502
+ const keys = new Set()
503
+ for (const entry of children) {
504
+ const key = entry?.[child.key]
505
+ if (!validListKey(key)) throw new Error(`Keyed list key "${child.key}" must be a string or finite number`)
506
+ assertListItem(entry)
507
+ assertListValue(entry, new Set(), true)
508
+ const token = keyToken(key)
509
+ if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
510
+ keys.add(token)
511
+ }
512
+ }
513
+ }
514
+
515
+ function listOwner(start) {
516
+ let owner = start.parentElement
517
+ while (owner && !listItems.has(owner)) owner = owner.parentElement
518
+ return owner
519
+ }
520
+
521
+ function ownedElements(root) {
522
+ const elements = []
523
+ const visit = node => {
524
+ elements.push(node)
525
+ for (let child = node.firstElementChild; child;) {
526
+ if (child.matches("template[data-k-list]")) {
527
+ const end = findEnd(child, JSON.parse(child.dataset.kList).id)
528
+ elements.push(child, end)
529
+ child = end.nextElementSibling
530
+ } else {
531
+ const next = child.nextElementSibling
532
+ visit(child)
533
+ child = next
534
+ }
535
+ }
536
+ }
537
+ visit(root)
538
+ return elements
539
+ }
540
+
409
541
  function listRoots(start, end) {
410
542
  const roots = []
411
543
  for (let node = start.nextSibling; node && node !== end; node = node.nextSibling) {
@@ -585,8 +717,10 @@ function assertListValue(value, seen, root = false) {
585
717
  }
586
718
 
587
719
  function findEnd(start, id) {
588
- return [...start.ownerDocument.querySelectorAll("template[data-k-list-end]")]
589
- .find(node => node.dataset.kListEnd === id)
720
+ for (let node = start.nextElementSibling; node; node = node.nextElementSibling) {
721
+ if (node.matches("template[data-k-list-end]") && node.dataset.kListEnd === id) return node
722
+ }
723
+ throw new Error("Keyed list marker has no end")
590
724
  }
591
725
 
592
726
  function register(targets, id, entry) {
@@ -2,6 +2,11 @@ import { browserState, commitDom, registerMountHook, registerUnmountHook } from
2
2
  import { deserialize } from "./serialization.js"
3
3
 
4
4
  const registrations = new WeakMap()
5
+ const modules = new Map()
6
+
7
+ export function registerNativeModules(entries) {
8
+ for (const [url, module] of entries) modules.set(url, module)
9
+ }
5
10
 
6
11
  export function createNativeContext(state, stateIds, commit, serializedScope = {}) {
7
12
  const changed = new Set()
@@ -52,7 +57,6 @@ export function createNativeContext(state, stateIds, commit, serializedScope = {
52
57
 
53
58
  if (typeof document !== "undefined") {
54
59
  const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
55
- const modules = new Map([])
56
60
  const mount = root => mountNative(root, eventNames, modules)
57
61
  registerMountHook(mount)
58
62
  registerUnmountHook(unmountNative)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.6.19",
3
+ "version": "0.6.21",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",