@kudzujs/core 0.7.24 → 0.7.25

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.24 - Router-shaped query reads.** Read-only React Router `useSearchParams()` authoring now lowers direct static `get()` reads to nullable route signals initialized by a minimal native query reader. Read the [release notes](./RELEASES.md#0724---router-shaped-query-reads) or open the [release page](https://kudzujs.cloud/releases/0.7.24).
13
+ **Latest release: 0.7.25 - Router-shaped native navigation.** Supported React Router `useNavigate()` calls now lower to base-aware native document navigation through `location.assign()` or `location.replace()`, with no router runtime. Read the [release notes](./RELEASES.md#0725---router-shaped-native-navigation) or open the [release page](https://kudzujs.cloud/releases/0.7.25).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
@@ -85,6 +85,7 @@ ordinary React-shaped TSX
85
85
  - A named or aliased React Router `Link` with a static root-relative `to` erases to a base-aware native anchor; no router package or runtime is emitted.
86
86
  - A direct named or aliased React Router `useParams()` call on a `runtimeParams` bracket route reuses Kudzu's route-specific pathname reader.
87
87
  - Read-only React Router `useSearchParams()` with direct static `get("name")` locals lowers to nullable signals initialized by a minimal route-specific query reader.
88
+ - A named or aliased React Router `useNavigate()` top-level binding lowers direct nested-callback calls with safe static root-relative destinations to native `location.assign()` or `location.replace()` navigation.
88
89
  - Unsupported nearby patterns fail during the build with a source location and actionable boundary.
89
90
 
90
91
  Migration input may retain supported imports from `react`; Kudzu erases those references and never emits or executes React. New Kudzu source should import framework APIs from `@kudzujs/core`.
package/RELEASES.md CHANGED
@@ -1,5 +1,31 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.25 - Router-shaped native navigation
4
+
5
+ Kudzu 0.7.25 accepts a narrow React Router `useNavigate()` source shape and lowers safe static destinations to native full-document navigation without shipping React Router or adding an SPA runtime.
6
+
7
+ ### New in 0.7.25
8
+
9
+ - A named or aliased `useNavigate` import may initialize one top-level `const` identifier in a component.
10
+ - Direct calls from nested browser callbacks with one safe static root-relative destination lower to `globalThis.location.assign()`.
11
+ - Exactly `{ replace: true }` lowers to `globalThis.location.replace()`.
12
+ - Configured `base` is applied while query strings and fragments are preserved.
13
+ - The React Router import and navigate binding are erased from emitted component code.
14
+ - Dynamic or relative destinations, render-time calls, passed aliases, optional calls, and other navigation options fail with source diagnostics.
15
+ - Native document navigation remains deliberate even when enhanced navigation is configured.
16
+ - Unaffected destination routes remain complete static documents with zero JavaScript.
17
+ - The complete suite passes 117/117 tests with Chrome coverage for base-aware full-document navigation.
18
+
19
+ ### Boundary
20
+
21
+ The supported migration shape is one top-level `const navigate = useNavigate()` and direct calls from nested browser callbacks. Destinations must be safe static root-relative strings. Only the absent options argument and exactly `{ replace: true }` are supported; dynamic values, relative paths, deltas, state, relative routing, scroll options, aliases, and render-time calls remain unsupported. No SPA router is included.
22
+
23
+ ### Upgrade
24
+
25
+ ```bash
26
+ npm install @kudzujs/core@^0.7.25
27
+ ```
28
+
3
29
  ## 0.7.24 - Router-shaped query reads
4
30
 
5
31
  Kudzu 0.7.24 accepts a narrow read-only React Router `useSearchParams()` shape and lowers each static query read to a nullable route signal backed by native `URLSearchParams`, without adding a router runtime.
@@ -10,6 +10,8 @@ A named or aliased React Router `useParams` import may also be called directly w
10
10
 
11
11
  A named or aliased read-only React Router `useSearchParams` import may initialize one top-level `const [params]` binding. Each top-level `const value = params.get("literal")` lowers to one cached nullable `useSearchParam()` signal. The existing parameter asset initializes those signals with native `URLSearchParams.get()` semantics before route effects mount, including during configured same-document navigation. Missing keys remain `null`, direct text renders blank, and nullable attributes are removed. Setter destructuring, dynamic names, other methods, aliases, wrapped reads, and layout ownership are rejected. Routes without query reads do not emit this branch or a parameter asset.
12
12
 
13
+ A named or aliased React Router `useNavigate` import may initialize one top-level `const` identifier. A direct call from a nested browser callback with one safe static root-relative string lowers to `location.assign()` after applying `base`; exactly `{ replace: true }` lowers to `location.replace()`. This deliberately performs native document navigation even when enhanced navigation is configured. Dynamic or relative destinations, render-time calls, aliases passed as values, and options such as `state`, `relative`, or `preventScrollReset` are rejected. Routes without an actual navigation handler emit no browser JavaScript.
14
+
13
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.
14
16
 
15
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.
@@ -1788,15 +1788,16 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1788
1788
  const links = new Set()
1789
1789
  const params = new Set()
1790
1790
  const searchHooks = new Set()
1791
+ const navigateHooks = new Set()
1791
1792
  for (const statement of sourceFile.statements) {
1792
1793
  if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
1793
1794
  if (ts.isExportDeclaration(statement)) throw sourceNodeError(statement, sourceFile, "React Router exports are not supported; import Link directly where it renders")
1794
1795
  const clause = statement.importClause
1795
1796
  if (clause?.isTypeOnly) continue
1796
1797
  if (!clause) throw sourceNodeError(statement, sourceFile, "Side-effect React Router imports are not supported")
1797
- if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, or useSearchParams imports")
1798
+ if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, useSearchParams, or useNavigate imports")
1798
1799
  const bindings = clause.namedBindings
1799
- if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link, useParams, or useSearchParams imports")
1800
+ if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link, useParams, useSearchParams, or useNavigate imports")
1800
1801
  for (const entry of bindings.elements) {
1801
1802
  if (entry.isTypeOnly) continue
1802
1803
  const imported = (entry.propertyName ?? entry.name).text
@@ -1804,11 +1805,12 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1804
1805
  if (imported === "Link") links.add(entry.name.text)
1805
1806
  else if (imported === "useParams") params.add(entry.name.text)
1806
1807
  else if (imported === "useSearchParams") searchHooks.add(entry.name.text)
1807
- else throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link, useParams, and useSearchParams imports can be lowered`)
1808
+ else if (imported === "useNavigate") navigateHooks.add(entry.name.text)
1809
+ else throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link, useParams, useSearchParams, and useNavigate imports can be lowered`)
1808
1810
  }
1809
1811
  }
1810
1812
  }
