@kudzujs/core 0.7.4 → 0.7.6

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.4:** Memoized collection pipelines. React `useMemo` may retain analyzable `filter`, `map`, `flatMap`, and `Array.from` pipelines while compiling to existing keyed-list selectors without React, a VDOM, or hydration. See [release notes](./RELEASES.md#074---memoized-collection-pipelines).
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).
14
14
 
15
15
  Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
16
16
 
@@ -76,6 +76,10 @@ export default function Header() {
76
76
 
77
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.
78
78
 
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
+
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
+
79
83
  Create `src/pages/index.tsx`:
80
84
 
81
85
  ```tsx
package/RELEASES.md CHANGED
@@ -1,5 +1,50 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.6 - Zustand-shaped shared stores
4
+
5
+ Kudzu 0.7.6 lets reduced React migration source retain a Zustand `create(set => ...)` store across an explicitly configured shared-layout navigation group.
6
+
7
+ ### New in 0.7.6
8
+
9
+ - One exported store with one directly serializable data property and synchronous capture-free actions compiles to one ordinary layout-lifetime Kudzu state slot.
10
+ - Components select the data or an action with direct forms such as `state => state.quantities` and `state => state.add`.
11
+ - Selected actions inline through existing functional state updates, so repeated same-turn calls observe current logical state and DOM writes still batch.
12
+ - Same-group navigation retains the store and layout DOM while incoming route bindings mount against the current value.
13
+ - Neither React, Zustand, a subscription runtime, nor a generic external-store capability enters the deploy output.
14
+ - The shopping fixture verifies two same-turn additions, product-to-cart retention, removal, layout DOM identity, package erasure, and source diagnostics in Chrome.
15
+
16
+ ### Boundary
17
+
18
+ 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.
19
+
20
+ ### Upgrade
21
+
22
+ ```bash
23
+ npm install @kudzujs/core@^0.7.6
24
+ ```
25
+
26
+ ## 0.7.5 - Class composition migration
27
+
28
+ Kudzu 0.7.5 lets ordinary React source retain common direct `clsx` calls while compiling them to existing static and reactive class paths.
29
+
30
+ ### New in 0.7.5
31
+
32
+ - Default and named `clsx` imports lower at build time for string and number literals, literal arrays, literal object conditions, and conditional expressions.
33
+ - Dynamic object conditions reuse existing reactive class bindings without serializing or shipping the `clsx` function.
34
+ - Static uses add no browser JavaScript, and the package import is erased from compiled modules.
35
+ - Mixed React imports such as `import { useState, type ReactNode } from "react"` now erase type-only specifiers before runtime module rewriting.
36
+ - The React/Vite fixture verifies initial class output, state-driven class updates in Chrome, package erasure, and mixed type imports.
37
+
38
+ ### Boundary
39
+
40
+ `clsx` spreads, computed object keys, arbitrary calls, and indirect references remain unsupported. This is source lowering for a proven migration pattern, not package execution or a general React ecosystem runtime.
41
+
42
+ ### Upgrade
43
+
44
+ ```bash
45
+ npm install @kudzujs/core@^0.7.5
46
+ ```
47
+
3
48
  ## 0.7.4 - Memoized collection pipelines
4
49
 
5
50
  Kudzu 0.7.4 lets ordinary React/Vite source retain analyzable collection work inside `useMemo` while reusing the existing keyed-list selector and DOM identity model.
@@ -4,6 +4,10 @@ Kudzu specializes ordinary common React-shaped TSX so migrations need minimal so
4
4
 
5
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.
6
6
 
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
+
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
+
7
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.
8
12
  - `core.mjs`: server-side JSX rendering, state slots, context providers, behavior metadata, and serializable capture validation.
9
13
  - `jsx-runtime.mjs`: automatic JSX runtime used by TypeScript.
@@ -1744,6 +1744,141 @@ function hasReactModuleReference(source, file) {
1744
1744
  return found
1745
1745
  }
1746
1746
 
1747
+ function normalizeClsxSyntax(sourceFile, factory, context) {
1748
+ const names = new Set()
1749
+ for (const statement of sourceFile.statements) {
1750
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "clsx") continue
1751
+ if (statement.importClause?.name) names.add(statement.importClause.name.text)
1752
+ const bindings = statement.importClause?.namedBindings
1753
+ if (bindings && ts.isNamedImports(bindings)) for (const entry of bindings.elements) if (!entry.isTypeOnly && (entry.propertyName ?? entry.name).text === "clsx") names.add(entry.name.text)
1754
+ }
1755
+ if (!names.size) return sourceFile
1756
+
1757
+ const lower = node => {
1758
+ node = unwrapExpression(node)
1759
+ if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || ts.isNumericLiteral(node)) return node
1760
+ if (node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword) return factory.createStringLiteral("")
1761
+ if (ts.isConditionalExpression(node)) return factory.updateConditionalExpression(node, node.condition, node.questionToken, lower(node.whenTrue), node.colonToken, lower(node.whenFalse))
1762
+ if (ts.isArrayLiteralExpression(node)) return combine(node.elements.map(lower))
1763
+ if (ts.isObjectLiteralExpression(node)) return combine(node.properties.map(property => {
1764
+ if (!ts.isPropertyAssignment(property) || property.name && ts.isComputedPropertyName(property.name)) throw sourceNodeError(property, sourceFile, "clsx() object arguments require ordinary key/value properties")
1765
+ const name = property.name
1766
+ const value = name && (ts.isIdentifier(name) || ts.isStringLiteral(name) || ts.isNumericLiteral(name)) ? name.text : undefined
1767
+ if (value === undefined) throw sourceNodeError(property, sourceFile, "clsx() object keys must be identifiers or literals")
1768
+ return factory.createConditionalExpression(property.initializer, undefined, factory.createStringLiteral(value), undefined, factory.createStringLiteral(""))
1769
+ }))
1770
+ throw sourceNodeError(node, sourceFile, "clsx() arguments must be string/number literals, literal arrays, literal objects, or conditionals")
1771
+ }
1772
+ const combine = entries => entries.length ? entries.reduce((result, entry) => factory.createBinaryExpression(factory.createBinaryExpression(result, factory.createToken(ts.SyntaxKind.PlusToken), factory.createStringLiteral(" ")), factory.createToken(ts.SyntaxKind.PlusToken), entry)) : factory.createStringLiteral("")
1773
+
1774
+ const visitor = node => {
1775
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && names.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) return combine(node.arguments.map(lower))
1776
+ if (ts.isIdentifier(node) && names.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile) && !(ts.isCallExpression(node.parent) && node.parent.expression === node)) throw sourceNodeError(node, sourceFile, "clsx imports may only be called directly")
1777
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "clsx") {
1778
+ const clause = node.importClause
1779
+ if (!clause || clause.isTypeOnly) return node
1780
+ let bindings = clause.namedBindings
1781
+ if (bindings && ts.isNamedImports(bindings)) {
1782
+ const elements = bindings.elements.filter(entry => entry.isTypeOnly || (entry.propertyName ?? entry.name).text !== "clsx")
1783
+ bindings = elements.length ? factory.updateNamedImports(bindings, elements) : undefined
1784
+ }
1785
+ const defaultName = clause.name && names.has(clause.name.text) ? undefined : clause.name
1786
+ if (!defaultName && !bindings) return undefined
1787
+ return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, defaultName, bindings), node.moduleSpecifier, node.attributes)
1788
+ }
1789
+ return ts.visitEachChild(node, visitor, context)
1790
+ }
1791
+ return ts.visitNode(sourceFile, visitor)
1792
+ }
1793
+
1794
+ function analyzeZustandStores(sourceFile) {
1795
+ const createNames = new Set()
1796
+ for (const statement of sourceFile.statements) {
1797
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "zustand") continue
1798
+ const bindings = statement.importClause?.namedBindings
1799
+ if (statement.importClause?.name || !bindings || !ts.isNamedImports(bindings)) throw sourceNodeError(statement, sourceFile, "Zustand migration input requires a named create import")
1800
+ for (const entry of bindings.elements) {
1801
+ if (entry.isTypeOnly) continue
1802
+ if ((entry.propertyName ?? entry.name).text !== "create") throw sourceNodeError(entry, sourceFile, "Only Zustand create is supported")
1803
+ createNames.add(entry.name.text)
1804
+ }
1805
+ }
1806
+ const stores = new Map()
1807
+ if (!createNames.size) return stores
1808
+ for (const statement of sourceFile.statements) {
1809
+ if (!ts.isVariableStatement(statement) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
1810
+ for (const declaration of statement.declarationList.declarations) {
1811
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer || !ts.isCallExpression(declaration.initializer) || !ts.isIdentifier(declaration.initializer.expression) || !createNames.has(declaration.initializer.expression.text)) continue
1812
+ const callback = declaration.initializer.arguments[0]
1813
+ 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")
1814
+ const body = unwrapExpression(callback.body)
1815
+ if (!ts.isObjectLiteralExpression(body)) throw sourceNodeError(callback.body, sourceFile, "Zustand create() initializer must return one object literal")
1816
+ const data = []
1817
+ const actions = new Map()
1818
+ for (const property of body.properties) {
1819
+ 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")
1820
+ const name = property.name.text
1821
+ const value = unwrapExpression(property.initializer)
1822
+ if (ts.isArrowFunction(value) || ts.isFunctionExpression(value)) actions.set(name, value)
1823
+ else data.push({ name, value })
1824
+ }
1825
+ if (data.length !== 1 || !isSerializableStateLiteral(data[0].value)) throw sourceNodeError(body, sourceFile, "Zustand migration stores require exactly one directly serializable data property")
1826
+ if (!actions.size) throw sourceNodeError(body, sourceFile, "Zustand migration stores require at least one action")
1827
+ for (const [name, action] of actions) {
1828
+ if (action.asteriskToken || action.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(action, sourceFile, `Zustand action ${JSON.stringify(name)} must be synchronous`)
1829
+ const capture = [...nativeCaptureNames(action, new Map())].find(entry => entry !== callback.parameters[0].name.text)
1830
+ if (capture) throw sourceNodeError(action, sourceFile, `Zustand action ${JSON.stringify(name)} cannot capture ${JSON.stringify(capture)}`)
1831
+ const validateAction = node => {
1832
+ if (ts.isAwaitExpression(node) || ts.isYieldExpression(node) || ts.isNewExpression(node)) throw sourceNodeError(node, sourceFile, `Zustand action ${JSON.stringify(name)} must be synchronous`)
1833
+ 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`)
1834
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === callback.parameters[0].name.text && !isShadowedIdentifier(node.expression, action)) {
1835
+ if (nearestFunction(node) !== action) throw sourceNodeError(node, sourceFile, `Zustand action ${JSON.stringify(name)} must call set directly`)
1836
+ if (node.arguments.length !== 1) throw sourceNodeError(node, sourceFile, `Zustand action ${JSON.stringify(name)} set() requires exactly one partial update`)
1837
+ }
1838
+ ts.forEachChild(node, validateAction)
1839
+ }
1840
+ validateAction(action.body)
1841
+ }
1842
+ 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 })
1843
+ }
1844
+ }
1845
+ const visit = node => {
1846
+ const recognized = ts.isIdentifier(node) && ts.isCallExpression(node.parent) && node.parent.expression === node && [...stores.values()].some(store => store.declaration.initializer === node.parent)
1847
+ 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")
1848
+ ts.forEachChild(node, visit)
1849
+ }
1850
+ visit(sourceFile)
1851
+ return stores
1852
+ }
1853
+
1854
+ function normalizeZustandMigrationSyntax(sourceFile, factory, context) {
1855
+ const stores = analyzeZustandStores(sourceFile)
1856
+ if (!stores.size) {
1857
+ const declaration = sourceFile.statements.find(statement => ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "zustand" && !statement.importClause?.isTypeOnly)
1858
+ if (declaration) throw sourceNodeError(declaration, sourceFile, "Zustand create must directly initialize an exported const store")
1859
+ return sourceFile
1860
+ }
1861
+ const identity = name => `${relative(sourceDirectory, sourceFile.fileName).replaceAll(sep, "/")}#${name}`
1862
+ const visitor = node => {
1863
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && stores.has(node.name.text)) {
1864
+ const store = stores.get(node.name.text)
1865
+ return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, factory.createCallExpression(factory.createIdentifier("__kCreateStore"), undefined, [
1866
+ factory.createStringLiteral(identity(store.name)),
1867
+ factory.createStringLiteral(store.field),
1868
+ store.initialValue,
1869
+ factory.createArrayLiteralExpression([...store.actions.keys()].map(name => factory.createStringLiteral(name)))
1870
+ ]))
1871
+ }
1872
+ if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "zustand") return undefined
1873
+ return ts.visitEachChild(node, visitor, context)
1874
+ }
1875
+ const normalized = ts.visitNode(sourceFile, visitor)
1876
+ const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports([factory.createImportSpecifier(false, undefined, factory.createIdentifier("__kCreateStore"))])), factory.createStringLiteral("@kudzujs/core"))
1877
+ const statements = [...normalized.statements]
1878
+ statements.splice(statements.findLastIndex(statement => ts.isImportDeclaration(statement)) + 1, 0, declaration)
1879
+ return factory.updateSourceFile(normalized, statements)
1880
+ }
1881
+
1747
1882
  function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1748
