@kudzujs/core 0.7.6 → 0.7.8

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 is designed so ordinary common React-shaped TSX can migrate with minimal s
10
10
 
11
11
  > Experimental `0.7.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **0.7.6:** Zustand-shaped shared stores. Reduced `create(set => ...)` stores compile to persistent application-layout state and direct handler updates without shipping React or Zustand. See [release notes](./RELEASES.md#076---zustand-shaped-shared-stores).
13
+ **0.7.8:** Static collection fast paths. Compiler-owned static filters reuse validated item metadata and detached row prototypes, making 1,000-row filtering match Vue and restoring filtered rows faster than React, Vue, and Svelte in the matched benchmark. See [release notes](./RELEASES.md#078---static-collection-fast-paths).
14
14
 
15
15
  Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
16
16
 
@@ -74,7 +74,7 @@ export default function Header() {
74
74
  }
75
75
  ```
76
76
 
77
- Kudzu rewrites supported React imports to its compile-time APIs before evaluating the module; neither the React package nor a compatibility runtime enters the deploy output. Named or aliased `useState`, `useReducer`, `useEffect`, `useRef`, `createContext`, and `useContext` imports compile to their canonical forms. Default and namespace imports may call those APIs as direct members such as `React.useState`, and default, namespace, or named `Fragment` also works. `memo(Component)` is erased to a same-file component. Inline `useCallback(function, literalDependencies)` is erased to its function, while inline synchronous `useMemo` callbacks may return one expression over primitive literals/direct local state or an analyzable `filter`, `map`, `flatMap`, and `Array.from` collection pipeline. Scalar expressions inline into existing bindings; collection pipelines lower to existing keyed-list selectors and preserve row identity. Both hooks require inert literal dependency arrays and complete captured-state dependencies; memo locals cannot be duplicated or captured by nested functions. React classes and side-effect or dynamic React imports remain unsupported. A static route using these forms still emits zero JavaScript.
77
+ Kudzu rewrites supported React imports to its compile-time APIs before evaluating the module; neither the React package nor a compatibility runtime enters the deploy output. Named or aliased `useState`, `useReducer`, `useEffect`, `useRef`, `createContext`, and `useContext` imports compile to their canonical forms. Default and namespace imports may call those APIs as direct members such as `React.useState`, and default, namespace, or named `Fragment` also works. `memo(Component)` is erased to a same-file component. Inline `useCallback(function, literalDependencies)` is erased to its function, while inline synchronous `useMemo` callbacks may return one expression over primitive literals/direct local state or an analyzable `filter`, `map`, `flatMap`, and `Array.from` collection pipeline. Collection pipelines may start from local array state or a named relative import whose source is an exported JSON-safe `const` array; imported static collections may be filtered by direct local state listed in the dependency array. Scalar expressions inline into existing bindings; collection pipelines lower to existing keyed-list selectors and preserve row identity. Both hooks require inert literal dependency arrays and complete captured-state dependencies; memo locals cannot be duplicated or captured by nested functions. React classes and side-effect or dynamic React imports remain unsupported. A static route using these forms still emits zero JavaScript.
78
78
 
79
79
  Direct default or named `clsx` imports compile away for string/number literals, literal arrays, literal object conditions, and conditional expressions. Kudzu lowers those calls to ordinary class expressions, so reactive classes reuse existing bindings without shipping `clsx`; spreads, computed object keys, arbitrary calls, and indirect references remain unsupported.
80
80
 
@@ -418,7 +418,9 @@ return <ItemList items={items} />
418
418
 
419
419
  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. Nested item-local `&&` and ternary conditions patch bounded branches and mount or unmount their 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 row or page state, reruns only rows whose selected values changed; unrelated fields and reorder do not rerun it, while removal cleans it up. 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.
420
420
 
421
- Rendered collections may use one-use top-level aliases and analyzable pipelines over local array state. Inline arrow callbacks accept `(item)` or `(item, index)`; `filter()` supports pure synchronous expressions, `flatMap()` projects one direct array property, `Array.from()` accepts an optional pure mapper, and the final `map()` may use `key={item.field}` or positional `key={index}`. Field keys preserve the matching DOM node through filtering and reorder. Positional keys deliberately preserve the DOM node at each position while its item changes, matching React key semantics.
421
+ Rendered collections may use one-use top-level aliases and analyzable pipelines over local array state or named relative imports of exported JSON-safe `const` arrays. Inline arrow callbacks accept `(item)` or `(item, index)`; `filter()` supports pure synchronous expressions and direct local-state reads, `flatMap()` projects one direct array property, `Array.from()` accepts an optional pure mapper, and the final `map()` may use `key={item.field}` or positional `key={index}`. Field keys preserve the matching DOM node through filtering and reorder. Positional keys deliberately preserve the DOM node at each position while its item changes, matching React key semantics.
422
+
423
+ Compiler-owned static `filter` collections with structural keyed rows validate source items and key tokens once. Removed rows become detached prototypes; restoration clones fresh nodes and inserts only new runs, so retained keys keep identity while restored keys remount. In a 31-fresh-profile, 4x CPU-throttled Chrome benchmark over 1,000 alternating products, Kudzu measured 37.8 ms to visible rows, 8.4 ms to filter to 500, and 3.6 ms to restore 1,000. React measured 86.3/12.9/6.2 ms, Vue 54.1/8.4/4.3 ms, and Svelte 61.4/11.7/8.3 ms. This is a focused static-filter result, not a claim that rendering 100,000 or 1,000,000 DOM rows is appropriate; paginate or window large datasets so only the visible result set enters the document.
422
424
 
423
425
  A keyed row may contain multiple keyed maps over direct array properties of its item at any nesting depth. This supports recursively nested data populated after mount while preserving keyed DOM identity across updates and reorder:
424
426
 
@@ -463,7 +465,7 @@ Initial child rows remain complete HTML. Kudzu stores one child row prototype, i
463
465
 
464
466
  In the matched 100-parent/1,000-child fixture, Kudzu measured 1.3/0.4/5.0/0.7 ms for child update and condition change, child reverse, parent reverse, and parent removal. Hand-written Astro/native measured 0.5/0.4/3.9/0.2 ms, Svelte 2.7/1.2/6.7/1.3 ms, Vue 4.9/2.5/6.1/2.2 ms, and React 11.8/5.0/8.2/4.4 ms. Kudzu and Astro emit initial rows while the CSR targets do not, so artifact sizes are not architecture-equivalent.
465
467
 
