@kudzujs/core 0.7.1 → 0.7.2

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.1:** Vite-style landing assets. Common CSS, CSS Module, image, SVG, font, and `?url` imports now compile to static output without React, a VDOM, or hydration. See [release notes](./RELEASES.md#071---vite-style-landing-assets).
13
+ **0.7.2:** React hook normalization. Aliased hooks, direct members such as `React.useState`, and inline `useCallback` now compile to existing Kudzu capabilities without React, a VDOM, or hydration. See [release notes](./RELEASES.md#072---react-hook-normalization).
14
14
 
15
15
  Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
16
16
 
@@ -74,7 +74,7 @@ export default function Header() {
74
74
  }
75
75
  ```
76
76
 
77
- Kudzu rewrites supported React imports to its compile-time APIs before evaluating the module; neither the React package nor a compatibility runtime enters the deploy output. Named `useState`, `useReducer`, `useEffect`, `useRef`, `createContext`, and `useContext` imports must retain those names. Default and namespace React imports currently support `Fragment`; named `Fragment` also works. Aliased hooks, `React.useState`, `memo`, `useMemo`, `useCallback`, React classes, and side-effect or dynamic React imports remain unsupported. A static route using these import forms still emits zero JavaScript.
77
+ Kudzu rewrites supported React imports to its compile-time APIs before evaluating the module; neither the React package nor a compatibility runtime enters the deploy output. Named or aliased `useState`, `useReducer`, `useEffect`, `useRef`, `createContext`, and `useContext` imports compile to their canonical forms. Default and namespace imports may call those APIs as direct members such as `React.useState`, and default, namespace, or named `Fragment` also works. Inline `useCallback(function, literalDependencies)` is erased to its function because Kudzu does not retain or rerender a browser component. `memo`, `useMemo`, React classes, and side-effect or dynamic React imports remain unsupported. A static route using these import forms still emits zero JavaScript.
78
78
 
79
79
  Create `src/pages/index.tsx`:
80
80
 
@@ -225,7 +225,7 @@ const add = (title: string) => dispatch({ type: "add", title })
225
225
  return <Input onSubmit={add} />
226
226
  ```
227
227
 
228
- Kudzu substitutes the callback into the child's compiled event handler at build time. This is not general function-prop serialization: only one nested specialized callback boundary is supported, and `useCallback`, further forwarding, effects, component roots, and callback use outside event handlers are rejected.
228
+ Kudzu substitutes the callback into the child's compiled event handler at build time. An inline React `useCallback` wrapper is erased before this analysis. This is not general function-prop serialization: only one nested specialized callback boundary is supported, and further forwarding, effects, component roots, and callback use outside event handlers are rejected.
229
229
 
230
230
  Reducer dispatch and callback components may use destructured string, finite-number, boolean, or `null` defaults. A missing prop is replaced during specialization; object, array, computed, and function-call defaults remain unsupported:
231
231
 
package/RELEASES.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.2 - React hook normalization
4
+
5
+ Kudzu 0.7.2 accepts more ordinary React/Vite hook syntax while compiling through the existing static HTML and direct DOM capability paths.
6
+
7
+ ### New in 0.7.2
8
+
9
+ - Supported hooks imported from `react` may retain aliases such as `useState as useMenuState` and `useEffect as runEffect`.
10
+ - Default and namespace imports may use direct supported members such as `React.useState(...)`.
11
+ - Inline React `useCallback` wrappers with literal inert dependencies are erased to their callback before Kudzu specialization.
12
+ - Captured local state must appear in the callback dependency array, preserving an actionable boundary around stale React closures.
13
+ - Unsupported members, indirect hook references, computed React members, and effectful dependency expressions fail with source locations.
14
+ - Type-only React namespaces remain available to TypeScript and do not trigger runtime migration diagnostics.
15
+ - A React/Vite-shaped app fixture verifies CSS, SVG assets, a non-root base, aliased state/effects, member state, repeated callback updates, browser interaction, and a zero-JavaScript static route.
16
+
17
+ ### Boundary
18
+
19
+ `useCallback` requires an inline function and a literal array containing only identifiers or primitive literals. `memo`, `useMemo`, React classes, indirect hook references, side-effect React imports, and dynamic React imports remain unsupported. React, a VDOM, hydration, and a retained browser component tree are never emitted.
20
+
21
+ ### Upgrade
22
+
23
+ ```bash
24
+ npm install @kudzujs/core@^0.7.2
25
+ ```
26
+
3
27
  ## 0.7.1 - Vite-style landing assets
4
28
 
5
29
  Kudzu 0.7.1 lets ordinary React/Vite landing-page source retain its common local stylesheet and static asset imports while preserving static HTML output and capability-only JavaScript.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Kudzu specializes ordinary common React-shaped TSX so migrations need minimal source restructuring. Declarative components, collection pipelines, conditions, hooks, and handlers should be lowered at build time rather than replaced with application-owned imperative DOM code. This principle applies across migrations and is not Stay-specific; it does not imply a React package, VDOM, hydration, or ecosystem runtime.
4
4
 
5
- Migration source may retain conventional `react` imports for unaliased supported named hooks and default, namespace, or named `Fragment`. `build.mjs` rewrites those module references to `@kudzujs/core` before build-time evaluation. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports. Member hook calls and aliased hooks are deliberately not inferred yet.
5
+ Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, inline `useCallback`, and default, namespace, or named `Fragment`. `build.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. `useCallback` is erased to its function because no browser component rerender occurs. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
6
6
 
7
7
  - `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
8
  - `core.mjs`: server-side JSX rendering, state slots, context providers, behavior metadata, and serializable capture validation.
@@ -1744,10 +1744,133 @@ function hasReactModuleReference(source, file) {
1744
1744
  return found
1745
1745
  }
1746
1746
 
1747
+ function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1748
+ const supported = new Set(["createContext", "useContext", "useEffect", "useReducer", "useRef", "useState"])
1749
+ const aliases = new Map()
1750
+ const reactObjects = new Set()
1751
+ for (const statement of sourceFile.statements) {
1752
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "react") continue
1753
+ if (statement.importClause?.name) reactObjects.add(statement.importClause.name.text)
1754
+ const bindings = statement.importClause?.namedBindings
1755
+ if (bindings && ts.isNamespaceImport(bindings)) reactObjects.add(bindings.name.text)
1756
+ if (bindings && ts.isNamedImports(bindings)) for (const entry of bindings.elements) {
1757
+ const imported = (entry.propertyName ?? entry.name).text
1758
+ if (!entry.isTypeOnly && (supported.has(imported) || imported === "useCallback")) aliases.set(entry.name.text, imported)
1759
+ else if (!entry.isTypeOnly && (imported === "memo" || /^use[A-Z]/.test(imported))) throw sourceNodeError(entry, sourceFile, `React ${imported} is not supported by Kudzu migration input`)
1760
+ }
1761
+ }
1762
+ if (!aliases.size && !reactObjects.size) return sourceFile
1763
+
1764
+ const migrationCallName = call => {
1765
+ if (ts.isIdentifier(call.expression) && aliases.has(call.expression.text) && !isShadowedIdentifier(call.expression, sourceFile)) return aliases.get(call.expression.text)
1766
+ if (ts.isPropertyAccessExpression(call.expression) && ts.isIdentifier(call.expression.expression) && reactObjects.has(call.expression.expression.text) && !isShadowedIdentifier(call.expression.expression, sourceFile)) return call.expression.name.text
1767
+ return undefined
1768
+ }
1769
+ const ownerStateNames = owner => {
1770
+ const names = new Set()
1771
+ const collect = node => {
1772
+ if (node !== owner && isFunctionLike(node)) return
1773
+ if (ts.isVariableDeclaration(node) && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ["useReducer", "useState"].includes(migrationCallName(node.initializer))) {
1774
+ const state = node.name.elements[0]
1775
+ if (state && ts.isBindingElement(state) && ts.isIdentifier(state.name)) names.add(state.name.text)
1776
+ }
1777
+ ts.forEachChild(node, collect)
1778
+ }
1779
+ collect(owner)
1780
+ return names
1781
+ }
1782
+
1783
+ const validate = node => {
1784
+ if (ts.isTypeNode(node)) return
1785
+ if (ts.isIdentifier(node) && aliases.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile) && !(ts.isCallExpression(node.parent) && node.parent.expression === node)) throw sourceNodeError(node, sourceFile, `Aliased React ${aliases.get(node.text)} must be called directly`)
1786
+ if (ts.isIdentifier(node) && reactObjects.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile) && !(ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node)) throw sourceNodeError(node, sourceFile, "React default or namespace imports may only be used for direct supported members or React.Fragment")
1787
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && reactObjects.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
1788
+ const name = node.name.text
1789
+ if (name !== "Fragment" && !(ts.isCallExpression(node.parent) && node.parent.expression === node && (supported.has(name) || name === "useCallback"))) throw sourceNodeError(node, sourceFile, `React.${name} is not supported; use a directly supported hook call or React.Fragment`)
1790
+ }
1791
+ ts.forEachChild(node, validate)
1792
+ }
1793
+ validate(sourceFile)
1794
+
1795
+ const required = new Set()
1796
+ const imported = new Set()
1797
+ const visitor = node => {
1798
+ if (ts.isCallExpression(node)) {
1799
+ const name = migrationCallName(node)
1800
+ if (name === "useCallback") {
1801
+ if (node.arguments.length !== 2 || !ts.isArrayLiteralExpression(node.arguments[1]) || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React useCallback() requires an inline function and a literal dependency array")
1802
+ const dependency = node.arguments[1].elements.find(entry => !isReactCallbackDependency(entry))
1803
+ if (dependency) throw sourceNodeError(dependency, sourceFile, "React useCallback() dependencies must be identifiers or primitive literals")
1804
+ const dependencies = new Set(node.arguments[1].elements.map(unwrapExpression).filter(ts.isIdentifier).map(entry => entry.text))
1805
+ const owner = nearestFunction(node)
1806
+ const stale = owner && [...ownerStateNames(owner)].find(state => referenceIdentifiers(node.arguments[0], state).length && !dependencies.has(state))
1807
+ if (stale) throw sourceNodeError(node.arguments[1], sourceFile, `React useCallback() must list captured state ${JSON.stringify(stale)} as a dependency`)
1808
+ return ts.visitNode(node.arguments[0], visitor)
1809
+ }
1810
+ if (name && supported.has(name)) {
1811
+ required.add(name)
1812
+ return factory.updateCallExpression(node, factory.createIdentifier(name), node.typeArguments, ts.visitNodes(node.arguments, visitor))
1813
+ }
1814
+ }
1815
+ if (ts.isImportDeclaration(node) && !node.importClause?.isTypeOnly && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react") {
1816
+ const clause = node.importClause
1817
+ if (!clause) return node
1818
+ let bindings = clause.namedBindings
1819
+ if (bindings && ts.isNamedImports(bindings)) {
1820
+ const entries = []
1821
+ for (const entry of bindings.elements) {
1822
+ const name = (entry.propertyName ?? entry.name).text
1823
+ if (!entry.isTypeOnly && name === "useCallback") continue
1824
+ if (!entry.isTypeOnly && supported.has(name)) {
1825
+ if (imported.has(name)) continue
1826
+ imported.add(name)
1827
+ required.add(name)
1828
+ entries.push(factory.createImportSpecifier(false, undefined, factory.createIdentifier(name)))
1829
+ } else {
1830
+ entries.push(entry)
1831
+ }
1832
+ }
1833
+ bindings = entries.length ? factory.updateNamedImports(bindings, entries) : undefined
1834
+ }
1835
+ if (!clause.name && !bindings) return undefined
1836
+ return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, clause.name, bindings), node.moduleSpecifier, node.attributes)
1837
+ }
1838
+ return ts.visitEachChild(node, visitor, context)
1839
+ }
1840
+ let normalized = ts.visitNode(sourceFile, visitor)
1841
+ const missing = [...required].filter(name => !imported.has(name)).sort()
1842
+ if (!missing.length) return normalized
1843
+ for (const name of missing) {
1844
+ const collision = sourceFile.statements.some(statement => statementDeclaresName(statement, name) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name) && statement.moduleSpecifier.text !== "react")
1845
+ if (collision) throw sourceNodeError(sourceFile, sourceFile, `React.${name} cannot be normalized because ${JSON.stringify(name)} is already declared`)
1846
+ }
1847
+ const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports(missing.map(name => factory.createImportSpecifier(false, undefined, factory.createIdentifier(name))))), factory.createStringLiteral("react"))
1848
+ const statements = [...normalized.statements]
1849
+ const lastImport = statements.findLastIndex(statement => ts.isImportDeclaration(statement))
1850
+ statements.splice(lastImport + 1, 0, declaration)
1851
+ normalized = factory.updateSourceFile(normalized, statements)
1852
+ return normalized
1853
+ }
1854
+
1855
+ function importDeclarationNames(statement) {
1856
+ const names = []
1857
+ if (statement.importClause?.name) names.push(statement.importClause.name.text)
1858
+ const bindings = statement.importClause?.namedBindings
1859
+ if (bindings && ts.isNamespaceImport(bindings)) names.push(bindings.name.text)
1860
+ if (bindings && ts.isNamedImports(bindings)) for (const entry of bindings.elements) names.push(entry.name.text)
1861
+ return names
1862
+ }
1863
+
1864
+ function isReactCallbackDependency(node) {
1865
+ node = unwrapExpression(node)
1866
+ return ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword
1867
+ }
1868
+
1747
1869
  function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base, clientImports, workerReferences) {
1748
1870
  return context => sourceFile => {
1749
1871
  const factory = context.factory
1750
1872
  const hasLinkElements = /<link/i.test(sourceFile.text)
1873
+ sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context)
1751
1874
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
1752
1875
  ts.setParentRecursive(sourceFile, false)
1753
1876
  rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles)
