@kudzujs/core 0.8.0 → 0.8.2

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
@@ -10,7 +10,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
10
10
 
11
11
  > Experimental `0.8.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **Latest release: 0.8.0 - URL-backed custom hooks.** Relative React custom hooks, reachable source graphs, imported structured calculations and static collections, event-only package ESM, and native SVG now compose across a fourteen-route FIRE migration without adding React or a hook runtime. Read the [release notes](./RELEASES.md#080---url-backed-custom-hooks) or open the [release page](https://kudzujs.cloud/releases/0.8.0).
13
+ **Latest release: 0.8.1 - Custom-hook reset actions.** Relative custom hooks can batch direct primitive state resets into one behavior command, including imported negative and null literals, while dependent URL and storage effects observe the same commit. Read the [release notes](./RELEASES.md#081---custom-hook-reset-actions) or open the [release page](https://kudzujs.cloud/releases/0.8.1).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
package/RELEASES.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.2 - Calculated keyed collections
4
+
5
+ Kudzu 0.8.2 lets one direct array field from a synchronous relative calculation result drive an intrinsic keyed list, preserving static HTML and existing DOM ownership while improving list and navigation hot paths.
6
+
7
+ ### New in 0.8.2
8
+
9
+ - A top-level `const result = calculate(state)` may feed `result.items.map(...)` when the relative synchronous helper returns a JSON-safe array field.
10
+ - Route-specific evaluator ESM refreshes a compiler-owned array anchor after source-state commits; the existing keyed reconciler retains keys, current handlers, and SVG namespaces.
11
+ - Initial structural rows hydrate list bookkeeping without repeating DOM writes, retained direct attributes skip redundant DOM reads, and stable-list fallback avoids duplicate validation.
12
+ - Immutable local filter sources with unused ordinary `useState` setters use the existing static-filter restoration path while indirect custom-hook and reducer mutation paths remain dynamic.
13
+ - Required route modules emit `modulepreload` hints, and navigation prefetch resolves validated destination capabilities before commit while preserving complete-document fallback and cleanup ordering.
14
+ - Navigation prefetch safely ignores focus and pointer targets outside anchors.
15
+ - Calculated collection fixtures verify insert, reorder, removal, retained identity, latest handlers, scalar bindings, SVG namespace, and zero JavaScript on unaffected static routes.
16
+ - The complete suite passes 133/133 tests, and the FIRE migration retains all chart nodes and acceptance behavior.
17
+
18
+ ### Boundary
19
+
20
+ The calculation must be one top-level immutable result from a synchronous relative TypeScript function, and the consumed field must remain a JSON-safe array. Indirect result aliases, dynamic fields, nested calculated SVG lists, package calculations, and asynchronous helpers remain unsupported. Static-filter specialization applies only when an ordinary local setter is provably unused; lifecycle-bearing and indirectly mutable collections retain the general path.
21
+
22
+ ### Upgrade
23
+
24
+ ```bash
25
+ npm install @kudzujs/core@^0.8.2
26
+ ```
27
+
28
+ ## 0.8.1 - Custom-hook reset actions
29
+
30
+ Kudzu 0.8.1 compiles direct multi-state reset callbacks returned by relative custom hooks into one existing behavior command, keeping conventional hook ownership without adding a callback runtime.
31
+
32
+ ### New in 0.8.1
33
+
34
+ - Returned custom-hook callbacks may reset their exposed state/setter pairs to direct string, number, negative-number, boolean, or `null` literals in one batched commit.
35
+ - Imported callback literals are synthesized before command emission, fixing source-range corruption when optimized behavior commands originate in another module.
36
+ - The FIRE Standard calculator now owns its twelve-field reset inside `useStandardParams()`; existing dependency effects update URL parameters and local storage after the same commit.
37
+ - A React/Vite migration fixture verifies exact optimized command output plus browser updates for multiple reset values.
38
+ - Static routes and routes without reset actions add no JavaScript or runtime capability.
39
+
40
+ ### Boundary
41
+
42
+ Reset callbacks may capture only directly returned state/setter pairs and use direct literal setter calls. Private defaults, helper-indirect setters, dynamic values, timers, clipboard work, aliases, and arbitrary callback graphs remain unsupported.
43
+
44
+ ### Upgrade
45
+
46
+ ```bash
47
+ npm install @kudzujs/core@^0.8.1
48
+ ```
49
+
3
50
  ## 0.8.0 - URL-backed custom hooks
4
51
 
5
52
  Kudzu 0.8.0 migrates practical relative React custom hooks that combine local state, effects, direct actions, writable URL search parameters, and guarded browser storage while preserving static HTML and route-specific capabilities.
@@ -2,9 +2,9 @@
2
2
 
3
3
  Kudzu specializes ordinary common React-shaped TSX so migrations need minimal source restructuring. Declarative components, collection pipelines, conditions, hooks, and handlers should be lowered at build time rather than replaced with application-owned imperative DOM code. This principle applies across migrations and is not Stay-specific; it does not imply a React package, VDOM, hydration, or ecosystem runtime.
4
4
 
5
- Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. A named or default zero-argument custom hook imported from a relative TypeScript module may return direct shorthand state/setter pairs and callbacks that capture those states; callers use one top-level `const` object destructuring without aliases, defaults, or rest. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
5
+ Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. A named or default zero-argument custom hook imported from a relative TypeScript module may return direct shorthand state/setter pairs and callbacks that capture those states, including a callback that resets multiple states to direct primitive literals; callers use one top-level `const` object destructuring without aliases, defaults, or rest. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
6
6
 
7
- Compilation begins from page entries and follows relative runtime imports, re-exports, and validated Worker references; unreachable TypeScript migration files are not transformed. Direct maps over imported immutable JSON-safe arrays fold to literals for zero-JavaScript static rows. Synchronous relative calculation functions may return objects whose direct static fields feed reactive JSX bindings; build rendering uses current signal values and route-specific binding ESM reevaluates the same helper after state commits. Package imports have a separate narrow boundary: direct references inside intrinsic JSX event callbacks are erased from build modules and bundled into route handler ESM, while render-time, effect, helper-indirect, and mixed package use fails.
7
+ Compilation begins from page entries and follows relative runtime imports, re-exports, and validated Worker references; unreachable TypeScript migration files are not transformed. Direct maps over imported immutable JSON-safe arrays fold to literals for zero-JavaScript static rows. Synchronous relative calculation functions may return objects whose direct static fields feed reactive JSX bindings; build rendering uses current signal values and route-specific binding ESM reevaluates the same helper after state commits. One direct array field may instead feed a keyed intrinsic map: its evaluator refreshes a compiler-owned array anchor before the existing list reconciler runs, preserving keyed DOM and SVG identity without a calculation runtime. That field must remain a JSON-safe array after every source-state commit. Package imports have a separate narrow boundary: direct references inside intrinsic JSX event callbacks are erased from build modules and bundled into route handler ESM, while render-time, effect, helper-indirect, and mixed package use fails.
8
8
 
9
9
  A named or aliased `Link` import from `react-router-dom` may render directly with one static root-relative `to` plus native anchor props. The compiler prefixes the configured `base`, changes the element to `<a href>`, and erases the import, so native navigation remains the default and configured navigation groups see an ordinary eligible anchor. Dynamic or relative destinations, `NavLink`, router-only props, spreads, default/namespace imports, and non-JSX uses fail with source diagnostics. No React Router package code or router runtime is emitted.
10
10
 
@@ -268,7 +268,7 @@ function register(targets, id, entry) {
268
268
  targets.set(id, entries)
269
269
  }
270
270
 
271
- async function loadEvaluator(descriptor) {
271
+ export async function loadEvaluator(descriptor) {
272
272
  let modulePromise = imports.get(descriptor.module)
273
273
  if (!modulePromise) {
274
274
  modulePromise = import(descriptor.module)
@@ -209,13 +209,14 @@ export async function build({ quiet = false, minify = true } = {}) {
209
209
  const hasListEvents = plans.some(plan => plan.lists.some(list => list.events))
210
210
  const hasListExpressions = plans.some(plan => plan.lists.some(list => list.expressions))
211
211
  const hasListExpressionAttributes = plans.some(plan => plan.lists.some(list => list.expressionAttributes))
212
- const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed))
212
+ const hasListSeeds = plans.some(plan => plan.lists.some(list => list.seed || list.valueSeed))
213
213
  const hasListEffects = plans.some(plan => plan.lists.some(list => list.effects))
214
214
  const hasListRowHooks = plans.some(plan => plan.lists.some(list => list.rowStates?.length || list.rowRefs?.length))
215
215
  const hasListRowRefs = plans.some(plan => plan.lists.some(list => list.rowRefs?.length))
216
216
  const hasComplexListRowState = plans.some(plan => plan.lists.some(list => list.rowStates?.some(state => state.initialValue !== null && typeof state.initialValue === "object")))
217
217
  const hasNestedLists = plans.some(plan => plan.lists.some(list => list.ownerField))
218
218
  const hasCollectionSelectors = plans.some(plan => plan.lists.some(list => list.selector))
219
+ const hasCalculatedCollections = plans.some(plan => plan.lists.some(list => list.source))
219
220
  const hasDerivedEffectDependencies = plans.some(plan => plan.effects.some(effect => effect.dependencyExpressions?.length))
220
221
  const hasStaticCollections = plans.some(plan => plan.lists.some(list => list.static))
221
222
  const hasListIndexes = plans.some(plan => plan.lists.some(list => list.indexed))
@@ -237,7 +238,7 @@ export async function build({ quiet = false, minify = true } = {}) {
237
238
  for (const entry of pageEntries) {
238
239
  const routeDirectory = join(outputDirectory, entry.route)
239
240
  await mkdir(routeDirectory, { recursive: true })
240
- const html = entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`)))
241
+ const html = preloadModules(entry.html.replace(runtimePlaceholder, escapeAttribute(assetPath(base, `assets/${runtimeName(entry.usesDependencyRuntime)}`))))
241
242
  await writeFile(join(routeDirectory, "index.html"), html)