1811
- if (!links.size && !params.size && !searchHooks.size) return sourceFile
1813
+ if (!links.size && !params.size && !searchHooks.size && !navigateHooks.size) return sourceFile
1812
1814
 
1813
1815
  let searchHelper = "__kUseSearchParam"
1814
1816
  while (sourceFile.text.includes(searchHelper)) searchHelper += "_"
@@ -1831,7 +1833,7 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1831
1833
  ts.forEachChild(node, collectSearchHooks)
1832
1834
  }
1833
1835
  collectSearchHooks(sourceFile)
1834
- const searchObjectShadowed = (node, entry) => {
1836
+ const localBindingShadowed = (node, entry) => {
1835
1837
  for (let current = node.parent; current && current !== entry.owner; current = current.parent) {
1836
1838
  if (isFunctionLike(current) && (current.parameters.some(parameter => bindingNames(parameter.name).includes(entry.name)) || functionVarDeclaresName(current, entry.name))) return true
1837
1839
  if (ts.isBlock(current) && current.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, entry.name))) return true
@@ -1843,7 +1845,7 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1843
1845
  }
1844
1846
  for (const entry of searchObjects) {
1845
1847
  const collectReads = node => {
1846
- if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !searchObjectShadowed(node, entry)) {
1848
+ if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !localBindingShadowed(node, entry)) {
1847
1849
  const property = node.parent
1848
1850
  const call = property?.parent
1849
1851
  const declaration = call?.parent
@@ -1862,6 +1864,55 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1862
1864
  collectReads(entry.owner.body)
1863
1865
  }
1864
1866
 
1867
+ const navigateDeclarations = new Set()
1868
+ const navigateCalls = new Map()
1869
+ const navigateFunctions = []
1870
+ const collectNavigateHooks = node => {
1871
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && navigateHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
1872
+ const declaration = node.parent
1873
+ const statement = declaration?.parent?.parent
1874
+ const owner = nearestFunction(node)
1875
+ if (node.questionDotToken || node.arguments.length || node.typeArguments?.length || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || !owner || statement?.parent !== owner.body) {
1876
+ throw sourceNodeError(node, sourceFile, "React Router useNavigate must initialize one top-level const identifier in a component")
1877
+ }
1878
+ const entry = { name: declaration.name.text, declaration, statement, owner }
1879
+ navigateDeclarations.add(declaration)
1880
+ navigateFunctions.push(entry)
1881
+ }
1882
+ ts.forEachChild(node, collectNavigateHooks)
1883
+ }
1884
+ collectNavigateHooks(sourceFile)
1885
+ for (const entry of navigateFunctions) {
1886
+ const collectCalls = node => {
1887
+ if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !localBindingShadowed(node, entry)) {
1888
+ const call = node.parent
1889
+ if (!ts.isCallExpression(call) || call.expression !== node || call.questionDotToken || call.typeArguments?.length || nearestFunction(call) === entry.owner) {
1890
+ throw sourceNodeError(node, sourceFile, "React Router navigate bindings may only be called directly from a nested browser callback")
1891
+ }
1892
+ if (call.arguments.length < 1 || call.arguments.length > 2 || !ts.isStringLiteral(call.arguments[0])) {
1893
+ throw sourceNodeError(call, sourceFile, 'React Router useNavigate requires a static root-relative navigate("/path") destination')
1894
+ }
1895
+ const destination = call.arguments[0].text
1896
+ const pathname = destination.match(/^[^?#]*/)[0]
1897
+ let decoded
1898
+ try { decoded = decodeURIComponent(pathname) } catch { throw sourceNodeError(call.arguments[0], sourceFile, 'React Router useNavigate requires a safe static root-relative navigate("/path") destination') }
1899
+ if (!destination.startsWith("/") || destination.startsWith("//") || /%(?:2f|5c)/i.test(pathname) || /[\\\0]/.test(decoded) || decoded.split("/").includes("..") || [...decoded].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159)) throw sourceNodeError(call.arguments[0], sourceFile, 'React Router useNavigate requires a safe static root-relative navigate("/path") destination')
1900
+ let method = "assign"
1901
+ if (call.arguments.length === 2) {
1902
+ const options = unwrapExpression(call.arguments[1])
1903
+ const property = ts.isObjectLiteralExpression(options) && options.properties.length === 1 ? options.properties[0] : undefined
1904
+ const name = property && ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name)) ? property.name.text : undefined
1905
+ if (name !== "replace" || property.initializer.kind !== ts.SyntaxKind.TrueKeyword) throw sourceNodeError(call.arguments[1], sourceFile, 'React Router useNavigate only supports exactly { replace: true } as a second argument')
1906
+ method = "replace"
1907
+ }
1908
+ navigateCalls.set(call, { method, destination: withBase(base, destination) })
1909
+ return
1910
+ }
1911
+ ts.forEachChild(node, collectCalls)
1912
+ }
1913
+ collectCalls(entry.owner.body)
1914
+ }
1915
+
1865
1916
  const routerProps = new Set(["discover", "end", "prefetch", "preventScrollReset", "relative", "reloadDocument", "replace", "state", "viewTransition"])
