@kudzujs/core 0.7.26 → 0.7.27

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
10
10
 
11
11
  > Experimental `0.7.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **Latest release: 0.7.26 - React-shaped migration and faster lists.** React-compatible JSX typing, setter-adapter specialization, imported collection normalization, and indexed keyed-row release make ordinary migrations easier while moving the measured 500-card search ahead of React. Read the [release notes](./RELEASES.md#0726---react-shaped-migration-and-faster-lists) or open the [release page](https://kudzujs.cloud/releases/0.7.26).
13
+ **Latest release: 0.7.27 - Stateful component migration.** Setter-adapter children can retain ordinary state, IDs, refs, effects, prop-synchronized drafts, and derived JSX locals while Kudzu still emits no browser component runtime. Read the [release notes](./RELEASES.md#0727---stateful-component-migration) or open the [release page](https://kudzujs.cloud/releases/0.7.27).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
package/RELEASES.md CHANGED
@@ -1,5 +1,33 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.27 - Stateful component migration
4
+
5
+ Kudzu 0.7.27 lets ordinary setter-adapter components keep their local hooks and derived render values while compiling them into static HTML plus existing route-specific capabilities, not a browser component runtime.
6
+
7
+ ### New in 0.7.27
8
+
9
+ - Same-file and relative-imported children receiving one supported setter callback may own multiple directly serializable `useState()` values.
10
+ - A child string draft may initialize from one direct primitive state prop through zero-argument `.toString()`, covering conventional controlled age and numeric inputs.
11
+ - Setter-adapter children may use deterministic build-time `useId()` values and `null`-initialized object refs.
12
+ - Supported child effects reuse existing route ESM, dependency comparison, cleanup, conditional ownership, and stale-work isolation.
13
+ - Conditional removal deletes child state, resolves refs to `null`, and runs cleanup; remount creates fresh ownership and prop dependency effects synchronize drafts to the latest parent value.
14
+ - Inline and simple `const` parent callbacks continue to specialize into the generated child helper instead of becoming serialized function captures.
15
+ - Reactive JSX text and attributes may read recursively chained top-level immutable locals built from supported pure primitive state expressions.
16
+ - Impure derived render calls, non-null child refs, unsupported dynamic state initializers, repeated callback use, and wider callback forwarding retain source-located diagnostics.
17
+ - React Router normalization now safely ignores local export declarations without module specifiers.
18
+ - A migration-derived FIRE age input verifies local draft editing, parent updates, blur validation, effect synchronization, and remount after a hidden parent update.
19
+ - The complete suite passes 121/121 tests, including same-file/imported hook ownership and Chrome lifecycle coverage.
20
+
21
+ ### Boundary
22
+
23
+ This remains one compiler-specialized component boundary with one supported setter callback use. Prop-derived state initialization accepts only a direct primitive state prop's zero-argument `.toString()`; other dynamic initializers remain unsupported. Refs must initialize with `null`, effects must use the existing supported callback and dependency forms, and React is never emitted or executed.
24
+
25
+ ### Upgrade
26
+
27
+ ```bash
28
+ npm install @kudzujs/core@^0.7.27
29
+ ```
30
+
3
31
  ## 0.7.26 - React-shaped migration and faster lists
4
32
 
5
33
  Kudzu 0.7.26 broadens ordinary React-shaped migration input and removes repeated keyed-row cleanup scans, making the matched 500-card search faster than React without adding a VDOM, hydration, or a retained component tree.
@@ -14,7 +14,7 @@ A named or aliased React Router `useNavigate` import may initialize one top-leve
14
14
 
15
15
  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.
16
16
 
17
- Repeated ordinary same-file and relative-imported child components execute independently at build time, so each `useState` call receives a distinct concrete state ID while shared native handler modules retain per-element state maps and captures. A direct JSON-safe primitive parent state passed to a destructured child prop remains the same signal for child DOM bindings and effect dependencies; repeated calls own independent effect records, and conditional removal cleans up before remount recreates the effect. Multiple direct primitive dependencies share the existing commit batching path: every value is compared with `Object.is`, and one or more same-turn changes cause one cleanup and rerun. A top-level immutable local derived through a supported pure primitive expression from direct state may also be an effect dependency: source state commits schedule evaluation, the derived result is compared with `Object.is`, and the expression is substituted into setup and cleanup handlers. Effect setup and directly returned cleanup callbacks may each resolve one top-level simple `const` function in the same component; those functions are substituted into the existing handler graph rather than retained in a browser registry. Reactive conditional descriptors own state created by their direct branch: initial visible output reuses the rendered template IDs, removal deletes those slots, and remount recreates them from serialized initial values. Static sibling routes and branches without local state add no ownership metadata, component function, hook dispatcher, or rerender loop.
17
+ Repeated ordinary same-file and relative-imported child components execute independently at build time, so each `useState` call receives a distinct concrete state ID while shared native handler modules retain per-element state maps and captures. A direct JSON-safe primitive parent state passed to a destructured child prop remains the same signal for child DOM bindings and effect dependencies; repeated calls own independent effect records, and conditional removal cleans up before remount recreates the effect. Reactive text and attributes may reference recursively chained top-level immutable locals derived through supported pure primitive expressions from direct state; the compiler substitutes those expressions into the existing binding evaluator and subscribes every source state. Multiple direct primitive dependencies share the existing commit batching path: every value is compared with `Object.is`, and one or more same-turn changes cause one cleanup and rerun. A top-level immutable local derived through a supported pure primitive expression from direct state may also be an effect dependency: source state commits schedule evaluation, the derived result is compared with `Object.is`, and the expression is substituted into setup and cleanup handlers. Effect setup and directly returned cleanup callbacks may each resolve one top-level simple `const` function in the same component; those functions are substituted into the existing handler graph rather than retained in a browser registry. Reactive conditional descriptors own state created by their direct branch: initial visible output reuses the rendered template IDs, removal deletes those slots, and remount recreates them from serialized initial values. Static sibling routes and branches without local state add no ownership metadata, component function, hook dispatcher, or rerender loop.
18
18
 
19
19
  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.
20
20
 
@@ -47,7 +47,7 @@ Rendered collection selectors compile immutable local aliases and inline `(item)
47
47
 
