@kudzujs/core 0.9.2 → 0.10.0

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.
@@ -12,6 +12,7 @@ Supported React Router-shaped migration remains narrow:
12
12
 
13
13
  - static root-relative `Link` lowers to a base-prefixed native `<a>`;
14
14
  - bracket runtime routes may lower direct `useParams()` to the pathname reader;
15
+ - route-owned exact static root-relative `useMatch()` bindings fold case-insensitively from the build-known application route;
15
16
  - top-level direct `useSearchParams()` reads/writes lower to URL signals and native history;
16
17
  - direct nested `useNavigate()` calls lower to native `location.assign()` or `location.replace()`;
17
18
  - no React Router package executes in build output or the browser.
@@ -1,6 +1,6 @@
1
1
  # Planned Version Sequence
2
2
 
3
- This is an execution sequence, not release history. `0.8.16` through `0.8.62` are completed scopes represented by package/release records.
3
+ This is an execution sequence, not release history. `0.8.16` through `0.8.62` are completed scopes represented by package/release records. The active `0.10.0` through `1.0.0` minor/patch sequence is maintained in [`application-capability-release-plan.md`](./application-capability-release-plan.md); that plan supersedes the provisional tool-first 0.10/0.11/0.12 ordering in the completed 0.9 handoff.
4
4
 
5
5
  Keep each patch behavior-preserving and independently reviewable. If a boundary proves inseparable, revise this plan before combining releases; do not silently broaden a patch.
6
6
 
@@ -62,15 +62,24 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
62
62
  - A planned patch may ship under a different actual version only if this table and release-facing documentation are updated before release.
63
63
  - Release notes describe completed facts; this file continues to describe future work.
64
64
  - Internal decomposition alone does not require `0.9.0`; use a minor only for a public compiler/tooling or compatibility boundary.
65
+ - From `0.10.0` onward, each application-capability section owns one minor and each independently accepted evidence packet owns one patch as defined by the active application-capability release plan.
65
66
 
66
67
  ## Generator Versions
67
68
 
68
- `create-kudzu@0.1.103` retains the explicit install instructions and generates projects with `@kudzujs/core@^0.9.0`.
69
+ `create-kudzu@0.1.104` retains the explicit install instructions and generates projects with `@kudzujs/core@^0.10.0`.
69
70
 
70
71
  ## Release Boundary
71
72
 
72
73
  Every patch is independently revertible before publication. After npm publication, never repoint its tag; forward-fix with the next patch. Require the exact commit to pass CI, package smoke installation, registry verification, and the performance gates before starting the next ownership seam.
73
74
 
75
+ From `0.10.0` onward every completed patch packet is a full release transaction:
76
+ version and lockfile updates, release records, an accepted `Release Kudzu X.Y.Z`
77
+ commit, commit push and CI, an immutable `vX.Y.Z` tag push, a published GitHub
78
+ release, npm publication through `.github/workflows/publish.yml`, registry
79
+ verification, and fresh-install verification. Incomplete work sessions retain
80
+ the same target version and do not create commits, tags, or npm releases merely
81
+ because the session ended.
82
+
74
83
  ## Per-Patch Continuation Checklist
75
84
 
76
85
  - [ ] Confirm all earlier planned patches are complete or explicitly replanned.
@@ -10,7 +10,7 @@ Native platform work remains ordinary source. A direct async handler or directly
10
10
 
11
11
  Imperative canvas migrations use the same effect ownership rather than a component or canvas runtime. One `null`-initialized canvas DOM ref may feed an inline effect whose local variables persist across a recursive animation-frame callback, an `IntersectionObserver`, and native canvas/window listeners; the returned cleanup must cancel the latest frame, disconnect the observer, and remove every listener. Bare `IntersectionObserver` and `performance` identifiers remain browser globals in emitted effect ESM. Component-level mutable value refs and callbacks shared across effects or JSX handlers remain unsupported; move resource-private state and listeners into the owning effect.
12
12
 
13
- Localized static migrations do not need a package router. `getStaticPaths()` emits each build-known locale, a small relative Link replacement prefixes native hrefs from the locale prop, and an optional `/` mount effect may choose a stored locale or inspect `navigator.languages` before calling `location.replace()` with the original query and hash. This preserves automatic browser-language entry and locale-prefixed navigation; request-time `Accept-Language` negotiation still requires host or edge configuration. Build-known MDX becomes static `dangerouslySetInnerHTML`, while copy controls, tabs, and other supported interactive components remain ordinary state and handler capabilities beside that HTML.
13
+ Localized static migrations do not need a package router. `getStaticPaths()` emits each build-known locale, a small relative Link replacement prefixes native hrefs from the locale prop, and an optional `/` mount effect may choose a stored locale or inspect `navigator.languages` before calling `location.replace()` with the original query and hash. Runtime-resource `react-i18next` `useTranslation()` calls receive a source diagnostic directing this migration instead of silently selecting one language or retaining i18next. This preserves automatic browser-language entry and locale-prefixed navigation; request-time `Accept-Language` negotiation still requires host or edge configuration. Build-known MDX becomes static `dangerouslySetInnerHTML`, while copy controls, tabs, and other supported interactive components remain ordinary state and handler capabilities beside that HTML.
14
14
 
15
15
  A top-level component local shaped exactly as `const supported = "literal" in navigator` may control one direct `supported && <Branch />` JSX site. Kudzu emits the branch as false static fallback, evaluates the browser capability once through an existing mount effect, and uses existing state-conditional ownership to mount its DOM and handlers only when supported. The value cannot escape or feed aliases, composed expressions, ternaries, dynamic properties, `canShare()`, or other browser globals.
16
16
 
@@ -20,10 +20,16 @@ A named or aliased `Link` import from `react-router-dom` may render directly wit
20
20
 
21
21
  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.
22
22
 