242
243
  }
243
244
  if (navigationRoutes.length || behaviorCount && (hasSharedRuntime || regularBehaviorCount)) {
@@ -284,6 +285,9 @@ export async function build({ quiet = false, minify = true } = {}) {
284
285
  if (hasCollectionSelectors && !hasDerivedEffectDependencies) await writeJavaScript(join(assetsDirectory, "kudzu-collection-selector.js"), await readFile(new URL("./collection-selector.js", import.meta.url), "utf8"), minify)
285
286
  let listRuntime = (await readFile(new URL("./list-runtime.js", import.meta.url), "utf8"))
286
287
  .replace('"./shared-runtime.js"', '"./kudzu.js"')
288
+ listRuntime = hasCalculatedCollections
289
+ ? listRuntime.replace('"./binding-runtime.js"', '"./kudzu-binding.js"')
290
+ : listRuntime.replace(/^const loadListEvaluator[^\n]+\n/m, "")
287
291
  listRuntime = hasCollectionSelectors
288
292
  ? listRuntime.replace('"./collection-selector.js"', '"./kudzu-collection-selector.js"')
289
293
  : listRuntime.replace(/^import \{ selectCollection \}[^\n]+\n/m, "")
@@ -297,8 +301,8 @@ export async function build({ quiet = false, minify = true } = {}) {
297
301
  .replace("function addListRoot(list, { item, index = list.roots.size, key, token, value })", "function addListRoot(list, { item, key, token, value })")
298
302
  .replace("fillListParts(root, listItemParts(root), listItems.get(owner), 0, __KUDZU_LIST_INDEXES__ ? listIndexes.get(owner) ?? 0 : 0)", "fillListParts(root, listItemParts(root), listItems.get(owner), 0)")
299
303
  .replace("function fillListItem(root, item, nested = false, index = 0)", "function fillListItem(root, item, nested = false)")
300
- .replace("fillListParts(root, parts, item, revision, index)", "fillListParts(root, parts, item, revision)")
301
- .replace("function fillListParts(root, parts, item, revision, index = 0)", "function fillListParts(root, parts, item, revision)")
304
+ .replace("fillListParts(root, parts, item, revision, index, previous)", "fillListParts(root, parts, item, revision, previous)")
305
+ .replace("function fillListParts(root, parts, item, revision, index = 0, previous)", "function fillListParts(root, parts, item, revision, previous)")
302
306
  .replaceAll('value?.type === "list-item" ? serializeItem(item) : value?.type === "list-index" ? index : value', 'value?.type === "list-item" ? serializeItem(item) : value')
303
307
  .replaceAll("evaluate(descriptor, item, index)", "evaluate(descriptor, item)")
304
308
  .replaceAll("evaluate({ module, handler }, item, index)", "evaluate({ module, handler }, item)")
@@ -459,6 +463,13 @@ export async function build({ quiet = false, minify = true } = {}) {
459
463
  if (!quiet) console.log(`Built ${plans.length} page(s), ${behaviorCount} interactive page(s) into dist/`)
460
464
  }
461
465
 
466
+ function preloadModules(html) {
467
+ const scripts = [...html.matchAll(/<script type="module"[^>]* src="([^"]+)"[^>]*><\/script>/g)]
468
+ if (!scripts.length) return html
469
+ const links = [...new Set(scripts.map(match => match[1]))].map(href => `<link rel="modulepreload" href="${href}">`).join("")
470
+ return html.replace(scripts[0][0], `${links}${scripts[0][0]}`)
471
+ }
472
+
462
473
  function specializeEvents(source, events) {
463
474
  return source.replace(/const eventNames = \[[^\n]+\]/, `const eventNames = ${JSON.stringify(events)}`)
464
475
  }
@@ -492,10 +503,11 @@ async function mountInitial() {
492
503
  }
493
504
  `, "")
494
505
  .replace(" await ready\n", "")
495
- .replace(" const capabilities = await loadCapabilities(parsed)\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n")
506
+ .replace(" const { incoming, parsed, capabilities } = documentResult\n", " const { incoming, parsed } = documentResult\n")
496
507
  .replace(" await routeDispose()\n if (current !== revision) return\n", "")
497
508
  .replace(" commit(incoming, parsed.nodes, capabilities.params, url.pathname, url.search)\n", " commit(incoming, parsed.nodes)\n")
498
509
  .replace(" routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose\n", "")
510
+ .replace(" return { incoming, parsed, capabilities: await loadCapabilities(parsed), record }\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n return { incoming, parsed, record }\n")
499
511
  .replace(`
500
512
  async function loadCapabilities(parsed) {
501
513
  const modules = await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))
@@ -1508,7 +1520,7 @@ async function writeBundledJavaScript(file, source, minify, define) {
1508
1520
  stdin: { contents: source, resolveDir: dirname(file), sourcefile: file },
1509
1521
  bundle: true,
1510
1522
  write: false,
1511
- external: ["./kudzu.js", "./kudzu-serialization.js", "./kudzu-style.js"],
1523
+ external: ["./kudzu.js", "./kudzu-binding.js", "./kudzu-serialization.js", "./kudzu-style.js"],
1512
1524
  define,
1513
1525
  format: "esm",
1514
1526
  target: "es2022",
@@ -2655,6 +2667,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2655
2667
  } catch {}
2656
2668
  }
2657
2669
  const settersByFunction = new Map()
2670
+ const localStateSettersByFunction = new Map()
2658
2671
  const reducersByFunction = new Map()
2659
2672
  const zustandStores = new Map()
2660
2673
  const resolvedZustandStore = entry => {
@@ -2813,6 +2826,9 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2813
2826
  const setters = settersByFunction.get(owner) ?? new Map()
2814
2827
  setters.set(setterElement.name.text, stateElement.name.text)
2815
2828
  settersByFunction.set(owner, setters)
2829
+ const localSetters = localStateSettersByFunction.get(owner) ?? new Set()
2830
+ localSetters.add(setterElement.name.text)
2831
+ localStateSettersByFunction.set(owner, localSetters)
2816
2832
  }
2817
2833
  }
