@kudzujs/core 0.7.5 → 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.5:** Class composition migration. Direct `clsx` calls compile to ordinary reactive class expressions without shipping the package, and mixed React type imports erase cleanly. See [release notes](./RELEASES.md#075---class-composition-migration).
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,10 +74,12 @@ 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
 
81
+ Migration source may also retain a reduced Zustand store declared as one exported `const` initialized by a named `create` import. The initializer accepts `set`, returns exactly one directly serializable data property plus synchronous actions, and components select one direct property with `state => state.property`. A shared layout must select the store before its routes use it; Kudzu then owns the data as layout state, inlines action updates into existing handler ESM, and ships neither React nor Zustand. Derived selectors, multiple data properties, middleware, `get`, subscriptions, equality functions, persist/devtools wrappers, async actions, helper captures, replacement updates, and indirect action forwarding remain unsupported.
82
+
81
83
  Create `src/pages/index.tsx`:
82
84
 
83
85
  ```tsx
@@ -416,7 +418,9 @@ return <ItemList items={items} />
416
418
 
417
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.
418
420
 
419
- 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.
420
424
 
421
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:
422
426
 
@@ -461,7 +465,7 @@ Initial child rows remain complete HTML. Kudzu stores one child row prototype, i
461
465
 
462
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.
463
467
 
464
- 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>`.
465
469
 
466
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.
467
471
 
package/RELEASES.md CHANGED
@@ -1,5 +1,78 @@
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
+
53
+ ## 0.7.6 - Zustand-shaped shared stores
54
+
55
+ Kudzu 0.7.6 lets reduced React migration source retain a Zustand `create(set => ...)` store across an explicitly configured shared-layout navigation group.
56
+
57
+ ### New in 0.7.6
58
+
59
+ - One exported store with one directly serializable data property and synchronous capture-free actions compiles to one ordinary layout-lifetime Kudzu state slot.
60
+ - Components select the data or an action with direct forms such as `state => state.quantities` and `state => state.add`.
61
+ - Selected actions inline through existing functional state updates, so repeated same-turn calls observe current logical state and DOM writes still batch.
62
+ - Same-group navigation retains the store and layout DOM while incoming route bindings mount against the current value.
63
+ - Neither React, Zustand, a subscription runtime, nor a generic external-store capability enters the deploy output.
64
+ - The shopping fixture verifies two same-turn additions, product-to-cart retention, removal, layout DOM identity, package erasure, and source diagnostics in Chrome.
65
+
66
+ ### Boundary
67
+
68
+ The shared layout must initialize the store before route consumers. Derived selectors, multiple data properties, middleware, `get`, subscriptions, equality functions, persist/devtools wrappers, async actions, helper captures, replacement updates, keyed-row initialization, and indirect action forwarding remain unsupported.
69
+
70
+ ### Upgrade
71
+
72
+ ```bash
73
+ npm install @kudzujs/core@^0.7.6
74
+ ```
75
+
3
76
  ## 0.7.5 - Class composition migration
4
77
 
5
78
  Kudzu 0.7.5 lets ordinary React source retain common direct `clsx` calls while compiling them to existing static and reactive class paths.
@@ -2,10 +2,12 @@
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
 
9
+ Reduced Zustand migration stores lower to one ordinary layout-lifetime state slot. The compiler accepts one exported `create(set => ({ data, ...actions }))` store with one serializable data property, direct property selectors, and synchronous capture-free actions using one-argument merge-form `set`; selected actions reuse the reducer-style functional update compiler, so same-turn calls observe current logical state and DOM writes still batch. The shared layout must initialize the store before route consumers, outside keyed rows. No Zustand import, store subscription runtime, React hook, or generic external-store capability is emitted.
10
+
9
11
  - `build.mjs`: TSX compilation, static, `getStaticPaths`, and runtime-fallback routes, base paths, CSS collection, post-build hooks, behavior extraction, static HTML output, and the development server.
10
12
  - `core.mjs`: server-side JSX rendering, state slots, context providers, behavior metadata, and serializable capture validation.
11
13
  - `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
@@ -31,7 +33,7 @@ Inline SVG rendering normalizes an explicit set of common React presentation ali
31
33
 
32
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.
33
35
 
34
- 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.
35
37
 
36
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.
37
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
  })
@@ -1791,7 +1793,95 @@ function normalizeClsxSyntax(sourceFile, factory, context) {
1791
1793
  return ts.visitNode(sourceFile, visitor)
1792
1794
  }
1793
1795
 
1794
- function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1796
+ function analyzeZustandStores(sourceFile) {
1797
+ const createNames = new Set()
1798
+ for (const statement of sourceFile.statements) {
1799
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "zustand") continue
1800
+ const bindings = statement.importClause?.namedBindings
1801
+ if (statement.importClause?.name || !bindings || !ts.isNamedImports(bindings)) throw sourceNodeError(statement, sourceFile, "Zustand migration input requires a named create import")
1802
+ for (const entry of bindings.elements) {
1803
+ if (entry.isTypeOnly) continue
1804
+ if ((entry.propertyName ?? entry.name).text !== "create") throw sourceNodeError(entry, sourceFile, "Only Zustand create is supported")
1805
+ createNames.add(entry.name.text)
1806
+ }
1807
+ }
1808
+ const stores = new Map()
1809
+ if (!createNames.size) return stores
1810
+ for (const statement of sourceFile.statements) {
1811
+ if (!ts.isVariableStatement(statement) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
1812
+ for (const declaration of statement.declarationList.declarations) {
1813
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer || !ts.isCallExpression(declaration.initializer) || !ts.isIdentifier(declaration.initializer.expression) || !createNames.has(declaration.initializer.expression.text)) continue
1814
+ const callback = declaration.initializer.arguments[0]
1815
+ if (declaration.initializer.arguments.length !== 1 || !callback || (!ts.isArrowFunction(callback) && !ts.isFunctionExpression(callback)) || callback.parameters.length !== 1 || !ts.isIdentifier(callback.parameters[0].name) || callback.asteriskToken || callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(declaration.initializer, sourceFile, "Zustand create() requires one synchronous initializer with one set parameter")
1816
+ const body = unwrapExpression(callback.body)
1817
+ if (!ts.isObjectLiteralExpression(body)) throw sourceNodeError(callback.body, sourceFile, "Zustand create() initializer must return one object literal")
1818
+ const data = []
1819
+ const actions = new Map()
1820
+ for (const property of body.properties) {
1821
+ if (!ts.isPropertyAssignment(property) || !property.name || !(ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))) throw sourceNodeError(property, sourceFile, "Zustand store entries must be ordinary properties")
1822
+ const name = property.name.text
1823
+ const value = unwrapExpression(property.initializer)
1824
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) actions.set(name, value)
1825
+ else data.push({ name, value })
1826
+ }
1827
+ if (data.length !== 1 || !isSerializableStateLiteral(data[0].value)) throw sourceNodeError(body, sourceFile, "Zustand migration stores require exactly one directly serializable data property")
1828
+ if (!actions.size) throw sourceNodeError(body, sourceFile, "Zustand migration stores require at least one action")
1829
+ for (const [name, action] of actions) {
1830
+ if (action.asteriskToken || action.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(action, sourceFile, `Zustand action ${JSON.stringify(name)} must be synchronous`)
1831
+ const capture = [...nativeCaptureNames(action, new Map())].find(entry => entry !== callback.parameters[0].name.text)
1832
+ if (capture) throw sourceNodeError(action, sourceFile, `Zustand action ${JSON.stringify(name)} cannot capture ${JSON.stringify(capture)}`)
1833
+ const validateAction = node => {
1834
+ if (ts.isAwaitExpression(node) || ts.isYieldExpression(node) || ts.isNewExpression(node)) throw sourceNodeError(node, sourceFile, `Zustand action ${JSON.stringify(name)} must be synchronous`)
1835
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && ["then", "catch", "finally"].includes(node.expression.name.text)) throw sourceNodeError(node, sourceFile, `Zustand action ${JSON.stringify(name)} cannot schedule asynchronous updates`)
1836
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === callback.parameters[0].name.text && !isShadowedIdentifier(node.expression, action)) {
1837
+ if (nearestFunction(node) !== action) throw sourceNodeError(node, sourceFile, `Zustand action ${JSON.stringify(name)} must call set directly`)
1838
+ if (node.arguments.length !== 1) throw sourceNodeError(node, sourceFile, `Zustand action ${JSON.stringify(name)} set() requires exactly one partial update`)
1839
+ }
1840
+ ts.forEachChild(node, validateAction)
1841
+ }
1842
+ validateAction(action.body)
1843
+ }
1844
+ stores.set(declaration.name.text, { name: declaration.name.text, setName: callback.parameters[0].name.text, field: data[0].name, initialValue: data[0].value, actions, declaration })
1845
+ }
1846
+ }
1847
+ const visit = node => {
1848
+ const recognized = ts.isIdentifier(node) && ts.isCallExpression(node.parent) && node.parent.expression === node && [...stores.values()].some(store => store.declaration.initializer === node.parent)
1849
+ if (ts.isIdentifier(node) && createNames.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile) && !recognized) throw sourceNodeError(node, sourceFile, "Zustand create must directly initialize an exported const store")
1850
+ ts.forEachChild(node, visit)
1851
+ }
1852
+ visit(sourceFile)
1853
+ return stores
1854
+ }
1855
+
1856
+ function normalizeZustandMigrationSyntax(sourceFile, factory, context) {
1857
+ const stores = analyzeZustandStores(sourceFile)
1858
+ if (!stores.size) {
1859
+ const declaration = sourceFile.statements.find(statement => ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "zustand" && !statement.importClause?.isTypeOnly)
1860
+ if (declaration) throw sourceNodeError(declaration, sourceFile, "Zustand create must directly initialize an exported const store")
1861
+ return sourceFile
1862
+ }
1863
+ const identity = name => `${relative(sourceDirectory, sourceFile.fileName).replaceAll(sep, "/")}#${name}`
1864
+ const visitor = node => {
1865
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && stores.has(node.name.text)) {
1866
+ const store = stores.get(node.name.text)
1867
+ return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createCallExpression(factory.createIdentifier("__kCreateStore"), undefined, [
1868
+ factory.createStringLiteral(identity(store.name)),
1869
+ factory.createStringLiteral(store.field),
1870
+ store.initialValue,
1871
+ factory.createArrayLiteralExpression([...store.actions.keys()].map(name => factory.createStringLiteral(name)))
1872
+ ]))
1873
+ }
1874
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "zustand") return undefined
1875
+ return ts.visitEachChild(node, visitor, context)
1876
+ }
1877
+ const normalized = ts.visitNode(sourceFile, visitor)
1878
+ const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports([factory.createImportSpecifier(false, undefined, factory.createIdentifier("__kCreateStore"))])), factory.createStringLiteral("@kudzujs/core"))
1879
+ const statements = [...normalized.statements]
1880
+ statements.splice(statements.findLastIndex(statement => ts.isImportDeclaration(statement)) + 1, 0, declaration)
1881
+ return factory.updateSourceFile(normalized, statements)
1882
+ }
1883
+
1884
+ function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections = new Set()) {
1795
1885
  const supported = new Set(["createContext", "useContext", "useEffect", "useReducer", "useRef", "useState"])
1796
1886
  const erased = new Set(["memo", "useCallback", "useMemo"])
1797
1887
  const aliases = new Map()
@@ -1925,14 +2015,15 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1925
2015
  const dependencies = new Set(node.arguments[1].elements.map(unwrapExpression).filter(ts.isIdentifier).map(entry => entry.text))
1926
2016
  const owner = nearestFunction(node)
1927
2017
  const states = owner ? ownerStateNames(owner) : new Set()
1928
- const collectionState = expression && reactMemoCollectionState(expression, states, sourceFile)
1929
- if (!expression || !collectionState && !isPureReactMemoExpression(expression)) throw sourceNodeError(callback.body, sourceFile, "React useMemo() callback must return one pure expression or analyzable collection pipeline")
1930
- 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) {
1931
2021
  const unsupported = [...reactMemoReferenceNames(expression)].find(reference => !states.has(reference))
1932
2022
  if (unsupported) throw sourceNodeError(expression, sourceFile, `React useMemo() pure expressions may only reference direct local state; found ${JSON.stringify(unsupported)}`)
1933
2023
  }