23
- A named or aliased React Router `useSearchParams` import may initialize one top-level `const [params]` or `const [params, setParams]` binding. Each top-level `const value = params.get("literal")` lowers to one cached nullable `useSearchParam()` signal. New Kudzu source may call that same literal reader directly; binding nullable query values to hidden input `value` and `disabled` attributes initializes native GET form carry without a generic mount effect. Direct setter calls inside nested browser callbacks accept one synchronous inline updater over native `URLSearchParams`; no options pushes history and exactly `{ replace: true }` replaces it. The route parameter asset recommits changed query signals and follows `popstate`. Missing keys remain `null`, direct text renders blank, and nullable attributes are removed. Dynamic names, direct-value setters, other methods on the outer params object, aliases, wrapped reads, and layout ownership are rejected. Routes without query reads or writes do not emit this branch or a parameter asset.
23
+ A named or aliased React Router `useMatch` import may directly initialize one top-level `const` from an exact static root-relative string such as `useMatch("/")` in route scope. The compiler compares it case-insensitively with the build-known application route and returns the conventional match object or `null` during rendering, then erases the router import. Reused relative components receive the route of each page invocation. Layout use, runtime-parameter pages, params, wildcards, query/hash patterns, trailing slashes, dynamic patterns, indirect calls, and pattern objects are rejected. No route matcher ships to the browser.
24
+
25
+ A named or aliased React Router `useSearchParams` import may initialize one top-level `const [params]` or `const [params, setParams]` binding. Each top-level `const value = params.get("literal")` lowers to one cached nullable `useSearchParam()` signal. The exact top-level pagination form `const page = Number(params.get("literal")) || finiteNumber` and string form `const order = params.get("literal") || importedImmutableArray[staticIndex]` separate that raw signal into a compiler-owned local and reuse the existing primitive binding evaluator. The string fallback must be a static numeric element of a named relative JSON-safe immutable array. New Kudzu source may call the literal reader directly; binding nullable query values to hidden input `value` and `disabled` attributes initializes native GET form carry without a generic mount effect. Direct setter calls inside nested browser callbacks accept one synchronous inline updater over native `URLSearchParams`; no options pushes history and exactly `{ replace: true }` replaces it. The route parameter asset recommits changed query signals and follows `popstate`, including read-only routes. Missing keys remain `null`, direct text renders blank, and nullable attributes are removed. Dynamic names or indexes, direct-value setters, other methods on the outer params object, aliases, indirect reads, broader wrappers, and layout ownership are rejected. Routes without query reads or writes do not emit this branch or a parameter asset.
24
26
 
25
27
  A named or aliased React Router `useNavigate` import may initialize one top-level `const` identifier. A direct call from a nested browser callback with one safe static root-relative string lowers to `location.assign()` after applying `base`; exactly `{ replace: true }` lowers to `location.replace()`. This deliberately performs native document navigation even when enhanced navigation is configured. Dynamic or relative destinations, render-time calls, aliases passed as values, and options such as `state`, `relative`, or `preventScrollReset` are rejected. Routes without an actual navigation handler emit no browser JavaScript.
26
28
 
29
+ Named or aliased React Bootstrap `Row` and `Col` imports may be used as direct JSX elements with children and at most one static `className`. The React migration normalization pass erases those package imports and emits native `<div>` elements. `Row` emits `row`; a `Col` without spans emits `col`, while numeric literal `xs`, `sm`, `md`, `lg`, `xl`, and `xxl` values from 1 through 12 emit Bootstrap span classes in deterministic largest-to-smallest breakpoint order. The application must keep Bootstrap CSS reachable. Boolean, object, dynamic, offset, and order grid props, dynamic classes, spreads, default/subpath/namespace imports, non-JSX references, and other React Bootstrap components are rejected or remain unsupported. This shape emits no browser JavaScript.
30
+
31
+ Browser-only package-query migrations use one inline dependency effect with native `fetch()` and application-owned loading, error, and data state. Apache Answer-shaped page/order search signals may derive both effect dependencies and endpoint values; dependency replacement prevents a superseded response from writing state, while existing keyed-list ownership handles nested result rows. SWR, axios, `qs`, query clients, caches, Providers, retries, and background schedulers are not retained. Build-known reads should remain async page/component work instead.
32
+
27
33
  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.
28
34
 
29
35
  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. Reactive text and attributes may reference recursively chained top-level immutable locals derived through supported pure primitive expressions from direct state; the compiler substitutes those expressions into the existing binding evaluator and subscribes every source state. Fixed-locale `new Intl.NumberFormat("literal").format(Math.round(expression))` display chains reuse that binding ESM, while dynamic locales and options remain unsupported. 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.
@@ -47,7 +53,7 @@ Reduced Zustand migration stores lower through package-neutral `SharedStateIR` a
47
53
  - `compiler/handler-lowering.mjs`: source-local native/effect callback, reducer, Context, package-neutral shared-action, reactive-binding, and list-evaluator AST lowering before IR finalization.
48
54
  - `compiler/handler-codegen.mjs`: AST-free ordered import rendering and finalized handler/binding export-source assembly.
49
55
  - `compiler/render-control-pass.mjs`: render-function early-return and exhaustive adjacent-assignment normalization.
50
- - `compiler/router-pass.mjs`: React Router import validation and native Link, pathname parameter, search parameter, and imperative navigation lowering.
56
+ - `compiler/router-pass.mjs`: React Router import validation and native Link, build-time exact match, pathname parameter, search parameter, and imperative navigation lowering.
51
57
  - `compiler/route-capability-planner.mjs`: RouteIR v1 validation and pure CapabilityIR v1 projection into runtime and artifact requirements.
52
58
  - `compiler/runtime-family-planner.mjs`: deterministic standalone capability deduplication and enhanced-navigation group unioning at the ESM singleton boundary.
53
59
  - `compiler/route-artifact-report.mjs`: deterministic per-route capability signatures and runtime requirements plus handler, Worker, stylesheet, and transitive esbuild chunk closure reporting.