1883
  const supported = new Set(["createContext", "useContext", "useEffect", "useReducer", "useRef", "useState"])
1749
1884
  const erased = new Set(["memo", "useCallback", "useMemo"])
@@ -1903,6 +2038,7 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1903
2038
  const entries = []
1904
2039
  for (const entry of bindings.elements) {
1905
2040
  const name = (entry.propertyName ?? entry.name).text
2041
+ if (entry.isTypeOnly) continue
1906
2042
  if (!entry.isTypeOnly && erased.has(name)) continue
1907
2043
  if (!entry.isTypeOnly && supported.has(name)) {
1908
2044
  if (imported.has(name)) continue
@@ -2014,8 +2150,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2014
2150
  return context => sourceFile => {
2015
2151
  const factory = context.factory
2016
2152
  const hasLinkElements = /<link/i.test(sourceFile.text)
2153
+ sourceFile = normalizeClsxSyntax(sourceFile, factory, context)
2154
+ ts.setParentRecursive(sourceFile, false)
2017
2155
  sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context)
2018
2156
  ts.setParentRecursive(sourceFile, false)
2157
+ sourceFile = normalizeZustandMigrationSyntax(sourceFile, factory, context)
2158
+ ts.setParentRecursive(sourceFile, false)
2019
2159
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
2020
2160
  ts.setParentRecursive(sourceFile, false)
2021
2161
  rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles)
@@ -2025,7 +2165,11 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2025
2165
  const importedSource = target => {
2026
2166
  let imported = importedSources.get(target)
2027
2167
  if (!imported) {
2028
- imported = normalizeReactMigrationSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context)
2168
+ imported = normalizeClsxSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context)
2169
+ ts.setParentRecursive(imported, false)
2170
+ imported = normalizeReactMigrationSyntax(imported, factory, context)
2171
+ ts.setParentRecursive(imported, false)
2172
+ imported = normalizeZustandMigrationSyntax(imported, factory, context)
2029
2173
  ts.setParentRecursive(imported, false)
2030
2174
  imported = normalizeRenderControlFlow(imported, factory, context)
2031
2175
  ts.setParentRecursive(imported, false)
@@ -2035,6 +2179,16 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2035
2179
  }