@@ -1757,7 +1880,8 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1757
1880
  const importedSource = target => {
1758
1881
  let imported = importedSources.get(target)
1759
1882
  if (!imported) {
1760
- imported = normalizeRenderControlFlow(parseSourceFile(target, sourceIndex.get(target)), factory, context)
1883
+ imported = normalizeReactMigrationSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context)
1884
+ imported = normalizeRenderControlFlow(imported, factory, context)
1761
1885
  ts.setParentRecursive(imported, false)
1762
1886
  importedSources.set(target, imported)
1763
1887
  }
@@ -3616,6 +3740,7 @@ function isShadowedIdentifier(node, scopeRoot) {
3616
3740
  if (isFunctionLike(scopeRoot) && functionVarDeclaresName(scopeRoot, node.text)) return true
3617
3741
  for (let current = node.parent; current; current = current.parent) {
3618
3742
  if (current === scopeRoot) break
3743
+ if (ts.isFunctionExpression(current) && current.name?.text === node.text) return true
3619
3744
  if (ts.isBlock(current) && current.statements.some(statement => statementDeclaresName(statement, node.text))) return true
3620
3745
  if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statementDeclaresName(statement, node.text)))) return true
3621
3746
  if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(node.text)) return true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.1",
3
+ "version": "0.7.2",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",