@kudzujs/core 0.7.2 → 0.7.3

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.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).
13
+ **0.7.3:** React memo normalization. Same-file `memo`, inline `useCallback`, and direct-state expression `useMemo` now compile to existing Kudzu capabilities without React, a VDOM, or hydration. See [release notes](./RELEASES.md#073---react-memo-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 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.
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 returning one expression over primitive literals and direct local state are inlined at same-component uses so existing bindings track that state. Both hooks require inert literal dependency arrays and complete captured-state dependencies; memo locals cannot be duplicated or captured by nested functions. React classes and side-effect or dynamic React imports remain unsupported. A static route using these forms still emits zero JavaScript.
78
78
 
79
79
  Create `src/pages/index.tsx`:
80
80
 
package/RELEASES.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.3 - React memo normalization
4
+
5
+ Kudzu 0.7.3 accepts common React memo authoring forms while preserving build-time components, direct state bindings, static HTML, and capability-only JavaScript.
6
+
7
+ ### New in 0.7.3
8
+
9
+ - `memo(Component)` and aliased or direct-member equivalents lower to same-file build-time function components.
10
+ - Inline `useCallback` wrappers continue to lower directly to analyzable handler functions without a browser memo cache.
11
+ - Inline synchronous `useMemo` callbacks may return one expression over primitive literals and direct local state.
12
+ - Memoized state expressions are inlined at same-component JSX uses so existing bindings update them without component rerenders.
13
+ - Static pages wrapped in `memo` remain JavaScript-free.
14
+ - Component shadowing, impure expressions, incomplete state dependencies, duplicate memo locals, and nested memo-local captures fail with source locations.
15
+ - The React/Vite app fixture verifies repeated counter updates, derived `Double 2`/`Double 4` output, CSS and SVG assets, mount effects, and a zero-JavaScript static route.
16
+
17
+ ### Boundary
18
+
19
+ `memo` identifiers must name unshadowed same-file top-level function components. `useMemo` locals must use unique `const` declarations, may reference only direct local state and primitive literals, and cannot cross nested function boundaries. No browser component cache, React runtime, VDOM, hydration, or retained component tree is emitted.
20
+
21
+ ### Upgrade
22
+
23
+ ```bash
24
+ npm install @kudzujs/core@^0.7.3
25
+ ```
26
+
3
27
  ## 0.7.2 - React hook normalization
4
28
 
5
29
  Kudzu 0.7.2 accepts more ordinary React/Vite hook syntax while compiling through the existing static HTML and direct DOM capability paths.
@@ -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 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.
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 `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 because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
6
6
 
7
7
  - `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.
@@ -1746,6 +1746,7 @@ function hasReactModuleReference(source, file) {
1746
1746
 
1747
1747
  function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1748
1748
  const supported = new Set(["createContext", "useContext", "useEffect", "useReducer", "useRef", "useState"])
1749
+ const erased = new Set(["memo", "useCallback", "useMemo"])
1749
1750
  const aliases = new Map()
1750
1751
  const reactObjects = new Set()
1751
1752
  for (const statement of sourceFile.statements) {
@@ -1755,8 +1756,8 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1755
1756
  if (bindings && ts.isNamespaceImport(bindings)) reactObjects.add(bindings.name.text)
1756
1757
  if (bindings && ts.isNamedImports(bindings)) for (const entry of bindings.elements) {
1757
1758
  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`)
1759
+ if (!entry.isTypeOnly && (supported.has(imported) || erased.has(imported))) aliases.set(entry.name.text, imported)
1760
+ else if (!entry.isTypeOnly && /^use[A-Z]/.test(imported)) throw sourceNodeError(entry, sourceFile, `React ${imported} is not supported by Kudzu migration input`)
1760
1761
  }
1761
1762
  }
1762
1763
  if (!aliases.size && !reactObjects.size) return sourceFile
@@ -1786,17 +1787,68 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1786
1787
  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
1788
  if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.expression) && reactObjects.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
1788
1789
  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
+ if (name !== "Fragment" && !(ts.isCallExpression(node.parent) && node.parent.expression === node && (supported.has(name) || erased.has(name)))) throw sourceNodeError(node, sourceFile, `React.${name} is not supported; use a directly supported hook call or React.Fragment`)
1790
1791
  }
1791
1792
  ts.forEachChild(node, validate)
1792
1793
  }
1793
1794
  validate(sourceFile)
1794
1795
 
1796
+ const memoLocals = new Map()
1797
+ const collectMemoLocals = node => {
1798
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isCallExpression(node.initializer) && migrationCallName(node.initializer) === "useMemo") {
1799
+ if (!isLocalConst(node)) throw sourceNodeError(node, sourceFile, "React useMemo() local values must use const declarations")
1800
+ const callback = node.initializer.arguments[0]
1801
+ if (callback && (ts.isArrowFunction(callback) || ts.isFunctionExpression(callback))) {
1802
+ const expression = reactMemoExpression(callback)
1803
+ const owner = nearestFunction(node)
1804
+ if (owner && expression) {
1805
+ const entries = memoLocals.get(owner) ?? new Map()
1806
+ if (entries.has(node.name.text)) throw sourceNodeError(node.name, sourceFile, `React useMemo() local ${JSON.stringify(node.name.text)} must be unique within its component`)
1807
+ entries.set(node.name.text, { declaration: node, expression })
1808
+ memoLocals.set(owner, entries)
1809
+ }
1810
+ }
1811
+ }
1812
+ ts.forEachChild(node, collectMemoLocals)
1813
+ }
1814
+ collectMemoLocals(sourceFile)
1815
+ const memoLocalIsShadowed = (node, owner, entry) => {
1816
+ if (isShadowedByParameter(node, owner)) return true
1817
+ const declarationStatement = entry.declaration.parent?.parent
1818
+ for (let current = node.parent; current && current !== owner; current = current.parent) {
1819
+ if (ts.isFunctionExpression(current) && current.name?.text === node.text) return true
1820
+ if (ts.isBlock(current) && current.statements.some(statement => statement !== declarationStatement && statementDeclaresName(statement, node.text))) return true
1821
+ if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statement !== declarationStatement && statementDeclaresName(statement, node.text)))) return true
1822
+ if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(node.text)) return true
1823
+ if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, node.text)) return true
1824
+ }
1825
+ return false
1826
+ }
1827
+ for (const [owner, entries] of memoLocals) for (const [name, entry] of entries) {
1828
+ const visit = node => {
1829
+ if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node) && nearestFunctionLike(node) !== owner && !memoLocalIsShadowed(node, owner, entry)) throw sourceNodeError(node, sourceFile, `React useMemo() local ${JSON.stringify(name)} cannot be captured by a nested function`)
1830
+ ts.forEachChild(node, visit)
1831
+ }
1832
+ visit(owner.body)
1833
+ }
1834
+
1795
1835
  const required = new Set()
1796
1836
  const imported = new Set()
1797
1837
  const visitor = node => {
1838
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node)) {
1839
+ const owner = nearestFunctionLike(node)
1840
+ const entry = memoLocals.get(owner)?.get(node.text)
1841
+ if (entry && !memoLocalIsShadowed(node, owner, entry)) return ts.visitNode(cloneAst(entry.expression, factory, context), visitor)
1842
+ }
1798
1843
  if (ts.isCallExpression(node)) {
1799
1844
  const name = migrationCallName(node)
1845
+ if (name === "memo") {
1846
+ if (node.arguments.length !== 1 || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]) || ts.isIdentifier(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React memo() requires exactly one function component or component identifier")
1847
+ if (ts.isIdentifier(node.arguments[0]) && isShadowedIdentifier(node.arguments[0], sourceFile)) throw sourceNodeError(node.arguments[0], sourceFile, "React memo() component identifiers must resolve to an unshadowed same-file top-level function")
1848
+ const component = ts.isIdentifier(node.arguments[0]) ? reactMemoComponentExpression(node.arguments[0], sourceFile, factory, context) : node.arguments[0]
1849
+ if (!component) throw sourceNodeError(node.arguments[0], sourceFile, "React memo() identifiers must name a same-file top-level function component")
1850
+ return ts.visitNode(component, visitor)
1851
+ }
1800
1852
  if (name === "useCallback") {
1801
1853
  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
1854
  const dependency = node.arguments[1].elements.find(entry => !isReactCallbackDependency(entry))
@@ -1807,6 +1859,23 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1807
1859
  if (stale) throw sourceNodeError(node.arguments[1], sourceFile, `React useCallback() must list captured state ${JSON.stringify(stale)} as a dependency`)
1808
1860
  return ts.visitNode(node.arguments[0], visitor)
1809
1861
  }
1862
+ if (name === "useMemo") {
1863
+ 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 useMemo() requires an inline function and a literal dependency array")
1864
+ const callback = node.arguments[0]
1865
+ if (callback.parameters.length || callback.asteriskToken || callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(callback, sourceFile, "React useMemo() callback must be synchronous and parameterless")
1866
+ const dependency = node.arguments[1].elements.find(entry => !isReactCallbackDependency(entry))
1867
+ if (dependency) throw sourceNodeError(dependency, sourceFile, "React useMemo() dependencies must be identifiers or primitive literals")
1868
+ const expression = reactMemoExpression(callback)
1869
+ if (!expression || !isPureReactMemoExpression(expression)) throw sourceNodeError(callback.body, sourceFile, "React useMemo() callback must return one pure expression")
1870
+ const dependencies = new Set(node.arguments[1].elements.map(unwrapExpression).filter(ts.isIdentifier).map(entry => entry.text))
1871
+ const owner = nearestFunction(node)
1872
+ const states = owner ? ownerStateNames(owner) : new Set()
1873
+ const unsupported = [...reactMemoReferenceNames(expression)].find(reference => !states.has(reference))
1874
+ if (unsupported) throw sourceNodeError(expression, sourceFile, `React useMemo() pure expressions may only reference direct local state; found ${JSON.stringify(unsupported)}`)
1875
+ const stale = [...states].find(state => referenceIdentifiers(expression, state).length && !dependencies.has(state))
1876
+ if (stale) throw sourceNodeError(node.arguments[1], sourceFile, `React useMemo() must list captured state ${JSON.stringify(stale)} as a dependency`)
1877
+ return ts.visitNode(expression, visitor)
1878
+ }
1810
1879
  if (name && supported.has(name)) {
1811
1880
  required.add(name)
1812
1881
  return factory.updateCallExpression(node, factory.createIdentifier(name), node.typeArguments, ts.visitNodes(node.arguments, visitor))
@@ -1820,7 +1889,7 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context) {
1820
1889
  const entries = []
1821
1890
  for (const entry of bindings.elements) {
1822
1891
  const name = (entry.propertyName ?? entry.name).text
1823
- if (!entry.isTypeOnly && name === "useCallback") continue
1892
+ if (!entry.isTypeOnly && erased.has(name)) continue
1824
1893
  if (!entry.isTypeOnly && supported.has(name)) {
1825
1894
  if (imported.has(name)) continue
1826
1895
  imported.add(name)
@@ -1866,11 +1935,52 @@ function isReactCallbackDependency(node) {
1866
1935
  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
1936
  }
1868
1937
 
1938
+ function reactMemoExpression(callback) {
1939
+ if (!ts.isBlock(callback.body)) return callback.body
1940
+ if (callback.body.statements.length !== 1 || !ts.isReturnStatement(callback.body.statements[0])) return undefined
1941
+ return callback.body.statements[0].expression
1942
+ }
1943
+
1944
+ function reactMemoComponentExpression(identifier, sourceFile, factory, context) {
1945
+ for (const statement of sourceFile.statements) {
1946
+ if (ts.isFunctionDeclaration(statement) && statement.name?.text === identifier.text && statement.body) {
1947
+ const clone = cloneAst(statement, factory, context)
1948
+ return factory.createFunctionExpression(clone.modifiers?.filter(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword), clone.asteriskToken, clone.name, clone.typeParameters, clone.parameters, clone.type, clone.body)
1949
+ }
1950
+ if (!ts.isVariableStatement(statement)) continue
1951
+ const declaration = statement.declarationList.declarations.find(entry => ts.isIdentifier(entry.name) && entry.name.text === identifier.text)
1952
+ if (declaration?.initializer && (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer))) return cloneAst(declaration.initializer, factory, context)
1953
+ }
1954
+ return undefined
1955
+ }
1956
+
1957
+ function isPureReactMemoExpression(node) {
1958
+ node = unwrapExpression(node)
1959
+ if (ts.isIdentifier(node) || ts.isStringLiteral(node) || ts.isNumericLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node) || node.kind === ts.SyntaxKind.TrueKeyword || node.kind === ts.SyntaxKind.FalseKeyword || node.kind === ts.SyntaxKind.NullKeyword) return true
1960
+ if (ts.isParenthesizedExpression(node)) return isPureReactMemoExpression(node.expression)
1961
+ if (ts.isPrefixUnaryExpression(node)) return ![ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator) && isPureReactMemoExpression(node.operand)
1962
+ if (ts.isBinaryExpression(node)) return node.operatorToken.kind < ts.SyntaxKind.FirstAssignment && isPureReactMemoExpression(node.left) && isPureReactMemoExpression(node.right)
1963
+ if (ts.isConditionalExpression(node)) return isPureReactMemoExpression(node.condition) && isPureReactMemoExpression(node.whenTrue) && isPureReactMemoExpression(node.whenFalse)
1964
+ if (ts.isTemplateExpression(node)) return node.templateSpans.every(span => isPureReactMemoExpression(span.expression))
1965
+ return false
1966
+ }
1967
+
1968
+ function reactMemoReferenceNames(root) {
1969
+ const names = new Set()
1970
+ const visit = node => {
1971
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node)) names.add(node.text)
1972
+ ts.forEachChild(node, visit)
1973
+ }
1974
+ visit(root)
1975
+ return names
1976
+ }
1977
+
1869
1978
  function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings, listExpressions, handlerUrl, file, sourceFiles, sourceIndex, staticFiles, importedAssets, cssModules, base, clientImports, workerReferences) {
1870
1979
  return context => sourceFile => {
1871
1980
  const factory = context.factory
1872
1981
  const hasLinkElements = /<link/i.test(sourceFile.text)
1873
1982
  sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context)
1983
+ ts.setParentRecursive(sourceFile, false)
1874
1984
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
1875
1985
  ts.setParentRecursive(sourceFile, false)
1876
1986
  rejectOrdinaryWorkerImports(sourceFile, file, sourceFiles)
@@ -1881,6 +1991,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
1881
1991
  let imported = importedSources.get(target)
1882
1992
  if (!imported) {
1883
1993
  imported = normalizeReactMigrationSyntax(parseSourceFile(target, sourceIndex.get(target)), factory, context)
1994
+ ts.setParentRecursive(imported, false)
1884
1995
  imported = normalizeRenderControlFlow(imported, factory, context)
1885
1996
  ts.setParentRecursive(imported, false)
1886
1997
  importedSources.set(target, imported)
@@ -3725,6 +3836,11 @@ function nearestFunction(node) {
3725
3836
  return undefined
3726
3837
  }
3727
3838
 
3839
+ function nearestFunctionLike(node) {
3840
+ for (let current = node.parent; current; current = current.parent) if (isFunctionLike(current)) return current
3841
+ return undefined
3842
+ }
3843
+
3728
3844
  function isShadowedByParameter(node, scopeRoot) {
3729
3845
  for (let current = node.parent; current; current = current.parent) {
3730
3846
  if (isFunctionLike(current) && current.parameters.some(parameter => bindingNames(parameter.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.2",
3
+ "version": "0.7.3",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",