466
- 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. Collections must remain anchored to local array state; inline callbacks accept one or two identifier parameters, and row roots must be intrinsic JSX or supported same-file/relative components with `key={item.<field>}` or `key={index}`. State-backed list wrappers use one destructured props parameter, an intrinsic return root, no effects, and a direct local-state prop. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` effect dependencies are rejected. A collection alias may only be rendered once and cannot be read by other JavaScript. Collection callbacks and derived expressions must be pure and synchronous: supported reads, operators, templates, approved read-only methods, deterministic `Math`, and primitive conversion compile; imported callbacks, browser globals, promises, mutation, arbitrary calls, and prototype-sensitive properties fail. Lazy or dynamic keyed-row state initializers, non-`null` refs, callback refs, package/namespace/star row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, fragments, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
468
+ 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. Collections must remain anchored to local array state or a supported static named import; inline callbacks accept one or two identifier parameters, and row roots must be intrinsic JSX or supported same-file/relative components with `key={item.<field>}` or `key={index}`. State-backed list wrappers use one destructured props parameter, an intrinsic return root, no effects, and a direct local-state prop. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` effect dependencies are rejected. A collection alias may only be rendered once and cannot be read by other JavaScript. Collection callbacks and derived expressions must be pure and synchronous: supported reads, operators, templates, approved read-only methods, deterministic `Math`, and primitive conversion compile; imported callbacks, browser globals, promises, mutation, arbitrary calls, and prototype-sensitive properties fail. Lazy or dynamic keyed-row state initializers, non-`null` refs, callback refs, package/namespace/star row imports, same-file exported rows, reusable aliases, prop spreads/defaults/rest, children, fragments, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
467
469
 
468
470
  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.
469
471
 
package/RELEASES.md CHANGED
@@ -1,5 +1,55 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.8 - Static collection fast paths
4
+
5
+ Kudzu 0.7.8 specializes compiler-owned static `filter` collections so repeated category changes avoid general keyed-list validation and reconciliation.
6
+
7
+ ### New in 0.7.8
8
+
9
+ - Static source items, unique keys, source positions, and reference entries validate and cache once when the list mounts.
10
+ - Structural rows removed by a filter become detached prototypes; restoration clones fresh DOM, preserving remount semantics without moving retained keys.
11
+ - Interleaved additions insert only new contiguous runs instead of attaching additions and then reordering the complete list.
12
+ - General local-state lists, dynamic selectors, indexed rows, handlers, effects, refs, and row state retain their existing reconciliation paths.
13
+ - The capability is route-specific and compiles out when no compiler-owned static collection is rendered.
14
+ - The matched route adds 648 B gzip compared with 0.7.7's initial implementation while reducing 500-row restoration from 13.2 ms to 3.6 ms.
15
+
16
+ ### Benchmark
17
+
18
+ The matched fixture imports 1,000 alternating products, filters to 500, then restores all 1,000 while checking retained and remounted DOM identity. Thirty-one rotating fresh Chrome 150 profiles on an Apple M3 used 4x CPU throttling. Median visible/filter/restore times were Kudzu 37.8/8.4/3.6 ms, React 86.3/12.9/6.2 ms, Vue 54.1/8.4/4.3 ms, and Svelte 61.4/11.7/8.3 ms. React, Vue, and Svelte used Vite CSR shells while Kudzu emitted initial HTML, so visible-row and artifact comparisons are architecture-dependent.
19
+
20
+ ### Boundary
21
+
22
+ The fast path requires a compiler-owned static source, field keys, filter-only selection, and structural rows without handlers, effects, refs, row state, or index dependencies. It is intended for ordinary catalog-sized collections, not direct rendering of 100,000 or 1,000,000 DOM rows; use pagination or windowing for those workloads.
23
+
24
+ ### Upgrade
25
+
26
+ ```bash
27
+ npm install @kudzujs/core@^0.7.8
28
+ ```
29
+
30
+ ## 0.7.7 - Imported memo collections
31
+
32
+ Kudzu 0.7.7 lets ordinary React migration source filter an imported static catalog through state-dependent `useMemo` while reusing existing keyed-list reconciliation.
33
+
34
+ ### New in 0.7.7
35
+
36
+ - Named relative imports of exported JSON-safe `const` arrays can anchor analyzable collection pipelines.
37
+ - Direct local-state reads in collection selectors invalidate the list when their declared state dependencies change.
38
+ - Existing keys retain DOM identity through filtering and restoration; removed keys remount when restored.
39
+ - Compiler-owned static collection state is excluded from development snapshot restoration.
40
+ - Static routes remain JavaScript-free, and interactive routes ship no React runtime or browser memo cache.
41
+ - Focused fixtures verify dependency diagnostics, static output, generated selectors, and browser DOM identity.
42
+
43
+ ### Boundary
44
+
45
+ Static collections must be named relative imports of exported JSON-safe `const` arrays. Package, namespace, default, dynamic, mutable, and non-serializable collection sources remain unsupported, as do arbitrary callbacks and general-purpose memo caching.
46
+
47
+ ### Upgrade
48
+
49
+ ```bash
50
+ npm install @kudzujs/core@^0.7.7
51
+ ```
52
+
3
53
  ## 0.7.6 - Zustand-shaped shared stores
4
54
 
5
55
  Kudzu 0.7.6 lets reduced React migration source retain a Zustand `create(set => ...)` store across an explicitly configured shared-layout navigation group.
