@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 +3 -1
- package/RELEASES.md +49 -0
- package/framework/README.md +4 -0
- package/framework/build.mjs +105 -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)
|
|
@@ -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.
|
package/framework/README.md
CHANGED
|
@@ -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.
|
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) {
|
|
@@ -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
|
|
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
|
|
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
|
|
1793
|
-
|
|
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
|
|
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
|
-
|
|
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) {
|
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
|
|