@kudzujs/core 0.7.22 → 0.7.24

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.22 - SVG structures and native links.** Reactive SVG conditionals and flat keyed lists preserve their namespace, while supported React Router `Link` authoring erases to base-aware native anchors with zero router runtime. Read the [release notes](./RELEASES.md#0722---svg-structures-and-native-links) or open the [release page](https://kudzujs.cloud/releases/0.7.22).
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).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
@@ -83,6 +83,8 @@ ordinary React-shaped TSX
83
83
  - Build-known data and routes become complete HTML through async components and `getStaticPaths()`.
84
84
  - Native document navigation is the default; static routes do not load a client runtime.
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
+ - A direct named or aliased React Router `useParams()` call on a `runtimeParams` bracket route reuses Kudzu's route-specific pathname reader.
87
+ - Read-only React Router `useSearchParams()` with direct static `get("name")` locals lowers to nullable signals initialized by a minimal route-specific query reader.
86
88
  - Unsupported nearby patterns fail during the build with a source location and actionable boundary.
87
89
 
88
90
  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,54 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.24 - Router-shaped query reads
4
+
5
+ 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.
6
+
7
+ ### New in 0.7.24
8
+
9
+ - A named or aliased `useSearchParams` import may initialize one top-level `const [params]` tuple without a setter.
10
+ - Direct top-level `const value = params.get("literal")` reads lower to cached nullable query signals.
11
+ - The route-specific parameter module uses native `URLSearchParams.get()` semantics for missing, empty, duplicate, plus-encoded, Unicode, and percent-encoded values.
12
+ - Static fallback HTML renders missing query text as blank and omits nullable attributes; browser values initialize before route effects mount.
13
+ - Query signals compose with direct text and attributes, effect dependencies, native handlers, React Router Link lowering, and configured same-document navigation.
14
+ - Routes without query reads emit no parameter module or query branch.
15
+ - Setter tuples, dynamic names, other methods, aliases, wrapped expressions, and indirect reads fail with source diagnostics.
16
+ - The complete suite passes 115/115 tests with Chrome coverage for standalone and navigation-group query initialization.
17
+
18
+ ### Boundary
19
+
20
+ The supported migration shape is one top-level `const [params] = useSearchParams()` and one or more top-level direct `const value = params.get("literal")` reads. Query setters, `getAll`, `has`, iteration, dynamic names, aliases, inline JSX calls, fallback wrappers, layout ownership, and general URLSearchParams semantics remain unsupported. Native document navigation remains the default; no SPA router is included.
21
+
22
+ ### Upgrade
23
+
24
+ ```bash
25
+ npm install @kudzujs/core@^0.7.24
26
+ ```
27
+
28
+ ## 0.7.23 - Router-shaped runtime params
29
+
30
+ Kudzu 0.7.23 accepts the conventional React Router `useParams()` source shape on runtime bracket routes and redirects it to the existing capability-specific pathname reader without shipping React Router.
31
+
32
+ ### New in 0.7.23
33
+
34
+ - Named or aliased `useParams` imports from `react-router-dom` may be called directly on bracket pages exporting `runtimeParams = true`.
35
+ - One optional TypeScript type argument is preserved through normalization while the runtime call remains argument-free.
36
+ - The React Router binding is redirected to `@kudzujs/core`; no `react-router-dom` module reference survives emitted code.
37
+ - Mixed `Link` and `useParams` imports lower together: Link becomes a base-aware native anchor and params reuse the pathname reader.
38
+ - Runtime parameter values retain existing secure decoding, direct DOM bindings, effects, handlers, and navigation-group lifecycle behavior.
39
+ - Indirect references, runtime arguments, default/namespace imports, and unsupported router hooks fail with source diagnostics.
40
+ - The complete suite passes 112/112 tests with browser coverage for multi-parameter paths and mixed Link/params migration input.
41
+
42
+ ### Boundary
43
+
44
+ React Router `useParams()` lowering requires a direct named or aliased zero-argument call on a bracket route exporting `runtimeParams = true`. Build-known `getStaticPaths()` routes continue to receive params through page props. Catch-all routes, optional segments, indirect calls, runtime arguments, and other router hooks remain unsupported. No SPA router or shared router runtime is added.
45
+
46
+ ### Upgrade
47
+
48
+ ```bash
49
+ npm install @kudzujs/core@^0.7.23
50
+ ```
51
+
3
52
  ## 0.7.22 - SVG structures and native links