2818
2834
  }
@@ -3327,7 +3343,23 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3327
3343
  return
3328
3344
  }
3329
3345
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
3330
- const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction), jsxLocalDeclarations.get(nearestFunction(node)), fail, new Set(), importedCollections, factory, context, importedCollectionTransforms)
3346
+ const owner = nearestFunction(node)
3347
+ const setters = settersForNode(node, settersByFunction)
3348
+ const staticCollection = state => [...(localStateSettersByFunction.get(owner) ?? [])].some(setter => setters.get(setter) === state && !referenceIdentifiers(owner.body, setter).length)
3349
+ const calculatedCollection = expression => {
3350
+ const value = unwrapExpression(expression)
3351
+ if (!ts.isPropertyAccessExpression(value) || !ts.isIdentifier(value.expression)) return undefined
3352
+ const entries = jsxLocalDeclarations.get(nearestFunction(node))?.get(value.expression.text)
3353
+ if (!entries?.length) return undefined
3354
+ const initializer = entries.length === 1 ? unwrapExpression(entries[0].initializer) : undefined
3355
+ if (!initializer || !ts.isCallExpression(initializer) || !ts.isIdentifier(initializer.expression) || !importBindings.has(initializer.expression.text)) return undefined
3356
+ if (entries[0].node.parent?.parent?.parent !== nearestFunction(entries[0].node)?.body) fail(value.expression, `Calculated collection result "${value.expression.text}" must be one top-level immutable local`)
3357
+ validateImportedCalculation(initializer, value.name.text)
3358
+ const expanded = resolveReactiveJsxExpression(value, nearestFunction(node), setters)
3359
+ if (expanded === value || !referencedStateNames(expanded, setters).size) fail(value, "Calculated collection fields must directly depend on local state")
3360
+ return expanded
3361
+ }
3362
+ const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, setters, jsxLocalDeclarations.get(owner), fail, new Set(), importedCollections, factory, context, importedCollectionTransforms, calculatedCollection, staticCollection)
3331
3363
  if (parts) {
3332
3364
  for (const declaration of parts.aliasDeclarations ?? []) listLocalDeclarations.add(declaration)
3333
3365
  rawRenderedLists.push({ node, parts })
@@ -3731,15 +3763,21 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3731
3763
  if (listParts) {
3732
3764
  usesBehavior = true
3733
3765
  usesList = true
3766
+ let listSource = listParts.state
3767
+ if (listParts.calculation) {
3768
+ usesBinding = true
3769
+ listSource = compileReactiveBinding(listParts.calculation, settersForNode(node, settersByFunction), factory, context, reactiveBindings, handlerUrl, importBindings, clientImports)
3770
+ }
3734
3771
  const arguments_ = [
3735
- listParts.state,
3772
+ listSource,
3736
3773
  listParts.keyField === null ? factory.createNull() : factory.createStringLiteral(listParts.keyField),
3737
3774
  ts.visitNode(listParts.callback, visitor),
3738
3775
  factory.createStringLiteral(listParts.ownerField ?? ""),
3739
3776
  jsonExpression(listParts.selector ?? [], factory),
3740
3777
  listParts.indexed ? factory.createTrue() : factory.createFalse()
3741
3778
  ]
3742
- if (listParts.selectorStates?.size) arguments_.push(factory.createArrayLiteralExpression([...listParts.selectorStates].map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))))
3779
+ if (listParts.selectorStates?.size || listParts.static) arguments_.push(factory.createArrayLiteralExpression([...(listParts.selectorStates ?? [])].map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))))
3780
+ if (listParts.static) arguments_.push(factory.createTrue())
3743
3781
  return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, arguments_))