@@ -246,6 +246,7 @@ async function buildInto(project, outputDirectory, { changedFiles, minify, quiet
246
246
  styles: styleUrls.length ? styleUrls : false,
247
247
  managedStyles: navigable ? sourceStyleUrls : [],
248
248
  base,
249
+ applicationRoute,
249
250
  runtimeAsset: runtimePlaceholder,
250
251
  bindingAsset: bindingPlaceholder,
251
252
  listAsset: listPlaceholder,
@@ -39,7 +39,7 @@ function decodeSegment(raw, param) {
39
39
  return value
40
40
  }
41
41
  ` : ""
42
- const searchInitializer = searchParamsWritable && searchParams.length ? `function initializeSearch(search) {
42
+ const searchInitializer = searchParams.length ? `function initializeSearch(search) {
43
43
  const query = new URLSearchParams(search)
44
44
  for (const param of ${inlineJson(searchParams)}) {
45
45
  const value = query.get(param.name)
@@ -48,13 +48,7 @@ for (const param of ${inlineJson(searchParams)}) {
48
48
  }
49
49
  }
50
50
  ` : ""
51
- const query = searchParams.length ? searchParamsWritable ? "initializeSearch(search)\n" : `const query = new URLSearchParams(search)
52
- for (const param of ${inlineJson(searchParams)}) {
53
- const value = query.get(param.name)
54
- browserState.set(param.id, value)
55
- commitDom(param.id, value)
56
- }
57
- ` : ""
51
+ const query = searchParams.length ? "initializeSearch(search)\n" : ""
58
52
  const writer = searchParamsWritable ? `
59
53
  function setSearchParams(update, replace) {
60
54
  const next = update(new URLSearchParams(location.search))
@@ -66,7 +60,8 @@ function setSearchParams(update, replace) {
66
60
  }
67
61
  ${navigable ? "" : `globalThis.__kSetSearchParams = setSearchParams
68
62
  addEventListener("popstate", () => ${searchParams.length ? "initializeSearch(location.search)" : "undefined"})`}` : ""
63
+ const reader = !navigable && !searchParamsWritable && searchParams.length ? '\naddEventListener("popstate", () => initializeSearch(location.search))' : ""
69
64
  return `import { browserState, commitDom } from ${JSON.stringify(relativeModulePath(output, join(runtimeDirectory, runtimeName)))}
70
- ${searchInitializer}${prefix}${pathname}${query}${suffix}${writer}`
65
+ ${searchInitializer}${prefix}${pathname}${query}${suffix}${writer}${reader}`
71
66
  }
72
67
  }
@@ -3,6 +3,68 @@ import { bindingNames, importDeclarationNames, isFunctionLike, isLocalConst, isR
3
3
  import { analyzeCollectionPipeline, isArrayFromCall } from "./collection-analysis.mjs"
4
4
 
5
5
  export function createReactMigrationPass({ cloneAst, jsxTagName }) {
6
+ function normalizeReactBootstrapLayout(sourceFile, factory, context) {
7
+ const layouts = new Map()
8
+ for (const statement of sourceFile.statements) {
9
+ if (!ts.isImportDeclaration(statement) || statement.importClause?.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text !== "react-bootstrap") continue
10
+ const clause = statement.importClause
11
+ if (!clause || clause.name || !clause.namedBindings || ts.isNamespaceImport(clause.namedBindings)) throw sourceNodeError(statement, sourceFile, "React Bootstrap layout migration requires named Row or Col imports")
12
+ for (const entry of clause.namedBindings.elements) {
13
+ const imported = (entry.propertyName ?? entry.name).text
14
+ if (!entry.isTypeOnly && ["Row", "Col"].includes(imported)) layouts.set(entry.name.text, imported.toLowerCase())
15
+ }
16
+ }
17
+ if (!layouts.size) return sourceFile
18
+
19
+ const layout = tag => ts.isIdentifier(tag) && layouts.has(tag.text) && !isShadowedIdentifier(tag, sourceFile) ? layouts.get(tag.text) : undefined
20
+ const attributes = node => {
21
+ let authored
22
+ const spans = new Map()
23
+ const kind = layout(node.parent.tagName)
24
+ const breakpoints = ["xxl", "xl", "lg", "md", "sm", "xs"]
25
+ for (const property of node.properties) {
26
+ if (ts.isJsxSpreadAttribute(property)) throw sourceNodeError(property, sourceFile, "React Bootstrap Row and Col do not support spread props during native layout lowering")
27
+ const name = property.name.text
28
+ const initializer = property.initializer
29
+ const value = ts.isStringLiteral(initializer) ? initializer : initializer && ts.isJsxExpression(initializer) ? unwrapExpression(initializer.expression) : undefined
30
+ if (name === "className") {
31
+ if (authored !== undefined) throw sourceNodeError(property, sourceFile, "React Bootstrap Row and Col accept className only once")
32
+ if (!value || !ts.isStringLiteral(value) && !ts.isNoSubstitutionTemplateLiteral(value)) throw sourceNodeError(property, sourceFile, "React Bootstrap Row and Col className must be a static string")
33
+ authored = value.text
34
+ continue
35
+ }
36
+ if (kind === "col" && breakpoints.includes(name)) {
37
+ const span = value && ts.isNumericLiteral(value) ? Number(value.text) : NaN
38
+ if (!Number.isInteger(span) || span < 1 || span > 12) throw sourceNodeError(property, sourceFile, `React Bootstrap Col breakpoint ${JSON.stringify(name)} must be an integer literal from 1 through 12`)
39
+ spans.set(name, span)
40
+ continue
41
+ }
42
+ throw sourceNodeError(property, sourceFile, `React Bootstrap Row and Col only support a static className or numeric Col breakpoint prop; found ${JSON.stringify(name)}`)
43
+ }
44
+ const generated = kind === "row" ? ["row"] : spans.size ? breakpoints.filter(name => spans.has(name)).map(name => name === "xs" ? `col-${spans.get(name)}` : `col-${name}-${spans.get(name)}`) : ["col"]
45
+ return factory.createJsxAttributes([factory.createJsxAttribute(factory.createIdentifier("className"), factory.createStringLiteral([...generated, authored].filter(Boolean).join(" ")))])
46
+ }
47
+ const visitor = node => {
48
+ if (ts.isJsxElement(node) && layout(node.openingElement.tagName)) {
49
+ const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("div"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
50
+ const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("div"))
51
+ return factory.updateJsxElement(node, opening, ts.visitNodes(node.children, visitor), closing)
52
+ }
53
+ if (ts.isJsxSelfClosingElement(node) && layout(node.tagName)) return factory.updateJsxSelfClosingElement(node, factory.createIdentifier("div"), node.typeArguments, attributes(node.attributes))
54
+ if (ts.isIdentifier(node) && layouts.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Bootstrap Row and Col imports may only be used as direct JSX elements")
55
+ if (ts.isImportDeclaration(node) && !node.importClause?.isTypeOnly && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-bootstrap") {
56
+ const clause = node.importClause
57
+ const bindings = clause?.namedBindings
58
+ if (!clause || !bindings || !ts.isNamedImports(bindings)) return node
59
+ const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Row", "Col"].includes((entry.propertyName ?? entry.name).text))
60
+ if (!elements.length) return undefined
61
+ return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
62
+ }
63
+ return ts.visitEachChild(node, visitor, context)
64
+ }
65
+ return ts.visitNode(sourceFile, visitor)
66
+ }
67
+
6
68
  function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections = new Set()) {
7
69
  const supported = new Set(["createContext", "useContext", "useEffect", "useId", "useReducer", "useRef", "useState"])
8
70
  const erased = new Set(["createRef", "forwardRef", "memo", "useCallback", "useMemo"])
@@ -19,7 +81,7 @@ export function createReactMigrationPass({ cloneAst, jsxTagName }) {
19
81
  else if (!entry.isTypeOnly && /^use[A-Z]/.test(imported)) throw sourceNodeError(entry, sourceFile, `React ${imported} is not supported by Kudzu migration input`)
20
82
  }
21
83
  }
22
- if (!aliases.size && !reactObjects.size) return sourceFile
84
+ if (!aliases.size && !reactObjects.size) return normalizeReactBootstrapLayout(sourceFile, factory, context)
23
85
 
24
86
  const migrationCallName = call => {
25
87
  if (ts.isIdentifier(call.expression) && aliases.has(call.expression.text) && !isShadowedIdentifier(call.expression, sourceFile)) return aliases.get(call.expression.text)
@@ -193,7 +255,7 @@ export function createReactMigrationPass({ cloneAst, jsxTagName }) {
193
255
  }
194
256
  let normalized = ts.visitNode(sourceFile, visitor)
195
257
  const missing = [...required].filter(name => !imported.has(name)).sort()
196
- if (!missing.length) return normalized
258
+ if (!missing.length) return normalizeReactBootstrapLayout(normalized, factory, context)
197
259
  for (const name of missing) {
198
260
  const collision = sourceFile.statements.some(statement => statementDeclaresName(statement, name) || ts.isImportDeclaration(statement) && importDeclarationNames(statement).includes(name) && statement.moduleSpecifier.text !== "react")
199
261
  if (collision) throw sourceNodeError(sourceFile, sourceFile, `React.${name} cannot be normalized because ${JSON.stringify(name)} is already declared`)
@@ -203,7 +265,7 @@ export function createReactMigrationPass({ cloneAst, jsxTagName }) {
203
265
  const lastImport = statements.findLastIndex(statement => ts.isImportDeclaration(statement))
204
266
  statements.splice(lastImport + 1, 0, declaration)
205
267
  normalized = factory.updateSourceFile(normalized, statements)
206
- return normalized
268
+ return normalizeReactBootstrapLayout(normalized, factory, context)
207
269
  }
208
270
 
209
271
  function lowerReactForwardRef(call, sourceFile, factory) {
@@ -2,9 +2,10 @@ import ts from "typescript"
2
2
  import { bindingNames, functionVarDeclaresName, isFunctionLike, isLocalConst, isReferenceIdentifier, isShadowedIdentifier, loopDeclaresName, nearestFunction, sourceNodeError, statementDeclaresName, unwrapExpression } from "./ast-helpers.mjs"
3
3
 
4
4
  export function createRouterPass({ withBase }) {
5
- return function normalizeReactRouterSyntax(sourceFile, factory, context, base) {
5
+ return function normalizeReactRouterSyntax(sourceFile, factory, context, base, importedCollections = new Map()) {
6
6
  const links = new Set()
7
7
  const params = new Set()
8
+ const matchHooks = new Set()
8
9
  const searchHooks = new Set()
9
10
  const navigateHooks = new Set()
10
11
  for (const statement of sourceFile.statements) {
@@ -13,22 +14,62 @@ export function createRouterPass({ withBase }) {
13
14
  const clause = statement.importClause
14
15
  if (clause?.isTypeOnly) continue
15
16
  if (!clause) throw sourceNodeError(statement, sourceFile, "Side-effect React Router imports are not supported")
16
- if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, useSearchParams, or useNavigate imports")
17
+ if (clause.name) throw sourceNodeError(clause.name, sourceFile, "React Router default imports are not supported; use named Link, useParams, useMatch, useSearchParams, or useNavigate imports")
17
18
  const bindings = clause.namedBindings
18
- if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link, useParams, useSearchParams, or useNavigate imports")
19
+ if (!bindings || ts.isNamespaceImport(bindings)) throw sourceNodeError(bindings ?? statement, sourceFile, "React Router namespace imports are not supported; use named Link, useParams, useMatch, useSearchParams, or useNavigate imports")
19
20
  for (const entry of bindings.elements) {
20
21
  if (entry.isTypeOnly) continue
21
22
  const imported = (entry.propertyName ?? entry.name).text
22
23
  if (imported === "NavLink") throw sourceNodeError(entry, sourceFile, "React Router NavLink active-route semantics cannot be erased to a native anchor")
23
24
  if (imported === "Link") links.add(entry.name.text)
24
25
  else if (imported === "useParams") params.add(entry.name.text)
26
+ else if (imported === "useMatch") matchHooks.add(entry.name.text)
25
27
  else if (imported === "useSearchParams") searchHooks.add(entry.name.text)
26
28
  else if (imported === "useNavigate") navigateHooks.add(entry.name.text)
27
- else throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link, useParams, useSearchParams, and useNavigate imports can be lowered`)
29
+ else throw sourceNodeError(entry, sourceFile, `React Router ${imported} is not supported; only named Link, useParams, useMatch, useSearchParams, and useNavigate imports can be lowered`)
28
30
  }
29
31
  }