@@ -2,7 +2,7 @@
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`, and default, namespace, or named `Fragment`. `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`, and default, namespace, or named `Fragment`. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array 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
7
  Direct `clsx` calls over literal strings, numbers, arrays, object conditions, and conditional expressions are similarly lowered to ordinary concatenation and conditional expressions. The package import is erased, and dynamic classes continue through the existing binding compiler without serializing or shipping the `clsx` function.
8
8
 
@@ -33,7 +33,7 @@ Inline SVG rendering normalizes an explicit set of common React presentation ali
33
33
 
34
34
  Same-file and relative-imported component chains receiving a direct local-state array or keyed item are recursively specialized to intrinsic JSX before keyed-list analysis, so their component functions are not retained in the browser. Rows may own multiple direct-property child maps recursively, nested conditions, latest-item handlers, multiple directly serializable state slots, effects, and `null`-initialized object refs. Structural list sites and ancestor key paths scope hooks across updates and reorder and release them on removal. Handler modules are emitted only when a rendered descriptor references them. Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal and unrelated fields do nothing. Builds without item dependencies emit no item reader or list-state subscription code.
35
35
 
36
- Rendered collection selectors compile one-use aliases and inline `(item)` or `(item, index)` pipelines over local array state. Supported selectors are pure `filter`, direct-property `flatMap`, and `Array.from` before a final keyed `map`; field keys retain item identity while `key={index}` retains positional identity. Arbitrary callbacks, mutation, asynchronous selectors, imported callback functions, prototype-sensitive reads, lazy/dynamic row state initializers, non-`null` or callback refs, and recursive component cycles fail during compilation.
36
+ Rendered collection selectors compile one-use aliases and inline `(item)` or `(item, index)` pipelines over local array state or supported static named imports. Supported selectors are pure `filter` with direct local-state reads, direct-property `flatMap`, and `Array.from` before a final keyed `map`; dependency commits re-evaluate the selector against the immutable build-time collection while field keys retain item identity and `key={index}` retains positional identity. Compiler-owned static filters over structural keyed rows validate source references and keys once, retain removed rows as detached prototypes, clone fresh restoration nodes, and insert only new runs without moving retained DOM. This route-specific path is compiled out elsewhere. Compiler-owned collection state is excluded from development snapshot restoration. Arbitrary callbacks, mutation, asynchronous selectors, imported callback functions, prototype-sensitive reads, lazy/dynamic row state initializers, non-`null` or callback refs, and recursive component cycles fail during compilation.
37
37
 
38
38
  The reduced `useReducer` form reuses ordinary state slots and React's pure reducer contract. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. Pure reducer-owned keyed lists reuse unchanged item identities for reorder, one removal, and append fast paths; ordinary `useState` lists retain full validation. One direct dispatch prop into a same-file or relative-imported synchronous component, including a direct keyed row, is specialized to intrinsic JSX at the call site, so its handler retains the parent reducer scope and no dispatch capture or child handler asset is emitted. A reducer row reads the latest item through the existing list scope and uses the same multiple serializable state, effect, condition, and object-ref specialization as other keyed rows. Relative TypeScript imports referenced inside that child handler receive collision-free call-site aliases and join the parent handler graph. One nested relative-imported intrinsic child may receive an inline or simple `const` callback containing dispatch; the compiler recursively substitutes that callback once and omits the nested child handler asset. Missing primitive literal defaults in these reducer specializations are substituted at the same call site. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
39
39
 
@@ -213,6 +213,7 @@ export async function build({ quiet = false, minify = true } = {}) {
213
213
  const hasComplexListRowState = plans.some(plan => plan.lists.some(list => list.rowStates?.some(state => state.initialValue !== null && typeof state.initialValue === "object")))
214
214
  const hasNestedLists = plans.some(plan => plan.lists.some(list => list.ownerField))
215
215
  const hasCollectionSelectors = plans.some(plan => plan.lists.some(list => list.selector))
216
+ const hasStaticCollections = plans.some(plan => plan.lists.some(list => list.static))
216
217
  const hasListIndexes = plans.some(plan => plan.lists.some(list => list.indexed))
217
218
  const hasListStableFastPaths = plans.some(plan => plan.lists.some(list => !list.children && !list.ownerField && list.key !== null && !list.indexed && !list.reducer && !list.selector))
218
219
  const hasGeneralListRowHooks = hasListRowRefs || hasComplexListRowState || plans.some(plan => plan.lists.some(list => list.ownerField && (list.rowStates?.length || list.rowRefs?.length)))
@@ -343,6 +344,7 @@ export async function build({ quiet = false, minify = true } = {}) {
343
344
  __KUDZU_COMPLEX_LIST_ROW_STATE__: String(hasComplexListRowState),
344
345
  __KUDZU_NESTED_LISTS__: String(hasNestedLists),
345
346
  __KUDZU_COLLECTION_SELECTORS__: String(hasCollectionSelectors),
347
+ __KUDZU_STATIC_COLLECTIONS__: String(hasStaticCollections),
346
348
  __KUDZU_LIST_INDEXES__: String(hasListIndexes),
347
349
  __KUDZU_LIST_STABLE_FAST_PATHS__: String(hasListStableFastPaths)
348
350
  })
@@ -1879,7 +1881,7 @@ function normalizeZustandMigrationSyntax(sourceFile, factory, context) {
1879
1881
  return factory.updateSourceFile(normalized, statements)
1880
1882
  }
1881
1883
 
1882
- function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1884
+ function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections = new Set()) {
1883
1885
  const supported = new Set(["createContext", "useContext", "useEffect", "useReducer", "useRef", "useState"])
1884
1886
  const erased = new Set(["memo", "useCallback", "useMemo"])
1885
1887
  const aliases = new Map()
@@ -2013,14 +2015,15 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context) {
2013
2015
  const dependencies = new Set(node.arguments[1].elements.map(unwrapExpression).filter(ts.isIdentifier).map(entry => entry.text))
2014
2016
  const owner = nearestFunction(node)
2015
2017
  const states = owner ? ownerStateNames(owner) : new Set()
2016
- const collectionState = expression && reactMemoCollectionState(expression, states, sourceFile)
2017
- if (!expression || !collectionState && !isPureReactMemoExpression(expression)) throw sourceNodeError(callback.body, sourceFile, "React useMemo() callback must return one pure expression or analyzable collection pipeline")
2018
- if (!collectionState) {
2018
+ const collection = expression && reactMemoCollection(expression, states, importedCollections, sourceFile)
2019
+ if (!expression || !collection && !isPureReactMemoExpression(expression)) throw sourceNodeError(callback.body, sourceFile, "React useMemo() callback must return one pure expression or analyzable collection pipeline")
2020
+ if (!collection) {
2019
2021
  const unsupported = [...reactMemoReferenceNames(expression)].find(reference => !states.has(reference))
2020
2022
  if (unsupported) throw sourceNodeError(expression, sourceFile, `React useMemo() pure expressions may only reference direct local state; found ${JSON.stringify(unsupported)}`)
2021
2023
  }
2022
- const stale = collectionState
2023
- ? !dependencies.has(collectionState) ? collectionState : undefined
2024
+ const collectionDependencies = collection ? new Set([...collection.selectorStates, ...(collection.static ? [] : [collection.state.text])]) : undefined
2025
+ const stale = collection
2026
+ ? [...collectionDependencies].find(state => !dependencies.has(state))
2024
2027
  : [...states].find(state => referenceIdentifiers(expression, state).length && !dependencies.has(state))
2025
2028
  if (stale) throw sourceNodeError(node.arguments[1], sourceFile, `React useMemo() must list captured state ${JSON.stringify(stale)} as a dependency`)
2026
2029
  return ts.visitNode(expression, visitor)
@@ -2106,10 +2109,10 @@ function lowerReactMemoCollectionExpression(expression, factory) {
2106
2109
  return visit(expression)
2107
2110
  }
2108
2111
 
2109
- function reactMemoCollectionState(expression, states, sourceFile) {
2112
+ function reactMemoCollection(expression, states, importedCollections, sourceFile) {
2110
2113
  const setters = new Map([...states].map(state => [state, state]))
2111
2114
  const fail = (node, message) => { throw sourceNodeError(node, sourceFile, message) }
2112
- return renderedCollectionSource(expression, setters, undefined, fail, new Set())?.state?.text
2115
+ return renderedCollectionSource(expression, setters, undefined, fail, new Set(), importedCollections, states)
2113
2116
  }
2114
2117
 
2115
2118
  function reactMemoComponentExpression(identifier, sourceFile, factory, context) {
@@ -2150,9 +2153,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2150
2153
  return context => sourceFile => {
2151
2154
  const factory = context.factory
2152
2155
  const hasLinkElements = /<link/i.test(sourceFile.text)
2156
+ const importedCollections = importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex)
2153
2157
  sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
2154
2158
  ts.setParentRecursive(sourceFile, false)
2155
- sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context)
2159
+ sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections)
2156
2160
  ts.setParentRecursive(sourceFile, false)
2157
2161
  sourceFile = normalizeZustandMigrationSyntax(sourceFile, factory, context)
2158
2162
  ts.setParentRecursive(sourceFile, false)
@@ -2167,7 +2171,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2167
2171
  if (!imported) {
2168
2172
  imported = normalizeClsxSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context)
2169
2173
  ts.setParentRecursive(imported, false)
2170
- imported = normalizeReactMigrationSyntax(imported, factory, context)
2174
+ imported = normalizeReactMigrationSyntax(imported, factory, context, importedSerializableCollectionNames(imported, target, sourceFiles, sourceIndex))
2171
2175
  ts.setParentRecursive(imported, false)
2172
2176
  imported = normalizeZustandMigrationSyntax(imported, factory, context)
2173
2177
  ts.setParentRecursive(imported, false)
@@ -2314,7 +2318,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2314
2318
  const setters = settersByFunction.get(owner) ?? new Map()
2315
2319
  for (const [name, entries] of declarations) {
2316
2320
  for (const declaration of entries) {
2317
- const parts = keyedListParts(declaration.initializer, setters, declarations, (target, message) => { throw sourceNodeError(target, sourceFile, message) })
2321
+ const parts = keyedListParts(declaration.initializer, setters, declarations, (target, message) => { throw sourceNodeError(target, sourceFile, message) }, new Set(), importedCollections)
2318
2322
  if (!parts) continue
2319
2323
  const uses = []
2320
2324
  const collectUses = node => {
@@ -2508,7 +2512,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2508
2512
  return
2509
2513
  }
2510
2514
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
2511
- const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction), jsxLocalDeclarations.get(nearestFunction(node)), fail)
2515
+ const parts = listLocalUses.get(node) ?? keyedListParts(node.expression, settersForNode(node, settersByFunction), jsxLocalDeclarations.get(nearestFunction(node)), fail, new Set(), importedCollections)
2512
2516
  if (parts) {
2513
2517
  for (const declaration of parts.aliasDeclarations ?? []) listLocalDeclarations.add(declaration)
2514
2518
  rawRenderedLists.push({ node, parts })
@@ -2824,14 +2828,16 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2824
2828
  if (listParts) {
2825
2829
  usesBehavior = true
2826
2830
  usesList = true
2827
- return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
2831
+ const arguments_ = [
2828
2832
  listParts.state,
2829
2833
  listParts.keyField === null ? factory.createNull() : factory.createStringLiteral(listParts.keyField),
2830
2834
  ts.visitNode(listParts.callback, visitor),
2831
2835
  factory.createStringLiteral(listParts.ownerField ?? ""),
2832
2836
  jsonExpression(listParts.selector ?? [], factory),
2833
2837
  listParts.indexed ? factory.createTrue() : factory.createFalse()
2834
- ]))
2838
+ ]
2839
+ if (listParts.selectorStates?.size) arguments_.push(factory.createArrayLiteralExpression([...listParts.selectorStates].map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))))
2840
+ return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, arguments_))
2835
2841
  }
2836
2842
  const conditional = conditionalParts(node.expression)
2837
2843
  if (conditional) {
@@ -3017,11 +3023,11 @@ function containsRenderControl(root, knownLocals) {
3017
3023
  return found
3018
3024
  }
3019
3025
 
3020
- function keyedListParts(expression, setters, declarations, fail, aliases = new Set()) {
3026
+ function keyedListParts(expression, setters, declarations, fail, aliases = new Set(), importedCollections = new Set()) {
3021
3027
  const value = unwrapExpression(expression)
3022
3028
  const directFrom = isArrayFromCall(value) && value.arguments.length === 2 && containsJsx(value.arguments[1])
3023
3029
  if (!directFrom && (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map")) return undefined
3024
- const collection = renderedCollectionSource(directFrom ? value.arguments[0] : value.expression.expression, setters, declarations, fail, aliases)
3030
+ const collection = renderedCollectionSource(directFrom ? value.arguments[0] : value.expression.expression, setters, declarations, fail, aliases, importedCollections, new Set(setters.values()))
3025
3031
  if (!collection?.state) return undefined
3026
3032
  if (directFrom) collection.selector.push(["from", undefined])
3027
3033
  const callback = directFrom ? value.arguments[1] : value.arguments[0]
@@ -3055,16 +3061,17 @@ function nestedKeyedListParts(expression, parentItem, fail) {
3055
3061
  return { ...collection, callback, root, item: parameters.item, index: parameters.index, indexed: Boolean(parameters.index), keyField: positional ? null : keyField }
3056
3062
  }
3057
3063
 
3058
- function renderedCollectionSource(expression, setters, declarations, fail, aliases) {
3064
+ function renderedCollectionSource(expression, setters, declarations, fail, aliases, importedCollections = new Set(), stateNames = new Set()) {
3059
3065
  const value = unwrapExpression(expression)
3060
3066
  if (ts.isIdentifier(value)) {
3061
- if ([...setters.values()].includes(value.text)) return { state: value, selector: [] }
3067
+ if ([...setters.values()].includes(value.text)) return { state: value, selector: [], selectorStates: new Set() }
3068
+ if (importedCollections.has(value.text)) return { state: value, static: true, selector: [], selectorStates: new Set() }
3062
3069
  const entries = declarations?.get(value.text)
3063
3070
  if (!entries) return undefined
3064
3071
  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`)