3744
3782
  }
3745
3783
  const conditional = conditionalParts(node.expression)
@@ -3932,12 +3970,12 @@ function containsRenderControl(root, knownLocals) {
3932
3970
  return found
3933
3971
  }
3934
3972
 
3935
- function keyedListParts(expression, setters, declarations, fail, aliases = new Set(), importedCollections = new Set(), factory = ts.factory, context, importedCollectionTransforms = new Map()) {
3973
+ function keyedListParts(expression, setters, declarations, fail, aliases = new Set(), importedCollections = new Set(), factory = ts.factory, context, importedCollectionTransforms = new Map(), calculatedCollection, staticCollection) {
3936
3974
  const value = unwrapExpression(expression)
3937
3975
  const directFrom = isArrayFromCall(value) && value.arguments.length === 2 && containsJsx(value.arguments[1])
3938
3976
  if (!directFrom && (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map")) return undefined
3939
- const collection = renderedCollectionSource(directFrom ? value.arguments[0] : value.expression.expression, setters, declarations, fail, aliases, importedCollections, new Set(setters.values()), importedCollectionTransforms, factory, context)
3940
- if (!collection?.state) return undefined
3977
+ const collection = renderedCollectionSource(directFrom ? value.arguments[0] : value.expression.expression, setters, declarations, fail, aliases, importedCollections, new Set(setters.values()), importedCollectionTransforms, factory, context, calculatedCollection, staticCollection)
3978
+ if (!collection?.state && !collection?.calculation) return undefined
3941
3979
  if (directFrom) collection.selector.push(["from", undefined])
3942
3980
  let callback = directFrom ? value.arguments[1] : value.arguments[0]
3943
3981
  const parameters = collectionParameters(callback, "Keyed list map", fail)
@@ -3962,7 +4000,7 @@ function keyedListParts(expression, setters, declarations, fail, aliases = new S
3962
4000
  const field = keyExpression && directProperty(keyExpression, parameters.item)
3963
4001
  const positional = Boolean(keyExpression && parameters.index && ts.isIdentifier(unwrapExpression(keyExpression)) && unwrapExpression(keyExpression).text === parameters.index)
3964
4002
  if (!field && !positional) fail(key ?? root, `Keyed list root must have key={${parameters.item}.<field>} or key={${parameters.index ?? "index"}}`)
3965
- return { ...collection, callback, root, item: parameters.item, index: parameters.index, indexed: Boolean(parameters.index), keyField: positional ? null : field }
4003
+ return { ...collection, static: collection.static && (!collection.localStatic || collection.selector.length > 0), callback, root, item: parameters.item, index: parameters.index, indexed: Boolean(parameters.index), keyField: positional ? null : field }
3966
4004
  }
3967
4005
 
3968
4006
  function nestedKeyedListParts(expression, parentItem, fail) {
@@ -3983,20 +4021,27 @@ function nestedKeyedListParts(expression, parentItem, fail) {
3983
4021
  return { ...collection, callback, root, item: parameters.item, index: parameters.index, indexed: Boolean(parameters.index), keyField: positional ? null : keyField }
3984
4022
  }
3985
4023
 
3986
- function renderedCollectionSource(expression, setters, declarations, fail, aliases, importedCollections = new Set(), stateNames = new Set(), importedCollectionTransforms = new Map(), factory = ts.factory, context) {
4024
+ function renderedCollectionSource(expression, setters, declarations, fail, aliases, importedCollections = new Set(), stateNames = new Set(), importedCollectionTransforms = new Map(), factory = ts.factory, context, calculatedCollection, staticCollection) {
3987
4025
  const value = unwrapExpression(expression)
3988
4026
  if (ts.isIdentifier(value)) {
3989
- if ([...setters.values()].includes(value.text)) return { state: value, selector: [], selectorStates: new Set() }
4027
+ if ([...setters.values()].includes(value.text)) {
4028
+ const localStatic = staticCollection?.(value.text)
4029
+ return { state: value, static: localStatic, localStatic, selector: [], selectorStates: new Set() }
4030
+ }
3990
4031
  if (importedCollections.has(value.text)) return { state: value, static: true, selector: [], selectorStates: new Set() }
3991
4032
  const entries = declarations?.get(value.text)
3992
4033
  if (!entries) return undefined
3993
4034
  if (entries.length !== 1 || aliases.has(value.text) || entries[0].node.parent?.parent?.parent !== nearestFunction(entries[0].node)?.body) fail(value, `Rendered collection alias "${value.text}" must be one top-level immutable local`)
3994
4035
  aliases.add(value.text)
3995
- const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
4036
+ const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context, calculatedCollection, staticCollection)
3996
4037
  aliases.delete(value.text)
3997
4038
  return source && { ...source, aliasDeclarations: [...(source.aliasDeclarations ?? []), entries[0].node], aliasUses: [...(source.aliasUses ?? []), value] }
3998
4039
  }
3999
- if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression)) return { state: undefined, ownerField: value.name.text, selector: [], parentItem: value.expression.text }
4040
+ if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression)) {
4041
+ const calculation = calculatedCollection?.(value)
4042
+ if (calculation) return { calculation, selector: [], selectorStates: new Set() }
4043
+ return { state: undefined, ownerField: value.name.text, selector: [], parentItem: value.expression.text }
4044
+ }
4000
4045
  if (ts.isCallExpression(value) && ts.isIdentifier(value.expression) && importedCollectionTransforms.has(value.expression.text)) {
4001
4046
  const transform = importedCollectionTransforms.get(value.expression.text)
4002
4047
  const parameter = transform.parameters[0]
@@ -4007,7 +4052,7 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
4007
4052
  if (!returned) fail(value, `Imported collection transform "${value.expression.text}" must contain only one returned collection expression`)
4008
4053
  const transformSource = renderedCollectionSource(returned, new Map([[parameter.name.text, parameter.name.text]]), undefined, fail, new Set(), new Set(), new Set([parameter.name.text]))
4009
4054
  if (!transformSource?.state || transformSource.state.text !== parameter.name.text || transformSource.selectorStates.size) fail(value, `Imported collection transform "${value.expression.text}" must return a supported pure pipeline rooted only in its parameter`)
4010
- const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
4055
+ const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context, calculatedCollection, staticCollection)
4011
4056
  if (!source) fail(value.arguments[0], `Imported collection transform "${value.expression.text}" requires a supported collection argument`)
4012
4057
  return { ...source, selector: [...source.selector, ...transformSource.selector] }
4013
4058
  }
@@ -4015,7 +4060,7 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
4015
4060
  const method = value.expression.name.text
4016
4061
  if (method === "filter") {
4017
4062
  if (value.arguments.length !== 1) fail(value, "Rendered collection filter() requires one inline predicate")
4018
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
4063
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context, calculatedCollection, staticCollection)
4019
4064
  if (!source) return undefined
4020
4065
  const parameters = collectionParameters(value.arguments[0], "Rendered collection filter()", fail)
4021
4066
  const selectorStates = new Set(source.selectorStates)
@@ -4023,7 +4068,7 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
4023
4068
  }
