@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 +13 -2
- package/framework/build.mjs +96 -16
- package/framework/core.d.ts +2 -1
- package/framework/core.mjs +49 -17
- package/framework/list-runtime.js +161 -27
- package/framework/native-runtime.js +5 -1
- package/package.json +1 -1
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.
|
|
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
|
-
|
|
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
|
|
package/framework/build.mjs
CHANGED
|
@@ -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
|
|
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"),
|
|
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
|
|
415
|
-
const
|
|
416
|
-
|
|
417
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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++
|
package/framework/core.d.ts
CHANGED
|
@@ -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
|
package/framework/core.mjs
CHANGED
|
@@ -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
|
|
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
|
|
739
|
-
const
|
|
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.
|
|
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.
|
|
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 =
|
|
783
|
-
renderContext.listRowRoot =
|
|
784
|
-
renderContext.listTemplate =
|
|
785
|
-
renderContext.listInitialMarkers =
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
79
|
-
|
|
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
|
-
|
|
589
|
-
.
|
|
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)
|