@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.
- package/MIGRATION_ROADMAP.md +29 -1
- package/README.md +6 -3
- package/RELEASES.md +93 -0
- package/docs/next-architecture/1.0-large-application-compatibility-audit.md +370 -0
- package/docs/next-architecture/README.md +19 -13
- package/docs/next-architecture/application-capability-release-plan.md +838 -0
- package/docs/next-architecture/goal-d-routing-compatibility-decisions.md +1 -0
- package/docs/next-architecture/versioning.md +11 -2
- package/framework/README.md +9 -3
- package/framework/build.mjs +1 -0
- package/framework/compiler/param-codegen.mjs +4 -9
- package/framework/compiler/react-migration-pass.mjs +65 -3
- package/framework/compiler/router-pass.mjs +99 -11
- package/framework/compiler/source-compiler.mjs +21 -7
- package/framework/core.mjs +14 -1
- package/package.json +1 -1
|
@@ -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.
|
|
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.
|
package/framework/README.md
CHANGED
|
@@ -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 `
|
|
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.
|
package/framework/build.mjs
CHANGED
|
@@ -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 =
|
|
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 ?
|
|
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
|
|
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 (!
|
|
80
|
-
throw sourceNodeError(call, sourceFile,
|
|
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
|
|
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
|
package/framework/core.mjs
CHANGED
|
@@ -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 }
|