4
53
 
5
54
  Kudzu 0.7.22 extends ordinary React-shaped structural authoring into SVG and erases the common React Router `Link` form to native navigation without adding an SVG renderer or router runtime.
@@ -6,6 +6,10 @@ Migration source may retain conventional `react` imports for supported named or
6
6
 
7
7
  A named or aliased `Link` import from `react-router-dom` may render directly with one static root-relative `to` plus native anchor props. The compiler prefixes the configured `base`, changes the element to `<a href>`, and erases the import, so native navigation remains the default and configured navigation groups see an ordinary eligible anchor. Dynamic or relative destinations, `NavLink`, router-only props, spreads, default/namespace imports, and non-JSX uses fail with source diagnostics. No React Router package code or router runtime is emitted.
8
8
 
9
+ A named or aliased React Router `useParams` import may also be called directly without runtime arguments on a bracket route exporting `runtimeParams = true`. The compiler redirects that local binding to `@kudzujs/core`, preserving one optional TypeScript type argument, and reuses the existing route-specific pathname matcher. Indirect calls and other router hooks remain unsupported. Build-known `getStaticPaths()` routes use page props instead because no browser pathname capability is needed.
10
+
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
+
9
13
  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.
10
14
 
11
15
  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.
@@ -115,7 +115,7 @@ export async function build({ quiet = false, minify = true } = {}) {
115
115
  const navigable = Boolean(navigationGroup)
116
116
  const effectPath = `effects/${route ? `${route}/index` : "index"}.js`
117
117
  const nativePath = `native/${route ? `${route}/index` : "index"}.js`
118
- const paramPath = `params/${route}/index.js`
118
+ const paramPath = `params/${route ? `${route}/index` : "index"}.js`
119
119
  if (emittedRoutes.has(routePath)) throw new Error(`Duplicate route: ${routePath}`)
120
120
  emittedRoutes.add(routePath)
121
121
  emittedApplicationRoutes.add(applicationRoute)
@@ -153,7 +153,7 @@ export async function build({ quiet = false, minify = true } = {}) {
153
153
  const usesDependencyRuntime = !navigable && hasDependencies && !result.plan.effects.some(effect => effect.owner) && !result.hasBindings && !result.hasLists && !result.plan.events.some(event => event.native)
154
154
  pageEntries.push({ route, html: result.html, usesDependencyRuntime })
155
155
  plans.push({ route: routePath, ...result.plan })
156
- if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, usesDependencyRuntime, navigable })
156
+ if (result.hasParams) paramEntries.push({ path: paramPath, schema: runtimeSchema, params: result.plan.params, searchParams: result.plan.searchParams, usesDependencyRuntime, navigable })
157
157
  if (result.hasEffects) effectEntries.push({ path: effectPath, effects: runtimeEffects(result.plan.effects, navigable), paramPath: result.hasParams ? paramPath : undefined, usesDependencyRuntime, navigable })