4024
4069
  if (method === "flatMap") {
4025
4070
  if (value.arguments.length !== 1) fail(value, "Rendered collection flatMap() requires one inline projector")
4026
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
4071
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context, calculatedCollection, staticCollection)
4027
4072
  if (!source) return undefined
4028
4073
  const parameters = collectionParameters(value.arguments[0], "Rendered collection flatMap()", fail)
4029
4074
  const field = directProperty(value.arguments[0].body, parameters.item)
@@ -4033,7 +4078,7 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
4033
4078
  }
4034
4079
  if (method === "slice") {
4035
4080
  if (value.arguments.length < 1 || value.arguments.length > 2) fail(value, "Rendered collection slice() requires a start and optional end")
4036
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
4081
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context, calculatedCollection, staticCollection)
4037
4082
  if (!source) return undefined
4038
4083
  const selectorStates = new Set(source.selectorStates)
4039
4084
  const start = collectionExpression(value.arguments[0], {}, fail, stateNames, selectorStates)
@@ -4042,7 +4087,7 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
4042
4087
  }
4043
4088
  if (method === "toSorted") {
4044
4089
  if (value.arguments.length !== 1) fail(value, "Rendered collection toSorted() requires one inline comparator")
4045
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
4090
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context, calculatedCollection, staticCollection)
4046
4091
  if (!source) return undefined
4047
4092
  const comparator = value.arguments[0]
4048
4093
  const parameters = collectionParameters(comparator, "Rendered collection toSorted()", fail)
@@ -4055,7 +4100,7 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
4055
4100
  }
4056
4101
  if (isArrayFromCall(value)) {
4057
4102
  if (value.arguments.length < 1 || value.arguments.length > 2) fail(value, "Rendered Array.from() requires an anchor and optional inline mapper")
4058
- const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context)
4103
+ const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames, importedCollectionTransforms, factory, context, calculatedCollection, staticCollection)
4059
4104
  if (!source) return undefined
4060
4105
  let mapper