30
32
  }
31
- if (!links.size && !params.size && !searchHooks.size && !navigateHooks.size) return sourceFile
33
+ if (!links.size && !params.size && !matchHooks.size && !searchHooks.size && !navigateHooks.size) return sourceFile
34
+
35
+ let matchHelper = "__kUseRouteMatch"
36
+ while (sourceFile.text.includes(matchHelper)) matchHelper += "_"
37
+ const matchCalls = new Map()
38
+ const topLevelFunction = owner => owner.parent === sourceFile || ts.isExportAssignment(owner.parent) && owner.parent.parent === sourceFile || (ts.isArrowFunction(owner) || ts.isFunctionExpression(owner)) && ts.isVariableDeclaration(owner.parent) && owner.parent.parent?.parent?.parent === sourceFile
39
+ const componentFunction = owner => {
40
+ if (!topLevelFunction(owner)) return false
41
+ if (ts.isExportAssignment(owner.parent)) return true
42
+ if (ts.isFunctionDeclaration(owner) && owner.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.DefaultKeyword)) return true
43
+ const name = ts.isFunctionDeclaration(owner) ? owner.name?.text : ts.isVariableDeclaration(owner.parent) && ts.isIdentifier(owner.parent.name) ? owner.parent.name.text : undefined
44
+ if (!name || !/^[A-Z]/.test(name)) return false
45
+ let eventHandlerUse = false
46
+ const inspect = node => {
47
+ if (eventHandlerUse) return
48
+ if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) {
49
+ for (let current = node.parent; current && current !== sourceFile; current = current.parent) {
50
+ if (ts.isJsxAttribute(current) && /^on[A-Z]/.test(current.name.text)) eventHandlerUse = true
51
+ }
52
+ }
53
+ ts.forEachChild(node, inspect)
54
+ }
55
+ inspect(sourceFile)
56
+ return !eventHandlerUse
57
+ }
58
+ const collectMatchHooks = node => {
59
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && matchHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
60
+ const declaration = node.parent
61
+ const statement = declaration?.parent?.parent
62
+ const owner = nearestFunction(node)
63
+ if (node.questionDotToken || node.typeArguments?.length || node.arguments.length !== 1 || !ts.isStringLiteral(node.arguments[0]) || !ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || !owner || !componentFunction(owner) || statement?.parent !== owner.body) {
64
+ throw sourceNodeError(node, sourceFile, 'React Router useMatch must directly initialize one top-level const from one static root-relative pattern such as useMatch("/")')
65
+ }
66
+ const pattern = node.arguments[0].text
67
+ if (!pattern.startsWith("/") || pattern.startsWith("//") || pattern !== "/" && pattern.endsWith("/") || /[:*?#\\\0]/.test(pattern)) throw sourceNodeError(node.arguments[0], sourceFile, 'React Router useMatch only supports an exact static root-relative pattern without params, wildcards, query, hash, or a trailing slash')
68
+ matchCalls.set(node, pattern)
69
+ }
70
+ ts.forEachChild(node, collectMatchHooks)
71
+ }
72
+ collectMatchHooks(sourceFile)
32
73
 
33
74
  let searchHelper = "__kUseSearchParam"
34
75
  while (sourceFile.text.includes(searchHelper)) searchHelper += "_"
@@ -36,8 +77,18 @@ export function createRouterPass({ withBase }) {
36
77
  while (sourceFile.text.includes(searchWriterHelper)) searchWriterHelper += "_"
37
78
  const searchDeclarations = new Set()
38
79
  const searchReads = new Map()
80
+ const searchFallbackValues = new Map()
81
+ const composedSearchDeclarations = new Map()
82
+ const composedSearchCalls = new Map()
39
83
  const searchWrites = new Map()
40
84
  const searchObjects = []
85
+ let composedSearchIndex = 0
86
+ const composedSearchName = () => {
87
+ let name
88
+ do name = `__kRouterSearchParam${composedSearchIndex++ || ""}`
89
+ while (sourceFile.text.includes(name) || [...composedSearchCalls.values()].includes(name))
90
+ return name
91
+ }
41
92
  const collectSearchHooks = node => {
42
93
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && searchHooks.has(node.expression.text) && !isShadowedIdentifier(node.expression, sourceFile)) {
43
94
  const declaration = node.parent
@@ -71,15 +122,38 @@ export function createRouterPass({ withBase }) {
71
122
  if (entry.setter && ts.isCallExpression(node.parent) && node.parent.arguments.includes(node) && ts.isIdentifier(node.parent.expression) && node.parent.expression.text === entry.setter) return
72
123
  const property = node.parent
73
124
  const call = property?.parent
74
- const declaration = call?.parent
125
+ const directDeclaration = call?.parent
126
+ const numberCall = directDeclaration && ts.isCallExpression(directDeclaration) && ts.isIdentifier(directDeclaration.expression) && directDeclaration.expression.text === "Number" && !isShadowedIdentifier(directDeclaration.expression, sourceFile) && directDeclaration.arguments.length === 1 && directDeclaration.arguments[0] === call && !directDeclaration.questionDotToken && !directDeclaration.typeArguments?.length ? directDeclaration : undefined
127
+ const binary = numberCall?.parent ?? directDeclaration
128
+ const numericRight = binary?.right
129
+ const numericValue = numericRight && ts.isNumericLiteral(numericRight) ? Number(numericRight.text) : numericRight && ts.isPrefixUnaryExpression(numericRight) && [ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken].includes(numericRight.operator) && ts.isNumericLiteral(numericRight.operand) ? Number(numericRight.getText(sourceFile)) : NaN
130
+ const numericFallback = numberCall && ts.isBinaryExpression(binary) && binary.left === numberCall && binary.operatorToken.kind === ts.SyntaxKind.BarBarToken && Number.isFinite(numericValue) ? binary : undefined
131
+ const importedValue = !numberCall && ts.isBinaryExpression(binary) && binary.left === call && binary.operatorToken.kind === ts.SyntaxKind.BarBarToken ? binary.right : undefined
132
+ const importedCollection = importedValue && ts.isElementAccessExpression(importedValue) && !importedValue.questionDotToken && ts.isIdentifier(importedValue.expression) && ts.isNumericLiteral(importedValue.argumentExpression) ? importedCollections.get(importedValue.expression.text) : undefined
133
+ const importedIndex = importedValue && ts.isElementAccessExpression(importedValue) && ts.isNumericLiteral(importedValue.argumentExpression) ? Number(importedValue.argumentExpression.text) : undefined
134
+ const importedElement = importedCollection && Number.isSafeInteger(importedIndex) ? importedCollection.elements[importedIndex] : undefined
135
+ const importedString = importedElement && unwrapExpression(importedElement)
136
+ const importedFallback = importedString && (ts.isStringLiteral(importedString) || ts.isNoSubstitutionTemplateLiteral(importedString)) ? binary : undefined
137
+ const fallback = numericFallback ?? importedFallback
138
+ let composedInitializer = fallback
139
+ while (composedInitializer?.parent && (ts.isParenthesizedExpression(composedInitializer.parent) || ts.isAsExpression(composedInitializer.parent) || ts.isTypeAssertionExpression(composedInitializer.parent) || ts.isSatisfiesExpression(composedInitializer.parent) || ts.isNonNullExpression(composedInitializer.parent)) && composedInitializer.parent.expression === composedInitializer) composedInitializer = composedInitializer.parent
140
+ const composedDeclaration = composedInitializer?.parent
141
+ const declaration = directDeclaration && ts.isVariableDeclaration(directDeclaration) ? directDeclaration : composedDeclaration && ts.isVariableDeclaration(composedDeclaration) ? composedDeclaration : undefined
75
142
  const statement = declaration?.parent?.parent
76
143
  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])) {
77
144
  throw sourceNodeError(node, sourceFile, 'React Router search parameters only support direct get("static-name") reads')
78
145
  }
79
- if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== call || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || statement?.parent !== entry.owner.body) {
80
- throw sourceNodeError(call, sourceFile, "React Router search parameter get() must directly initialize one top-level const identifier")
146
+ if (!declaration || declaration.initializer !== call && declaration.initializer !== composedInitializer || !ts.isIdentifier(declaration.name) || !isLocalConst(declaration) || statement?.parent !== entry.owner.body) {
147
+ throw sourceNodeError(call, sourceFile, 'React Router search parameter get() must directly initialize one top-level const, appear as Number(params.get("name")) || finiteNumber, or use params.get("name") || importedArray[staticIndex]')
81
148
  }
149
+ if (fallback && declaration.parent.declarations.length !== 1) throw sourceNodeError(declaration, sourceFile, "React Router composed search parameter reads require their own top-level const statement")
82
150
  searchReads.set(call, call.arguments[0])
151
+ if (importedValue && importedFallback) searchFallbackValues.set(importedValue, importedString.text)
152
+ if (fallback) {
153
+ const name = composedSearchName()
154
+ composedSearchDeclarations.set(declaration, { call, name })
155
+ composedSearchCalls.set(call, name)
156
+ }
83
157
  return
84
158
  }