48
48
  The reduced `useReducer` form reuses ordinary state slots and React's pure reducer contract. An optional inline, same-file, or relative-imported synchronous one-parameter initializer may derive a directly serializable literal only from its directly serializable initial argument; the compiler substitutes that argument and lowers the call to the ordinary two-argument ownership path. 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 directly serializable literal defaults and direct intrinsic rest props 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.
49
49
 
50
- A direct setter may cross one same-file or relative-imported component boundary when the child invokes it exactly once inside an intrinsic event handler. Inline and simple `const` setter callbacks may use that adapter shape or direct event forwarding. Value adapters such as `event => onValueChange(event.currentTarget.value)` specialize into the parent setter graph instead of serializing a function. A `null`-initialized object ref may cross the same boundary to the direct intrinsic root. Both remain compiler-owned descriptors: conditional removal drops the handler with the element and makes the ref resolve to `null`, while remount creates a fresh element without retaining a component instance.
50
+ A direct setter may cross one same-file or relative-imported component boundary when the child invokes it exactly once inside an intrinsic event handler. Inline and simple `const` setter callbacks may use that adapter shape or direct event forwarding. Value adapters such as `event => onValueChange(event.currentTarget.value)` specialize into the parent setter graph instead of serializing a function. The child may also own directly serializable `useState()` values or initialize string state with one direct primitive state prop's zero-argument `.toString()`, plus `useId()` values, supported effects, and object refs initialized with `null`. Other dynamic state initializers remain unsupported. A parent-owned `null`-initialized object ref may cross the same boundary to the direct intrinsic root. These remain compiler-owned descriptors: conditional removal drops the handler with the element, deletes child state, resolves refs to `null`, and runs effect cleanup, while remount creates fresh ownership and dependency effects synchronize prop-derived state without retaining a component instance.
51
51
 