4061
4106
  if (value.arguments[1]) {
@@ -6136,7 +6181,7 @@ function scopeRead(factory, name) {
6136
6181
 
6137
6182
  function compileEventCommand(expression, setters, factory) {
6138
6183
  if (ts.isCallExpression(expression) && ts.isPropertyAccessExpression(expression.expression) && ts.isIdentifier(expression.expression.expression) && expression.expression.expression.text === "console" && expression.expression.name.text === "log" && expression.arguments.length === 2 && ts.isStringLiteral(expression.arguments[0]) && ts.isIdentifier(expression.arguments[1]) && [...setters.values()].includes(expression.arguments[1].text)) {
6139
- return command(factory, "log", expression.arguments[1], expression.arguments[0])
6184
+ return command(factory, "log", expression.arguments[1], factory.createStringLiteral(expression.arguments[0].text))
6140
6185
  }
6141
6186
 
6142
6187
  if (!ts.isCallExpression(expression) || !ts.isIdentifier(expression.expression) || expression.arguments.length !== 1) return undefined
@@ -6153,7 +6198,7 @@ function compileEventCommand(expression, setters, factory) {
6153
6198
  if (value.body.operatorToken.kind !== ts.SyntaxKind.PlusToken && value.body.operatorToken.kind !== ts.SyntaxKind.MinusToken) return undefined
6154
6199
  return command(factory, "add", state, numericExpression(factory, Number(value.body.right.text), value.body.operatorToken.kind === ts.SyntaxKind.MinusToken))
6155
6200
  }
6156
- if (isPrimitiveLiteral(value)) return command(factory, "set", state, value)
6201
+ if (isPrimitiveLiteral(value)) return command(factory, "set", state, synthesizeSerializableStateLiteral(value, factory))
6157
6202
  return undefined
6158
6203
  }
6159
6204
 
@@ -6162,7 +6207,7 @@ function command(factory, operation, state, value) {
6162
6207
  }
6163
6208
 
6164
6209
  function isPrimitiveLiteral(node) {
6165
- return ts.isStringLiteral(node) || ts.isNumericLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword
6210
+ return isPrimitiveDefaultLiteral(node)
6166
6211
  }
6167
6212
 
6168
6213
  function numericExpression(factory, value, negative) {
@@ -36,7 +36,7 @@ export function nativeBehavior(module: string, handler: string, states: Array<[s
36
36
  export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
37
37
  export function bindingValue(value: unknown): unknown
38
38
  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
39
- export function list(items: unknown, keyField: string | null, render: (item: unknown, index: number) => unknown, ownerField?: string, selector?: unknown[], indexed?: boolean, selectorStates?: Array<[string, unknown]>): unknown
39
+ export function list(items: unknown, keyField: string | null, render: (item: unknown, index: number) => unknown, ownerField?: string, selector?: unknown[], indexed?: boolean, selectorStates?: Array<[string, unknown]>, staticCollection?: boolean): unknown
40
40
  export function listField(read: () => unknown, field: string): unknown
41
41
  export function listExpression(read: () => unknown, module: string, handler: string): unknown
42
42
  export function listItem(): unknown
@@ -291,7 +291,9 @@ export function stateConditional(kind, state, truthy, falsy) {
291
291
  return { [conditionalMarker]: true, kind, value: state.value, truthy, falsy, state: state.id }
292
292
  }
293
293
 
294
- export function list(items, keyField, render, ownerField, selector = [], indexed = false, selectorStates = []) {
294
+ export function list(items, keyField, render, ownerField, selector = [], indexed = false, selectorStates = [], staticCollection = false) {
295
+ const source = items?.[bindingMarker] ? bindingDescriptor(items) : undefined
296
+ if (source) items = createInternalState(items.value)
295
297
  if (Array.isArray(items)) items = createInternalState(items)
296
298
  if (!items?.[signalMarker] || !Array.isArray(items.value)) throw new Error("A keyed list must use local array state or a supported imported static array")
297
299
  const selectorStateMap = new Map(selectorStates)
@@ -315,7 +317,7 @@ export function list(items, keyField, render, ownerField, selector = [], indexed
315
317
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
316
318
  keys.add(token)
317
319
  }
318
- return { [listMarker]: true, items, values, keyField, render, ownerField, selector, indexed, selectorStates }
320
+ return { [listMarker]: true, items, values, keyField, render, ownerField, selector, indexed, static: staticCollection, selectorStates, source }
319
321
  }
320
322
 
321
323
  export function listField(read, field) {
@@ -870,7 +872,8 @@ async function renderList(node, namespace, selectValue) {
870
872
  const ownerTemplate = Boolean(node.ownerField && renderContext.listTemplate)
871
873
  const rowList = node.ownerField ? nextRowList() : undefined
872
874
  const id = rowList?.id ?? nextRenderId("l")
873
- const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map((item, index) => node.keyField === null ? index : item[node.keyField]), ...(namespace === "svg" ? { svg: true } : {}), ...(node.items[internalStateMarker] ? { static: true } : {}), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.selector.length ? { selector: node.selector } : {}), ...(node.selectorStates.length ? { selectorStates: Object.fromEntries(node.selectorStates.map(([name, state]) => [name, state.id])) } : {}), ...(node.indexed ? { indexed: true } : {}), ...(!node.selector.length && node.keyField !== null && node.items[reducerStateMarker] ? { reducer: true } : {}) }
875
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map((item, index) => node.keyField === null ? index : item[node.keyField]), ...(namespace === "svg" ? { svg: true } : {}), ...(node.static || node.items[internalStateMarker] && !node.source ? { static: true } : {}), ...(node.source ? { source: node.source } : {}), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.selector.length ? { selector: node.selector } : {}), ...(node.selectorStates.length ? { selectorStates: Object.fromEntries(node.selectorStates.map(([name, state]) => [name, state.id])) } : {}), ...(node.indexed ? { indexed: true } : {}), ...(!node.selector.length && node.keyField !== null && node.items[reducerStateMarker] ? { reducer: true } : {}) }
876
+ if (node.source) renderContext.hasBindings = true
874
877
  if (ownerTemplate) {
875
878
  ownerRoot.descriptor.children ??= []
876
879
  ownerRoot.descriptor.children.push({ id, field: node.ownerField, key: node.keyField, ...(node.selector.length ? { selector: node.selector } : {}) })
@@ -919,8 +922,10 @@ async function renderList(node, namespace, selectValue) {
919
922
  descriptor.mount = true
920
923
  }
921
924
  if (descriptor.rowStates && !descriptor.effects && !descriptor.nested && !template.includes("data-k-text")) descriptor.fastRelease = true
922
- const seed = node.ownerField || node.selector.length || node.keyField === null ? undefined : listSeed(node.values, renderContext.listFields)
925
+ const valueSeed = node.ownerField || node.keyField === null ? undefined : listSeed(node.values, renderContext.listFields)
926
+ const seed = node.selector.length ? undefined : valueSeed
923
927
  if (seed) descriptor.seed = seed
928
+ else if (valueSeed) descriptor.valueSeed = valueSeed
924
929
  let current = ""
925
930
  renderContext.listTemplate = false
926
931
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
@@ -1,8 +1,10 @@
1
1
  import { browserState, mountDom, notifyListItem, registerCommitter, registerMountHook, registerUnmountHook, releaseState, unmountDom } from "./shared-runtime.js"
2
2
  import { selectCollection } from "./collection-selector.js"
3
+ const loadListEvaluator = descriptor => import("./binding-runtime.js").then(module => module.loadEvaluator(descriptor))
3
4
 
4
5
  const listTargets = new Map()
5
6
  const listRegistrations = new WeakMap()
7
+ const listLoads = new WeakMap()
6
8
  const mountedLists = new WeakSet()
7
9
  const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
8
10
  const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
@@ -68,7 +70,8 @@ function mountLists(root) {
68
70
  parts,
69
71
  ...(__KUDZU_STATIC_COLLECTIONS__ && staticRows ? { staticRows } : {}),
70
72
  ...(__KUDZU_STATIC_COLLECTIONS__ && staticEntries ? { staticEntries, staticPositions: staticEntries.positions } : {}),
71
- seedFields: __KUDZU_LIST_SEEDS__ && descriptor.seed && Object.keys(descriptor.seed),
73
+ seedFields: __KUDZU_LIST_SEEDS__ && (descriptor.seed || descriptor.valueSeed) && Object.keys(descriptor.seed ?? descriptor.valueSeed),
74
+ valueSeed: __KUDZU_LIST_SEEDS__ && (descriptor.seed ?? descriptor.valueSeed),
72
75
  roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
73
76
  ...(__KUDZU_LIST_STABLE_FAST_PATHS__ ? { orderedRoots: roots } : {}),
74
77
  values: new Map(),
@@ -84,6 +87,17 @@ function mountLists(root) {
84
87
  lists.set(descriptor.id, list)
85
88
  ownedLists.set(list.owner, lists)
86
89
  listRegistrations.set(start, { list, owner: list.owner })
90
+ } else if (descriptor.source) {
91
+ const load = {}
92
+ listLoads.set(start, load)
93
+ loadListEvaluator(descriptor.source).then(evaluator => {
94
+ if (listLoads.get(start) !== load || !start.isConnected) return
95
+ list.sourceEvaluator = evaluator
96
+ const states = [...new Set([...evaluator.stateIds, ...(__KUDZU_COLLECTION_SELECTORS__ ? Object.values(descriptor.selectorStates ?? {}) : [])])]
97
+ for (const state of states) register(listTargets, state, list)
98
+ listRegistrations.set(start, { states, list })
99
+ updateList(list)
100
+ }).catch(error => console.error(error))
87
101
  } else {
88
102
  if (__KUDZU_COLLECTION_SELECTORS__ && descriptor.selectorStates) {
89
103
  const states = [...new Set([descriptor.state, ...Object.values(descriptor.selectorStates)])]
@@ -94,7 +108,7 @@ function mountLists(root) {
94
108
  listRegistrations.set(start, { state: descriptor.state, list })
95
109
  }
96
110
  }
97
- updateList(list)
111
+ if (!descriptor.source) updateList(list)
98
112
  }
99
113
  }
100
114
 
@@ -103,6 +117,7 @@ function unmountLists(root) {
103
117
  }
104
118
 
105
119
  function unregisterList(start) {
120
+ listLoads.delete(start)
106
121
  const registration = listRegistrations.get(start)
107
122
  if (registration) {
108
123
  if (__KUDZU_NESTED_LISTS__ && registration.owner) {
@@ -110,7 +125,7 @@ function unregisterList(start) {
110
125
  if (lists?.get(registration.list.descriptor.id) === registration.list) lists.delete(registration.list.descriptor.id)
111
126
  if (!lists?.size) ownedLists.delete(registration.owner)
112
127
  } else {
113
- if (__KUDZU_COLLECTION_SELECTORS__ && registration.states) {
128
+ if ((__KUDZU_COLLECTION_SELECTORS__ || registration.list.descriptor.source) && registration.states) {
114
129
  for (const state of registration.states) {
115
130
  const lists = listTargets.get(state)
116
131
  lists?.delete(registration.list)
@@ -129,6 +144,7 @@ function unregisterList(start) {
129
144
  }
130
145
 
131
146
  function updateList(list) {
147
+ if (list.sourceEvaluator) browserState.set(list.descriptor.state, list.sourceEvaluator.read())
132
148
  let items = __KUDZU_NESTED_LISTS__ && list.descriptor.ownerField
133
149
  ? listItems.get(list.owner)?.[list.descriptor.ownerField]
134
150
  : browserState.get(list.descriptor.state)
@@ -148,13 +164,26 @@ function updateList(list) {
148
164
  const key = list.descriptor.key === null ? index : item?.[list.descriptor.key]
149
165
  if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
150
166
  assertListItem(item)
151
- const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed, !referenceOnly) : undefined
167
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.valueSeed, !referenceOnly) : undefined
152
168
  if (seededValue === undefined) assertListValue(item, seen, true)
153
169
  const token = keyToken(key)
154
170
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
155
171
  keys.add(token)
156
172
  entries.push({ item, index, key, token, value: referenceOnly ? item : seededValue === undefined ? JSON.stringify(item) : seededValue })
157
173
  }
174
+ if (list.items === undefined && list.parts.directFill && entries.length === list.roots.size) {
175
+ const roots = __KUDZU_LIST_STABLE_FAST_PATHS__ ? list.orderedRoots : [...list.roots.values()]
176
+ if (entries.every((entry, index) => list.roots.get(entry.token) === roots[index])) {
177
+ for (let index = 0; index < entries.length; index++) {
178
+ const entry = entries[index]
179
+ listItems.set(roots[index], entry.item)
180
+ if (__KUDZU_LIST_INDEXES__) listIndexes.set(roots[index], index)
181
+ if (!referenceOnly) list.values.set(entry.token, entry.value)
182
+ }
183
+ list.items = items
184
+ return
185
+ }
186
+ }
158
187
  if (list.descriptor.key !== null && !list.descriptor.indexed && !list.descriptor.selector && (referenceOnly || list.values.size) && entries.every(entry => {
159
188
  const root = list.roots.get(entry.token)
160
189
  return !root || (referenceOnly ? listItems.get(root) === entry.item : list.values.get(entry.token) === entry.value)
@@ -349,19 +378,18 @@ function updateStableList(list, items) {
349
378
  const seen = new Set()
350
379
  const keyField = list.descriptor.key
351
380
  const fragment = list.start.ownerDocument.createDocumentFragment()
352
- let stable = true
353
381
  for (let index = 0; index < items.length; index++) {
354
382
  const item = items[index]
355
383
  assertListItem(item)
356
- const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed, !referenceOnly) : undefined
384
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.valueSeed, !referenceOnly) : undefined
357
385
  if (seededValue === undefined) assertListValue(item, seen, true)
358
386
  const key = item[keyField]
359
387
  if (!validListKey(key)) throw new Error(`Keyed list key "${keyField}" must be a string or finite number`)
360
388
  const token = keyToken(key)
361
389
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
362
390
  keys.add(token)
363
- if (index < previous.length && (key !== previous[index][keyField] || appending && item !== previous[index])) stable = false
364
- if (!stable || items.length === previous.length + 1 || index < previous.length) continue
391
+ if (index < previous.length && (key !== previous[index][keyField] || appending && item !== previous[index])) return false
392
+ if (items.length === previous.length + 1 || index < previous.length) continue
365
393
  let node = list.templateRoot?.cloneNode(true)
366
394
  if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
367
395
  if (!node) throw new Error("Keyed list template has no root element")
@@ -371,13 +399,12 @@ function updateStableList(list, items) {
371
399
  else fillListItem(node, item, list.descriptor.nested, index, mapListItemParts(list.parts, node, list.descriptor.nested))
372
400
  fragment.append(node)
373
401
  }
374
- if (!stable) return false
375
402
  if (appending) {
376
403
  if (items.length === previous.length + 1) {
377
404
  const item = items.at(-1)
378
405
  const key = item[keyField]
379
406
  const token = keyToken(key)
380
- const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed, !referenceOnly) : undefined
407
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.valueSeed, !referenceOnly) : undefined
381
408
  addListRoot(list, { item, index: previous.length, key, token, value: referenceOnly ? item : seededValue === undefined ? JSON.stringify(item) : seededValue })
382
409
  if (referenceOnly) list.values.clear()
383
410
  list.items = items
@@ -391,7 +418,7 @@ function updateStableList(list, items) {
391
418
  list.roots.set(token, node)
392
419
  list.orderedRoots.push(node)
393
420
  if (!referenceOnly) {
394
- const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed) : undefined
421
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.valueSeed) : undefined
395
422
  list.values.set(token, seededValue === undefined ? JSON.stringify(item) : seededValue)
396
423
  }
397
424
  node = node.nextSibling
@@ -414,7 +441,7 @@ function updateStableList(list, items) {
414
441
  const item = items[index]
415
442
  if (item === previous[index]) continue
416
443
  const token = keyToken(item[keyField])
417
- const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed) : undefined
444
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.valueSeed) : undefined
418
445
  const value = seededValue === undefined ? JSON.stringify(item) : seededValue
419
446
  if (list.values.get(token) !== value) {
420
447
  const node = list.orderedRoots[index]
@@ -535,7 +562,7 @@ function updateReducerList(list, items) {
535
562
  if (!validListKey(key)) throw new Error(`Keyed list key "${list.descriptor.key}" must be a string or finite number`)
536
563
  assertListItem(item)
537
564
  const seen = new Set()
538
- const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.descriptor.seed) : undefined
565
+ const seededValue = __KUDZU_LIST_SEEDS__ ? list.seedFields && seededListValue(item, list.seedFields, list.valueSeed) : undefined
539
566
  if (seededValue === undefined) assertListValue(item, seen, true)
540
567
  const token = keyToken(key)
541
568
  if (list.roots.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
@@ -574,11 +601,12 @@ function removeListRoot(list, token) {
574
601
  }
575
602
 
576
603
  function fillListItem(root, item, nested = false, index = 0, parts = listItemParts(root, nested)) {
604
+ const previous = listItems.get(root)
577
605
  listItems.set(root, item)
578
606
  if (__KUDZU_LIST_INDEXES__) listIndexes.set(root, index)
579
607
  const revision = __KUDZU_LIST_ASYNC_PARTS__ ? (revisions.get(root) ?? 0) + 1 : 0
580
608
  if (__KUDZU_LIST_ASYNC_PARTS__) revisions.set(root, revision)
581
- fillListParts(root, parts, item, revision, index)
609
+ fillListParts(root, parts, item, revision, index, previous)
582
610
  if (__KUDZU_NESTED_LISTS__) {
583
611
  const children = ownedLists.get(root)
584
612
  if (children) for (const child of children.values()) updateList(child)
@@ -586,7 +614,7 @@ function fillListItem(root, item, nested = false, index = 0, parts = listItemPar
586
614
  if (__KUDZU_LIST_ROW_HOOKS__) replaceRowIds(root, rowReplacements.get(root))
587
615
  }
588
616
 
589
- function fillListParts(root, parts, item, revision, index = 0) {
617
+ function fillListParts(root, parts, item, revision, index = 0, previous) {
590
618
  if (__KUDZU_LIST_EFFECTS__) for (const node of parts.effects) node.dataset.kEffectItem = JSON.stringify(item)
591
619
  for (const [node, field] of parts.directTexts) {
592
620
  const text = item?.[field]
@@ -603,7 +631,10 @@ function fillListParts(root, parts, item, revision, index = 0) {
603
631
  }
604
632
  if (__KUDZU_LIST_ATTRIBUTES__) {
605
633
  for (const [node, attributes] of parts.attributes) {
606
- for (const [target, field] of attributes) patchBinding(node, target, item?.[field])
634
+ for (const [target, field] of attributes) {
635
+ if (previous && Object.is(previous[field], item?.[field])) continue
636
+ patchBinding(node, target, item?.[field], true)
637
+ }
607
638
  }
608
639
  }
609
640
  if (__KUDZU_LIST_EVENTS__) {
@@ -978,7 +1009,7 @@ function serializeItem(value) {
978
1009
  return { type: "object", nullPrototype: false, value: Object.entries(value).map(([key, entry]) => [key, serializeItem(entry)]) }
979
1010
  }
980
1011
 
981
- function patchBinding(node, target, value) {
1012
+ function patchBinding(node, target, value, write = false) {
982
1013
  /* list-style */
983
1014
  if (target === "disabled") {
984
1015
  node.toggleAttribute("disabled", Boolean(value))
@@ -995,7 +1026,7 @@ function patchBinding(node, target, value) {
995
1026
  node.removeAttribute(target)
996
1027
  } else {
997
1028
  const next = value === true && !isStringBooleanAttribute(target) ? "" : String(value)
998
- if (node.getAttribute(target) !== next) node.setAttribute(target, next)
1029
+ if (write || node.getAttribute(target) !== next) node.setAttribute(target, next)
999
1030
  }
1000
1031
  }
1001
1032
 
@@ -63,6 +63,7 @@ function eligibleClick(event, anchor) {
63
63
  }
64
64
 
65
65
  function eligibleAnchor(anchor) {
66
+ if (!anchor) return false
66
67
  if (anchor.hasAttribute("download") || anchor.hasAttribute("data-k-native") || !["", "_self"].includes(anchor.target)) return false
67
68
  if (anchor.relList?.contains("external")) return false
68
69
  const url = new URL(anchor.href)
@@ -125,8 +126,7 @@ async function navigate(url, push) {
125
126
  catch { documentResult = await fetchDocument(url, record, request.signal) }
126
127
  } else documentResult = await fetchDocument(url, record, request.signal)
127
128
  documents.set(url.href, Promise.resolve(documentResult))
128
- const { incoming, parsed } = documentResult
129
- const capabilities = await loadCapabilities(parsed)
129
+ const { incoming, parsed, capabilities } = documentResult
130
130
  if (current !== revision) return
131
131
  await routeDispose()
132
132
  if (current !== revision) return
@@ -157,7 +157,8 @@ async function fetchDocument(url, record, signal) {
157
157
  const response = await fetch(url, { signal, redirect: "manual", headers: { accept: "text/html" } })
158
158
  if (!response.ok || response.redirected || response.type === "opaqueredirect" || !response.headers.get("content-type")?.toLowerCase().includes("text/html")) throw new Error("Navigation response is not successful nonredirected HTML")
159
159
  const incoming = new DOMParser().parseFromString(await response.text(), "text/html")
160
- return { incoming, parsed: validate(incoming, record), record }
160
+ const parsed = validate(incoming, record)
161
+ return { incoming, parsed, capabilities: await loadCapabilities(parsed), record }
161
162
  }
162
163
 
163
164
  function validate(incoming, record) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.0",
3
+ "version": "0.8.2",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",