@kudzujs/core 0.6.11 → 0.6.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -1
- package/framework/README.md +1 -1
- package/framework/build.mjs +107 -14
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -180,7 +180,17 @@ const [todos, dispatch] = useReducer(todoReducer, [])
|
|
|
180
180
|
dispatch({ type: "add", title: "Ship" })
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
-
The reduced migration form requires `[state, dispatch]`, exactly two hook arguments, and a synchronous two-parameter reducer exported as a default or named value from a relative TypeScript module. Dispatches in compiled handlers lower to functional state updates
|
|
183
|
+
The reduced migration form requires `[state, dispatch]`, exactly two hook arguments, and a synchronous two-parameter reducer exported as a default or named value from a relative TypeScript module. Dispatches in compiled handlers lower to functional state updates. A dispatch may cross one direct prop boundary into a same-file or relative-imported synchronous component whose intrinsic root contains the compiled handler:
|
|
184
|
+
|
|
185
|
+
```tsx
|
|
186
|
+
function Controls({ dispatch }: { dispatch: Dispatch<TodoAction> }) {
|
|
187
|
+
return <button onClick={() => dispatch({ type: "add", title: "Ship" })}>Add</button>
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return <Controls dispatch={dispatch} />
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
Kudzu specializes that call at build time; no function prop or child component survives in the browser. Relative TypeScript constants and helpers used inside the child handler are renamed for call-site safety and bundled into the parent handler graph. Lazy initializers, package, namespace, local, async, and generator reducers, package imports or child imports used outside event handlers, forwarding across another component, and reducer dispatch through context remain unsupported.
|
|
184
194
|
|
|
185
195
|
## Reactive Attributes
|
|
186
196
|
|
package/framework/README.md
CHANGED
|
@@ -25,7 +25,7 @@ Inline SVG rendering normalizes an explicit set of common React presentation ali
|
|
|
25
25
|
|
|
26
26
|
Same-file and relative-imported components receiving a direct local-state array prop are specialized to intrinsic JSX before keyed-list analysis, so their component function is not retained in the browser. Handler modules are emitted only when a rendered descriptor references them, preventing specialized imported components from adding dead browser assets. Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal, unrelated fields do nothing, and key changes remain remove plus mount. Builds without item dependencies emit no item reader or list-state subscription code.
|
|
27
27
|
|
|
28
|
-
The reduced `useReducer` form reuses ordinary state slots. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
|
|
28
|
+
The reduced `useReducer` form reuses ordinary state slots. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. One direct dispatch prop into a same-file or relative-imported synchronous component is specialized to intrinsic JSX at the call site, so its handler retains the parent reducer scope and no dispatch capture or child handler asset is emitted. Relative TypeScript imports referenced inside that child handler receive collision-free call-site aliases and join the parent handler graph. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
|
|
29
29
|
|
|
30
30
|
`kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
|
|
31
31
|
|
package/framework/build.mjs
CHANGED
|
@@ -1738,6 +1738,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
1738
1738
|
const specializedDeclarations = new WeakSet()
|
|
1739
1739
|
const stateBackedComponentFunctions = new WeakSet()
|
|
1740
1740
|
const stateBackedComponentRoots = []
|
|
1741
|
+
let specializedImportIndex = 0
|
|
1741
1742
|
for (const [name, component] of components) {
|
|
1742
1743
|
const calls = jsxTagUses(sourceFile, name)
|
|
1743
1744
|
const stateBackedCalls = calls.filter(call => isStateBackedListComponentCall(call, component.function, settersByFunction.get(nearestFunction(call)) ?? new Map()))
|
|
@@ -1774,6 +1775,61 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
|
|
|
1774
1775
|
stateBackedComponentRoots.push(specialization.root)
|
|
1775
1776
|
}
|
|
1776
1777
|
}
|
|
1778
|
+
for (const [name, component] of components) {
|
|
1779
|
+
const calls = jsxTagUses(sourceFile, name)
|
|
1780
|
+
const dispatchCalls = calls.filter(call => jsxCallHasDirectReducerProp(call, reducersForNode(call, reducersByFunction)))
|
|
1781
|
+
if (!dispatchCalls.length) continue
|
|
1782
|
+
if (isExportedDeclaration(component.declaration)) fail(component.declaration, `Reducer-dispatch component ${name} cannot be exported`)
|
|
1783
|
+
if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `Reducer-dispatch component ${name} may only be referenced as JSX`)
|
|
1784
|
+
if (dispatchCalls.length !== calls.length) fail(component.declaration, `Reducer-dispatch component ${name} must receive a direct local reducer dispatch at every call`)
|
|
1785
|
+
for (const call of dispatchCalls) {
|
|
1786
|
+
if (componentSpecializations.has(call)) fail(call, "Reducer dispatch props cannot be combined with another component specialization")
|
|
1787
|
+
const specialization = specializeComponentCall(call, component.function, sourceFile, factory, context, fail, "Reducer-dispatch")
|
|
1788
|
+
if (specialization.effects.length) fail(call, "Reducer-dispatch components cannot declare effects")
|
|
1789
|
+
componentSpecializations.set(call, specialization)
|
|
1790
|
+
}
|
|
1791
|
+
specializedDeclarations.add(component.declaration)
|
|
1792
|
+
}
|
|
1793
|
+
for (const [name, binding] of importBindings) {
|
|
1794
|
+
if (binding.kind === "namespace") continue
|
|
1795
|
+
const calls = jsxTagUses(sourceFile, name)
|
|
1796
|
+
const dispatchCalls = calls.filter(call => jsxCallHasDirectReducerProp(call, reducersForNode(call, reducersByFunction)))
|
|
1797
|
+
if (!dispatchCalls.length) continue
|
|
1798
|
+
const imported = binding.kind === "default" ? "default" : binding.imported
|
|
1799
|
+
let component
|
|
1800
|
+
try {
|
|
1801
|
+
component = resolveComponentExport(binding.target, imported, importedSource, sourceFiles)
|
|
1802
|
+
} catch {
|
|
1803
|
+
fail(dispatchCalls[0], `Reducer dispatch props require a component imported from a relative TypeScript module`)
|
|
1804
|
+
}
|
|
1805
|
+
const componentSource = component.getSourceFile()
|
|
1806
|
+
const componentImports = clientImportBindings(componentSource, componentSource.fileName, sourceFiles)
|
|
1807
|
+
const packageImports = runtimeImportNames(componentSource, false)
|
|
1808
|
+
for (const call of dispatchCalls) {
|
|
1809
|
+
if (componentSpecializations.has(call)) fail(call, "Reducer dispatch props cannot be combined with another component specialization")
|
|
1810
|
+
const specialization = specializeComponentCall(call, component, sourceFile, factory, context, fail, "Reducer-dispatch")
|
|
1811
|
+
if (specialization.effects.length) fail(call, "Reducer-dispatch components cannot declare effects")
|
|
1812
|
+
for (const name of packageImports) if (referenceIdentifiers(specialization.root, name).length) fail(call, "Imported reducer-dispatch component handlers may only use relative TypeScript runtime imports")
|
|
1813
|
+
const substitutions = new Map()
|
|
1814
|
+
for (const [name, entry] of componentImports) {
|
|
1815
|
+
const references = referenceIdentifiers(specialization.root, name)
|
|
1816
|
+
if (!references.length) continue
|
|
1817
|
+
if (references.some(reference => !insideJsxEventHandler(reference, specialization.root))) fail(call, `Imported reducer-dispatch component runtime import "${name}" may only be used inside event handlers`)
|
|
1818
|
+
let local
|
|
1819
|
+
do local = `__kDispatchImport${specializedImportIndex++}`
|
|
1820
|
+
while (importBindings.has(local))
|
|
1821
|
+
substitutions.set(name, factory.createIdentifier(local))
|
|
1822
|
+
importBindings.set(local, { ...entry, local })
|
|
1823
|
+
}
|
|
1824
|
+
if (substitutions.size) {
|
|
1825
|
+
specialization.root = substituteClone(specialization.root, substitutions, factory, context)
|
|
1826
|
+
ts.setParentRecursive(specialization.root, false)
|
|
1827
|
+
specialization.root.parent = call.parent
|
|
1828
|
+
}
|
|
1829
|
+
synthesizeTree(specialization.root)
|
|
1830
|
+
componentSpecializations.set(call, specialization)
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1777
1833
|
const rawRenderedLists = []
|
|
1778
1834
|
const collectRenderedLists = node => {
|
|
1779
1835
|
const specialization = componentSpecializations.get(node)
|
|
@@ -2259,6 +2315,43 @@ function jsxCallHasDirectStateProp(call, setters) {
|
|
|
2259
2315
|
})
|
|
2260
2316
|
}
|
|
2261
2317
|
|
|
2318
|
+
function jsxCallHasDirectReducerProp(call, reducers) {
|
|
2319
|
+
const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
|
|
2320
|
+
return attributes.properties.some(attribute => {
|
|
2321
|
+
const value = ts.isJsxAttribute(attribute) && attribute.initializer && ts.isJsxExpression(attribute.initializer) ? unwrapExpression(attribute.initializer.expression) : undefined
|
|
2322
|
+
return value && ts.isIdentifier(value) && reducers.has(value.text)
|
|
2323
|
+
})
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
function runtimeImportNames(sourceFile, relative) {
|
|
2327
|
+
const names = new Set()
|
|
2328
|
+
for (const statement of sourceFile.statements) {
|
|
2329
|
+
if (!ts.isImportDeclaration(statement) || !statement.importClause || statement.importClause.isTypeOnly || !ts.isStringLiteral(statement.moduleSpecifier) || statement.moduleSpecifier.text.startsWith(".") !== relative) continue
|
|
2330
|
+
const clause = statement.importClause
|
|
2331
|
+
if (clause.name) names.add(clause.name.text)
|
|
2332
|
+
if (clause.namedBindings && ts.isNamespaceImport(clause.namedBindings)) names.add(clause.namedBindings.name.text)
|
|
2333
|
+
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) for (const entry of clause.namedBindings.elements) if (!entry.isTypeOnly) names.add(entry.name.text)
|
|
2334
|
+
}
|
|
2335
|
+
return names
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
function referenceIdentifiers(root, name) {
|
|
2339
|
+
const references = []
|
|
2340
|
+
const visit = node => {
|
|
2341
|
+
if (ts.isIdentifier(node) && node.text === name && isReferenceIdentifier(node) && !isShadowedIdentifier(node, root)) references.push(node)
|
|
2342
|
+
ts.forEachChild(node, visit)
|
|
2343
|
+
}
|
|
2344
|
+
visit(root)
|
|
2345
|
+
return references
|
|
2346
|
+
}
|
|
2347
|
+
|
|
2348
|
+
function insideJsxEventHandler(node, root) {
|
|
2349
|
+
for (let current = node.parent; current && current !== root.parent; current = current.parent) {
|
|
2350
|
+
if (ts.isJsxAttribute(current) && /^on[A-Z]/.test(current.name.text)) return true
|
|
2351
|
+
}
|
|
2352
|
+
return false
|
|
2353
|
+
}
|
|
2354
|
+
|
|
2262
2355
|
function validateKeyedList(parts, sourceFile, listValues, listEventItems, listConditions) {
|
|
2263
2356
|
const fail = (node, message) => {
|
|
2264
2357
|
throw sourceNodeError(node, sourceFile, message)
|
|
@@ -2314,17 +2407,17 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
|
|
|
2314
2407
|
visit(root)
|
|
2315
2408
|
}
|
|
2316
2409
|
|
|
2317
|
-
function specializeComponentCall(call, component, sourceFile, factory, context, fail) {
|
|
2318
|
-
if (component.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || component.asteriskToken) fail(component,
|
|
2319
|
-
if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component,
|
|
2320
|
-
if (ts.isJsxElement(call) && call.children.some(child => !ts.isJsxText(child) || child.text.trim())) fail(call,
|
|
2410
|
+
function specializeComponentCall(call, component, sourceFile, factory, context, fail, label = "Keyed list") {
|
|
2411
|
+
if (component.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || component.asteriskToken) fail(component, `${label} components must be synchronous`)
|
|
2412
|
+
if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component, `${label} components must use one destructured props parameter`)
|
|
2413
|
+
if (ts.isJsxElement(call) && call.children.some(child => !ts.isJsxText(child) || child.text.trim())) fail(call, `${label} component children are not supported`)
|
|
2321
2414
|
const callAttributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
|
|
2322
2415
|
const props = new Map()
|
|
2323
2416
|
let key
|
|
2324
2417
|
for (const attribute of callAttributes.properties) {
|
|
2325
|
-
if (ts.isJsxSpreadAttribute(attribute)) fail(attribute,
|
|
2418
|
+
if (ts.isJsxSpreadAttribute(attribute)) fail(attribute, `${label} component prop spreads are not supported`)
|
|
2326
2419
|
const name = attribute.name.getText()
|
|
2327
|
-
if (props.has(name) || name === "key" && key) fail(attribute, `Duplicate
|
|
2420
|
+
if (props.has(name) || name === "key" && key) fail(attribute, `Duplicate ${label.toLowerCase()} component prop "${name}"`)
|
|
2328
2421
|
const value = !attribute.initializer
|
|
2329
2422
|
? factory.createTrue()
|
|
2330
2423
|
: ts.isStringLiteral(attribute.initializer)
|
|
@@ -2338,12 +2431,12 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
2338
2431
|
const substitutions = new Map()
|
|
2339
2432
|
const acceptedProps = new Set()
|
|
2340
2433
|
for (const element of component.parameters[0].name.elements) {
|
|
2341
|
-
if (element.dotDotDotToken || element.initializer || !ts.isIdentifier(element.name)) fail(element,
|
|
2434
|
+
if (element.dotDotDotToken || element.initializer || !ts.isIdentifier(element.name)) fail(element, `${label} component props cannot use rest, defaults, or nested destructuring`)
|
|
2342
2435
|
const prop = (element.propertyName ?? element.name).getText()
|
|
2343
2436
|
acceptedProps.add(prop)
|
|
2344
2437
|
substitutions.set(element.name.text, props.get(prop) ?? factory.createIdentifier("undefined"))
|
|
2345
2438
|
}
|
|
2346
|
-
for (const prop of props.keys()) if (!acceptedProps.has(prop)) fail(call, `Unknown
|
|
2439
|
+
for (const prop of props.keys()) if (!acceptedProps.has(prop)) fail(call, `Unknown ${label.toLowerCase()} component prop "${prop}"`)
|
|
2347
2440
|
|
|
2348
2441
|
let returned
|
|
2349
2442
|
const calculations = []
|
|
@@ -2353,15 +2446,15 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
2353
2446
|
} else {
|
|
2354
2447
|
const statements = [...component.body.statements]
|
|
2355
2448
|
const last = statements.pop()
|
|
2356
|
-
if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body,
|
|
2449
|
+
if (!last || !ts.isReturnStatement(last) || !last.expression) fail(component.body, `${label} component must end with one JSX return`)
|
|
2357
2450
|
for (const statement of statements) {
|
|
2358
2451
|
if (ts.isExpressionStatement(statement) && ts.isCallExpression(statement.expression) && ts.isIdentifier(statement.expression.expression) && statement.expression.expression.text === "useEffect") {
|
|
2359
2452
|
effectCalls.push(statement.expression)
|
|
2360
2453
|
continue
|
|
2361
2454
|
}
|
|
2362
|
-
if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement,
|
|
2455
|
+
if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.declarationList.declarations.length !== 1) fail(statement, `${label} component locals must be single const declarations`)
|
|
2363
2456
|
const declaration = statement.declarationList.declarations[0]
|
|
2364
|
-
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration,
|
|
2457
|
+
if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
|
|
2365
2458
|
const calculation = substituteClone(declaration.initializer, substitutions, factory, context)
|
|
2366
2459
|
calculations.push(calculation)
|
|
2367
2460
|
substitutions.set(declaration.name.text, calculation)
|
|
@@ -2369,11 +2462,11 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
|
|
|
2369
2462
|
returned = last.expression
|
|
2370
2463
|
}
|
|
2371
2464
|
let root = unwrapExpression(substituteClone(returned, substitutions, factory, context))
|
|
2372
|
-
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned,
|
|
2465
|
+
if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned, `${label} component must return one JSX element`)
|
|
2373
2466
|
const tag = jsxTagName(root)
|
|
2374
|
-
if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(returned,
|
|
2467
|
+
if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(returned, `${label} component must directly return an intrinsic JSX element`)
|
|
2375
2468
|
const rootAttributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
|
|
2376
|
-
if (rootAttributes.properties.some(attribute => ts.isJsxAttribute(attribute) && attribute.name.text === "key")) fail(root,
|
|
2469
|
+
if (rootAttributes.properties.some(attribute => ts.isJsxAttribute(attribute) && attribute.name.text === "key")) fail(root, `${label} component intrinsic root cannot declare key`)
|
|
2377
2470
|
if (key) root = addJsxAttribute(root, cloneAst(key, factory, context), factory)
|
|
2378
2471
|
ts.setParentRecursive(root, false)
|
|
2379
2472
|
root.parent = call.parent
|