3065
3072
  if (identifierReferenceCount(nearestFunction(entries[0].node).body, value.text) !== 1) fail(value, `Rendered collection alias "${value.text}" may only be rendered once`)
3066
3073
  aliases.add(value.text)
3067
- const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases)
3074
+ const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases, importedCollections, stateNames)
3068
3075
  aliases.delete(value.text)
3069
3076
  return source && { ...source, aliasDeclarations: [...(source.aliasDeclarations ?? []), entries[0].node] }
3070
3077
  }
@@ -3073,14 +3080,15 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
3073
3080
  const method = value.expression.name.text
3074
3081
  if (method === "filter") {
3075
3082
  if (value.arguments.length !== 1) fail(value, "Rendered collection filter() requires one inline predicate")
3076
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases)
3083
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames)
3077
3084
  if (!source) return undefined
3078
3085
  const parameters = collectionParameters(value.arguments[0], "Rendered collection filter()", fail)
3079
- return { ...source, selector: [...source.selector, ["filter", collectionExpression(unwrapExpression(value.arguments[0].body), parameters, fail)]] }
3086
+ const selectorStates = new Set(source.selectorStates)
3087
+ return { ...source, selector: [...source.selector, ["filter", collectionExpression(unwrapExpression(value.arguments[0].body), parameters, fail, stateNames, selectorStates)]], selectorStates }
3080
3088
  }
3081
3089
  if (method === "flatMap") {
3082
3090
  if (value.arguments.length !== 1) fail(value, "Rendered collection flatMap() requires one inline projector")
3083
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases)
3091
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames)
3084
3092
  if (!source) return undefined
3085
3093
  const parameters = collectionParameters(value.arguments[0], "Rendered collection flatMap()", fail)
3086
3094
  const field = directProperty(value.arguments[0].body, parameters.item)
@@ -3091,12 +3099,14 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
3091
3099
  }
3092
3100
  if (isArrayFromCall(value)) {
3093
3101
  if (value.arguments.length < 1 || value.arguments.length > 2) fail(value, "Rendered Array.from() requires an anchor and optional inline mapper")
3094
- const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases)
3102
+ const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames)
3095
3103
  if (!source) return undefined
3096
3104
  let mapper
3097
3105
  if (value.arguments[1]) {
3098
3106
  const parameters = collectionParameters(value.arguments[1], "Rendered Array.from() mapper", fail)
3099
- mapper = collectionExpression(unwrapExpression(value.arguments[1].body), parameters, fail)
3107
+ const selectorStates = new Set(source.selectorStates)
3108
+ mapper = collectionExpression(unwrapExpression(value.arguments[1].body), parameters, fail, stateNames, selectorStates)
3109
+ source.selectorStates = selectorStates
3100
3110
  }