1934
- const stale = collectionState
1935
- ? !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))
1936
2027
  : [...states].find(state => referenceIdentifiers(expression, state).length && !dependencies.has(state))
1937
2028
  if (stale) throw sourceNodeError(node.arguments[1], sourceFile, `React useMemo() must list captured state ${JSON.stringify(stale)} as a dependency`)
1938
2029
  return ts.visitNode(expression, visitor)
@@ -2018,10 +2109,10 @@ function lowerReactMemoCollectionExpression(expression, factory) {
2018
2109
  return visit(expression)
2019
2110
  }
2020
2111
 
2021
- function reactMemoCollectionState(expression, states, sourceFile) {
2112
+ function reactMemoCollection(expression, states, importedCollections, sourceFile) {
2022
2113
  const setters = new Map([...states].map(state => [state, state]))
2023
2114
  const fail = (node, message) => { throw sourceNodeError(node, sourceFile, message) }
2024
- return renderedCollectionSource(expression, setters, undefined, fail, new Set())?.state?.text
2115
+ return renderedCollectionSource(expression, setters, undefined, fail, new Set(), importedCollections, states)
2025
2116
  }
2026
2117
 
2027
2118
  function reactMemoComponentExpression(identifier, sourceFile, factory, context) {
@@ -2062,9 +2153,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2062
2153
  return context => sourceFile => {
2063
2154
  const factory = context.factory
2064
2155
  const hasLinkElements = /<link/i.test(sourceFile.text)
2156
+ const importedCollections = importedSerializableCollectionNames(sourceFile, file, sourceFiles, sourceIndex)
2065
2157
  sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
2066
2158
  ts.setParentRecursive(sourceFile, false)
2067
- sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context)
2159
+ sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections)
2160
+ ts.setParentRecursive(sourceFile, false)
2161
+ sourceFile = normalizeZustandMigrationSyntax(sourceFile, factory, context)
2068
2162
  ts.setParentRecursive(sourceFile, false)
2069
2163
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
2070
2164
  ts.setParentRecursive(sourceFile, false)
@@ -2077,7 +2171,9 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2077
2171
  if (!imported) {
2078
2172
  imported = normalizeClsxSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context)
2079
2173
  ts.setParentRecursive(imported, false)
2080
- imported = normalizeReactMigrationSyntax(imported, factory, context)
2174
+ imported = normalizeReactMigrationSyntax(imported, factory, context, importedSerializableCollectionNames(imported, target, sourceFiles, sourceIndex))
2175
+ ts.setParentRecursive(imported, false)
2176
+ imported = normalizeZustandMigrationSyntax(imported, factory, context)
2081
2177
  ts.setParentRecursive(imported, false)
2082
2178
  imported = normalizeRenderControlFlow(imported, factory, context)
2083
2179
  ts.setParentRecursive(imported, false)
@@ -2087,6 +2183,16 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2087
2183
  }