158
158
  if (result.plan.events.some(event => event.native)) nativeEntries.push({
159
159
  path: nativePath,
@@ -386,7 +386,7 @@ export async function build({ quiet = false, minify = true } = {}) {
386
386
  for (const entry of paramEntries) {
387
387
  const output = join(assetsDirectory, entry.path)
388
388
  await mkdir(dirname(output), { recursive: true })
389
- await writeJavaScript(output, printParamEntry(entry.schema, entry.params, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime), entry.navigable), minify)
389
+ await writeJavaScript(output, printParamEntry(entry.schema, entry.params, entry.searchParams, output, assetsDirectory, base, runtimeName(entry.usesDependencyRuntime), entry.navigable), minify)
390
390
  }
391
391
  for (const entry of effectEntries) {
392
392
  const output = join(assetsDirectory, entry.path)
@@ -482,7 +482,7 @@ async function mountInitial() {
482
482
  const record = matchRoute(location.pathname)
483
483
  if (!record) throw new Error("Initial navigation route does not match")
484
484
  const capabilities = await loadCapabilities(validate(document, record))
485
- capabilities.params?.(location.pathname)
485
+ capabilities.params?.(location.pathname, location.search)
486
486
  layoutDispose = await capabilities.effects?.mountLayoutEffects?.() ?? noDispose
487
487
  routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
488
488
  } catch (error) {
@@ -493,7 +493,7 @@ async function mountInitial() {
493
493
  .replace(" await ready\n", "")
494
494
  .replace(" const capabilities = await loadCapabilities(parsed)\n", " await Promise.all(parsed.assets.filter(path => path !== navigationAsset).map(path => import(path)))\n")
495
495
  .replace(" await routeDispose()\n if (current !== revision) return\n", "")
496
- .replace(" commit(incoming, parsed.nodes, capabilities.params, url.pathname)\n", " commit(incoming, parsed.nodes)\n")
496
+ .replace(" commit(incoming, parsed.nodes, capabilities.params, url.pathname, url.search)\n", " commit(incoming, parsed.nodes)\n")
497
497
  .replace(" routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose\n", "")
498
498
  .replace(`
499
499
  async function loadCapabilities(parsed) {
@@ -1406,14 +1406,14 @@ async function invokeCleanup() {
1406
1406
  }${disposal}`
1407
1407
  }
1408
1408
 
1409
- function printParamEntry(schema, params, output, assetsDirectory, base, runtimeName, navigable) {
1410
- const prefix = navigable ? "export function initializeParams(pathname) {\n" : "let pathname = location.pathname\n"
1409
+ function printParamEntry(schema, params, searchParams, output, assetsDirectory, base, runtimeName, navigable) {
1410
+ const signature = searchParams.length ? "pathname, search" : "pathname"
1411
+ const prefix = navigable ? `export function initializeParams(${signature}) {\n` : `${schema ? "let pathname = location.pathname\n" : ""}${searchParams.length ? "let search = location.search\n" : ""}`
1411
1412
  const suffix = navigable ? "\n}" : ""
1412
- return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
1413
- const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
1413
+ const pathname = schema ? `const base = ${inlineJson(browserPath(base).slice(1).split("/").filter(Boolean).map(segment => decodeURIComponent(segment)))}
1414
1414
  const schema = ${inlineJson(schema.segments)}
1415
1415
  const params = ${inlineJson(params)}
1416
- ${prefix}let path = pathname
1416
+ let path = pathname
1417
1417
  if (base.length) {
1418
1418
  const pathSegments = path.slice(1).split("/")
1419
1419
  if (pathSegments.length < base.length || base.some((segment, index) => decodeSegment(pathSegments[index], false) !== segment)) throw new Error("Runtime route is outside the configured base")
@@ -1441,7 +1441,17 @@ function decodeSegment(raw, param) {
1441
1441
  const decodedDots = value.replace(/%2e/gi, ".")
1442
1442
  if (param && (!value || value === "." || value === ".." || decodedDots === "." || decodedDots === ".." || /[\\/?#]/.test(value) || [...value].some(character => character.charCodeAt(0) < 32 || character.charCodeAt(0) >= 127 && character.charCodeAt(0) <= 159) || /%(?:2f|5c)/i.test(value))) throw new Error("Runtime route parameter is invalid")
1443
1443
  return value
1444
- }${suffix}`
1444
+ }
1445
+ ` : ""
1446
+ const query = searchParams.length ? `const query = new URLSearchParams(search)
1447
+ for (const param of ${inlineJson(searchParams)}) {
1448
+ const value = query.get(param.name)
1449
+ browserState.set(param.id, value)
1450
+ commitDom(param.id, value)
1451
+ }
1452
+ ` : ""
1453
+ return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(assetsDirectory, runtimeName)))}
1454
+ ${prefix}${pathname}${query}${suffix}`
1445
1455
  }
1446
1456
 
1447
1457
  function hasCaptureType(value, type) {
@@ -1776,25 +1786,81 @@ function emittedPackageReference(source, file, packages) {
1776
1786
 
1777
1787
  function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1778
1788
  const links = new Set()
1789
+ const params = new Set()
1790
+ const searchHooks = new Set()
1779
1791
  for (const statement of sourceFile.statements) {
1780
1792
  if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
1781
1793
  if (ts.isExportDeclaration(statement)) throw sourceNodeError(statement, sourceFile, "React Router exports are not supported; import Link directly where it renders")
1782
1794
  const clause = statement.importClause
1783
1795
  if (clause?.isTypeOnly) continue
1784
1796
  if (!clause) throw sourceNodeError(statement, sourceFile, "Side-effect React Router imports are not supported")
1785
- if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use a named Link import")
1797
+ if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, or useSearchParams imports")
1786
1798
  const bindings = clause.namedBindings
1787
- if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use a named Link import")
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")
1788
1800
  for (const entry of bindings.elements) {
1789
1801
  if (entry.isTypeOnly) continue
1790
1802
  const imported = (entry.propertyName ?? entry.name).text
1791
1803
  if (imported === "NavLink") throw sourceNodeError(entry, sourceFile, "React Router NavLink active-route semantics cannot be erased to a native anchor")
1792
- if (imported !== "Link") throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link imports can be erased to native anchors`)
1793
- links.add(entry.name.text)
1804
+ if (imported === "Link") links.add(entry.name.text)
1805
+ else if (imported === "useParams") params.add(entry.name.text)
1806
+ 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`)
1794
1808
  }
1795
1809
  }
1796
1810
  }
1797
- if (!links.size) return sourceFile
1811
+ if (!links.size && !params.size && !searchHooks.size) return sourceFile
1812
+
1813
+ let searchHelper = "__kUseSearchParam"
1814
+ while (sourceFile.text.includes(searchHelper)) searchHelper += "_"
1815
+ const searchDeclarations = new Set()
1816
+ const searchReads = new Map()
1817
+ const searchObjects = []
1818
+ const collectSearchHooks = node => {
1819
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && searchHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
1820
+ const declaration = node.parent
1821
+ const statement = declaration?.parent?.parent
1822
+ const owner = nearestFunction(node)
1823
+ const first = ts.isVariableDeclaration(declaration) && ts.isArrayBindingPattern(declaration.name) ? declaration.name.elements[0] : undefined
1824
+ if (node.questionDotToken || node.arguments.length || node.typeArguments?.length || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !isLocalConst(declaration) || !owner || statement?.parent !== owner.body || !ts.isBindingElement(first) || !ts.isIdentifier(first.name) || declaration.name.elements.length !== 1) {
1825
+ throw sourceNodeError(node, sourceFile, "React Router useSearchParams must use one top-level const [params] = useSearchParams() without a setter")
1826
+ }
1827
+ const entry = { name: first.name.text, declaration, statement, owner }
1828
+ searchDeclarations.add(declaration)
1829
+ searchObjects.push(entry)
1830
+ }
1831
+ ts.forEachChild(node, collectSearchHooks)
1832
+ }
1833
+ collectSearchHooks(sourceFile)
1834
+ const searchObjectShadowed = (node, entry) => {
1835
+ for (let current = node.parent; current && current !== entry.owner; current = current.parent) {
1836
+ if (isFunctionLike(current) && (current.parameters.some(parameter => bindingNames(parameter.name).includes(entry.name)) || functionVarDeclaresName(current, entry.name))) return true
1837
+ if (ts.isBlock(current) && current.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, entry.name))) return true
1838
+ if (ts.isCaseBlock(current) && current.clauses.some(clause => clause.statements.some(statement => statement !== entry.statement && statementDeclaresName(statement, entry.name)))) return true
1839
+ if (ts.isCatchClause(current) && current.variableDeclaration && bindingNames(current.variableDeclaration.name).includes(entry.name)) return true
1840
+ if ((ts.isForStatement(current) || ts.isForInStatement(current) || ts.isForOfStatement(current)) && loopDeclaresName(current, entry.name)) return true
1841
+ }
1842
+ return false
1843
+ }
1844
+ for (const entry of searchObjects) {
1845
+ const collectReads = node => {
1846
+ if (ts.isIdentifier(node) && node.text === entry.name && isReferenceIdentifier(node) && !searchObjectShadowed(node, entry)) {
1847
+ const property = node.parent
1848
+ const call = property?.parent
1849
+ const declaration = call?.parent
1850
+ const statement = declaration?.parent?.parent
1851
+ if (!ts.isPropertyAccessExpression(property) || property.expression !== node || property.name.text !== "get" || !ts.isCallExpression(call) || call.expression !== property || call.questionDotToken || call.typeArguments?.length || call.arguments.length !== 1 || !ts.isStringLiteral(call.arguments[0])) {
1852
+ throw sourceNodeError(node, sourceFile, 'React Router search parameters only support direct get("static-name") reads')
1853
+ }
1854
+ if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== call || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || statement?.parent !== entry.owner.body) {
1855
+ throw sourceNodeError(call, sourceFile, "React Router search parameter get() must directly initialize one top-level const identifier")
1856
+ }
1857
+ searchReads.set(call, call.arguments[0])
1858
+ return
1859
+ }
1860
+ ts.forEachChild(node, collectReads)
1861
+ }
1862
+ collectReads(entry.owner.body)
1863
+ }
1798
1864
 
1799
1865
  const routerProps = new Set(["discover", "end", "prefetch", "preventScrollReset", "relative", "reloadDocument", "replace", "state", "viewTransition"])
1800
1866
  const attributes = attributesNode => {
@@ -1823,25 +1889,46 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1823
1889
  }
1824
1890
  const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
1825
1891
  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))