85
159
  ts.forEachChild(node, collectReads)
@@ -185,6 +259,17 @@ export function createRouterPass({ withBase }) {
185
259
  }
186
260
  const importedLink = tag => ts.isIdentifier(tag) && links.has(tag.text) && !isShadowedIdentifier(tag, sourceFile)
187
261
  const visitor = node => {
262
+ if (searchFallbackValues.has(node)) return factory.createStringLiteral(searchFallbackValues.get(node))
263
+ if (ts.isVariableStatement(node) && node.declarationList.declarations.length === 1) {
264
+ const declaration = node.declarationList.declarations[0]
265
+ const composed = composedSearchDeclarations.get(declaration)
266
+ if (composed) {
267
+ const raw = factory.createVariableStatement(undefined, factory.createVariableDeclarationList([
268
+ factory.createVariableDeclaration(composed.name, undefined, undefined, factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(composed.call)]))
269
+ ], ts.NodeFlags.Const))
270
+ return [raw, ts.visitEachChild(node, visitor, context)]
271
+ }
272
+ }
188
273
  if (ts.isVariableStatement(node) && node.declarationList.declarations.some(declaration => searchDeclarations.has(declaration) || navigateDeclarations.has(declaration))) {
189
274
  const declarations = node.declarationList.declarations.flatMap(declaration => {
190
275
  if (navigateDeclarations.has(declaration)) return []
@@ -196,7 +281,7 @@ export function createRouterPass({ withBase }) {
196
281
  if (!declarations.length) return undefined
197
282
  return factory.updateVariableStatement(node, node.modifiers, factory.updateVariableDeclarationList(node.declarationList, declarations))
198
283
  }
199
- if (ts.isCallExpression(node) && searchReads.has(node)) return factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(node)])
284
+ if (ts.isCallExpression(node) && searchReads.has(node)) return composedSearchCalls.has(node) ? factory.createIdentifier(composedSearchCalls.get(node)) : factory.createCallExpression(factory.createIdentifier(searchHelper), undefined, [searchReads.get(node)])
200
285
  if (ts.isCallExpression(node) && searchWrites.has(node)) {
201
286
  const { updater, replace } = searchWrites.get(node)
202
287
  return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "__kSetSearchParams"), undefined, [ts.visitNode(updater, visitor), replace ? factory.createTrue() : factory.createFalse()])