2088
2184
  const settersByFunction = new Map()
2089
2185
  const reducersByFunction = new Map()
2186
+ const zustandStores = new Map()
2187
+ const resolvedZustandStore = entry => {
2188
+ const exportName = entry.kind === "default" ? "default" : entry.imported
2189
+ const key = `${entry.target}:${exportName}`
2190
+ if (zustandStores.has(key)) return zustandStores.get(key)
2191
+ const targetSource = parseSourceFile(entry.target, sourceIndex.get(entry.target))
2192
+ const store = analyzeZustandStores(targetSource).get(exportName)
2193
+ zustandStores.set(key, store)
2194
+ return store
2195
+ }
2090
2196
  const functions = new Map()
2091
2197
  const components = new Map()
2092
2198
  const contexts = new Set()
@@ -2111,6 +2217,26 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2111
2217
  const collect = node => {
2112
2218
  if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
2113
2219
  const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
2220
+ if (ts.isIdentifier(node.name) && callName && importBindings.has(callName) && importBindings.get(callName).kind !== "namespace") {
2221
+ const storeImport = importBindings.get(callName)
2222
+ const store = resolvedZustandStore(storeImport)
2223
+ if (store) {
2224
+ const selector = node.initializer.arguments[0]
2225
+ if (node.initializer.arguments.length !== 1 || !selector || !ts.isArrowFunction(selector) || selector.parameters.length !== 1 || !ts.isIdentifier(selector.parameters[0].name) || !ts.isPropertyAccessExpression(unwrapExpression(selector.body)) || !ts.isIdentifier(unwrapExpression(selector.body).expression) || unwrapExpression(selector.body).expression.text !== selector.parameters[0].name.text) throw sourceNodeError(node.initializer, sourceFile, "Zustand selectors must be direct arrows such as state => state.quantities")
2226
+ const selected = unwrapExpression(selector.body).name.text
2227
+ const owner = nearestFunction(node)
2228
+ if (!owner) throw sourceNodeError(node, sourceFile, "Zustand stores cannot be used outside a Kudzu component")
2229
+ const setters = settersByFunction.get(owner) ?? new Map()
2230
+ if (selected === store.field) setters.set(`__kStoreState_${node.name.text}`, node.name.text)
2231
+ else if (store.actions.has(selected)) {
2232
+ setters.set(node.name.text, node.name.text)
2233
+ const reducers = reducersByFunction.get(owner) ?? new Map()
2234
+ reducers.set(node.name.text, { state: node.name.text, store, action: selected })
2235
+ reducersByFunction.set(owner, reducers)
2236
+ } else throw sourceNodeError(unwrapExpression(selector.body).name, sourceFile, `Zustand store ${JSON.stringify(store.name)} has no supported property ${JSON.stringify(selected)}`)
2237
+ settersByFunction.set(owner, setters)
2238
+ }
2239
+ }
2114
2240
  if (callName === "useReducer") {
2115
2241
  if (!ts.isArrayBindingPattern(node.name)) throw sourceNodeError(node.name, sourceFile, "useReducer() must use [state, dispatch] identifier destructuring")
2116
2242
  const [stateElement, dispatchElement] = node.name.elements
@@ -2192,7 +2318,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2192
2318
  const setters = settersByFunction.get(owner) ?? new Map()
2193
2319
  for (const [name, entries] of declarations) {
2194
2320
  for (const declaration of entries) {
2195
- 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)
2196
2322
  if (!parts) continue
2197
2323
  const uses = []
2198
2324
  const collectUses = node => {
@@ -2386,7 +2512,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2386
2512
  return
2387
2513
  }
2388
2514
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
2389
- 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)
2390
2516
  if (parts) {
2391
2517
  for (const declaration of parts.aliasDeclarations ?? []) listLocalDeclarations.add(declaration)
2392
2518
  rawRenderedLists.push({ node, parts })
@@ -2702,14 +2828,16 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2702
2828
  if (listParts) {
2703
2829
  usesBehavior = true
2704
2830
  usesList = true
2705
- return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
2831
+ const arguments_ = [
2706
2832
  listParts.state,
2707
2833
  listParts.keyField === null ? factory.createNull() : factory.createStringLiteral(listParts.keyField),
2708
2834
  ts.visitNode(listParts.callback, visitor),
2709
2835
  factory.createStringLiteral(listParts.ownerField ?? ""),
2710
2836
  jsonExpression(listParts.selector ?? [], factory),
2711
2837
  listParts.indexed ? factory.createTrue() : factory.createFalse()
2712
- ]))
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_))
2713
2841
  }
