@kudzujs/core 0.7.23 → 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.23 - Router-shaped runtime params.** Named or aliased React Router `useParams()` authoring now redirects to Kudzu's existing route-specific pathname reader with no router runtime. Read the [release notes](./RELEASES.md#0723---router-shaped-runtime-params) or open the [release page](https://kudzujs.cloud/releases/0.7.23).
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)
@@ -84,6 +84,7 @@ ordinary React-shaped TSX
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
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.
87
88
  - Unsupported nearby patterns fail during the build with a source location and actionable boundary.
88
89
 
89
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,30 @@
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
+
3
28
  ## 0.7.23 - Router-shaped runtime params
4
29
 
5
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.
@@ -8,6 +8,8 @@ A named or aliased `Link` import from `react-router-dom` may render directly wit
8
8
 
9
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
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
+
11
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.
12
14
 
13
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) {
@@ -1777,26 +1787,80 @@ function emittedPackageReference(source, file, packages) {
1777
1787
  function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1778
1788
  const links = new Set()
1779
1789
  const params = new Set()
1790
+ const searchHooks = new Set()
1780
1791
  for (const statement of sourceFile.statements) {
1781
1792
  if ((ts.isExportDeclaration(statement) || ts.isImportDeclaration(statement)) && ts.isStringLiteral(statement.moduleSpecifier) && statement.moduleSpecifier.text === "react-router-dom") {
1782
1793
  if (ts.isExportDeclaration(statement)) throw sourceNodeError(statement, sourceFile, "React Router exports are not supported; import Link directly where it renders")
1783
1794
  const clause = statement.importClause
1784
1795
  if (clause?.isTypeOnly) continue
1785
1796
  if (!clause) throw sourceNodeError(statement, sourceFile, "Side-effect React Router imports are not supported")
1786
- if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link or useParams imports")
1797
+ if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, or useSearchParams imports")
1787
1798
  const bindings = clause.namedBindings
1788
- if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link or useParams imports")
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")
1789
1800
  for (const entry of bindings.elements) {
1790
1801
  if (entry.isTypeOnly) continue
1791
1802
  const imported = (entry.propertyName ?? entry.name).text
1792
1803
  if (imported === "NavLink") throw sourceNodeError(entry, sourceFile, "React Router NavLink active-route semantics cannot be erased to a native anchor")
1793
1804
  if (imported === "Link") links.add(entry.name.text)
1794
1805
  else if (imported === "useParams") params.add(entry.name.text)
1795
- else throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link and useParams imports can be lowered`)
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`)
1796
1808
  }
1797
1809
  }
1798
1810
  }
1799
- if (!links.size && !params.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
+ }
1800
1864
 
1801
1865
  const routerProps = new Set(["discover", "end", "prefetch", "preventScrollReset", "relative", "reloadDocument", "replace", "state", "viewTransition"])
1802
1866
  const attributes = attributesNode => {
@@ -1825,6 +1889,12 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1825
1889
  }
1826
1890
  const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
1827
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)])
1828
1898
  if (ts.isJsxElement(node) && importedLink(node.openingElement.tagName)) {
1829
1899
  const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("a"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
1830
1900
  const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("a"))
@@ -1837,20 +1907,25 @@ function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
1837
1907
  }
1838
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")
1839
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")
1840
1911
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
1841
1912
  const clause = node.importClause
1842
1913
  if (!clause || clause.isTypeOnly) return node
1843
1914
  const bindings = clause.namedBindings
1844
1915
  if (!bindings || !ts.isNamedImports(bindings)) return node
1845
- const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams"].includes((entry.propertyName ?? entry.name).text))
1916
+ const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useSearchParams"].includes((entry.propertyName ?? entry.name).text))
1846
1917
  if (!elements.length) return undefined
1847
1918
  return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
1848
1919
  }
1849
1920
  return ts.visitEachChild(node, visitor, context)
1850
1921
  }
1851
1922
  const normalized = ts.visitNode(sourceFile, visitor)
1852
- if (!params.size) return normalized
1853
- const declaration = factory.createImportDeclaration(undefined, factory.createImportClause(false, undefined, factory.createNamedImports([...params].map(name => factory.createImportSpecifier(false, name === "useParams" ? undefined : factory.createIdentifier("useParams"), factory.createIdentifier(name))))), factory.createStringLiteral("@kudzujs/core"))
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"))
1854
1929
  const statements = [...normalized.statements]
1855
1930
  statements.splice(statements.findLastIndex(statement => ts.isImportDeclaration(statement)) + 1, 0, declaration)
1856
1931
  return factory.updateSourceFile(normalized, statements)
@@ -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.23",
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",