@@ -205,6 +290,7 @@ export function createRouterPass({ withBase }) {
205
290
  const { method, destination } = navigateCalls.get(node)
206
291
  return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createPropertyAccessExpression(factory.createIdentifier("globalThis"), "location"), method), undefined, [factory.createStringLiteral(destination)])
207
292
  }
293
+ if (ts.isCallExpression(node) && matchCalls.has(node)) return factory.createCallExpression(factory.createIdentifier(matchHelper), undefined, [factory.createStringLiteral(matchCalls.get(node))])
208
294
  if (ts.isJsxElement(node) && importedLink(node.openingElement.tagName)) {
209
295
  const opening = factory.updateJsxOpeningElement(node.openingElement, factory.createIdentifier("a"), node.openingElement.typeArguments, attributes(node.openingElement.attributes))
210
296
  const closing = factory.updateJsxClosingElement(node.closingElement, factory.createIdentifier("a"))
@@ -217,6 +303,7 @@ export function createRouterPass({ withBase }) {
217
303
  }
218
304
  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")
219
305
  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")
306
+ if (ts.isIdentifier(node) && matchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useMatch imports may only initialize the supported top-level const binding")
220
307
  if (ts.isIdentifier(node) && searchHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useSearchParams imports may only initialize the supported top-level tuple binding")
221
308
  if (ts.isIdentifier(node) && navigateHooks.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, sourceFile)) throw sourceNodeError(node, sourceFile, "React Router useNavigate imports may only initialize the supported top-level navigate binding")
222
309
  if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier) && node.moduleSpecifier.text === "react-router-dom") {
@@ -224,16 +311,17 @@ export function createRouterPass({ withBase }) {
224
311
  if (!clause || clause.isTypeOnly) return node
225
312
  const bindings = clause.namedBindings
226
313
  if (!bindings || !ts.isNamedImports(bindings)) return node
227
- const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useSearchParams", "useNavigate"].includes((entry.propertyName ?? entry.name).text))
314
+ const elements = bindings.elements.filter(entry => entry.isTypeOnly || !["Link", "useParams", "useMatch", "useSearchParams", "useNavigate"].includes((entry.propertyName ?? entry.name).text))
228
315
  if (!elements.length) return undefined
229
316
  return factory.updateImportDeclaration(node, node.modifiers, factory.updateImportClause(clause, clause.isTypeOnly, undefined, factory.updateNamedImports(bindings, elements)), node.moduleSpecifier, node.attributes)
230
317
  }
231
318
  return ts.visitEachChild(node, visitor, context)
232
319
  }