3101
3111
  return { ...source, selector: [...source.selector, ["from", mapper]] }
3102
3112
  }
@@ -3111,7 +3121,7 @@ function collectionParameters(callback, label, fail) {
3111
3121
  return { item: callback.parameters[0].name.text, index: callback.parameters[1]?.name.text }
3112
3122
  }
3113
3123
 
3114
- function collectionExpression(expression, parameters, fail) {
3124
+ function collectionExpression(expression, parameters, fail, stateNames = new Set(), selectorStates = new Set()) {
3115
3125
  const encode = node => {
3116
3126
  node = unwrapExpression(node)
3117
3127
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isNumericLiteral(node)) return ["value", ts.isNumericLiteral(node) ? Number(node.text) : node.text]
@@ -3122,6 +3132,10 @@ function collectionExpression(expression, parameters, fail) {
3122
3132
  if (node.text === parameters.item) return ["item"]
3123
3133
  if (node.text === parameters.index) return ["index"]
3124
3134
  if (node.text === "undefined") return ["undefined"]
3135
+ if (stateNames.has(node.text)) {
3136
+ selectorStates.add(node.text)
3137
+ return ["state", node.text]
3138
+ }
3125
3139
  fail(node, `Rendered collection expression identifier "${node.text}" is not allowed`)
3126
3140
  }
3127
3141
  if (ts.isPropertyAccessExpression(node)) {
@@ -4160,6 +4174,20 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
4160
4174
  return bindings
4161
4175
  }
4162
4176
 
4177
+ function importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex) {
4178
+ const names = new Set()
4179
+ for (const [name, binding] of clientImportBindings(sourceFile, file, sourceFiles)) {
4180
+ if (binding.kind !== "named") continue
4181
+ const imported = parseSourceFile(binding.target, sourceIndex.get(binding.target))
4182
+ for (const statement of imported.statements) {
4183
+ if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
4184
+ const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === binding.imported)
4185
+ if (declaration?.initializer && ts.isArrayLiteralExpression(unwrapExpression(declaration.initializer)) && isSerializableStateLiteral(declaration.initializer)) names.add(name)
4186
+ }
4187
+ }
4188
+ return names
4189
+ }
4190
+
4163
4191
  function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
4164
4192
  const key = `${file}:${exportName}`
4165
4193
  if (trail.includes(key)) throw new Error(`Imported keyed list component re-export cycle: ${[...trail, key].map(entry => relative(root, entry.slice(0, entry.lastIndexOf(":")))).join(" -> ")}`)
