@kudzujs/core 0.7.27 → 0.7.30

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.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).
13
+ **Latest release: 0.7.30 - Reactive number formatting.** Fixed-locale `Intl.NumberFormat` display chains now remain reactive, restoring comma-formatted controlled inputs without broadening arbitrary render calls. Read the [release notes](./RELEASES.md#0730---reactive-number-formatting) or open the [release page](https://kudzujs.cloud/releases/0.7.30).
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,73 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.30 - Reactive number formatting
4
+
5
+ Kudzu 0.7.30 supports fixed-locale `Intl.NumberFormat` display chains in reactive JSX locals, restoring conventional comma-formatted controlled inputs while preserving narrow render-call validation.
6
+
7
+ ### New in 0.7.30
8
+
9
+ - Reactive text and attributes accept `new Intl.NumberFormat("literal").format(Math.round(expression))` over supported state-derived primitive expressions.
10
+ - Initial HTML is formatted during the build and existing binding ESM reevaluates the same native expression after state changes.
11
+ - No collection selector opcode, shared formatting runtime, hydration, or browser component instance is added.
12
+ - Locale values must be direct string literals; dynamic locales and options remain unsupported.
13
+ - Optional calls, aliases, shadowed `Intl` or `Math`, arbitrary constructors, and other render calls retain source diagnostics.
14
+ - A migration-derived FIRE `CurrencyInput` restores `100,000` annual and `8,333` monthly display while preserving annualized state updates.
15
+ - The complete suite passes 123/123 tests, including emitted evaluator and invalid dynamic-locale coverage.
16
+
17
+ ### Boundary
18
+
19
+ This is display formatting only. The accepted expression requires one static locale and exactly `Math.round(expression)` as the format argument. Collection selectors and effect dependency expressions keep their existing pure expression language.
20
+
21
+ ### Upgrade
22
+
23
+ ```bash
24
+ npm install @kudzujs/core@^0.7.30
25
+ ```
26
+
27
+ ## 0.7.29 - Hermetic TypeScript checks
28
+
29
+ Kudzu 0.7.29 makes project and migration-fixture typechecks independent of unrelated ambient types and package declarations installed in ancestor directories.
30
+
31
+ ### New in 0.7.29
32
+
33
+ - The root TypeScript configuration declares an empty ambient `types` set instead of scanning every visible ancestor `node_modules/@types` directory.
34
+ - `create-kudzu` emits the same isolated TypeScript configuration for new projects.
35
+ - Migration fixture checks skip third-party declaration bodies while continuing to typecheck fixture TS and TSX against Kudzu's reduced declarations.
36
+ - A clean `npm ci` checkout nested below conflicting `@types/minimatch`, React, and React Router installations now passes `npm run check`.
37
+ - The complete suite continues to pass 122/122 tests.
38
+
39
+ ### Upgrade
40
+
41
+ ```bash
42
+ npm install @kudzujs/core@^0.7.29
43
+ ```
44
+
45
+ ## 0.7.28 - Nested component specialization
46
+
47
+ Kudzu 0.7.28 recursively specializes components rendered inside setter-adapter children, allowing conventional optional UI such as an imported hookful Tooltip to remain declarative without adding a browser component runtime.
48
+
49
+ ### New in 0.7.28
50
+
51
+ - Setter-adapter children may recursively render synchronous same-file or relative-imported components.
52
+ - Nested component props, defaults, JSX children, static assets, handlers, and relative TypeScript imports reuse the existing call-site specialization path.
53
+ - Nested `useState()`, `useId()`, `useRef()`, and supported effects join the outer generated owner on unconditional or statically truthy paths.
54
+ - Literal `&&` and ternary conditions fold before specialization, so omitted and false optional components allocate no hooks or browser capabilities.
55
+ - Recursive component cycles, namespace/package components, and unresolved component imports retain source diagnostics.
56
+ - Hookful nested components on dynamic paths fail instead of receiving incorrect unconditional ownership.
57
+ - Setter callbacks cannot cross a second component boundary.
58
+ - A migration-derived FIRE `AgeInput` now restores its imported hookful `Tooltip`, including generated ARIA identity and hover/focus state.
59
+ - The complete suite passes 122/122 tests with Chrome coverage for nested tooltip state, omitted optional UI, and existing parent/child lifecycle behavior.
60
+
61
+ ### Boundary
62
+
63
+ Nested hooks are accepted only when the nested component is unconditional or guarded by a statically truthy literal after outer prop substitution. Dynamic conditional hook ownership and forwarding the setter through another component remain unsupported. Components still erase to intrinsic HTML and route-specific capabilities; React is never emitted or executed.
64
+
65
+ ### Upgrade
66
+
67
+ ```bash
68
+ npm install @kudzujs/core@^0.7.28
69
+ ```
70
+
3
71
  ## 0.7.27 - Stateful component migration
4
72
 
5
73
  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.
@@ -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. 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.
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. Fixed-locale `new Intl.NumberFormat("literal").format(Math.round(expression))` display chains reuse that binding ESM, while dynamic locales and options remain unsupported. 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. 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.
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`. Same-file and relative-imported nested components recursively specialize away; nested hooks join the same generated owner only on unconditional or statically truthy paths. Dynamic nested hook paths, forwarding the setter through a second boundary, and 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
 
@@ -2722,6 +2722,24 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2722
2722
  const fail = (node, message) => {
2723
2723
  throw sourceNodeError(node, sourceFile, message)
2724
2724
  }
2725
+ const validateReactiveJsxExpression = (expression, allowedNames) => {
2726
+ const value = unwrapExpression(expression)
2727
+ const formatAccess = ts.isCallExpression(value) && !value.questionDotToken && ts.isPropertyAccessExpression(value.expression) && !value.expression.questionDotToken && value.expression.name.text === "format" ? value.expression : undefined
2728
+ const formatter = formatAccess && unwrapExpression(formatAccess.expression)
2729
+ const constructor = formatter && ts.isNewExpression(formatter) && ts.isPropertyAccessExpression(formatter.expression) && formatter.expression.name.text === "NumberFormat" && ts.isIdentifier(formatter.expression.expression) && formatter.expression.expression.text === "Intl" ? formatter : undefined
2730
+ if (!constructor) {
2731
+ collectionExpression(value, {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
2732
+ return
2733
+ }
2734
+ const intl = constructor.expression.expression
2735
+ if (!isUnshadowedGlobal(intl, sourceFile)) fail(intl, "Reactive JSX Intl.NumberFormat requires the unshadowed global Intl object")
2736
+ if (constructor.arguments?.length !== 1 || !ts.isStringLiteral(constructor.arguments[0])) fail(constructor, "Reactive JSX Intl.NumberFormat requires exactly one static string locale")
2737
+ const rounded = value.arguments.length === 1 ? unwrapExpression(value.arguments[0]) : undefined
2738
+ const roundAccess = rounded && ts.isCallExpression(rounded) && !rounded.questionDotToken && rounded.arguments.length === 1 && ts.isPropertyAccessExpression(rounded.expression) && !rounded.expression.questionDotToken && rounded.expression.name.text === "round" && ts.isIdentifier(rounded.expression.expression) && rounded.expression.expression.text === "Math" ? rounded.expression : undefined
2739
+ if (!roundAccess) fail(value, "Reactive JSX Intl.NumberFormat format() requires exactly Math.round(expression)")
2740
+ if (!isUnshadowedGlobal(roundAccess.expression, sourceFile)) fail(roundAccess.expression, "Reactive JSX Intl.NumberFormat requires the unshadowed global Math object")
2741
+ collectionExpression(rounded.arguments[0], {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
2742
+ }
2725
2743
  const resolveReactiveJsxExpression = (expression, owner, setters) => {
2726
2744
  const declarations = jsxLocalDeclarations.get(owner)
2727
2745
  if (!declarations) return expression
@@ -2758,7 +2776,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2758
2776
  if (!usedStates.size) return expression
2759
2777
  const captures = captureNames(expanded, expanded, setters)
2760
2778
  const allowedNames = new Set([...setters.values(), ...captures])
2761
- collectionExpression(expanded, {}, (node, message) => fail(node, message.replace("Rendered collection", "Reactive JSX local")), allowedNames)
2779
+ validateReactiveJsxExpression(expanded, allowedNames)
2762
2780
  return expanded
2763
2781
  }
2764
2782
  const componentSpecializations = new WeakMap()
@@ -2851,6 +2869,89 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2851
2869
  expanded.parent = root.parent
2852
2870
  return expanded
2853
2871
  }
2872
+ const staticConditionValue = expression => {
2873
+ const value = unwrapExpression(expression)
2874
+ if (value.kind === ts.SyntaxKind.TrueKeyword) return true
2875
+ if (value.kind === ts.SyntaxKind.FalseKeyword || value.kind === ts.SyntaxKind.NullKeyword || ts.isIdentifier(value) && value.text === "undefined") return false
2876
+ if (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) return Boolean(value.text)
2877
+ if (ts.isNumericLiteral(value)) return Number(value.text) !== 0
2878
+ return undefined
2879
+ }
2880
+ const foldSetterStaticConditions = root => {
2881
+ const visit = node => {
2882
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
2883
+ const condition = staticConditionValue(node.left)
2884
+ if (condition !== undefined) return condition ? ts.visitNode(node.right, visit) : node.left
2885
+ }
2886
+ if (ts.isConditionalExpression(node)) {
2887
+ const condition = staticConditionValue(node.condition)
2888
+ if (condition !== undefined) return ts.visitNode(condition ? node.whenTrue : node.whenFalse, visit)
2889
+ }
2890
+ return ts.visitEachChild(node, visit, context)
2891
+ }
2892
+ const folded = ts.visitNode(root, visit)
2893
+ ts.setParentRecursive(folded, false)
2894
+ folded.parent = root.parent
2895
+ return folded
2896
+ }
2897
+ const expandSetterComponents = (root, componentSource, trail, aggregate, parentSetters) => {
2898
+ root = foldSetterStaticConditions(root)
2899
+ const replacements = new WeakMap()
2900
+ let count = 0
2901
+ const visit = (node, dynamic = false) => {
2902
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) {
2903
+ visit(node.left, dynamic)
2904
+ visit(node.right, true)
2905
+ return
2906
+ }
2907
+ if (ts.isConditionalExpression(node)) {
2908
+ visit(node.condition, dynamic)
2909
+ visit(node.whenTrue, true)
2910
+ visit(node.whenFalse, true)
2911
+ return
2912
+ }
2913
+ const tag = jsxTagName(node)
2914
+ if (tag && (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase())) {
2915
+ if (!ts.isIdentifier(tag)) fail(node, "Nested setter-callback components must use identifier JSX tags")
2916
+ const name = tag.text
2917
+ let component = localComponentDeclaration(componentSource, name)
2918
+ let imported = false
2919
+ if (!component) {
2920
+ const binding = clientImportBindings(componentSource, componentSource.fileName, sourceFiles).get(name)
2921
+ if (!binding || binding.kind === "namespace") fail(node, `Nested setter-callback component ${name} must be declared locally or imported from a relative TypeScript module`)
2922
+ component = resolveComponentExport(binding.target, binding.kind === "default" ? "default" : binding.imported, importedSource, sourceFiles)
2923
+ imported = true
2924
+ }
2925
+ if (trail.includes(component)) {
2926
+ const chain = [...trail, component].map(entry => entry.name?.text || "anonymous").join(" -> ")
2927
+ fail(node, `Nested setter-callback component cycle: ${chain}`)
2928
+ }
2929
+ const setters = new Map(parentSetters)
2930
+ for (const state of aggregate.ordinaryStates) setters.set(state.setter, state.state)
2931
+ if (jsxSetterCallbackProps(node, setters, functions, reducersForNode(node, reducersByFunction)).length) fail(node, "Setter callbacks cannot cross a second component boundary")
2932
+ const nested = specializeComponentCall(node, component, sourceFile, factory, context, fail, "Nested setter-callback", true, true, new Set(setters.values()))
2933
+ if (dynamic && (nested.hookDeclarations.length || nested.effects.length)) fail(node, "Hookful nested setter-callback components require an unconditional or statically truthy render path")
2934
+ nested.root = expandSetterComponents(nested.root, component.getSourceFile(), [...trail, component], nested, setters)
2935
+ if (imported) synthesizeTree(nested.root = mergeSpecializedImports(nested.root, component.getSourceFile(), node, nested.effects))
2936
+ aggregate.calculations.push(...nested.calculations)
2937
+ aggregate.effects.push(...nested.effects)
2938
+ aggregate.hookDeclarations.push(...nested.hookDeclarations)
2939
+ aggregate.ordinaryStates.push(...nested.ordinaryStates)
2940
+ aggregate.ordinaryRefs.push(...nested.ordinaryRefs)
2941
+ aggregate.usesComponentId ||= nested.usesComponentId
2942
+ replacements.set(node, nested.root)
2943
+ count++
2944
+ return
2945
+ }
2946
+ ts.forEachChild(node, child => visit(child, dynamic))
2947
+ }
2948
+ visit(root)
2949
+ if (!count) return root
2950
+ const expanded = replaceSpecializedCalls(root, replacements, context)
2951
+ ts.setParentRecursive(expanded, false)
2952
+ expanded.parent = root.parent
2953
+ return expanded
2954
+ }
2854
2955
  for (const [name, component] of components) {
2855
2956
  const calls = jsxTagUses(sourceFile, name)
2856
2957
  const stateBackedCalls = calls.filter(call => isStateBackedListComponentCall(call, component.function, settersByFunction.get(nearestFunction(call)) ?? new Map()))
@@ -2915,6 +3016,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2915
3016
  for (const effect of specialization.effects) effect.call = substituteClone(effect.call, substitutions, factory, context)
2916
3017
  }
2917
3018
  }
3019
+ specialization.root = expandSetterComponents(specialization.root, component.getSourceFile(), [component], specialization, settersForNode(call, settersByFunction))
2918
3020
  if (imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, component.getSourceFile(), call, specialization.effects))
2919
3021
  if (specialization.hookDeclarations.length || specialization.effects.length) {
2920
3022
  const owner = nearestFunction(call)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.27",
3
+ "version": "0.7.30",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",