1894
+ if (!declarations.length) return undefined
1895
+ return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations.map(declaration => ts.visitEachChild(declaration, visitor, context))))
1896
+ }
1897
+ if (ts.isCallExpression(node) && searchReads.has(node)) return factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(node)])
1826
1898
  if (ts.isJsxElement(node) && importedLink(node.openingElement.tagName)) {
1827
1899
  const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("a"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
1828
1900
  const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("a"))
1829
1901
  return factory.updateJsxElement(node, opening, ts.visitNodes(node.children, visitor), closing)
1830
1902
  }
1831
1903
  if (ts.isJsxSelfClosingElement(node) && importedLink(node.tagName)) return factory.updateJsxSelfClosingElement(node, factory.createIdentifier("a"), node.typeArguments, attributes(node.attributes))
1904
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && params.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
1905
+ if (node.questionDotToken || node.arguments.length || (node.typeArguments?.length ?? 0) > 1) throw sourceNodeError(node, sourceFile, "React Router useParams must be called directly without runtime arguments and with at most one type argument")
1906
+ return node
1907
+ }
1832
1908
  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
+ 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
+ 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")
1833
1911
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
1834
1912
  const clause = node.importClause
1835
1913
  if (!clause || clause.isTypeOnly) return node
1836
1914
  const bindings = clause.namedBindings
