@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 +2 -1
- package/RELEASES.md +25 -0
- package/framework/README.md +2 -0
- package/framework/build.mjs +93 -18
- package/framework/core.d.ts +2 -0
- package/framework/core.mjs +19 -2
- package/framework/navigation-runtime.js +4 -4
- package/package.json +1 -1
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.
|
|
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.
|
package/framework/README.md
CHANGED
|
@@ -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.
|
package/framework/build.mjs
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
package/framework/core.d.ts
CHANGED
|
@@ -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]>
|
package/framework/core.mjs
CHANGED
|
@@ -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
|
|