52
52
  `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
53
53
 
@@ -1790,7 +1790,7 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1790
1790
  const searchHooks = new Set()
1791
1791
  const navigateHooks = new Set()
1792
1792
  for (const statement of sourceFile.statements) {
1793
- if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
1793
+ if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && statement.moduleSpecifier && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
1794
1794
  if (ts.isExportDeclaration(statement)) throw sourceNodeError(statement, sourceFile, "React Router exports are not supported; import Link directly where it renders")
1795
1795
  const clause = statement.importClause
1796
1796
  if (clause?.isTypeOnly) continue
@@ -2584,6 +2584,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2584
2584
  const listConditions = new WeakMap()
2585
2585
  const nestedLists = new WeakMap()
2586
2586
  const listEffectEntries = new WeakMap()
2587
+ const componentEffectEntries = new WeakMap()
2587
2588
  let usesBehavior = false
2588
2589
  let usesBinding = false
2589
2590
  let usesConditional = false
@@ -2592,6 +2593,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2592
2593
  let usesListItem = false
2593
2594
  let usesRowState = false
2594
2595
  let usesRowRef = false
2596
+ let usesComponentState = false
2597
+ let usesComponentId = false
2598
+ let usesComponentRef = false
2599
+ let usesComponentEffects = false
2595
2600
 
2596
2601
  const collect = node => {
2597
2602
  if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
@@ -2717,7 +2722,47 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2717
2722
  const fail = (node, message) => {
2718
2723
  throw sourceNodeError(node, sourceFile, message)
2719
2724
  }
2725
+ const resolveReactiveJsxExpression = (expression, owner, setters) => {
2726
+ const declarations = jsxLocalDeclarations.get(owner)
2727
+ if (!declarations) return expression
2728
+ const substitutions = new Map()
2729
+ const resolving = []
2730
+ const resolve = (name, reference) => {
2731
+ if (substitutions.has(name)) return
2732
+ const entries = declarations.get(name)
2733
+ if (!entries?.length) return
2734
+ if (jsxLocalsByFunction.get(owner)?.has(name)) return
2735
+ if (entries.length !== 1 || entries[0].node.parent?.parent?.parent !== owner?.body) return
2736
+ const cycle = resolving.indexOf(name)
2737
+ if (cycle >= 0) fail(reference, `Reactive JSX local cycle: ${[...resolving.slice(cycle), name].join(" -> ")}`)
2738
+ resolving.push(name)
2739
+ const initializer = entries[0].initializer
2740
+ const visit = node => {
2741
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !isShadowedByParameter(node, initializer) && declarations.has(node.text)) resolve(node.text, node)
2742
+ ts.forEachChild(node, visit)
2743
+ }
2744
+ visit(initializer)
2745
+ substitutions.set(name, substituteClone(initializer, substitutions, factory, context))
2746
+ resolving.pop()
2747
+ }
2748
+ const visit = node => {
2749
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression) && declarations.has(node.text)) resolve(node.text, node)
2750
+ ts.forEachChild(node, visit)
2751
+ }
2752
+ visit(expression)
2753
+ if (!substitutions.size) return expression
2754
+ const expanded = substituteClone(expression, substitutions, factory, context)
2755
+ ts.setParentRecursive(expanded, false)
2756
+ expanded.parent = expression.parent
2757
+ const usedStates = referencedStateNames(expanded, setters)
2758
+ if (!usedStates.size) return expression
2759
+ const captures = captureNames(expanded, expanded, setters)
2760
+ const allowedNames = new Set([...setters.values(), ...captures])
2761
+ collectionExpression(expanded, {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
2762
+ return expanded
2763
+ }
2720
2764
  const componentSpecializations = new WeakMap()
2765
+ const setterHookHelpers = new WeakMap()
2721
2766
  const expandedRowSpecializations = new WeakMap()
2722
2767
  const nestedRowSpecializations = new Map()
2723
2768
  const reducerComponentCalls = new WeakSet()
@@ -2856,9 +2901,56 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2856
2901
  collectReferences(component.body)
2857
2902
  if (references.length !== 1) fail(element, `Setter-callback prop ${JSON.stringify(prop)} must be used exactly once in the component`)
2858
2903
  }
2859
- const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail, "Setter-callback")
2860
- if (specialization.effects.length || specialization.hookDeclarations.length) fail(call, "Setter-callback components cannot declare hooks")
2904
+ const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail, "Setter-callback", false, true, new Set(settersForNode(call, settersByFunction).values()))
2905
+ if (specialization.hookDeclarations.length || specialization.effects.length) {
2906
+ const substitutions = new Map()
2907
+ const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2908
+ for (const attribute of attributes.properties) {
2909
+ if (!ts.isJsxAttribute(attribute) || !callbackProps.includes(attribute.name.text) || !attribute.initializer || !ts.isJsxExpression(attribute.initializer) || !ts.isIdentifier(attribute.initializer.expression)) continue
2910
+ const callback = functions.get(attribute.initializer.expression.text)
2911
+ if (callback) substitutions.set(attribute.initializer.expression.text, callback)
2912
+ }
2913
+ if (substitutions.size) {
2914
+ specialization.root = substituteClone(specialization.root, substitutions, factory, context)
2915
+ for (const effect of specialization.effects) effect.call = substituteClone(effect.call, substitutions, factory, context)
2916
+ }
2917
+ }
2861
2918
  if (imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, component.getSourceFile(), call, specialization.effects))
2919
+ if (specialization.hookDeclarations.length || specialization.effects.length) {
2920
+ const owner = nearestFunction(call)
2921
+ const name = `KSetterComponent${Math.max(0, call.pos)}`
2922
+ const effectStatements = specialization.effects.map(entry => {
2923
+ const effectCall = factory.updateCallExpression(entry.call, factory.createIdentifier("__kComponentUseEffect"), entry.call.typeArguments, entry.call.arguments)
2924
+ synthesizeTree(effectCall)
2925
+ const effectSource = entry.source.getSourceFile()
2926
+ componentEffectEntries.set(effectCall, { source: entry.source, sourceFile: effectSource, imports: clientImportBindings(effectSource, effectSource.fileName, sourceFiles) })
2927
+ return factory.createExpressionStatement(effectCall)
2928
+ })
2929
+ const helper = factory.createFunctionDeclaration(
2930
+ undefined,
2931
+ undefined,
2932
+ name,
2933
+ undefined,
2934
+ [],
2935
+ undefined,
2936
+ factory.createBlock([...specialization.hookDeclarations, ...effectStatements, factory.createReturnStatement(specialization.root)], true)
2937
+ )
2938
+ ts.setParentRecursive(helper, false)
2939
+ helper.parent = owner.body
2940
+ const helpers = setterHookHelpers.get(owner.body) ?? []
2941
+ helpers.push(helper)
2942
+ setterHookHelpers.set(owner.body, helpers)
2943
+ const setters = new Map(settersForNode(call, settersByFunction))
2944
+ for (const state of specialization.ordinaryStates) setters.set(state.setter, state.state)
2945
+ settersByFunction.set(helper, setters)
2946
+ usesComponentState ||= specialization.ordinaryStates.length > 0
2947
+ usesComponentId ||= specialization.usesComponentId
2948
+ usesComponentRef ||= specialization.ordinaryRefs.length > 0
2949
+ usesComponentEffects ||= specialization.effects.length > 0
2950
+ specialization.root = factory.createJsxSelfClosingElement(factory.createIdentifier(name), undefined, factory.createJsxAttributes([]))
2951
+ ts.setParentRecursive(specialization.root, false)
2952
+ specialization.root.parent = call.parent
2953
+ }
2862
2954
  componentSpecializations.set(call, specialization)
2863
2955
  }
2864
2956
  for (const [name, component] of components) {
@@ -3114,6 +3206,9 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3114
3206
  }
3115
3207
 
3116
3208
  const visitor = node => {
3209
+ if (ts.isBlock(node) && setterHookHelpers.has(node)) {
3210
+ return ts.visitEachChild(factory.updateBlock(node, [...setterHookHelpers.get(node), ...node.statements]), visitor, context)
3211
+ }
3117
3212
  if (specializedDeclarations.has(node)) return node
3118
3213
  if (componentSpecializations.has(node)) return ts.visitNode(componentSpecializations.get(node).root, visitor)
3119
3214
 
@@ -3139,9 +3234,11 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3139
3234
  }
3140
3235
 
3141
3236
  const listEffect = ts.isCallExpression(node) ? listEffectEntries.get(node) : undefined
3142
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (hasUseEffectImport && node.expression.text === "useEffect" || listEffect)) {
3237
+ const componentEffect = ts.isCallExpression(node) ? componentEffectEntries.get(node) : undefined
3238
+ const specializedEffect = listEffect ?? componentEffect
3239
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && (hasUseEffectImport && node.expression.text === "useEffect" || specializedEffect)) {
3143
3240
  const effectFail = (target, message) => {
3144
- if (listEffect) throw sourceNodeError(listEffect.source, listEffect.sourceFile, message)
3241
+ if (specializedEffect) throw sourceNodeError(specializedEffect.source, specializedEffect.sourceFile, message)
3145
3242
  fail(target, message)
3146
3243
  }
3147
3244
  if (node.arguments.length !== 2) effectFail(node, "useEffect() requires exactly a callback and literal dependency array")
@@ -3234,7 +3331,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3234
3331
  const invalidCleanup = returns.cleanups.find(cleanup => cleanup.parameters.length || cleanup.asteriskToken)
3235
3332
  if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
3236
3333
  if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
3237
- const callbackSource = listEffect?.sourceFile ?? sourceFile
3334
+ const callbackSource = specializedEffect?.sourceFile ?? sourceFile
3238
3335
  const callbackFile = callbackSource.fileName
3239
3336
  const workerStart = workerReferences.length
3240
3337
  let compiledCallback = dependencySubstitutions.size ? substituteClone(callback, dependencySubstitutions, factory, context) : callback
@@ -3248,7 +3345,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3248
3345
  } else {
3249
3346
  compiledCallback = rewriteEffectWorkers(compiledCallback, callbackFile, callbackSource, sourceFiles, workerReferences, factory, context)
3250
3347
  }
3251
- const descriptor = compileNativeCallback(compiledCallback, setters, reducersForNode(node, reducersByFunction), factory, effectHandlers, listEffect?.imports ?? importBindings, clientImports, "effect", dependencyItem, true, returns.cleanup)
3348
+ const descriptor = compileNativeCallback(compiledCallback, setters, reducersForNode(node, reducersByFunction), factory, effectHandlers, specializedEffect?.imports ?? importBindings, clientImports, "effect", dependencyItem, true, returns.cleanup)
3252
3349
  for (const reference of workerReferences.slice(workerStart)) Object.assign(reference, { module: handlerUrl, handler: descriptor.exportName })
3253
3350
  usesListItem ||= Boolean(itemDependencies.length && !listEffect)
3254
3351
  usesBehavior = true
@@ -3259,7 +3356,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3259
3356
  factory.createStringLiteral(descriptor.exportName),
3260
3357
  descriptor.states,
3261
3358
  descriptor.scope,
3262
- factory.createStringLiteral(listEffect ? sourceLocation(listEffect.source, listEffect.sourceFile) : sourceLocation(node, sourceFile)),
3359
+ factory.createStringLiteral(specializedEffect ? sourceLocation(specializedEffect.source, specializedEffect.sourceFile) : sourceLocation(node, sourceFile)),
3263
3360
  returns.cleanup ? factory.createTrue() : factory.createFalse(),
3264
3361
  factory.createArrayLiteralExpression(itemDependencies.map(field => factory.createStringLiteral(field))),
3265
3362
  hasDerivedDependency ? jsonExpression(dependencyExpressions, factory) : factory.createArrayLiteralExpression(),
@@ -3267,7 +3364,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3267
3364
  ])
3268
3365
  }
3269
3366
 
3270
- if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && ((node.initializer.expression.text === "useState" || node.initializer.expression.text === "__kRowUseState") && node.initializer.arguments.length === 1 || node.initializer.expression.text === "useReducer" && node.initializer.arguments.length === 2)) {
3367
+ if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && ((node.initializer.expression.text === "useState" || node.initializer.expression.text === "__kRowUseState" || node.initializer.expression.text === "__kComponentUseState") && node.initializer.arguments.length === 1 || node.initializer.expression.text === "useReducer" && node.initializer.arguments.length === 2)) {
3271
3368
  const stateElement = node.name.elements[0]
3272
3369
  if (!stateElement || !ts.isBindingElement(stateElement) || !ts.isIdentifier(stateElement.name)) return node
3273
3370
  const initializer = factory.updateCallExpression(node.initializer, node.initializer.expression, node.initializer.typeArguments, [
@@ -3331,18 +3428,20 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3331
3428
  if (compiled !== node.expression) return factory.updateJsxExpression(node, compiled)
3332
3429
  }
3333
3430
  const setters = settersForNode(node, settersByFunction)
3334
- const usedStates = referencedStateNames(node.expression, setters)
3335
- const captures = captureNames(node.expression, node.expression, setters)
3336
- if ((usedStates.size || captures.size) && !ts.isIdentifier(node.expression) && !containsJsx(node.expression)) {
3431
+ const expression = resolveReactiveJsxExpression(node.expression, nearestFunction(node), setters)
3432
+ const usedStates = referencedStateNames(expression, setters)
3433
+ const captures = captureNames(expression, expression, setters)
3434
+ if ((usedStates.size || captures.size) && !ts.isIdentifier(expression) && !containsJsx(expression)) {
3337
3435
  usesBehavior = true
3338
3436
  usesBinding = true
3339
- return factory.updateJsxExpression(node, compileReactiveBinding(node.expression, setters, factory, context, reactiveBindings, handlerUrl))
3437
+ return factory.updateJsxExpression(node, compileReactiveBinding(expression, setters, factory, context, reactiveBindings, handlerUrl))
3340
3438
  }
3341
3439
  }
3342
3440
 
3343
3441
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !isContextProviderValue(node, contexts) && !/^on/i.test(node.name.text) && !["key", "ref", "dangerouslysetinnerhtml"].includes(node.name.text.toLowerCase())) {
3344
- const expression = node.initializer.expression
3442
+ const sourceExpression = node.initializer.expression
3345
3443
  const setters = settersForNode(node, settersByFunction)
3444
+ const expression = resolveReactiveJsxExpression(sourceExpression, nearestFunction(node), setters)
3346
3445
  const usedStates = referencedStateNames(expression, setters)
3347
3446
  const captures = captureNames(expression, expression, setters)
3348
3447
  if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
@@ -3393,6 +3492,10 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
3393
3492
  if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
3394
3493
  if (usesRowState) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useState"), factory.createIdentifier("__kRowUseState")))
3395
3494
  if (usesRowRef) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useRef"), factory.createIdentifier("__kRowUseRef")))
3495
+ if (usesComponentState) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useState"), factory.createIdentifier("__kComponentUseState")))
3496
+ if (usesComponentId) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useId"), factory.createIdentifier("__kComponentUseId")))
3497
+ if (usesComponentRef) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useRef"), factory.createIdentifier("__kComponentUseRef")))
3498
+ if (usesComponentEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kComponentUseEffect")))
3396
3499
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
3397
3500
  const behaviorImport = factory.createImportDeclaration(
3398
3501
  undefined,
@@ -4029,7 +4132,7 @@ function expandSpecializedRest(root, returned, component, rest, entries, factory
4029
4132
  return factory.updateJsxElement(root, opening, root.children, root.closingElement)
4030
4133
  }
4031
4134
 
4032
- function specializeComponentCall(call, component, sourceFile, factory, context, fail, label = "Keyed list", allowComponentRoot = false) {
4135
+ function specializeComponentCall(call, component, sourceFile, factory, context, fail, label = "Keyed list", allowComponentRoot = false, ordinaryHooks = false, ordinaryStateNames = new Set()) {
4033
4136
  if (component.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || component.asteriskToken) fail(component, `${label} components must be synchronous`)
4034
4137
  if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component, `${label} components must use one destructured props parameter`)
4035
4138
  const callAttributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
@@ -4090,6 +4193,9 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
4090
4193
  const hookDeclarations = []
4091
4194
  const rowStates = []
4092
4195
  const rowRefs = []
4196
+ const ordinaryStates = []
4197
+ const ordinaryRefs = []
4198
+ let usesComponentId = false
4093
4199
  if (!ts.isBlock(component.body)) {
4094
4200
  returned = component.body
4095
4201
  } else {
@@ -4104,35 +4210,52 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
4104
4210
  if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, `${label} component locals must be single const declarations`)
4105
4211
  const declaration = statement.declarationList.declarations[0]
4106
4212
  if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState") {
4107
- if (declaration.initializer.arguments.length !== 1 || !isSerializableStateLiteral(declaration.initializer.arguments[0])) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "Keyed row useState() must use one directly serializable primitive, plain object, or array initial value; dynamic initializers are not supported")
4108
- if (!ts.isArrayBindingPattern(declaration.name) || declaration.name.elements.length !== 2 || declaration.name.elements.some(element => !element || !ts.isBindingElement(element) || !ts.isIdentifier(element.name) || element.initializer || element.dotDotDotToken)) throw sourceNodeError(declaration.name, component.getSourceFile(), "Keyed row useState() must use [state, setter] identifier destructuring")
4109
- const suffix = `${Math.max(0, call.pos)}_${rowStates.length}`
4110
- const state = `__kRowState${suffix}`
4111
- const setter = `__kRowSetter${suffix}`
4213
+ const hookLabel = ordinaryHooks ? "Setter-callback component" : "Keyed row"
4214
+ const initialArgument = declaration.initializer.arguments[0]
4215
+ const propReceiver = ordinaryHooks && initialArgument && ts.isCallExpression(initialArgument) && initialArgument.arguments.length === 0 && !initialArgument.questionDotToken && ts.isPropertyAccessExpression(initialArgument.expression) && !initialArgument.expression.questionDotToken && initialArgument.expression.name.text === "toString" && ts.isIdentifier(initialArgument.expression.expression) ? initialArgument.expression.expression : undefined
4216
+ const substitutedProp = propReceiver ? substitutions.get(propReceiver.text) : undefined
4217
+ const propStringInitializer = substitutedProp && ts.isIdentifier(unwrapExpression(substitutedProp)) && ordinaryStateNames.has(unwrapExpression(substitutedProp).text)
4218
+ if (declaration.initializer.arguments.length !== 1 || !isSerializableStateLiteral(initialArgument) && !propStringInitializer) throw sourceNodeError(declaration.initializer, component.getSourceFile(), `${hookLabel} useState() must use one directly serializable primitive, plain object, or array initial value${ordinaryHooks ? " or direct primitive state prop.toString()" : ""}; other dynamic initializers are not supported`)
4219
+ if (!ts.isArrayBindingPattern(declaration.name) || declaration.name.elements.length !== 2 || declaration.name.elements.some(element => !element || !ts.isBindingElement(element) || !ts.isIdentifier(element.name) || element.initializer || element.dotDotDotToken)) throw sourceNodeError(declaration.name, component.getSourceFile(), `${hookLabel} useState() must use [state, setter] identifier destructuring`)
4220
+ const suffix = `${Math.max(0, call.pos)}_${ordinaryHooks ? ordinaryStates.length : rowStates.length}`
4221
+ const state = ordinaryHooks ? `__kComponentState${suffix}` : `__kRowState${suffix}`
4222
+ const setter = ordinaryHooks ? `__kComponentSetter${suffix}` : `__kRowSetter${suffix}`
4112
4223
  substitutions.set(declaration.name.elements[0].name.text, factory.createIdentifier(state))
4113
4224
  substitutions.set(declaration.name.elements[1].name.text, factory.createIdentifier(setter))
4114
4225
  const binding = factory.createArrayBindingPattern([
4115
4226
  factory.createBindingElement(undefined, undefined, factory.createIdentifier(state)),
4116
4227
  factory.createBindingElement(undefined, undefined, factory.createIdentifier(setter))
4117
4228
  ])
4118
- const initialValue = cloneAst(declaration.initializer.arguments[0], factory, context)
4229
+ const initialValue = propStringInitializer ? substituteClone(initialArgument, substitutions, factory, context) : cloneAst(initialArgument, factory, context)
4119
4230
  synthesizeTree(initialValue)
4120
- const initializer = factory.createCallExpression(factory.createIdentifier("__kRowUseState"), undefined, [initialValue])
4231
+ const initializer = factory.createCallExpression(factory.createIdentifier(ordinaryHooks ? "__kComponentUseState" : "__kRowUseState"), undefined, [initialValue])
4121
4232
  hookDeclarations.push(factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(binding, undefined, undefined, initializer)], ts.NodeFlags.Const)))
4122
- rowStates.push({ state, setter })
4233
+ if (ordinaryHooks) ordinaryStates.push({ state, setter })
4234
+ else rowStates.push({ state, setter })
4123
4235
  continue
4124
4236
  }
4125
4237
  if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useRef") {
4126
- if (declaration.initializer.arguments.length !== 1 || declaration.initializer.arguments[0].kind !== ts.SyntaxKind.NullKeyword) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "Keyed row useRef() must use the direct initial value null")
4127
- if (!ts.isIdentifier(declaration.name)) throw sourceNodeError(declaration.name, component.getSourceFile(), "Keyed row useRef() must be assigned to one identifier")
4128
- const name = `__kRowRef${Math.max(0, call.pos)}_${rowRefs.length}`
4238
+ const hookLabel = ordinaryHooks ? "Setter-callback component" : "Keyed row"
4239
+ if (declaration.initializer.arguments.length !== 1 || declaration.initializer.arguments[0].kind !== ts.SyntaxKind.NullKeyword) throw sourceNodeError(declaration.initializer, component.getSourceFile(), `${hookLabel} useRef() must use the direct initial value null`)
4240
+ if (!ts.isIdentifier(declaration.name)) throw sourceNodeError(declaration.name, component.getSourceFile(), `${hookLabel} useRef() must be assigned to one identifier`)
4241
+ const refs = ordinaryHooks ? ordinaryRefs : rowRefs
4242
+ const name = `${ordinaryHooks ? "__kComponentRef" : "__kRowRef"}${Math.max(0, call.pos)}_${refs.length}`
4243
+ substitutions.set(declaration.name.text, factory.createIdentifier(name))
4244
+ const initializer = factory.createCallExpression(factory.createIdentifier(ordinaryHooks ? "__kComponentUseRef" : "__kRowUseRef"), declaration.initializer.typeArguments?.map(type => cloneAst(type, factory, context)), [factory.createNull()])
4245
+ hookDeclarations.push(factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(factory.createIdentifier(name), undefined, undefined, initializer)], ts.NodeFlags.Const)))
4246
+ refs.push({ name })
4247
+ continue
4248
+ }
4249
+ if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useId") {
4250
+ if (!ordinaryHooks) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "useId() is not supported in keyed row components")
4251
+ if (declaration.initializer.arguments.length || !ts.isIdentifier(declaration.name)) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "Setter-callback component useId() must initialize one top-level const identifier without arguments")
4252
+ const name = `__kComponentId${Math.max(0, call.pos)}_${hookDeclarations.length}`
4129
4253
  substitutions.set(declaration.name.text, factory.createIdentifier(name))
4130
- const initializer = factory.createCallExpression(factory.createIdentifier("__kRowUseRef"), declaration.initializer.typeArguments?.map(type => cloneAst(type, factory, context)), [factory.createNull()])
4254
+ const initializer = factory.createCallExpression(factory.createIdentifier("__kComponentUseId"), undefined, [])
4131
4255
  hookDeclarations.push(factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(factory.createIdentifier(name), undefined, undefined, initializer)], ts.NodeFlags.Const)))
4132
- rowRefs.push({ name })
4256
+ usesComponentId = true
4133
4257
  continue
4134
4258
  }
4135
- if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useId") throw sourceNodeError(declaration.initializer, component.getSourceFile(), "useId() is not supported in keyed row components")
4136
4259
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
4137
4260
  const calculation = substituteClone(declaration.initializer, substitutions, factory, context)
4138
4261
  calculations.push({ name: declaration.name.text, expression: calculation })
@@ -4143,12 +4266,12 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
4143
4266
  let unsupportedHook
4144
4267
  const findUnsupportedHook = node => {
4145
4268
  if (unsupportedHook) return
4146
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && ["useState", "useRef"].includes(node.expression.text)) unsupportedHook = node
4269
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && ["useState", "useRef", "useId"].includes(node.expression.text)) unsupportedHook = node
4147
4270
  ts.forEachChild(node, findUnsupportedHook)
4148
4271
  }
4149
4272
  findUnsupportedHook(returned)
4150
4273
  for (const calculation of calculations) findUnsupportedHook(calculation.expression)
4151
- if (unsupportedHook) throw sourceNodeError(unsupportedHook, component.getSourceFile(), `Keyed row ${unsupportedHook.expression.text}() must be one top-level const declaration`)
4274
+ if (unsupportedHook) throw sourceNodeError(unsupportedHook, component.getSourceFile(), `${ordinaryHooks ? "Setter-callback component" : "Keyed row"} ${unsupportedHook.expression.text}() must be one top-level const declaration`)
4152
4275
  let root = unwrapExpression(flattenForwardedComponentChildren(substituteClone(returned, substitutions, factory, context), factory, context))
4153
4276
  if (rest) root = expandSpecializedRest(root, returned, component, rest, restEntries, factory, context, fail, label)
4154
4277
  if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned, `${label} component must return one JSX element`)
@@ -4168,7 +4291,10 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
4168
4291
  effects,
4169
4292
  hookDeclarations,
4170
4293
  rowStates,
4171
- rowRefs
4294
+ rowRefs,
4295
+ ordinaryStates,
4296
+ ordinaryRefs,
4297
+ usesComponentId
4172
4298
  }
4173
4299
  }
4174
4300
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.26",
3
+ "version": "0.7.27",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",