1837
1915
  if (!bindings || !ts.isNamedImports(bindings)) return node
1838
- const elements = bindings.elements.filter(entry => entry.isTypeOnly || (entry.propertyName ?? entry.name).text !== "Link")
1916
+ const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useSearchParams"].includes((entry.propertyName ?? entry.name).text))
1839
1917
  if (!elements.length) return undefined
1840
1918
  return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
1841
1919
  }
1842
1920
  return ts.visitEachChild(node, visitor, context)
1843
1921
  }
1844
- return ts.visitNode(sourceFile, visitor)
1922
+ const normalized = ts.visitNode(sourceFile, visitor)
1923
+ if (!params.size && !searchHooks.size) return normalized
1924
+ const imports = [
1925
+ ...[...params].map(name => factory.createImportSpecifier(false, name === "useParams" ? undefined : factory.createIdentifier("useParams"), factory.createIdentifier(name))),
1926
+ ...(searchHooks.size ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParam"), factory.createIdentifier(searchHelper))] : [])
1927
+ ]
1928
+ const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports(imports)), factory.createStringLiteral("@kudzujs/core"))
1929
+ const statements = [...normalized.statements]
1930
+ statements.splice(statements.findLastIndex(statement => ts.isImportDeclaration(statement)) + 1, 0, declaration)
1931
+ return factory.updateSourceFile(normalized, statements)
1845
1932
  }
1846
1933
 