233
320
  const normalized = ts.visitNode(sourceFile, visitor)
234
- if (!params.size && !searchHooks.size) return normalized
321
+ if (!params.size && !matchHooks.size && !searchHooks.size) return normalized
235
322
  const imports = [
236
323
  ...[...params].map(name => factory.createImportSpecifier(false, name === "useParams" ? undefined : factory.createIdentifier("useParams"), factory.createIdentifier(name))),
324
+ ...(matchHooks.size ? [factory.createImportSpecifier(false, matchHelper === "__kUseRouteMatch" ? undefined : factory.createIdentifier("__kUseRouteMatch"), factory.createIdentifier(matchHelper))] : []),
237
325
  ...(searchReads.size ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParam"), factory.createIdentifier(searchHelper))] : []),
238
326
  ...(searchObjects.some(entry => entry.setter) ? [factory.createImportSpecifier(false, factory.createIdentifier("useSearchParamsWriter"), factory.createIdentifier(searchWriterHelper))] : [])
239
327
  ]
@@ -315,12 +315,12 @@ function normalizeParameterizedDebounceHooks(sourceFile, factory, context) {
315
315
  return ts.visitNode(sourceFile, visitor)
316
316
  }
317
317
 
318
- function normalizeCompilerSource(sourceFile, { base, context, file, importedCollections, importedStaticCollections, sourceFiles, sourceIndex }) {
318
+ function normalizeCompilerSource(sourceFile, { base, context, file, importedCollections, importedFallbackCollections, importedStaticCollections, sourceFiles, sourceIndex }) {
319
319
  const factory = context.factory
320
320
  let customHookTimerStates = new Set()
321
321
  sourceFile = applyNormalizationPasses(sourceFile, [
322
322
  ...(importedStaticCollections ? [source => normalizeImportedStaticCollections(source, importedStaticCollections, factory, context)] : []),
323
- source => normalizeReactRouterSyntax(source, factory, context, base),
323
+ source => normalizeReactRouterSyntax(source, factory, context, base, importedFallbackCollections),
324
324
  source => normalizeClsxSyntax(source, factory, context),
325
325
  source => normalizeMediaQueryExternalStores(source, factory, context),
326
326
  source => normalizeReactMigrationSyntax(source, factory, context, importedCollections ?? importedSerializableCollectionNames(source, file, sourceFiles, sourceIndex)),
@@ -354,7 +354,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
354
354
  const hasLinkElements = /<link/i.test(sourceFile.text)
355
355
  const importedStaticCollections = importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex, true)
356
356
  const importedCollections = new Set(importedStaticCollections.keys())
357
- const normalized = normalizeCompilerSource(sourceFile, { base, context, file, importedCollections, importedStaticCollections, sourceFiles, sourceIndex })
357
+ const importedFallbackCollections = importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex)
358
+ const normalized = normalizeCompilerSource(sourceFile, { base, context, file, importedCollections, importedFallbackCollections, importedStaticCollections, sourceFiles, sourceIndex })
358
359
  sourceFile = normalized.sourceFile
359
360
  const { customHookTimerStates } = normalized
360
361
  const bindingIndex = createBindingIndex(sourceFile)
@@ -398,9 +399,10 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
398
399
  })
399
400
  const importBindings = clientImportBindings(sourceFile, file, sourceFiles)
400
401
  const packageBindings = packageImportBindings(sourceFile)
401
- for (const [name] of packageBindings) {
402
+ for (const [name, binding] of packageBindings) {
402
403
  const references = referenceIdentifiers(sourceFile, name)
403
404
  const invalid = references.find(reference => !insideJsxEventHandler(reference, sourceFile) && !insideOwnedEffectCallback(reference, sourceFile))
405
+ if (invalid && binding.target === "react-i18next" && binding.imported === "useTranslation") throw sourceNodeError(invalid, sourceFile, "React i18next useTranslation() depends on runtime locale resources; migrate build-known locales through getStaticPaths() and props, or browser-only locale reads through an owned effect")
404
406
  if (invalid) throw sourceNodeError(invalid, sourceFile, `Package import ${JSON.stringify(name)} may only be referenced directly inside JSX event handlers or owned effect setup/cleanup callbacks`)
405
407
  }
406
408
  const hasUseEffectImport = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && ["@kudzujs/core", "react"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useEffect"))
@@ -661,6 +663,15 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
661
663
  const collect = node => {
662
664
  if (ts.isVariableDeclaration(node) && node.initializer && ts.isCallExpression(node.initializer)) {
663
665
  const callName = ts.isIdentifier(node.initializer.expression) ? node.initializer.expression.text : ""
666
+ if (ts.isIdentifier(node.name) && /^__kRouterSearchParam\d*$/.test(node.name.text) && /^__kUseSearchParam_*$/.test(callName)) {
667
+ const owner = nearestFunction(node)
668
+ if (!owner || !isLocalConst(node) || node.parent?.parent?.parent !== owner.body) throw sourceNodeError(node, sourceFile, "Compiler-owned search parameter signals must be top-level component const declarations")
669
+ const setters = settersByFunction.get(owner) ?? new Map()
670
+ const setter = `__kSet${node.name.text}`
671
+ setters.set(setter, node.name.text)
672
+ registerState(owner, node.name.text, setter, "search-param", node)
673
+ settersByFunction.set(owner, setters)
674
+ }
664
675
  const customHookImport = callName && /^use[A-Z]/.test(callName) && importBindings.has(callName) && importBindings.get(callName).kind !== "namespace" && !resolvedSharedState(importBindings.get(callName))
665
676
  const debounce = customHookImport && ts.isIdentifier(node.name) ? resolveParameterizedDebounceHook(importBindings.get(callName)) : undefined
666
677
  if (debounce) {
@@ -1110,6 +1121,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1110
1121
  const declarations = jsxLocalDeclarations.get(owner)
1111
1122
  if (!declarations) return expression
1112
1123
  const substitutions = new Map()
1124
+ const stateNames = new Set(setters.values())
1113
1125
  const resolving = []
1114
1126
  const resolve = (name, reference) => {
1115
1127
  if (substitutions.has(name)) return
@@ -1122,7 +1134,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1122
1134
  resolving.push(name)
1123
1135
  const initializer = entries[0].initializer
1124
1136
  const visit = node => {
1125
- if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !isShadowedByParameter(node, initializer) && declarations.has(node.text)) resolve(node.text, node)
1137
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !isShadowedByParameter(node, initializer) && declarations.has(node.text) && !stateNames.has(node.text)) resolve(node.text, node)
1126
1138
  ts.forEachChild(node, visit)
1127
1139
  }
1128
1140
  visit(initializer)
@@ -1130,7 +1142,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1130
1142
  resolving.pop()
1131
1143
  }
1132
1144
  const visit = node => {
1133
- if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression) && declarations.has(node.text)) resolve(node.text, node)
1145
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node) && !isShadowedByParameter(node, expression) && declarations.has(node.text) && !stateNames.has(node.text)) resolve(node.text, node)
1134
1146
  ts.forEachChild(node, visit)
1135
1147
  }