2036
2180
  const settersByFunction = new Map()
2037
2181
  const reducersByFunction = new Map()
2182
+ const zustandStores = new Map()
2183
+ const resolvedZustandStore = entry => {
2184
+ const exportName = entry.kind === "default" ? "default" : entry.imported
2185
+ const key = `${entry.target}:${exportName}`
2186
+ if (zustandStores.has(key)) return zustandStores.get(key)
2187
+ const targetSource = parseSourceFile(entry.target, sourceIndex.get(entry.target))
2188
+ const store = analyzeZustandStores(targetSource).get(exportName)
2189
+ zustandStores.set(key, store)
2190
+ return store
2191
+ }
2038
2192
  const functions = new Map()
2039
2193
  const components = new Map()
2040
2194
  const contexts = new Set()
@@ -2059,6 +2213,26 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2059
2213
  const collect = node => {
2060
2214
  if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
2061
2215
  const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
2216
+ if (ts.isIdentifier(node.name) && callName && importBindings.has(callName) && importBindings.get(callName).kind !== "namespace") {
2217
+ const storeImport = importBindings.get(callName)
2218
+ const store = resolvedZustandStore(storeImport)
2219
+ if (store) {
2220
+ const selector = node.initializer.arguments[0]
2221
+ 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")
2222
+ const selected = unwrapExpression(selector.body).name.text
2223
+ const owner = nearestFunction(node)
2224
+ if (!owner) throw sourceNodeError(node, sourceFile, "Zustand stores cannot be used outside a Kudzu component")
2225
+ const setters = settersByFunction.get(owner) ?? new Map()
2226
+ if (selected === store.field) setters.set(`__kStoreState_${node.name.text}`, node.name.text)
2227
+ else if (store.actions.has(selected)) {
2228
+ setters.set(node.name.text, node.name.text)
2229
+ const reducers = reducersByFunction.get(owner) ?? new Map()
2230
+ reducers.set(node.name.text, { state: node.name.text, store, action: selected })
2231
+ reducersByFunction.set(owner, reducers)
2232
+ } else throw sourceNodeError(unwrapExpression(selector.body).name, sourceFile, `Zustand store ${JSON.stringify(store.name)} has no supported property ${JSON.stringify(selected)}`)
2233
+ settersByFunction.set(owner, setters)
2234
+ }
2235
+ }
2062
2236
  if (callName === "useReducer") {
2063
2237
  if (!ts.isArrayBindingPattern(node.name)) throw sourceNodeError(node.name, sourceFile, "useReducer() must use [state, dispatch] identifier destructuring")
2064
2238
  const [stateElement, dispatchElement] = node.name.elements
@@ -3665,7 +3839,7 @@ function compileNativeCallback(expression, setters, reducers, factory, entries,
3665
3839
  const allCaptures = nativeCaptureNames(expression, setters)
3666
3840
  const usedReducers = referencedReducerDispatches(expression.body, reducers, expression)
3667
3841
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
3668
- imports.push(...[...usedReducers].map(name => reducers.get(name).import))
3842
+ imports.push(...[...usedReducers].map(name => reducers.get(name).import).filter(Boolean))
3669
3843
  const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
3670
3844
  for (const entry of imports) clientImports.add(entry.target)
3671
3845
  const usedStates = nativeStateNames(expression, setters)
@@ -4492,13 +4666,17 @@ function printNativeHandler({ exportName, expression, captures, setters, reducer
4492
4666
  const transformer = context => root => {
4493
4667
  const visitor = node => {
4494
4668
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && reducers.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
4669
+ const reducer = reducers.get(node.expression.text)
4670
+ if (reducer.store) return zustandActionDispatch(factory, reducer, node.arguments.map(argument => ts.visitNode(argument, visitor)))
4495
4671
  if (node.arguments.length !== 1) throw sourceNodeError(node, expression.getSourceFile(), "Reducer dispatches require exactly one action")
4496
- return reducerDispatch(factory, reducers.get(node.expression.text), ts.visitNode(node.arguments[0], visitor))
4672
+ return reducerDispatch(factory, reducer, ts.visitNode(node.arguments[0], visitor))
4497
4673
  }
4498
4674
  if (ts.isShorthandPropertyAssignment(node) && reducers.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
4675
+ if (reducers.get(node.name.text).store) throw sourceNodeError(node, expression.getSourceFile(), "Zustand actions must be called directly inside an event handler")
4499
4676
  return factory.createPropertyAssignment(node.name, reducerReference(factory, reducers.get(node.name.text)))
4500
4677
  }
4501
4678
  if (ts.isIdentifier(node) && reducers.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
4679
+ if (reducers.get(node.text).store) throw sourceNodeError(node, expression.getSourceFile(), "Zustand actions must be called directly inside an event handler")
4502
4680
  return reducerReference(factory, reducers.get(node.text))
4503
4681
  }
4504
4682
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && setters.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
@@ -4613,11 +4791,44 @@ function reducerReference(factory, reducer) {
4613
4791
  }
4614
4792
 
4615
4793
  function reducerDispatch(factory, reducer, action) {
4794
+ if (reducer.store) return zustandActionDispatch(factory, reducer, [action])
4616
4795
  const previous = factory.createUniqueName("__kPrevious")
4617
4796
  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]))
4618
4797
  return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"), undefined, [factory.createStringLiteral(reducer.state), update])
4619
4798
  }
4620
4799
 
4800
+ function zustandActionDispatch(factory, reducer, args) {
4801
+ const previous = factory.createUniqueName("__kPrevious")
4802
+ const current = factory.createUniqueName("__kStore")
4803
+ const updateValue = factory.createUniqueName("__kUpdate")
4804
+ const partial = factory.createUniqueName("__kPartial")
4805
+ const action = factory.createUniqueName("__kAction")
4806
+ const set = factory.createIdentifier(reducer.store.setName)
4807
+ const merge = factory.createExpressionStatement(factory.createBinaryExpression(current, factory.createToken(ts.SyntaxKind.EqualsToken), factory.createObjectLiteralExpression([
4808
+ factory.createSpreadAssignment(current),
4809
+ factory.createSpreadAssignment(partial)
4810
+ ])))
4811
+ const setBody = factory.createBlock([
4812
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(partial, undefined, undefined, factory.createConditionalExpression(
4813
+ factory.createBinaryExpression(factory.createTypeOfExpression(updateValue), factory.createToken(ts.SyntaxKind.EqualsEqualsEqualsToken), factory.createStringLiteral("function")),
4814
+ undefined,
4815
+ factory.createCallExpression(updateValue, undefined, [current]),
4816
+ undefined,
4817
+ updateValue
4818
+ ))], ts.NodeFlags.Const)),
4819
+ merge
4820
+ ], true)
4821
+ const body = factory.createBlock([
4822
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(current, undefined, undefined, factory.createObjectLiteralExpression([factory.createPropertyAssignment(reducer.store.field, previous)]))], ts.NodeFlags.Let)),
4823
+ 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)),
4824
+ factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(action, undefined, undefined, synthesizeTree(reducer.store.actions.get(reducer.action)))], ts.NodeFlags.Const)),
4825
+ factory.createExpressionStatement(factory.createCallExpression(action, undefined, args)),
4826
+ factory.createReturnStatement(factory.createPropertyAccessExpression(current, reducer.store.field))
4827
+ ], true)
4828
+ const update = factory.createArrowFunction(undefined, undefined, [factory.createParameterDeclaration(undefined, undefined, previous)], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), body)
4829
+ return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "set"), undefined, [factory.createStringLiteral(reducer.state), update])
4830
+ }
4831
+
4621
4832
  function printReactiveBinding({ exportName, expression, captures, states }) {
4622
4833
  const factory = ts.factory
4623
4834
  const transformer = context => root => {
@@ -73,6 +73,32 @@ export function useReducer(reducer, initialValue, name) {
73
73
  return [state, dispatch]
74
74
  }
75
75
 
76
+ export function __kCreateStore(name, field, initialValue, actions) {
77
+ return selector => {
78
+ if (!renderContext) throw new Error("Zustand stores can only be read while rendering a Kudzu component")
79
+ let store = renderContext.stores.get(name)
80
+ if (!store) {
81
+ 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`)
82
+ if (renderContext.listRoot || renderContext.listRowRoot || renderContext.listTemplate) throw new Error(`Zustand store ${JSON.stringify(name)} cannot be initialized inside a keyed row`)
83
+ const [state] = useState(initialValue, `${name}.${field}`)
84
+ store = { [field]: state }
85
+ for (const action of actions) {
86
+ const marker = () => {
87
+ throw new Error("Zustand actions are compiled into browser handlers")
88
+ }
89
+ Object.defineProperties(marker, {
90
+ [signalMarker]: { value: true },
91
+ id: { value: state.id },
92
+ value: { get: () => state.value }
93
+ })
94
+ store[action] = marker
95
+ }
96
+ renderContext.stores.set(name, store)
97
+ }
98
+ return selector(store)
99
+ }
100
+ }
101
+
76
102
  export function useParams() {
77
103
  if (renderContext?.renderScope === "layout") throw new Error("useParams() is only supported in route scope")
78
104
  if (!renderContext?.runtimeParamNames?.length) throw new Error("useParams() requires export const runtimeParams = true on a bracket page")
@@ -368,7 +394,7 @@ function serializeCapture(name, value, seen) {
368
394
  }
369
395
 
370
396
  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 }
397
+ 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
398
 
373
399
  try {
374
400
  const page = { [routeScopeMarker]: true, component, props }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.4",
3
+ "version": "0.7.6",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",