@@ -1,10 +1,10 @@
1
- export function selectCollection(anchor, selector = []) {
1
+ export function selectCollection(anchor, selector = [], readState) {
2
2
  let values = anchor == null ? [] : anchor
3
3
  for (const operation of selector) {
4
- if (operation[0] === "from") values = Array.from(values, operation[1] ? (item, index) => evaluateCollectionExpression(operation[1], item, index) : undefined)
4
+ if (operation[0] === "from") values = Array.from(values, operation[1] ? (item, index) => evaluateCollectionExpression(operation[1], item, index, readState) : undefined)
5
5
  else {
6
6
  if (!Array.isArray(values)) throw new Error("Rendered collection source must remain an array")
7
- if (operation[0] === "filter") values = values.filter((item, index) => evaluateCollectionExpression(operation[1], item, index))
7
+ if (operation[0] === "filter") values = values.filter((item, index) => evaluateCollectionExpression(operation[1], item, index, readState))
8
8
  else if (operation[0] === "flatMap") values = values.flatMap(item => item?.[operation[1]] ?? [])
9
9
  }
10
10
  }
@@ -12,29 +12,33 @@ export function selectCollection(anchor, selector = []) {
12
12
  return values
13
13
  }
14
14
 
15
- function evaluateCollectionExpression(expression, item, index) {
15
+ function evaluateCollectionExpression(expression, item, index, readState) {
16
16
  const [kind, ...parts] = expression
17
17
  if (kind === "value") return parts[0]
18
18
  if (kind === "undefined") return undefined
19
19
  if (kind === "item") return item
20
20
  if (kind === "index") return index
21
+ if (kind === "state") {
22
+ if (!readState) throw new Error(`Rendered collection state ${JSON.stringify(parts[0])} is not available`)
23
+ return readState(parts[0])
24
+ }
21
25
  if (kind === "get") {
22
- const object = evaluateCollectionExpression(parts[0], item, index)
26
+ const object = evaluateCollectionExpression(parts[0], item, index, readState)
23
27
  return object == null && parts[2] ? undefined : object[parts[1]]
24
28
  }
25
29
  if (kind === "unary") {
26
- const value = evaluateCollectionExpression(parts[1], item, index)
30
+ const value = evaluateCollectionExpression(parts[1], item, index, readState)
27
31
  if (parts[0] === "!") return !value
28
32
  if (parts[0] === "+") return +value
29
33
  if (parts[0] === "-") return -value
30
34
  if (parts[0] === "typeof") return typeof value
31
35
  }
32
36
  if (kind === "binary") {
33
- const left = evaluateCollectionExpression(parts[1], item, index)
34
- if (parts[0] === "&&") return left && evaluateCollectionExpression(parts[2], item, index)
35
- if (parts[0] === "||") return left || evaluateCollectionExpression(parts[2], item, index)
36
- if (parts[0] === "??") return left ?? evaluateCollectionExpression(parts[2], item, index)
37
- const right = evaluateCollectionExpression(parts[2], item, index)
37
+ const left = evaluateCollectionExpression(parts[1], item, index, readState)
38
+ if (parts[0] === "&&") return left && evaluateCollectionExpression(parts[2], item, index, readState)
39
+ if (parts[0] === "||") return left || evaluateCollectionExpression(parts[2], item, index, readState)
40
+ if (parts[0] === "??") return left ?? evaluateCollectionExpression(parts[2], item, index, readState)
41
+ const right = evaluateCollectionExpression(parts[2], item, index, readState)
38
42
  if (parts[0] === "===") return left === right
39
43
  if (parts[0] === "!==") return left !== right
40
44
  if (parts[0] === "==") return left == right
@@ -49,15 +53,15 @@ function evaluateCollectionExpression(expression, item, index) {
49
53
  if (parts[0] === "/") return left / right
50
54
  if (parts[0] === "%") return left % right
51
55
  }
52
- if (kind === "conditional") return evaluateCollectionExpression(parts[0], item, index) ? evaluateCollectionExpression(parts[1], item, index) : evaluateCollectionExpression(parts[2], item, index)
53
- if (kind === "array") return parts.map(value => evaluateCollectionExpression(value, item, index))
54
- if (kind === "object") return Object.fromEntries(parts.map(([key, value]) => [key, evaluateCollectionExpression(value, item, index)]))
55
- if (kind === "template") return parts[0].map((text, offset) => text + (offset < parts[1].length ? evaluateCollectionExpression(parts[1][offset], item, index) : "")).join("")
56
+ if (kind === "conditional") return evaluateCollectionExpression(parts[0], item, index, readState) ? evaluateCollectionExpression(parts[1], item, index, readState) : evaluateCollectionExpression(parts[2], item, index, readState)
57
+ if (kind === "array") return parts.map(value => evaluateCollectionExpression(value, item, index, readState))
58
+ if (kind === "object") return Object.fromEntries(parts.map(([key, value]) => [key, evaluateCollectionExpression(value, item, index, readState)]))
59
+ if (kind === "template") return parts[0].map((text, offset) => text + (offset < parts[1].length ? evaluateCollectionExpression(parts[1][offset], item, index, readState) : "")).join("")
56
60
  if (kind === "call") {
57
- const receiver = evaluateCollectionExpression(parts[0], item, index)
58
- return receiver[parts[1]](...parts.slice(2).map(value => evaluateCollectionExpression(value, item, index)))
61
+ const receiver = evaluateCollectionExpression(parts[0], item, index, readState)
62
+ return receiver[parts[1]](...parts.slice(2).map(value => evaluateCollectionExpression(value, item, index, readState)))
59
63
  }
60
- if (kind === "global") return globalThis[parts[0]](...parts.slice(1).map(value => evaluateCollectionExpression(value, item, index)))
61
- if (kind === "math") return Math[parts[0]](...parts.slice(1).map(value => evaluateCollectionExpression(value, item, index)))
64
+ if (kind === "global") return globalThis[parts[0]](...parts.slice(1).map(value => evaluateCollectionExpression(value, item, index, readState)))
65
+ if (kind === "math") return Math[parts[0]](...parts.slice(1).map(value => evaluateCollectionExpression(value, item, index, readState)))
62
66
  throw new Error(`Unsupported rendered collection expression: ${String(kind)}`)
63
67
  }
@@ -31,7 +31,7 @@ export function nativeBehavior(module: string, handler: string, states: Array<[s
31
31
  export function binding(value: unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
32
32
  export function bindingValue(value: unknown): unknown
33
33
  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
34
- export function list(items: unknown, keyField: string | null, render: (item: unknown, index: number) => unknown, ownerField?: string, selector?: unknown[], indexed?: boolean): unknown
34
+ 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
35
35
  export function listField(read: () => unknown, field: string): unknown
36
36
  export function listExpression(read: () => unknown, module: string, handler: string): unknown
37
37
  export function listItem(): unknown
@@ -89,7 +89,7 @@ export function renderPage<Props = Record<string, never>>(
89
89
  hasStateSeed: boolean
90
90
  handlerModules: string[]
91
91
  plan: {
92
- states: Array<{ id: string; name: string; initialValue: unknown; lifetime?: "layout" | "route" }>
92
+ states: Array<{ id: string; name: string; initialValue: unknown; lifetime?: "layout" | "route"; internal?: true }>
93
93
  params: Array<{ name: string; id: string }>
94
94
  events: Array<{
95
95
  event: string
@@ -2,6 +2,7 @@ import { serializeStyle } from "./style.js"
2
2
  import { selectCollection } from "./collection-selector.js"
3
3
 
4
4
  const signalMarker = Symbol("kudzu.signal")
5
+ const internalStateMarker = Symbol("kudzu.internal-state")
5
6
  const setterMarker = Symbol("kudzu.setter")
6
7
  const reducerDispatchMarker = Symbol("kudzu.reducerDispatch")
7
8
  const reducerStateMarker = Symbol("kudzu.reducerState")
@@ -130,6 +131,14 @@ function createSignal(id, value) {
130
131
  }
131
132
  }
132
133
 
134
+ function createInternalState(initialValue) {
135
+ const id = nextRenderId("s")
136
+ const signal = createSignal(id, initialValue)
137
+ signal[internalStateMarker] = true
138
+ renderContext.states[id] = { name: id, initialValue, internal: true, ...(renderContext.scoped ? { lifetime: renderContext.renderScope } : {}) }
139
+ return signal
140
+ }
141
+
133
142
  export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup, itemDependencies = []) {
134
143
  if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
135
144
  if (typeof callback !== "function" || !Array.isArray(dependencies) || !module || !handler) throw new Error("useEffect() must be compiled with a literal dependency array")
@@ -247,8 +256,11 @@ export function stateConditional(kind, state, truthy, falsy) {
247
256
  return { [conditionalMarker]: true, kind, value: state.value, truthy, falsy, state: state.id }
248
257
  }
249
258
 
250
- export function list(items, keyField, render, ownerField, selector = [], indexed = false) {
251
- if (!items?.[signalMarker] || !Array.isArray(items.value)) throw new Error("A keyed list must use local array state")
259
+ export function list(items, keyField, render, ownerField, selector = [], indexed = false, selectorStates = []) {
260
+ if (Array.isArray(items)) items = createInternalState(items)
261
+ if (!items?.[signalMarker] || !Array.isArray(items.value)) throw new Error("A keyed list must use local array state or a supported imported static array")
262
+ const selectorStateMap = new Map(selectorStates)
263
+ for (const [name, state] of selectorStateMap) if (!state?.[signalMarker]) throw new Error(`Rendered collection selector state ${JSON.stringify(name)} must be framework state`)
252
264
  let values = items.value
253
265
  if (ownerField) {
254
266
  const owner = renderContext?.listRoot ?? renderContext?.listRowRoot
@@ -257,7 +269,7 @@ export function list(items, keyField, render, ownerField, selector = [], indexed
257
269
  values = renderContext.listTemplate ? [] : owner.item?.[ownerField]
258
270
  if (!Array.isArray(values) && values != null) throw new Error(`Nested keyed list property "${ownerField}" must remain an array`)
259
271
  }
260
- values = selectCollection(values, selector)
272
+ values = selectCollection(values, selector, name => selectorStateMap.get(name)?.value)
261
273
  const keys = new Set()
262
274
  for (const [index, item] of values.entries()) {
263
275
  const key = keyField === null ? index : item?.[keyField]
@@ -268,7 +280,7 @@ export function list(items, keyField, render, ownerField, selector = [], indexed
268
280
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
269
281
  keys.add(token)
270
282
  }
271
- return { [listMarker]: true, items, values, keyField, render, ownerField, selector, indexed }
283
+ return { [listMarker]: true, items, values, keyField, render, ownerField, selector, indexed, selectorStates }
272
284
  }
273
285
 
274
286
  export function listField(read, field) {
@@ -801,7 +813,7 @@ async function renderList(node, namespace, selectValue) {
801
813
  const ownerTemplate = Boolean(node.ownerField && renderContext.listTemplate)
802
814
  const rowList = node.ownerField ? nextRowList() : undefined
803
815
  const id = rowList?.id ?? nextRenderId("l")
804
- const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map((item, index) => node.keyField === null ? index : item[node.keyField]), ...(node.ownerField ? { ownerField: node.ownerField } : {}), ...(node.selector.length ? { selector: node.selector } : {}), ...(node.indexed ? { indexed: true } : {}), ...(!node.selector.length && node.keyField !== null && node.items[reducerStateMarker] ? { reducer: true } : {}) }
816
+ const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.values.map((item, index) => node.keyField === null ? index : item[node.keyField]), ...(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 } : {}) }
805
817
  if (ownerTemplate) {
806
818
  ownerRoot.descriptor.children ??= []
807
819
  ownerRoot.descriptor.children.push({ id, field: node.ownerField, key: node.keyField, ...(node.selector.length ? { selector: node.selector } : {}) })
@@ -2,9 +2,9 @@ const maxAge = 10000
2
2
 
3
3
  export function stateSchema(states) {
4
4
  const occurrences = new Map()
5
- for (const { name } of states) if (typeof name === "string") occurrences.set(name, (occurrences.get(name) ?? 0) + 1)
6
- return states.flatMap(({ id, name }) => {
7
- return typeof id === "string" && occurrences.get(name) === 1 ? [[id, name]] : []
5
+ for (const { name, internal } of states) if (!internal && typeof name === "string") occurrences.set(name, (occurrences.get(name) ?? 0) + 1)
6
+ return states.flatMap(({ id, name, internal }) => {
7
+ return !internal && typeof id === "string" && occurrences.get(name) === 1 ? [[id, name]] : []
8
8
  })
9
9
  }
10
10
 
@@ -47,6 +47,7 @@ function mountLists(root) {
47
47
  const templateRoot = __KUDZU_NESTED_LISTS__ ? nested.templateRoot : start.content.firstElementChild
48
48
  if (__KUDZU_LIST_ROW_HOOKS__) for (let index = 0; index < roots.length; index++) initializeGeneralRowHooks(descriptor, descriptor.keys[index], roots[index], nested?.owner)
49
49
  const parts = listItemPartPlan(templateRoot, descriptor.nested)
50
+ const staticRows = __KUDZU_STATIC_COLLECTIONS__ && descriptor.static && parts.directFill ? new Map() : undefined
50
51
  for (const root of roots) {
51
52
  if (__KUDZU_LIST_CONDITIONS__ && descriptor.conditions) {
52
53
  if (__KUDZU_NESTED_LISTS__ && descriptor.ownerField) itemPartPlans.set(root, parts)
@@ -56,11 +57,16 @@ function mountLists(root) {
56
57
  }
57
58
  if (__KUDZU_LIST_SEEDS__ && descriptor.seed && !browserState.has(descriptor.state)) browserState.set(descriptor.state, roots.map((root, index) => seedListItem(root, descriptor, index)))
58
59
  const items = browserState.get(descriptor.state)
60
+ const staticEntries = __KUDZU_STATIC_COLLECTIONS__ && descriptor.static && descriptor.key !== null && !descriptor.indexed && descriptor.selector?.every(operation => operation[0] === "filter") && parts.directFill
61
+ ? cacheStaticListEntries(descriptor, items)
62
+ : undefined
59
63
  const list = {
60
64
  start,
61
65
  descriptor,
62
66
  ...(__KUDZU_NESTED_LISTS__ ? { templateRoot, ...(nested.childPrototypes?.size ? { childPrototypes: nested.childPrototypes } : {}) } : {}),
63
67
  parts,
68
+ ...(__KUDZU_STATIC_COLLECTIONS__ && staticRows ? { staticRows } : {}),
69
+ ...(__KUDZU_STATIC_COLLECTIONS__ && staticEntries ? { staticEntries, staticPositions: staticEntries.positions } : {}),
64
70
  seedFields: __KUDZU_LIST_SEEDS__ && descriptor.seed && Object.keys(descriptor.seed),
65
71
  roots: new Map(roots.map((node, index) => [keyToken(descriptor.keys[index]), node])),
66
72
  ...(__KUDZU_LIST_STABLE_FAST_PATHS__ ? { orderedRoots: roots } : {}),
@@ -78,8 +84,14 @@ function mountLists(root) {
78
84
  ownedLists.set(list.owner, lists)
79
85
  listRegistrations.set(start, { list, owner: list.owner })
80
86
  } else {
81
- register(listTargets, descriptor.state, list)
82
- listRegistrations.set(start, { state: descriptor.state, list })
87
+ if (__KUDZU_COLLECTION_SELECTORS__ && descriptor.selectorStates) {
88
+ const states = [...new Set([descriptor.state, ...Object.values(descriptor.selectorStates)])]
89
+ for (const state of states) register(listTargets, state, list)
90
+ listRegistrations.set(start, { states, list })
91
+ } else {
92
+ register(listTargets, descriptor.state, list)
93
+ listRegistrations.set(start, { state: descriptor.state, list })
94
+ }
83
95
  }
84
96
  updateList(list)
85
97
  }
@@ -97,9 +109,17 @@ function unregisterList(start) {
97
109
  if (lists?.get(registration.list.descriptor.id) === registration.list) lists.delete(registration.list.descriptor.id)
98
110
  if (!lists?.size) ownedLists.delete(registration.owner)
99
111
  } else {
100
- const lists = listTargets.get(registration.state)
101
- lists?.delete(registration.list)
102
- if (!lists?.size) listTargets.delete(registration.state)
112
+ if (__KUDZU_COLLECTION_SELECTORS__ && registration.states) {
113
+ for (const state of registration.states) {
114
+ const lists = listTargets.get(state)
115
+ lists?.delete(registration.list)
116
+ if (!lists?.size) listTargets.delete(state)
117
+ }
118
+ } else {
119
+ const lists = listTargets.get(registration.state)
120
+ lists?.delete(registration.list)
121
+ if (!lists?.size) listTargets.delete(registration.state)
122
+ }
103
123
  }
104
124
  if (__KUDZU_LIST_ROW_HOOKS__ && registration.list.descriptor.rowStates) for (const node of registration.list.roots.values()) deleteRowStates(registration.list.descriptor, ownershipPaths.get(node))
105
125
  }
@@ -112,12 +132,13 @@ function updateList(list) {
112
132
  ? listItems.get(list.owner)?.[list.descriptor.ownerField]
113
133
  : browserState.get(list.descriptor.state)
114
134
  if (__KUDZU_NESTED_LISTS__ && list.descriptor.ownerField && items == null) items = []
115
- if (__KUDZU_COLLECTION_SELECTORS__) items = selectCollection(items, list.descriptor.selector)
135
+ if (__KUDZU_COLLECTION_SELECTORS__) items = selectCollection(items, list.descriptor.selector, name => browserState.get(list.descriptor.selectorStates?.[name]))
116
136
  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")
117
137
  if (__KUDZU_NESTED_LISTS__ && (list.descriptor.children || list.descriptor.ownerField) && !list.descriptor.indexed && !list.descriptor.selector && list.descriptor.key !== null && list.items && updateNestedList(list, items)) return
118
138
  if (__KUDZU_NESTED_LISTS__ && list.descriptor.children) validateChildLists(items, list.descriptor.children)
119
139
  if (list.descriptor.reducer && !list.descriptor.selector && list.descriptor.key !== null && list.items && updateReducerList(list, items)) return
120
140
  if (__KUDZU_LIST_STABLE_FAST_PATHS__ && list.descriptor.key !== null && !list.descriptor.indexed && !list.descriptor.selector && !list.descriptor.reducer && !list.descriptor.children && !list.descriptor.ownerField && list.items && updateStableList(list, items)) return
141
+ if (__KUDZU_STATIC_COLLECTIONS__ && list.staticEntries && updateStaticFilterList(list, items)) return
121
142
  const entries = []
122
143
  const keys = new Set()
123
144
  const seen = new Set()
@@ -174,14 +195,18 @@ function updateList(list) {
174
195
  for (const { item, index, key, token, value } of entries) {
175
196
  let node = list.roots.get(token)
176
197
  if (!node) {
177
- node = (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
178
- if (node?.dataset.kListRoot !== list.descriptor.id) node = undefined
198
+ const staticRoot = __KUDZU_STATIC_COLLECTIONS__ && list.staticRows?.get(token)?.cloneNode(true)
199
+ node = staticRoot ?? (__KUDZU_NESTED_LISTS__ ? list.templateRoot : list.start.content.firstElementChild)?.cloneNode(true)
200
+ if (!staticRoot && node?.dataset.kListRoot !== list.descriptor.id) node = undefined
179
201
  if (!node) throw new Error("Keyed list template has no root element")
180
202
  node.removeAttribute("data-k-list-root")
181
203
  if (__KUDZU_NESTED_LISTS__ && list.childPrototypes) childPrototypes.set(node, list.childPrototypes)
182
204
  if (__KUDZU_LIST_ROW_HOOKS__) initializeGeneralRowHooks(list.descriptor, key, node, list.owner)
183
- mapListItemParts(list.parts, node, list.descriptor.nested)
184
- fillListItem(node, item, list.descriptor.nested, index)
205
+ if (staticRoot) listItems.set(node, item)
206
+ else if (list.parts.directFill) {
207
+ listItems.set(node, item)
208
+ fillStructuralListParts(list.parts, node, item)
209
+ } else fillListItem(node, item, list.descriptor.nested, index, mapListItemParts(list.parts, node, list.descriptor.nested))
185
210
  additions.append(node)
186
211
  added = true
187
212
  } else if ((referenceOnly ? listItems.get(node) !== item : list.values.get(token) !== value) || list.descriptor.indexed || list.descriptor.key === null) {
@@ -199,20 +224,38 @@ function updateList(list) {
199
224
  } else node.remove()
200
225
  if (__KUDZU_LIST_ROW_HOOKS__ && list.descriptor.rowStates) deleteRowStates(list.descriptor, ownershipPaths.get(node))
201
226
  }
227
+ let ordered = false
202
228
  if (added) {
203
- if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
204
- const addedNodes = [...additions.childNodes]
205
- parent.insertBefore(additions, list.boundary)
206
- for (const node of addedNodes) mountDom(node)
229
+ const addedNodes = __KUDZU_LIST_MOUNTS__ && list.descriptor.mount ? [...additions.childNodes] : undefined
230
+ let seenAddition = false
231
+ let interleaved = false
232
+ for (const [, node] of next) {
233
+ if (node.parentNode === additions) seenAddition = true
234
+ else if (seenAddition) {
235
+ interleaved = true
236
+ break
237
+ }
238
+ }
239
+ if (interleaved) {
240
+ const run = parent.ownerDocument.createDocumentFragment()
241
+ for (const [, node] of next) {
242
+ if (node.parentNode === additions) run.append(node)
243
+ else if (run.firstChild) parent.insertBefore(run, node)
244
+ }
245
+ if (run.firstChild) parent.insertBefore(run, list.boundary)
246
+ ordered = true
207
247
  } else parent.insertBefore(additions, list.boundary)
248
+ if (addedNodes) for (const node of addedNodes) mountDom(node)
208
249
  list.container ??= parent
209
250
  }
210
251
  let anchor = list.boundary
211
252
  let misplaced = 0
212
- for (let index = next.length - 1; index >= 0; index--) {
213
- const node = next[index][1]
214
- if (node.nextSibling !== anchor) misplaced++
215
- anchor = node
253
+ if (!ordered) {
254
+ for (let index = next.length - 1; index >= 0; index--) {
255
+ const node = next[index][1]
256
+ if (node.nextSibling !== anchor) misplaced++
257
+ anchor = node
258
+ }
216
259
  }
217
260
  if (misplaced > next.length / 2) {
218
261
  const reordered = parent.ownerDocument.createDocumentFragment()
@@ -232,6 +275,68 @@ function updateList(list) {
232
275
  list.items = items
233
276
  }
234
277
 
278
+ function updateStaticFilterList(list, items) {
279
+ const entries = new Array(items.length)
280
+ for (let index = 0; index < items.length; index++) {
281
+ const entry = list.staticEntries.get(items[index])
282
+ if (!entry || !list.roots.has(entry.token) && !list.staticRows.has(entry.token)) return false
283
+ entries[index] = entry
284
+ }
285
+ let selectedIndex = 0
286
+ for (const [token, node] of list.roots) {
287
+ const position = list.staticPositions.get(token)
288
+ while (entries[selectedIndex]?.position < position) selectedIndex++
289
+ if (entries[selectedIndex]?.token === token) {
290
+ selectedIndex++
291
+ continue
292
+ }
293
+ list.staticRows.set(token, node)
294
+ node.remove()
295
+ }
296
+ const parent = list.container ?? list.start.parentNode
297
+ const run = parent.ownerDocument.createDocumentFragment()
298
+ const next = new Array(entries.length)
299
+ for (let index = 0; index < entries.length; index++) {
300
+ const entry = entries[index]
301
+ let node = list.roots.get(entry.token)
302
+ if (!node) {
303
+ node = list.staticRows.get(entry.token).cloneNode(true)
304
+ listItems.set(node, entry.item)
305
+ run.append(node)
306
+ } else if (run.firstChild) parent.insertBefore(run, node)
307
+ next[index] = [entry.token, node]
308
+ }
309
+ if (run.firstChild) parent.insertBefore(run, list.boundary)
310
+ list.roots = new Map(next)
311
+ if (__KUDZU_LIST_STABLE_FAST_PATHS__) list.orderedRoots = next.map(([, node]) => node)
312
+ list.values.clear()
313
+ list.items = items
314
+ list.container ??= parent
315
+ return true
316
+ }
317
+
318
+ function cacheStaticListEntries(descriptor, items) {
319
+ if (!Array.isArray(items)) return undefined
320
+ const entries = new WeakMap()
321
+ const positions = new Map()
322
+ const keys = new Set()
323
+ const seen = new Set()
324
+ for (let position = 0; position < items.length; position++) {
325
+ const item = items[position]
326
+ assertListItem(item)
327
+ assertListValue(item, seen, true)
328
+ const key = item[descriptor.key]
329
+ if (!validListKey(key)) throw new Error(`Keyed list key "${descriptor.key}" must be a string or finite number`)
330
+ const token = keyToken(key)
331
+ if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
332
+ keys.add(token)
333
+ entries.set(item, { item, key, token, value: item, position })
334
+ positions.set(token, position)
335
+ }
336
+ entries.positions = positions
337
+ return entries
338
+ }
339
+
235
340
  /* stable-list-fast-path */
236
341
  function updateStableList(list, items) {
237
342
  const previous = list.items
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.6",
3
+ "version": "0.7.8",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",