2714
2842
  const conditional = conditionalParts(node.expression)
2715
2843
  if (conditional) {
@@ -2895,11 +3023,11 @@ function containsRenderControl(root, knownLocals) {
2895
3023
  return found
2896
3024
  }
2897
3025
 
2898
- function keyedListParts(expression, setters, declarations, fail, aliases = new Set()) {
3026
+ function keyedListParts(expression, setters, declarations, fail, aliases = new Set(), importedCollections = new Set()) {
2899
3027
  const value = unwrapExpression(expression)
2900
3028
  const directFrom = isArrayFromCall(value) && value.arguments.length === 2 && containsJsx(value.arguments[1])
2901
3029
  if (!directFrom && (!ts.isCallExpression(value) || value.arguments.length !== 1 || !ts.isPropertyAccessExpression(value.expression) || value.expression.name.text !== "map")) return undefined
2902
- 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()))
2903
3031
  if (!collection?.state) return undefined
2904
3032
  if (directFrom) collection.selector.push(["from", undefined])
2905
3033
  const callback = directFrom ? value.arguments[1] : value.arguments[0]
@@ -2933,16 +3061,17 @@ function nestedKeyedListParts(expression, parentItem, fail) {
2933
3061
  return { ...collection, callback, root, item: parameters.item, index: parameters.index, indexed: Boolean(parameters.index), keyField: positional ? null : keyField }
2934
3062
  }
2935
3063
 
2936
- function renderedCollectionSource(expression, setters, declarations, fail, aliases) {
3064
+ function renderedCollectionSource(expression, setters, declarations, fail, aliases, importedCollections = new Set(), stateNames = new Set()) {
2937
3065
  const value = unwrapExpression(expression)
2938
3066
  if (ts.isIdentifier(value)) {
2939
- 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() }
2940
3069
  const entries = declarations?.get(value.text)
2941
3070
  if (!entries) return undefined
2942
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`)
2943
3072
  if (identifierReferenceCount(nearestFunction(entries[0].node).body, value.text) !== 1) fail(value, `Rendered collection alias "${value.text}" may only be rendered once`)
2944
3073
  aliases.add(value.text)
2945
- const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases)
3074
+ const source = renderedCollectionSource(entries[0].initializer, setters, declarations, fail, aliases, importedCollections, stateNames)
2946
3075
  aliases.delete(value.text)
2947
3076
  return source && { ...source, aliasDeclarations: [...(source.aliasDeclarations ?? []), entries[0].node] }
2948
3077
  }
@@ -2951,14 +3080,15 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
2951
3080
  const method = value.expression.name.text
2952
3081
  if (method === "filter") {
2953
3082
  if (value.arguments.length !== 1) fail(value, "Rendered collection filter() requires one inline predicate")
2954
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases)
3083
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames)
2955
3084
  if (!source) return undefined
2956
3085
  const parameters = collectionParameters(value.arguments[0], "Rendered collection filter()", fail)
2957
- 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 }
2958
3088
  }
2959
3089
  if (method === "flatMap") {
2960
3090
  if (value.arguments.length !== 1) fail(value, "Rendered collection flatMap() requires one inline projector")
2961
- const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases)
3091
+ const source = renderedCollectionSource(value.expression.expression, setters, declarations, fail, aliases, importedCollections, stateNames)
2962
3092
  if (!source) return undefined
2963
3093
  const parameters = collectionParameters(value.arguments[0], "Rendered collection flatMap()", fail)
2964
3094
  const field = directProperty(value.arguments[0].body, parameters.item)
@@ -2969,12 +3099,14 @@ function renderedCollectionSource(expression, setters, declarations, fail, alias
2969
3099
  }
2970
3100
  if (isArrayFromCall(value)) {
2971
3101
  if (value.arguments.length < 1 || value.arguments.length > 2) fail(value, "Rendered Array.from() requires an anchor and optional inline mapper")
2972
- const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases)
3102
+ const source = renderedCollectionSource(value.arguments[0], setters, declarations, fail, aliases, importedCollections, stateNames)
2973
3103
  if (!source) return undefined
2974
3104
  let mapper
2975
3105
  if (value.arguments[1]) {
2976
3106
  const parameters = collectionParameters(value.arguments[1], "Rendered Array.from() mapper", fail)
2977
- 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
2978
3110
  }
2979
3111
  return { ...source, selector: [...source.selector, ["from", mapper]] }
2980
3112
  }
@@ -2989,7 +3121,7 @@ function collectionParameters(callback, label, fail) {
2989
3121
  return { item: callback.parameters[0].name.text, index: callback.parameters[1]?.name.text }
2990
3122
  }
2991
3123
 
2992
- function collectionExpression(expression, parameters, fail) {
3124
+ function collectionExpression(expression, parameters, fail, stateNames = new Set(), selectorStates = new Set()) {
2993
3125
  const encode = node => {
2994
3126
  node = unwrapExpression(node)
2995
3127
  if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isNumericLiteral(node)) return ["value", ts.isNumericLiteral(node) ? Number(node.text) : node.text]
@@ -3000,6 +3132,10 @@ function collectionExpression(expression, parameters, fail) {
3000
3132
  if (node.text === parameters.item) return ["item"]
3001
3133
  if (node.text === parameters.index) return ["index"]
3002
3134
  if (node.text === "undefined") return ["undefined"]
3135
+ if (stateNames.has(node.text)) {
3136
+ selectorStates.add(node.text)
3137
+ return ["state", node.text]
3138
+ }
3003
3139
  fail(node, `Rendered collection expression identifier "${node.text}" is not allowed`)
3004
3140
  }
3005
3141
  if (ts.isPropertyAccessExpression(node)) {
@@ -3717,7 +3853,7 @@ function compileNativeCallback(expression, setters, reducers, factory, entries,
3717
3853
  const allCaptures = nativeCaptureNames(expression, setters)
3718
3854
  const usedReducers = referencedReducerDispatches(expression.body, reducers, expression)
3719
3855
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
3720
- imports.push(...[...usedReducers].map(name => reducers.get(name).import))
3856
+ imports.push(...[...usedReducers].map(name => reducers.get(name).import).filter(Boolean))
3721
3857
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
3722
3858
  for (const entry of imports) clientImports.add(entry.target)
3723
3859
  const usedStates = nativeStateNames(expression, setters)
@@ -4038,6 +4174,20 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
4038
4174
  return bindings
4039
4175
  }
4040
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
+
4041
4191
  function resolveComponentExport(file, exportName, getSource, sourceFiles, trail = []) {
4042
4192
  const key = `${file}:${exportName}`
4043
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(" -> ")}`)
@@ -4544,13 +4694,17 @@ function printNativeHandler({ exportName, expression, captures, setters, reducer
4544
4694
  const transformer = context => root => {
4545
4695
  const visitor = node => {
4546
4696
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && reducers.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
4697
+ const reducer = reducers.get(node.expression.text)
4698
+ if (reducer.store) return zustandActionDispatch(factory, reducer, node.arguments.map(argument => ts.visitNode(argument, visitor)))
4547
4699
  if (node.arguments.length !== 1) throw sourceNodeError(node, expression.getSourceFile(), "Reducer dispatches require exactly one action")
4548
- return reducerDispatch(factory, reducers.get(node.expression.text), ts.visitNode(node.arguments[0], visitor))
4700
+ return reducerDispatch(factory, reducer, ts.visitNode(node.arguments[0], visitor))
4549
4701
  }
4550
4702
  if (ts.isShorthandPropertyAssignment(node) && reducers.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
4703
+ if (reducers.get(node.name.text).store) throw sourceNodeError(node, expression.getSourceFile(), "Zustand actions must be called directly inside an event handler")
4551
4704
  return factory.createPropertyAssignment(node.name, reducerReference(factory, reducers.get(node.name.text)))
4552
4705
  }
4553
4706
  if (ts.isIdentifier(node) && reducers.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
4707
+ if (reducers.get(node.text).store) throw sourceNodeError(node, expression.getSourceFile(), "Zustand actions must be called directly inside an event handler")
4554
4708
  return reducerReference(factory, reducers.get(node.text))
4555
4709
  }
4556
4710
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
@@ -4665,11 +4819,44 @@ function reducerReference(factory, reducer) {
4665
4819
  }
4666
4820
 
4667
4821
  function reducerDispatch(factory, reducer, action) {
4822
+ if (reducer.store) return zustandActionDispatch(factory, reducer, [action])
4668
4823
  const previous = factory.createUniqueName("__kPrevious")
4669
4824
  const update = factory.createArrowFunction(undefined, undefined, [factory.createParameterDeclaration(undefined, undefined, previous)], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createCallExpression(factory.createIdentifier(reducer.reducer), undefined, [previous, action]))
4670
4825
  return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"), undefined, [factory.createStringLiteral(reducer.state), update])
4671
4826
  }
4672
4827
 
4828
+ function zustandActionDispatch(factory, reducer, args) {
4829
+ const previous = factory.createUniqueName("__kPrevious")
4830
+ const current = factory.createUniqueName("__kStore")
4831
+ const updateValue = factory.createUniqueName("__kUpdate")
4832
+ const partial = factory.createUniqueName("__kPartial")
4833
+ const action = factory.createUniqueName("__kAction")
4834
+ const set = factory.createIdentifier(reducer.store.setName)
4835
+ const merge = factory.createExpressionStatement(factory.createBinaryExpression(current, factory.createToken(ts.SyntaxKind.EqualsToken), factory.createObjectLiteralExpression([
4836
+ factory.createSpreadAssignment(current),
4837
+ factory.createSpreadAssignment(partial)
4838
+ ])))
4839
+ const setBody = factory.createBlock([
4840
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(partial, undefined, undefined, factory.createConditionalExpression(
4841
+ factory.createBinaryExpression(factory.createTypeOfExpression(updateValue), factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), factory.createStringLiteral("function")),
4842
+ undefined,
4843
+ factory.createCallExpression(updateValue, undefined, [current]),
4844
+ undefined,
4845
+ updateValue
4846
+ ))], ts.NodeFlags.Const)),
4847
+ merge
4848
+ ], true)
4849
+ const body = factory.createBlock([
4850
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(current, undefined, undefined, factory.createObjectLiteralExpression([factory.createPropertyAssignment(reducer.store.field, previous)]))], ts.NodeFlags.Let)),
4851
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(set, undefined, undefined, factory.createArrowFunction(undefined, undefined, [factory.createParameterDeclaration(undefined, undefined, updateValue)], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), setBody))], ts.NodeFlags.Const)),
4852
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(action, undefined, undefined, synthesizeTree(reducer.store.actions.get(reducer.action)))], ts.NodeFlags.Const)),
4853
+ factory.createExpressionStatement(factory.createCallExpression(action, undefined, args)),
4854
+ factory.createReturnStatement(factory.createPropertyAccessExpression(current, reducer.store.field))
4855
+ ], true)
4856
+ const update = factory.createArrowFunction(undefined, undefined, [factory.createParameterDeclaration(undefined, undefined, previous)], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), body)
4857
+ return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"), undefined, [factory.createStringLiteral(reducer.state), update])
4858
+ }
4859
+
4673
4860
  function printReactiveBinding({ exportName, expression, captures, states }) {
4674
4861
  const factory = ts.factory
4675
4862
  const transformer = context => root => {
@@ -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")
@@ -73,6 +74,32 @@ export function useReducer(reducer, initialValue, name) {
73
74
  return [state, dispatch]
74
75
  }
75
76
 
77
+ export function __kCreateStore(name, field, initialValue, actions) {
78
+ return selector => {
79
+ if (!renderContext) throw new Error("Zustand stores can only be read while rendering a Kudzu component")
80
+ let store = renderContext.stores.get(name)
81
+ if (!store) {
82
+ if (renderContext.scoped && renderContext.renderScope !== "layout") throw new Error(`Zustand store ${JSON.stringify(name)} must be initialized by the shared layout before route components use it`)
83
+ if (renderContext.listRoot || renderContext.listRowRoot || renderContext.listTemplate) throw new Error(`Zustand store ${JSON.stringify(name)} cannot be initialized inside a keyed row`)
84
+ const [state] = useState(initialValue, `${name}.${field}`)
85
+ store = { [field]: state }
86
+ for (const action of actions) {
87
+ const marker = () => {
88
+ throw new Error("Zustand actions are compiled into browser handlers")
89
+ }
90
+ Object.defineProperties(marker, {
91
+ [signalMarker]: { value: true },
92
+ id: { value: state.id },
93
+ value: { get: () => state.value }
94
+ })
95
+ store[action] = marker
96
+ }
97
+ renderContext.stores.set(name, store)
98
+ }
99
+ return selector(store)
100
+ }
101
+ }
102
+
76
103
  export function useParams() {
77
104
  if (renderContext?.renderScope === "layout") throw new Error("useParams() is only supported in route scope")
78
105
  if (!renderContext?.runtimeParamNames?.length) throw new Error("useParams() requires export const runtimeParams = true on a bracket page")
@@ -104,6 +131,14 @@ function createSignal(id, value) {
104
131
  }
105
132
  }
106
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
+
107
142
  export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup, itemDependencies = []) {
108
143
  if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
109
144
  if (typeof callback !== "function" || !Array.isArray(dependencies) || !module || !handler) throw new Error("useEffect() must be compiled with a literal dependency array")
@@ -221,8 +256,11 @@ export function stateConditional(kind, state, truthy, falsy) {
221
256
  return { [conditionalMarker]: true, kind, value: state.value, truthy, falsy, state: state.id }
222
257
  }
223
258
 
224
- export function list(items, keyField, render, ownerField, selector = [], indexed = false) {
225
- 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`)
226
264
  let values = items.value
227
265
  if (ownerField) {
228
266
  const owner = renderContext?.listRoot ?? renderContext?.listRowRoot
@@ -231,7 +269,7 @@ export function list(items, keyField, render, ownerField, selector = [], indexed
231
269
  values = renderContext.listTemplate ? [] : owner.item?.[ownerField]
232
270
  if (!Array.isArray(values) && values != null) throw new Error(`Nested keyed list property "${ownerField}" must remain an array`)
233
271
  }
234
- values = selectCollection(values, selector)
272
+ values = selectCollection(values, selector, name => selectorStateMap.get(name)?.value)
235
273
  const keys = new Set()
236
274
  for (const [index, item] of values.entries()) {
237
275
  const key = keyField === null ? index : item?.[keyField]
@@ -242,7 +280,7 @@ export function list(items, keyField, render, ownerField, selector = [], indexed
242
280
  if (keys.has(token)) throw new Error(`Duplicate keyed list key: ${String(key)}`)
243
281
  keys.add(token)
244
282
  }
245
- return { [listMarker]: true, items, values, keyField, render, ownerField, selector, indexed }
283
+ return { [listMarker]: true, items, values, keyField, render, ownerField, selector, indexed, selectorStates }
246
284
  }
247
285
 
248
286
  export function listField(read, field) {
@@ -368,7 +406,7 @@ function serializeCapture(name, value, seen) {
368
406
  }
369
407
 
370
408
  export async function renderPage(component, metadata = {}, props = {}, layout) {
371
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
409
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
372
410
 
373
411
  try {
374
412
  const page = { [routeScopeMarker]: true, component, props }
@@ -775,7 +813,7 @@ async function renderList(node, namespace, selectValue) {
775
813
  const ownerTemplate = Boolean(node.ownerField && renderContext.listTemplate)
776
814
  const rowList = node.ownerField ? nextRowList() : undefined
777
815
  const id = rowList?.id ?? nextRenderId("l")
778
- 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 } : {}) }
779
817
  if (ownerTemplate) {
780
818
  ownerRoot.descriptor.children ??= []
781
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.5",
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",