1136
1148
  visit(expression)
@@ -1632,7 +1644,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1632
1644
  const entries = jsxLocalDeclarations.get(nearestFunction(node))?.get(value.expression.text)
1633
1645
  if (!entries?.length) return undefined
1634
1646
  const initializer = entries.length === 1 ? unwrapExpression(entries[0].initializer) : undefined
1635
- if (!initializer || !ts.isCallExpression(initializer) || !ts.isIdentifier(initializer.expression) || !importBindings.has(initializer.expression.text)) return undefined
1647
+ if (!initializer || !ts.isCallExpression(initializer) || !ts.isIdentifier(initializer.expression) || !importBindings.has(initializer.expression.text) || resolvedSharedState(importBindings.get(initializer.expression.text))) return undefined
1636
1648
  if (entries[0].node.parent?.parent?.parent !== nearestFunction(entries[0].node)?.body) fail(value.expression, `Calculated collection result "${value.expression.text}" must be one top-level immutable local`)
1637
1649
  validateImportedCalculation(initializer, value.name.text)
1638
1650
  const expanded = resolveReactiveJsxExpression(value, nearestFunction(node), setters)
@@ -1974,11 +1986,13 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1974
1986
  if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
1975
1987
  if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
1976
1988
  const setters = settersForNode(node, settersByFunction)
1989
+ const effectStateNames = new Set(setters.values())
1977
1990
  const resolveCalculation = dependency => {
1978
1991
  if (specializedEffect) return undefined
1979
1992
  const value = unwrapExpression(dependency)
1980
1993
  const result = ts.isIdentifier(value) ? value : ts.isPropertyAccessExpression(value) || ts.isElementAccessExpression(value) ? unwrapExpression(value.expression) : undefined
1981
1994
  if (!result || !ts.isIdentifier(result)) return undefined
1995
+ if (effectStateNames.has(result.text)) return undefined
1982
1996
  const entries = jsxLocalDeclarations.get(effectOwner)?.get(result.text)
1983
1997
  const initializer = entries?.length === 1 && entries[0].node.parent?.parent?.parent === effectOwner?.body ? unwrapExpression(entries[0].initializer) : undefined
1984
1998
  if (!initializer || !ts.isCallExpression(initializer) || !ts.isIdentifier(initializer.expression) || !importBindings.has(initializer.expression.text)) return undefined
@@ -123,6 +123,19 @@ export function useParams() {
123
123
  return renderContext.params
124
124
  }
125
125
 
126
+ export function __kUseRouteMatch(pattern) {
127
+ if (!renderContext) throw new Error("React Router useMatch can only run while rendering a Kudzu component")
128
+ if (renderContext.renderScope === "layout") throw new Error("React Router useMatch is only supported in route scope")
129
+ if (renderContext.runtimeParamNames?.length) throw new Error("React Router useMatch requires a build-known route and cannot run on a runtimeParams bracket page")
130
+ const pathname = renderContext.applicationRoute
131
+ let decodedPathname = pathname
132
+ try {
133
+ decodedPathname = pathname.split("/").map(segment => decodeURIComponent(segment).replaceAll("/", "%2F")).join("/")
134
+ } catch {}
135
+ const matches = new RegExp(`^${pattern.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`, "i").test(decodedPathname)
136
+ return matches ? { params: {}, pathname: decodedPathname, pathnameBase: decodedPathname, pattern: { path: pattern, caseSensitive: false, end: true } } : null
137
+ }
138
+
126
139
  export function useSearchParam(name) {
127
140
  if (!renderContext) throw new Error("useSearchParam() can only run while rendering a Kudzu component")
128
141
  if (renderContext?.renderScope === "layout") throw new Error("useSearchParam() is only supported in route scope")
@@ -471,7 +484,7 @@ function serializeCapture(name, value, seen) {
471
484
  }
472
485
 
473
486
  export async function renderPage(component, metadata = {}, props = {}, layout) {
474
- 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: [], captureCache: new WeakMap(), sharedStates: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerReferences: new Map(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], searchParamsWritable: false, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
487
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", applicationRoute: metadata.applicationRoute, 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: [], captureCache: new WeakMap(), sharedStates: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerReferences: new Map(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], searchParamsWritable: false, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
475
488
 
476
489
  try {
477
490
  const page = { [routeScopeMarker]: true, component, props }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.9.2",
3
+ "version": "0.10.0",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",