1866
1917
  const attributes = attributesNode => {
1867
1918
  const output = []
@@ -1889,12 +1940,16 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1889
1940
  }
1890
1941
  const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
1891
1942
  const visitor = node => {
1892
- if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => searchDeclarations.has(declaration))) {
1893
- const declarations = node.declarationList.declarations.filter(declaration => !searchDeclarations.has(declaration))
1943
+ if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => searchDeclarations.has(declaration) || navigateDeclarations.has(declaration))) {
1944
+ const declarations = node.declarationList.declarations.filter(declaration => !searchDeclarations.has(declaration) && !navigateDeclarations.has(declaration))
1894
1945
  if (!declarations.length) return undefined
1895
1946
  return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations.map(declaration => ts.visitEachChild(declaration, visitor, context))))
1896
1947
  }
1897
1948
  if (ts.isCallExpression(node) && searchReads.has(node)) return factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(node)])
1949
+ if (ts.isCallExpression(node) && navigateCalls.has(node)) {
1950
+ const { method, destination } = navigateCalls.get(node)
1951
+ return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "location"), method), undefined, [factory.createStringLiteral(destination)])
1952
+ }
1898
1953
  if (ts.isJsxElement(node) && importedLink(node.openingElement.tagName)) {
1899
1954
  const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("a"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
1900
1955
  const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("a"))
@@ -1908,12 +1963,13 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1908
1963
  if (ts.isIdentifier(node) && links.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router Link imports may only be used as direct JSX elements")
1909
1964
  if (ts.isIdentifier(node) && params.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useParams imports may only be called directly")
1910
1965
  if (ts.isIdentifier(node) && searchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useSearchParams imports may only be used by the supported read-only pattern")
1966
+ if (ts.isIdentifier(node) && navigateHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useNavigate imports may only initialize the supported top-level navigate binding")
1911
1967
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
1912
1968
  const clause = node.importClause
1913
1969
  if (!clause || clause.isTypeOnly) return node
1914
1970
  const bindings = clause.namedBindings
1915
1971
  if (!bindings || !ts.isNamedImports(bindings)) return node
1916
- const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useSearchParams"].includes((entry.propertyName ?? entry.name).text))
1972
+ const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useSearchParams", "useNavigate"].includes((entry.propertyName ?? entry.name).text))
1917
1973
  if (!elements.length) return undefined
1918
1974
  return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
1919
1975
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.24",
3
+ "version": "0.7.25",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",