1847
1934
  function normalizeClsxSyntax(sourceFile, factory, context) {
@@ -12,6 +12,7 @@ export function useReducer<State, Action, InitialArg>(reducer: Reducer<State, Ac
12
12
  export function useReducer<State, Action>(reducer: Reducer<State, Action>, initialValue: State): [State, Dispatch<Action>]
13
13
  export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
14
14
  export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
15
+ export function useSearchParam(name: string): string | null
15
16
 
16
17
  export interface RefObject<T> {
17
18
  readonly current: T | null
@@ -94,6 +95,7 @@ export function renderPage<Props = Record<string, never>>(
94
95
  plan: {
95
96
  states: Array<{ id: string; name: string; initialValue: unknown; lifetime?: "layout" | "route"; internal?: true }>
96
97
  params: Array<{ name: string; id: string }>
98
+ searchParams: Array<{ name: string; id: string }>
97
99
  events: Array<{
98
100
  event: string
99
101
  commands?: Array<[string, string, unknown]>
@@ -123,6 +123,22 @@ export function useParams() {
123
123
  return renderContext.params
124
124
  }
125
125
 
126
+ export function useSearchParam(name) {
127
+ if (!renderContext) throw new Error("useSearchParam() can only run while rendering a Kudzu component")
128
+ if (renderContext?.renderScope === "layout") throw new Error("useSearchParam() is only supported in route scope")
129
+ if (typeof name !== "string") throw new Error("useSearchParam() requires a string name")
130
+ let signal = renderContext.searchParams.get(name)
131
+ if (!signal) {
132
+ const id = nextRenderId("p")
133
+ signal = createSignal(id, null)
134
+ renderContext.searchParams.set(name, signal)
135
+ renderContext.searchParamEntries.push({ name, id })
136
+ }
137
+ renderContext.hasBehaviors = true
138
+ renderContext.hasParams = true
139
+ return signal
140
+ }
141
+
126
142
  function createSignal(id, value) {
127
143
  return {
128
144
  [signalMarker]: true,
@@ -416,7 +432,7 @@ function serializeCapture(name, value, seen) {
416
432
  }
417
433
 
418
434
  export async function renderPage(component, metadata = {}, props = {}, layout) {
419
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
435
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
420
436
 
421
437
  try {
422
438
  const page = { [routeScopeMarker]: true, component, props }
@@ -496,6 +512,7 @@ export async function renderPage(component, metadata = {}, props = {}, layout) {
496
512
  plan: {
497
513
  states: Object.entries(renderContext.states).map(([id, state]) => ({ id, ...state })),
498
514
  params: renderContext.paramEntries,
515
+ searchParams: renderContext.searchParamEntries,
499
516
  events: renderContext.events,
500
517
  effects: renderContext.effects,
501
518
  bindings: renderContext.bindings,
@@ -560,7 +577,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
560
577
  if (node?.[signalMarker]) {
561
578
  renderContext.textStates.add(node.id)
562
579
  if (renderContext.conditionDepth || renderContext.listDepth) renderContext.conditionStates.add(node.id)
563
- return `<span data-k-text="${node.id}" data-k-value='${escapeJsonAttribute(node.value)}'>${escapeHtml(node.value)}</span>`
580
+ return `<span data-k-text="${node.id}" data-k-value='${escapeJsonAttribute(node.value)}'>${escapeHtml(node.value ?? "")}</span>`
564
581
  }
565
582
  if (typeof node === "string" || typeof node === "number" || typeof node === "bigint") {
566
583
  return escapeHtml(node)
@@ -49,7 +49,7 @@ async function mountInitial() {
49
49
  const record = matchRoute(location.pathname)
50
50
  if (!record) throw new Error("Initial navigation route does not match")
51
51
  const capabilities = await loadCapabilities(validate(document, record))
52
- capabilities.params?.(location.pathname)
52
+ capabilities.params?.(location.pathname, location.search)
53
53
  layoutDispose = await capabilities.effects?.mountLayoutEffects?.() ?? noDispose
54
54
  routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
55
55
  } catch (error) {
@@ -130,7 +130,7 @@ async function navigate(url, push) {
130
130
  if (current !== revision) return
131
131
  await routeDispose()
132
132
  if (current !== revision) return
133
- commit(incoming, parsed.nodes, capabilities.params, url.pathname)
133
+ commit(incoming, parsed.nodes, capabilities.params, url.pathname, url.search)
134
134
  committed = true
135
135
  routeDispose = await capabilities.effects?.mountRouteEffects?.() ?? noDispose
136
136
  if (push) history.pushState(null, "", url)
@@ -175,7 +175,7 @@ function validate(incoming, record) {
175
175
  return { nodes, assets: [...new Set(assets)] }
176
176
  }
177
177
 
178
- function commit(incoming, incomingNodes, initializeParams, pathname) {
178
+ function commit(incoming, incomingNodes, initializeParams, pathname, search) {
179
179
  const start = document.querySelector("template[data-k-route-start]")
180
180
  const end = document.querySelector("template[data-k-route-end]")
181
181
  if (!start || !end || document.querySelectorAll("template[data-k-route-start],template[data-k-route-end]").length !== 2) throw new Error("Current route markers are invalid")
@@ -189,7 +189,7 @@ function commit(incoming, incomingNodes, initializeParams, pathname) {
189
189
  document.body.dataset.kRoute = incoming.body.dataset.kRoute
190
190
  const nodes = incomingNodes.map(node => document.importNode(node, true))
191
191
  end.before(...nodes)
192
- initializeParams?.(pathname)
192
+ initializeParams?.(pathname, search)
193
193
  for (const node of nodes) mountDom(node)
194
194
  }
195
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.22",
3
+ "version": "0.7.24",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",