@kudzujs/core 0.8.62 → 0.9.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.
Files changed (47) hide show
  1. package/MIGRATION_ROADMAP.md +36 -1
  2. package/PERFORMANCE.md +79 -1
  3. package/README.md +2 -2
  4. package/RELEASES.md +29 -0
  5. package/bin/kudzu.mjs +10 -1
  6. package/docs/next-architecture/0.9-baseline.md +1199 -0
  7. package/docs/next-architecture/0.9-benchmark-contracts.md +507 -0
  8. package/docs/next-architecture/0.9-component-property-contract.md +89 -0
  9. package/docs/next-architecture/0.9-compression-ledger.md +227 -0
  10. package/docs/next-architecture/0.9-final-proof-audit.md +176 -0
  11. package/docs/next-architecture/0.9-implementation-plan.md +1819 -0
  12. package/docs/next-architecture/0.9-resource-lifecycle.md +118 -0
  13. package/docs/next-architecture/0.9-semantic-compression.md +384 -0
  14. package/docs/next-architecture/README.md +16 -12
  15. package/docs/next-architecture/compiler-current-architecture.md +7 -7
  16. package/docs/next-architecture/large-application-ai-native-roadmap.md +5 -3
  17. package/docs/next-architecture/versioning.md +1 -1
  18. package/framework/README.md +2 -0
  19. package/framework/binding-runtime.js +4 -4
  20. package/framework/build.mjs +135 -30
  21. package/framework/compiler/ast-helpers.mjs +5 -0
  22. package/framework/compiler/browser-signal-passes.mjs +2 -7
  23. package/framework/compiler/collection-analysis.mjs +4 -0
  24. package/framework/compiler/descriptor-session.mjs +36 -12
  25. package/framework/compiler/effect-analysis.mjs +28 -8
  26. package/framework/compiler/effect-codegen.mjs +79 -36
  27. package/framework/compiler/effect-private-ref-pass.mjs +4 -8
  28. package/framework/compiler/handler-lowering.mjs +12 -7
  29. package/framework/compiler/ir/module-ir.mjs +26 -4
  30. package/framework/compiler/list-runtime-codegen.mjs +4 -2
  31. package/framework/compiler/optimize/command-specialization.mjs +4 -7
  32. package/framework/compiler/route-artifact-report.mjs +4 -3
  33. package/framework/compiler/route-build-record.mjs +12 -0
  34. package/framework/compiler/route-capability-planner.mjs +3 -3
  35. package/framework/compiler/route-ir.mjs +27 -11
  36. package/framework/compiler/runtime-codegen.mjs +2 -2
  37. package/framework/compiler/source-compiler.mjs +359 -78
  38. package/framework/core.d.ts +1 -0
  39. package/framework/core.mjs +18 -5
  40. package/framework/dependency-runtime.js +1 -1
  41. package/framework/effect-runtime.js +2 -2
  42. package/framework/list-runtime.js +67 -24
  43. package/framework/native-runtime.js +12 -9
  44. package/framework/runtime.js +1 -1
  45. package/framework/serialization.js +13 -6
  46. package/framework/shared-runtime.js +14 -12
  47. package/package.json +1 -1
@@ -1,5 +1,6 @@
1
1
  import { readFile, realpath, stat } from "node:fs/promises"
2
2
  import { dirname, extname, isAbsolute, join, relative, resolve, sep } from "node:path"
3
+ import { transform, transformSync } from "esbuild"
3
4
  import ts from "typescript"
4
5
  import { createBindingIndex } from "./analysis/binding-index.mjs"
5
6
  import { createComponentAnalysisSession } from "./analysis/component-analysis.mjs"
@@ -28,16 +29,24 @@ const { root, sourceDirectory, pagesDirectory, workDirectory, workerCompiler, mo
28
29
  const buildDirectory = project.buildDirectory ?? workDirectory
29
30
  const { ordinaryRuntimeDependencies, resolveSourceImport, runtimeModuleReference } = project.graph
30
31
  const parseSourceFile = (file, source) => modules.read(file, source).sourceFile
32
+ const importFreeModules = new Map()
31
33
  const staticAssetExtensions = new Set([".avif", ".gif", ".ico", ".jpeg", ".jpg", ".otf", ".png", ".svg", ".ttf", ".webp", ".woff", ".woff2"])
32
34
 
33
- function compileSource(file, sourceFiles, sourceIndex, staticFiles, cssModules, base) {
35
+ async function compileSourceAsync(file, sourceFiles, sourceIndex, staticFiles, cssModules, base) {
36
+ const source = sourceIndex.get(file)
37
+ const output = importFreeTypeScriptModule(file, source) ? (await transform(source, { loader: "ts", format: "esm", target: "es2022", sourcefile: file })).code : undefined
38
+ return compileSource(file, sourceFiles, sourceIndex, staticFiles, cssModules, base, output)
39
+ }
40
+
41
+ function compileSource(file, sourceFiles, sourceIndex, staticFiles, cssModules, base, importFreeOutput) {
34
42
  const importedAssets = new Set()
35
43
  const source = sourceIndex.get(file)
36
44
  const semantic = createSemanticArtifact(relative(root, file).replaceAll(sep, "/"))
37
45
  const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
38
- const plain = plainTypeScriptModule(file, source, sourceFiles)
46
+ const importFreePlain = importFreeTypeScriptModule(file, source)
47
+ const plain = importFreePlain || plainTypeScriptModule(file, source, sourceFiles)
39
48
  if (plain && counters) counters.plainModules = (counters.plainModules ?? 0) + 1
40
- const result = ts.transpileModule(source, {
49
+ const result = importFreePlain ? { outputText: importFreeOutput ?? transformSync(source, { loader: "ts", format: "esm", target: "es2022", sourcefile: file }).code } : ts.transpileModule(source, {
41
50
  fileName: file,
42
51
  compilerOptions: {
43
52
  target: ts.ScriptTarget.ES2022,
@@ -87,6 +96,15 @@ function plainTypeScriptModule(file, source, sourceFiles) {
87
96
  return true
88
97
  }
89
98
 
99
+ function importFreeTypeScriptModule(file, source) {
100
+ if (!file.endsWith(".ts")) return false
101
+ const cached = importFreeModules.get(file)
102
+ if (cached?.source === source) return cached.result
103
+ const result = !source.includes(".worker.ts") && ts.preProcessFile(source, true, true).importedFiles.length === 0
104
+ importFreeModules.set(file, { source, result })
105
+ return result
106
+ }
107
+
90
108
  function createPlainModuleTransformer(file, sourceFiles) {
91
109
  return context => sourceFile => context.factory.updateSourceFile(sourceFile, sourceFile.statements.map(statement => {
92
110
  if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || !runtimeModuleReference(statement) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.moduleSpecifier.text.startsWith(".")) return statement
@@ -126,7 +144,9 @@ function reachableSourceFiles(entries, sourceFiles, sourceIndex) {
126
144
  if (visited.has(file)) continue
127
145
  visited.add(file)
128
146
  reachable.add(file)
129
- const sourceFile = parseSourceFile(file, sourceIndex.get(file))
147
+ const source = sourceIndex.get(file)
148
+ if (importFreeTypeScriptModule(file, source)) continue
149
+ const sourceFile = parseSourceFile(file, source)
130
150
  if (owner === "ordinary") for (const target of ordinaryRuntimeDependencies(file, sourceFile, sourceFiles, isStaticImport)) queue.push({ file: target, owner: target.endsWith(".worker.ts") ? "worker" : owner })
131
151
  const visit = node => {
132
152
  if (owner === "worker") {
@@ -332,7 +352,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
332
352
  const { moduleIR } = semantic
333
353
  return context => sourceFile => {
334
354
  const hasLinkElements = /<link/i.test(sourceFile.text)
335
- const importedStaticCollections = importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex)
355
+ const importedStaticCollections = importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex, true)
336
356
  const importedCollections = new Set(importedStaticCollections.keys())
337
357
  const normalized = normalizeCompilerSource(sourceFile, { base, context, file, importedCollections, importedStaticCollections, sourceFiles, sourceIndex })
338
358
  sourceFile = normalized.sourceFile
@@ -395,8 +415,16 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
395
415
  }
396
416
  const importedCollectionTransforms = new Map()
397
417
  const importedCalculationFunctions = new Map()
418
+ const validatedEffectCalculations = new WeakSet()
419
+ const transformImports = new Set()
420
+ const collectTransformImports = node => {
421
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression)) transformImports.add(node.expression.text)
422
+ if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && ts.isIdentifier(node.tagName)) transformImports.add(node.tagName.text)
423
+ ts.forEachChild(node, collectTransformImports)
424
+ }
425
+ collectTransformImports(sourceFile)
398
426
  for (const [name, binding] of importBindings) {
399
- if (binding.kind === "namespace") continue
427
+ if (binding.kind === "namespace" || !transformImports.has(name)) continue
400
428
  try {
401
429
  importedCollectionTransforms.set(name, resolveComponentExport(binding.target, binding.kind === "default" ? "default" : binding.imported, target => parseSourceFile(target, sourceIndex.get(target)), sourceFiles))
402
430
  } catch {}
@@ -642,7 +670,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
642
670
  const delay = node.initializer.arguments[1] && unwrapExpression(node.initializer.arguments[1])
643
671
  const setters = owner ? settersByFunction.get(owner) ?? new Map() : new Map()
644
672
  if (!owner) throw sourceNodeError(node, sourceFile, "Parameterized debounce hooks cannot be used outside a Kudzu component")
645
- if (node.initializer.arguments.length !== 2 || !ts.isIdentifier(value) || !new Set(setters.values()).has(value.text) || !componentHasDirectPrimitiveState(owner, value.text)) throw sourceNodeError(node.initializer, sourceFile, "Parameterized debounce hooks require one direct primitive state argument")
673
+ const stateInitializer = ts.isIdentifier(value) ? directStateInitializer(owner, value.text) : undefined
674
+ if (node.initializer.arguments.length !== 2 || !ts.isIdentifier(value) || !new Set(setters.values()).has(value.text) || !stateInitializer || !isPrimitiveDefaultLiteral(stateInitializer)) throw sourceNodeError(node.initializer, sourceFile, "Parameterized debounce hooks require one direct primitive state argument")
646
675
  if (!ts.isNumericLiteral(delay)) throw sourceNodeError(node.initializer.arguments[1] ?? node.initializer, sourceFile, "Parameterized debounce hook delays must be numeric literals")
647
676
  const syntheticSetter = `__kSetDebounced_${Math.max(0, node.pos)}`
648
677
  setters.set(syntheticSetter, node.name.text)
@@ -670,6 +699,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
670
699
  }
671
700
  }
672
701
  const contextSubstitutions = new Map()
702
+ const contextSharedStates = new Map()
673
703
  for (const [setter, state] of hook.states) {
674
704
  if (hook.context) {
675
705
  if (names.has(setter) && !names.has(state)) throw sourceNodeError(node.name, sourceFile, `Relative Context setter ${JSON.stringify(setter)} requires state ${JSON.stringify(state)} to be destructured`)
@@ -689,6 +719,9 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
689
719
  const localSetter = localName(setter)
690
720
  setters.set(localSetter, localState)
691
721
  registerState(owner, localState, localSetter, "context", node, { owner: hook.stateOwner, state: hook.stateSymbols.get(state) })
722
+ const sharedState = registerSharedState(moduleIR, { identity: hook.stateOwner.symbol.id, field: state })
723
+ contextSharedStates.set(state, sharedState)
724
+ stateOwnersByFunction.get(owner).set(localState, { kind: "shared-state", sharedState: sharedState.slot })
692
725
  if (requiredContextStates.has(state)) {
693
726
  for (const [field, local] of [[state, localState], [setter, localSetter]]) {
694
727
  if (names.has(field) || privateFields.some(entry => (typeof entry === "string" ? entry : entry.property) === field)) continue
@@ -723,7 +756,16 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
723
756
  if (hook.context) {
724
757
  const reducers = reducersByFunction.get(owner) ?? new Map()
725
758
  const states = new Map([...hook.states].map(([setter, state]) => [contextSubstitutions.get(setter)?.text ?? setter, contextSubstitutions.get(state)?.text ?? state]))
726
- reducers.set(name, { contextAction: callback, states })
759
+ const referenced = referencedStateNames(hook.callbacks.get(name).body, hook.states, hook.callbacks.get(name))
760
+ const anchor = [...hook.states.values()].find(state => referenced.has(state))
761
+ const sharedState = contextSharedStates.get(anchor)
762
+ if (!sharedState) {
763
+ reducers.set(name, { sourceKind: "Context", directImplementation: callback, states })
764
+ reducersByFunction.set(owner, reducers)
765
+ continue
766
+ }
767
+ const action = registerSharedAction(moduleIR, { state: sharedState.slot, name })
768
+ reducers.set(name, { state: contextSubstitutions.get(anchor)?.text ?? anchor, sourceKind: "Context", sharedAction: { ...action, directImplementation: callback, states } })
727
769
  reducersByFunction.set(owner, reducers)
728
770
  }
729
771
  }
@@ -960,6 +1002,78 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
960
1002
  if (!returns.length || returns.some(returned => !returned || !ts.isObjectLiteralExpression(returned))) fail(call.expression, "Reactive imported calculations must return a plain object")
961
1003
  const fieldExists = returns.every(returned => returned.properties.some(property => ts.isSpreadAssignment(property) || !ts.isComputedPropertyName(property.name) && property.name.text === field))
962
1004
  if (!fieldExists) fail(call.parent, `Reactive imported calculation does not return field ${JSON.stringify(field)}`)
1005
+ return { calculation, returns }
1006
+ }
1007
+ const validateSelectedEffectCalculation = (call, field) => {
1008
+ const { calculation, returns } = validateImportedCalculation(call, field)
1009
+ const calculationSource = calculation.getSourceFile()
1010
+ const reject = (target, message) => { throw sourceNodeError(target, calculationSource, message) }
1011
+ if (calculation.asteriskToken || calculation.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) reject(calculation, "Imported calculations used by useEffect() must be synchronous; move async work into the owned effect")
1012
+ if (calculation.parameters.some(parameter => !ts.isIdentifier(parameter.name) || parameter.dotDotDotToken || parameter.initializer || parameter.questionToken)) reject(calculation, "Imported calculations used by useEffect() require ordinary identifier parameters without defaults or rest")
1013
+ if (call.arguments.some(argument => !ts.isIdentifier(unwrapExpression(argument)))) fail(call, "useEffect() selected calculation arguments must be direct primitive state identifiers; aliases, property paths, and composed arguments are not supported")
1014
+ const shapes = returns.map(returned => returned.properties.map(property => {
1015
+ if (!ts.isPropertyAssignment(property) && !ts.isShorthandPropertyAssignment(property) || ts.isComputedPropertyName(property.name) || ["__proto__", "constructor", "prototype"].includes(property.name.text)) reject(property, "Imported calculations used by useEffect() must return direct safe plain-object fields without spreads, methods, or computed names")
1016
+ return property.name.text
1017
+ }).sort())
1018
+ const expected = JSON.stringify(shapes[0])
1019
+ if (shapes.some(shape => JSON.stringify(shape) !== expected)) reject(calculation, `Imported calculations used by useEffect() must return the same direct plain-object fields on every path; expected ${expected}`)
1020
+ if (validatedEffectCalculations.has(calculation)) return
1021
+ const staticImports = importedSerializableCollections(calculationSource, calculationSource.fileName, sourceFiles, sourceIndex)
1022
+ const imported = new Map()
1023
+ for (const statement of calculationSource.statements) if (ts.isImportDeclaration(statement) && statement.importClause && !statement.importClause.isTypeOnly && ts.isStringLiteral(statement.moduleSpecifier)) {
1024
+ const names = []
1025
+ if (statement.importClause.name) names.push(statement.importClause.name.text)
1026
+ const bindings = statement.importClause.namedBindings
1027
+ if (bindings && ts.isNamespaceImport(bindings)) names.push(bindings.name.text)
1028
+ if (bindings && ts.isNamedImports(bindings)) names.push(...bindings.elements.filter(entry => !entry.isTypeOnly).map(entry => entry.name.text))
1029
+ for (const name of names) imported.set(name, statement.moduleSpecifier.text)
1030
+ }
1031
+ for (const [name, target] of imported) if (!target.startsWith(".") && referencesIdentifier(calculation.body, name)) reject(calculation.body, `Imported calculations used by useEffect() cannot reference package import ${JSON.stringify(name)} from ${JSON.stringify(target)}`)
1032
+ const localNames = new Set(calculation.parameters.flatMap(parameter => bindingNames(parameter.name)))
1033
+ if (ts.isBlock(calculation.body)) for (const statement of calculation.body.statements) {
1034
+ if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) for (const name of bindingNames(declaration.name)) localNames.add(name)
1035
+ if (ts.isFunctionDeclaration(statement) && statement.name) localNames.add(statement.name.text)
1036
+ }
1037
+ const localDeclarations = new Map()
1038
+ if (ts.isBlock(calculation.body)) for (const statement of calculation.body.statements) if (ts.isVariableStatement(statement)) for (const declaration of statement.declarationList.declarations) if (ts.isIdentifier(declaration.name) && declaration.initializer) localDeclarations.set(declaration.name.text, declaration.initializer)
1039
+ const visiting = []
1040
+ const visited = new Set()
1041
+ const visitLocal = name => {
1042
+ if (visited.has(name)) return
1043
+ const cycle = visiting.indexOf(name)
1044
+ if (cycle >= 0) reject(localDeclarations.get(name), `Imported calculation derived-local cycle: ${[...visiting.slice(cycle), name].join(" -> ")}`)
1045
+ visiting.push(name)
1046
+ const initializer = localDeclarations.get(name)
1047
+ if (initializer) for (const candidate of localDeclarations.keys()) if (referencesIdentifier(initializer, candidate)) visitLocal(candidate)
1048
+ visiting.pop()
1049
+ visited.add(name)
1050
+ }
1051
+ for (const name of localDeclarations.keys()) visitLocal(name)
1052
+ const visit = node => {
1053
+ if (ts.isTypeNode(node)) return
1054
+ if (ts.isBinaryExpression(node) && assignmentOperators.has(node.operatorToken.kind) || ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator) || ts.isDeleteExpression(node)) reject(node, "Imported calculations used by useEffect() must be pure; assignments, updates, delete, and mutation are not supported")
1055
+ if (ts.isAwaitExpression(node) || ts.isYieldExpression(node) || ts.isNewExpression(node)) reject(node, "Imported calculations used by useEffect() must be deterministic and synchronous; await, yield, and construction are not supported")
1056
+ if (ts.isCallExpression(node)) {
1057
+ if (ts.isIdentifier(node.expression) && ["Boolean", "Number", "String"].includes(node.expression.text)) {
1058
+ // Primitive conversions are deterministic.
1059
+ } else if (ts.isPropertyAccessExpression(node.expression)) {
1060
+ const method = node.expression.name.text
1061
+ const receiver = unwrapExpression(node.expression.expression)
1062
+ if (mutatingListMethods.has(method)) reject(node, `Imported calculations used by useEffect() must be pure; mutating method ${JSON.stringify(method)} is not supported`)
1063
+ const math = ts.isIdentifier(receiver) && receiver.text === "Math"
1064
+ const find = method === "find" && node.arguments.length === 1 && ts.isArrowFunction(node.arguments[0]) && !ts.isBlock(node.arguments[0].body) && !node.arguments[0].modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)
1065
+ if (!find && !(math && pureMathMethods.has(method)) && !pureListMethods.has(method)) reject(node, `Imported calculations used by useEffect() must be deterministic; call ${JSON.stringify(node.expression.getText(calculationSource))} is not supported`)
1066
+ } else reject(node, "Imported calculations used by useEffect() cannot call arbitrary functions")
1067
+ }
1068
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node)) {
1069
+ const target = imported.get(node.text)
1070
+ if (target && !target.startsWith(".")) reject(node, `Imported calculations used by useEffect() cannot reference package import ${JSON.stringify(node.text)} from ${JSON.stringify(target)}`)
1071
+ if (target?.startsWith(".") && !staticImports.has(node.text)) reject(node, `Imported calculations used by useEffect() may capture only relative exported JSON-safe const arrays; capture ${JSON.stringify(node.text)} is opaque or nonserializable`)
1072
+ }
1073
+ ts.forEachChild(node, visit)
1074
+ }
1075
+ visit(calculation.body)
1076
+ validatedEffectCalculations.add(calculation)
963
1077
  }
964
1078
  const validateReactiveJsxExpression = (expression, allowedNames) => {
965
1079
  const value = unwrapExpression(expression)
@@ -1033,6 +1147,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1033
1147
  }
1034
1148
  const componentSpecializations = new WeakMap()
1035
1149
  const specializedEffectStateOwners = new WeakMap()
1150
+ const calculationDerivedByOwner = new WeakMap()
1036
1151
  const setterHookHelpers = new WeakMap()
1037
1152
  const expandedRowSpecializations = new WeakMap()
1038
1153
  const nestedRowSpecializations = new Map()
@@ -1063,7 +1178,9 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1063
1178
  props: result.props.map(prop => {
1064
1179
  const expression = result.propExpressions.get(prop.name)
1065
1180
  const signals = expression ? propSignals(expression) : []
1066
- return { ...prop, ...(signals.length ? { signals } : {}) }
1181
+ const uses = result.propertyUses.get(prop.local) ?? []
1182
+ const properties = signals.length === 1 ? uses.map(use => ({ signal: signals[0], path: use.path, consumers: use.consumers, equality: "object-is" })) : []
1183
+ return { ...prop, ...(signals.length ? { signals } : {}), ...(properties.length ? { properties } : {}) }
1067
1184
  }),
1068
1185
  states: [
1069
1186
  ...result.rowStates.map(({ state, setter, source }) => ({ name: state, setter, kind: "row", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) })),
@@ -1141,6 +1258,44 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1141
1258
  merged.parent = root.parent
1142
1259
  return merged
1143
1260
  }
1261
+ const materializeComponentHelper = (call, specialization, prefix, prepend) => {
1262
+ const owner = nearestFunction(call)
1263
+ const name = `${prefix}${Math.max(0, call.pos)}`
1264
+ const effectStatements = specialization.effects.map(entry => {
1265
+ const effectCall = factory.updateCallExpression(entry.call, factory.createIdentifier("__kComponentUseEffect"), entry.call.typeArguments, entry.call.arguments)
1266
+ synthesizeTree(effectCall)
1267
+ ts.setOriginalNode(effectCall, entry.source)
1268
+ specializedEffectStateOwners.set(effectCall, { owner: { kind: "specialization", slot: specialization.analysis.slot }, references: specialization.propStateOwners })
1269
+ return factory.createExpressionStatement(effectCall)
1270
+ })
1271
+ const rendered = prepend ? factory.createNull() : specialization.root
1272
+ const helper = factory.createFunctionDeclaration(undefined, undefined, name, undefined, [], undefined, factory.createBlock([...specialization.hookDeclarations, ...effectStatements, factory.createReturnStatement(rendered)], true))
1273
+ ts.setParentRecursive(helper, false)
1274
+ helper.parent = owner.body
1275
+ const helpers = setterHookHelpers.get(owner.body) ?? []
1276
+ helpers.push(helper)
1277
+ setterHookHelpers.set(owner.body, helpers)
1278
+ const setters = new Map(settersForNode(call, settersByFunction))
1279
+ for (const state of specialization.ordinaryStates) setters.set(state.setter, state.state)
1280
+ settersByFunction.set(helper, setters)
1281
+ const stateOwners = new Map([...stateOwnersForNode(call), ...specialization.propStateOwners])
1282
+ for (const state of specialization.ordinaryStates) stateOwners.set(state.state, state.analysisReference)
1283
+ stateOwnersByFunction.set(helper, stateOwners)
1284
+ usesComponentState ||= specialization.ordinaryStates.length > 0
1285
+ usesComponentId ||= specialization.usesComponentId
1286
+ usesComponentRef ||= specialization.ordinaryRefs.length > 0
1287
+ usesComponentEffects ||= specialization.effects.length > 0
1288
+ const invocation = factory.createJsxSelfClosingElement(factory.createIdentifier(name), undefined, factory.createJsxAttributes([]))
1289
+ specialization.root = prepend ? prependJsxChild(specialization.root, invocation, factory) : invocation
1290
+ ts.setParentRecursive(specialization.root, false)
1291
+ specialization.root.parent = call.parent
1292
+ }
1293
+ const attachStateBackedEffects = (call, specialization, componentSource, imported) => {
1294
+ if (imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, componentSource, call, specialization.effects))
1295
+ if (!specialization.effects.length) return
1296
+ if (specialization.hookDeclarations.length) fail(call, "State-backed object property components cannot declare state, refs, or IDs")
1297
+ materializeComponentHelper(call, specialization, "KPropertyEffect", true)
1298
+ }
1144
1299
  const expandReducerCallbacks = (root, componentSource, call, ownership) => {
1145
1300
  const componentImports = clientImportBindings(componentSource, componentSource.fileName, sourceFiles)
1146
1301
  const replacements = new WeakMap()
@@ -1324,8 +1479,10 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1324
1479
  if (identifierReferenceCount(sourceFile, name) !== calls.length) fail(component.declaration, `State-backed list component ${name} may only be referenced as JSX`)
1325
1480
  if (stateBackedCalls.length !== calls.length) fail(component.declaration, `State-backed list component ${name} must receive its mapped prop from local state at every call`)
1326
1481
  for (const call of stateBackedCalls) {
1327
- const specialization = specialize(call, component.function)
1328
- if (specialization.effects.length) fail(call, "State-backed list components cannot declare effects")
1482
+ const objectProperty = isStateBackedListComponentCall(call, component.function, settersByFunction.get(nearestFunction(call)) ?? new Map(), true)
1483
+ const specialization = specialize(call, component.function, objectProperty ? "Object-property component" : "Keyed list")
1484
+ if (specialization.effects.length && !objectProperty) fail(call, "State-backed list components cannot declare effects")
1485
+ if (objectProperty) attachStateBackedEffects(call, specialization, component.function.getSourceFile(), false)
1329
1486
  componentSpecializations.set(call, specialization)
1330
1487
  stateBackedComponentRoots.push(specialization.root)
1331
1488
  }
@@ -1346,8 +1503,10 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1346
1503
  }
1347
1504
  const stateBackedCalls = calls.filter(call => isStateBackedListComponentCall(call, component, settersByFunction.get(nearestFunction(call)) ?? new Map()))
1348
1505
  for (const call of stateBackedCalls) {
1349
- const specialization = specialize(call, component)
1350
- if (specialization.effects.length) fail(call, "State-backed list components cannot declare effects")
1506
+ const objectProperty = isStateBackedListComponentCall(call, component, settersByFunction.get(nearestFunction(call)) ?? new Map(), true)
1507
+ const specialization = specialize(call, component, objectProperty ? "Object-property component" : "Keyed list")
1508
+ if (specialization.effects.length && !objectProperty) fail(call, "State-backed list components cannot declare effects")
1509
+ if (objectProperty) attachStateBackedEffects(call, specialization, component.getSourceFile(), true)
1351
1510
  componentSpecializations.set(call, specialization)
1352
1511
  stateBackedComponentRoots.push(specialization.root)
1353
1512
  }
@@ -1364,7 +1523,8 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1364
1523
  const setterAttribute = attributes.properties.find(entry => ts.isJsxAttribute(entry) && entry.name.text === prop)
1365
1524
  const stateValue = stateAttribute?.initializer && ts.isJsxExpression(stateAttribute.initializer) ? unwrapExpression(stateAttribute.initializer.expression) : undefined
1366
1525
  const setterValue = setterAttribute?.initializer && ts.isJsxExpression(setterAttribute.initializer) ? unwrapExpression(setterAttribute.initializer.expression) : undefined
1367
- if (!ts.isIdentifier(stateValue) || !ts.isIdentifier(setterValue) || setters.get(setterValue.text) !== stateValue.text || !componentHasDirectArrayState(nearestFunction(call), stateValue.text)) fail(setterAttribute ?? call, `Setter-callback effect prop ${JSON.stringify(prop)} must target the same direct array state passed through ${JSON.stringify(effect.stateProp)}`)
1526
+ const stateInitializer = ts.isIdentifier(stateValue) ? directStateInitializer(nearestFunction(call), stateValue.text) : undefined
1527
+ if (!ts.isIdentifier(stateValue) || !ts.isIdentifier(setterValue) || setters.get(setterValue.text) !== stateValue.text || !stateInitializer || !ts.isArrayLiteralExpression(stateInitializer)) fail(setterAttribute ?? call, `Setter-callback effect prop ${JSON.stringify(prop)} must target the same direct array state passed through ${JSON.stringify(effect.stateProp)}`)
1368
1528
  }
1369
1529
  const specialization = specialize(call, component, "Setter-callback", true, true, new Set(settersForNode(call, settersByFunction).values()))
1370
1530
  if (specialization.hookDeclarations.length || specialization.effects.length) {
@@ -1382,44 +1542,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1382
1542
  }
1383
1543
  specialization.root = expandSetterComponents(specialization.root, component.getSourceFile(), [component], specialization, settersForNode(call, settersByFunction), stateOwnersForNode(call))
1384
1544
  if (imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, component.getSourceFile(), call, specialization.effects))
1385
- if (specialization.hookDeclarations.length || specialization.effects.length) {
1386
- const owner = nearestFunction(call)
1387
- const name = `KSetterComponent${Math.max(0, call.pos)}`
1388
- const effectStatements = specialization.effects.map(entry => {
1389
- const effectCall = factory.updateCallExpression(entry.call, factory.createIdentifier("__kComponentUseEffect"), entry.call.typeArguments, entry.call.arguments)
1390
- synthesizeTree(effectCall)
1391
- ts.setOriginalNode(effectCall, entry.source)
1392
- specializedEffectStateOwners.set(effectCall, { owner: { kind: "specialization", slot: specialization.analysis.slot }, references: specialization.propStateOwners })
1393
- return factory.createExpressionStatement(effectCall)
1394
- })
1395
- const helper = factory.createFunctionDeclaration(
1396
- undefined,
1397
- undefined,
1398
- name,
1399
- undefined,
1400
- [],
1401
- undefined,
1402
- factory.createBlock([...specialization.hookDeclarations, ...effectStatements, factory.createReturnStatement(specialization.root)], true)
1403
- )
1404
- ts.setParentRecursive(helper, false)
1405
- helper.parent = owner.body
1406
- const helpers = setterHookHelpers.get(owner.body) ?? []
1407
- helpers.push(helper)
1408
- setterHookHelpers.set(owner.body, helpers)
1409
- const setters = new Map(settersForNode(call, settersByFunction))
1410
- for (const state of specialization.ordinaryStates) setters.set(state.setter, state.state)
1411
- settersByFunction.set(helper, setters)
1412
- const stateOwners = new Map([...stateOwnersForNode(call), ...specialization.propStateOwners])
1413
- for (const state of specialization.ordinaryStates) stateOwners.set(state.state, state.analysisReference)
1414
- stateOwnersByFunction.set(helper, stateOwners)
1415
- usesComponentState ||= specialization.ordinaryStates.length > 0
1416
- usesComponentId ||= specialization.usesComponentId
1417
- usesComponentRef ||= specialization.ordinaryRefs.length > 0
1418
- usesComponentEffects ||= specialization.effects.length > 0
1419
- specialization.root = factory.createJsxSelfClosingElement(factory.createIdentifier(name), undefined, factory.createJsxAttributes([]))
1420
- ts.setParentRecursive(specialization.root, false)
1421
- specialization.root.parent = call.parent
1422
- }
1545
+ if (specialization.hookDeclarations.length || specialization.effects.length) materializeComponentHelper(call, specialization, "KSetterComponent", false)
1423
1546
  componentSpecializations.set(call, specialization)
1424
1547
  }
1425
1548
  for (const [name, component] of components) {
@@ -1726,7 +1849,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1726
1849
  : collectionSymbol !== undefined ? { kind: "symbol", symbol: collectionSymbol } : { kind: "static" }
1727
1850
  if (listParts.calculation) {
1728
1851
  usesBinding = true
1729
- const compiled = descriptors.compileReactiveBinding(listParts.calculation, { setters: settersForNode(node, settersByFunction), importBindings, keyedBlock: blockSlot })
1852
+ const compiled = descriptors.compileReactiveBinding(listParts.calculation, { setters: settersForNode(node, settersByFunction), importBindings, keyedBlock: blockSlot, derived: calculationBinding(node, nearestFunction(node)) })
1730
1853
  listSource = compiled.node
1731
1854
  collection = { kind: "binding", binding: compiled.binding }
1732
1855
  }
@@ -1851,6 +1974,26 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1851
1974
  if (callback.parameters.length) effectFail(callback, "useEffect() callback cannot declare parameters")
1852
1975
  if (!ts.isArrayLiteralExpression(dependencies)) effectFail(dependencies, "useEffect() dependencies must be a literal array")
1853
1976
  const setters = settersForNode(node, settersByFunction)
1977
+ const resolveCalculation = dependency => {
1978
+ if (specializedEffect) return undefined
1979
+ const value = unwrapExpression(dependency)
1980
+ const result = ts.isIdentifier(value) ? value : ts.isPropertyAccessExpression(value) || ts.isElementAccessExpression(value) ? unwrapExpression(value.expression) : undefined
1981
+ if (!result || !ts.isIdentifier(result)) return undefined
1982
+ const entries = jsxLocalDeclarations.get(effectOwner)?.get(result.text)
1983
+ const initializer = entries?.length === 1 && entries[0].node.parent?.parent?.parent === effectOwner?.body ? unwrapExpression(entries[0].initializer) : undefined
1984
+ if (!initializer || !ts.isCallExpression(initializer) || !ts.isIdentifier(initializer.expression) || !importBindings.has(initializer.expression.text)) return undefined
1985
+ if (ts.isIdentifier(value)) effectFail(value, `useEffect() cannot depend on the whole imported calculation result ${JSON.stringify(value.text)}; select one JSON-safe primitive field such as ${value.text}.id`)
1986
+ if (ts.isElementAccessExpression(value)) effectFail(value, `useEffect() imported calculation dependencies require one direct static result field such as ${result.text}.id; computed result properties are not supported`)
1987
+ if (!ts.isPropertyAccessExpression(value) || ["__proto__", "constructor", "prototype"].includes(value.name.text)) effectFail(value, "useEffect() imported calculation dependency field must not be __proto__, prototype, or constructor")
1988
+ validateSelectedEffectCalculation(initializer, value.name.text)
1989
+ const expanded = unwrapExpression(resolveReactiveJsxExpression(dependency, effectOwner, setters))
1990
+ if (!ts.isPropertyAccessExpression(expanded) || expanded.name.text !== value.name.text) return undefined
1991
+ const call = unwrapExpression(expanded.expression)
1992
+ if (!ts.isCallExpression(call) || !ts.isIdentifier(call.expression) || !importBindings.has(call.expression.text)) return undefined
1993
+ const states = referencedStateNames(call, setters, call, bindingIndex)
1994
+ if (states.size < 2 || call.arguments.some(argument => !ts.isIdentifier(unwrapExpression(argument)) || !states.has(unwrapExpression(argument).text))) effectFail(call, "useEffect() selected calculations require at least two direct primitive state arguments")
1995
+ return { name: value.expression.text, field: value.name.text, call, states, source: dependency }
1996
+ }
1854
1997
  const dependencyAnalysis = analyzeEffectDependencies({
1855
1998
  dependencies,
1856
1999
  node,
@@ -1860,9 +2003,12 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1860
2003
  localDeclarations: jsxLocalDeclarations.get(nearestFunction(node)),
1861
2004
  factory,
1862
2005
  fail: effectFail,
1863
- bindingIndex
2006
+ bindingIndex,
2007
+ resolveCalculation
1864
2008
  })
1865
2009
  const { dependencyItem, itemDependencies, ordinaryDependencies, entries: dependencyEntries, dependencyStates, substitutions: dependencySubstitutions, subscriptions: subscriptionDependencies, hasDerived: hasDerivedDependency } = dependencyAnalysis
2010
+ const calculationEntries = dependencyEntries.filter(entry => entry.kind === "calculation")
2011
+ if (calculationEntries.length && (calculationEntries.length !== 1 || dependencyEntries.length !== 1)) effectFail(dependencies, "useEffect() selected calculation fields must be the only dependency")
1866
2012
  if (!effectOwner) fail(node, "useEffect() cannot be used outside a Kudzu component")
1867
2013
  if (!ts.isBlock(callback.body)) effectFail(callback, "useEffect() callback must use a block body")
1868
2014
  const cleanupSubstitutions = new Map()
@@ -1886,6 +2032,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1886
2032
  if (invalidCleanup) effectFail(invalidCleanup, "useEffect() cleanup functions cannot declare parameters or be generators")
1887
2033
  if (returns.cleanup && callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) effectFail(callback, "useEffect() async callbacks cannot return cleanup functions")
1888
2034
  validateEffectOwnedBrowserResources(callback, returns, effectFail, bindingIndex)
2035
+ const calculationEvaluators = dependencyEntries.map(entry => entry.kind === "calculation" ? descriptors.compileDerivedEvaluator(entry.call, { setters, importBindings }) : undefined)
1889
2036
  const callbackSource = specializedEffect?.sourceFile ?? sourceFile
1890
2037
  const callbackFile = callbackSource.fileName
1891
2038
  let compiledCallback = dependencySubstitutions.size ? substituteClone(callback, dependencySubstitutions, factory, context) : callback
@@ -1915,7 +2062,16 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1915
2062
  })
1916
2063
  usesListItem ||= Boolean(itemDependencies.length && !listEffect)
1917
2064
  usesBehavior = true
1918
- const derivedDependencies = hasDerivedDependency ? dependencyEntries.map(entry => entry.kind === "derived" ? descriptors.registerDerived("expression", entry.expression, entry.states, entry.source) : undefined) : []
2065
+ const derivedDependencies = hasDerivedDependency ? dependencyEntries.map((entry, index) => entry.kind === "derived"
2066
+ ? descriptors.registerDerived("expression", entry.expression, entry.states, entry.source)
2067
+ : entry.kind === "calculation" ? descriptors.registerDerived("calculation", { binding: calculationEvaluators[index].binding, fields: [entry.field] }, entry.states, entry.source) : undefined) : []
2068
+ if (calculationEntries.length) {
2069
+ const calculations = calculationDerivedByOwner.get(effectOwner) ?? new Map()
2070
+ dependencyEntries.forEach((entry, index) => {
2071
+ if (entry.kind === "calculation") calculations.set(entry.name, derivedDependencies[index])
2072
+ })
2073
+ calculationDerivedByOwner.set(effectOwner, calculations)
2074
+ }
1919
2075
  const effectSource = specializedEffect?.source ?? node
1920
2076
  const lexicalOwner = nearestFunction(effectSource)
1921
2077
  const effectStateOwners = new Map([...stateOwnersForNode(callbackArgument), ...stateOwnersForNode(node), ...(specializedEffectStateOwners.get(node)?.references ?? []), ...(activeStateOwners ?? [])])
@@ -1924,7 +2080,9 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1924
2080
  const dependencyStateNames = [...dependencyStates.keys()]
1925
2081
  const effect = descriptors.registerEffect(descriptor, {
1926
2082
  cleanup: returns.cleanup,
1927
- dependencies: hasDerivedDependency ? dependencyEntries.map((entry, index) => entry.kind === "derived" ? { kind: "derived", derived: derivedDependencies[index].slot, sources: [...entry.states].map(signalFor) } : { kind: "signal", signal: signalFor(entry.name) }) : ordinaryDependencies.map(dependency => ({ kind: "signal", signal: signalFor(dependency.text) })),
2083
+ dependencies: hasDerivedDependency ? dependencyEntries.map((entry, index) => entry.kind === "derived"
2084
+ ? { kind: "derived", derived: derivedDependencies[index].slot, sources: [...entry.states].map(signalFor) }
2085
+ : entry.kind === "calculation" ? { kind: "derived", derived: derivedDependencies[index].slot, sources: [...entry.states].map(signalFor), field: entry.field, evaluator: calculationEvaluators[index].binding } : { kind: "signal", signal: signalFor(entry.name) }) : ordinaryDependencies.map(dependency => ({ kind: "signal", signal: signalFor(dependency.text) })),
1928
2086
  subscriptions: subscriptionNames.map(signalFor),
1929
2087
  dependencySignals: dependencyStateNames.map(signalFor),
1930
2088
  itemDependencies,
@@ -1938,7 +2096,12 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1938
2096
  ...(analysisSite(effectSource, "hook") ? { site: analysisSite(effectSource, "hook") } : {}),
1939
2097
  ...(analysisSource(effectSource) ? { source: analysisSource(effectSource) } : {})
1940
2098
  })
1941
- const dependencyExpressions = effect.dependencies.map((dependency, index) => dependency.kind === "derived" ? moduleIR.derived[dependency.derived].expression : ["state", dependencyEntries[index]?.name ?? ordinaryDependencies[index].text])
2099
+ const hasExpressionDependency = dependencyEntries.some(entry => entry.kind === "derived")
2100
+ const dependencyExpressions = hasExpressionDependency ? effect.dependencies.map((dependency, index) => dependency.kind === "derived" ? moduleIR.derived[dependency.derived].expression : ["state", dependencyEntries[index]?.name ?? ordinaryDependencies[index].text]) : []
2101
+ const dependencyEvaluators = calculationEntries.length ? calculationEvaluators.map((evaluator, index) => evaluator ? factory.createObjectLiteralExpression([
2102
+ factory.createSpreadAssignment(evaluator.descriptor),
2103
+ factory.createPropertyAssignment("field", factory.createStringLiteral(dependencyEntries[index].field))
2104
+ ]) : factory.createNull()) : []
1942
2105
  const buildCallback = [...packageBindings].some(([name]) => referenceIdentifiers(callback, name).length)
1943
2106
  ? factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), factory.createBlock([], false))
1944
2107
  : callback
@@ -1952,8 +2115,9 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1952
2115
  factory.createStringLiteral(specializedEffect ? sourceLocation(specializedEffect.source, specializedEffect.sourceFile) : sourceLocation(node, sourceFile)),
1953
2116
  effect.cleanup ? factory.createTrue() : factory.createFalse(),
1954
2117
  factory.createArrayLiteralExpression(effect.itemDependencies.map(field => factory.createStringLiteral(field))),
1955
- hasDerivedDependency ? jsonExpression(dependencyExpressions, factory) : factory.createArrayLiteralExpression(),
1956
- factory.createArrayLiteralExpression(dependencyStateNames.map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)])))
2118
+ dependencyExpressions.length ? jsonExpression(dependencyExpressions, factory) : factory.createArrayLiteralExpression(),
2119
+ factory.createArrayLiteralExpression(dependencyStateNames.map(name => factory.createArrayLiteralExpression([factory.createStringLiteral(name), factory.createIdentifier(name)]))),
2120
+ factory.createArrayLiteralExpression(dependencyEvaluators)
1957
2121
  ])
1958
2122
  }
1959
2123
 
@@ -2030,7 +2194,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2030
2194
  if ((usedStates.size || captures.size) && !ts.isIdentifier(expression) && !containsJsx(expression)) {
2031
2195
  usesBehavior = true
2032
2196
  usesBinding = true
2033
- return factory.updateJsxExpression(node, descriptors.compileReactiveBinding(expression, { setters, importBindings }).node)
2197
+ return factory.updateJsxExpression(node, descriptors.compileReactiveBinding(expression, { setters, importBindings, derived: calculationBinding(node.expression, nearestFunction(node)) }).node)
2034
2198
  }
2035
2199
  }
2036
2200
 
@@ -2043,7 +2207,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2043
2207
  if ((usedStates.size || captures.size) && !ts.isIdentifier(expression)) {
2044
2208
  usesBehavior = true
2045
2209
  usesBinding = true
2046
- const compiled = descriptors.compileReactiveBinding(expression, { setters, importBindings })
2210
+ const compiled = descriptors.compileReactiveBinding(expression, { setters, importBindings, derived: calculationBinding(sourceExpression, nearestFunction(node)) })
2047
2211
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compiled.node))
2048
2212
  }
2049
2213
  }
@@ -2072,6 +2236,27 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2072
2236
  return ts.visitEachChild(node, visitor, context)
2073
2237
  }
2074
2238
 
2239
+ function calculationBinding(expression, owner) {
2240
+ const calculations = calculationDerivedByOwner.get(owner)
2241
+ if (!calculations?.size) return undefined
2242
+ const fields = new Map()
2243
+ const visit = node => {
2244
+ const value = unwrapExpression(node)
2245
+ if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(value.expression) && calculations.has(value.expression.text)) {
2246
+ const derived = calculations.get(value.expression.text)
2247
+ const entry = fields.get(derived.slot) ?? { derived, fields: new Set() }
2248
+ entry.fields.add(value.name.text)
2249
+ fields.set(derived.slot, entry)
2250
+ }
2251
+ ts.forEachChild(node, visit)
2252
+ }
2253
+ visit(expression)
2254
+ if (fields.size !== 1) return undefined
2255
+ const [{ derived, fields: selected }] = fields.values()
2256
+ for (const field of selected) if (!derived.calculation.fields.includes(field)) derived.calculation.fields.push(field)
2257
+ return { derived: derived.slot, fields: [...selected] }
2258
+ }
2259
+
2075
2260
  const transformed = ts.visitNode(sourceFile, visitor)
2076
2261
  descriptors.finalize()
2077
2262
  if (!usesBehavior) return transformed
@@ -2207,7 +2392,7 @@ function jsonExpression(value, factory) {
2207
2392
  return factory.createCallExpression(factory.createPropertyAccessExpression(factory.createIdentifier("JSON"), "parse"), undefined, [factory.createStringLiteral(JSON.stringify(value))])
2208
2393
  }
2209
2394
 
2210
- function isStateBackedListComponentCall(call, component, setters) {
2395
+ function isStateBackedListComponentCall(call, component, setters, propertyOnly = false) {
2211
2396
  if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) return false
2212
2397
  const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2213
2398
  const stateNames = new Set(setters.values())
@@ -2227,7 +2412,9 @@ function isStateBackedListComponentCall(call, component, setters) {
2227
2412
  let found = false
2228
2413
  const visit = node => {
2229
2414
  if (found || node !== returned && isFunctionLike(node)) return
2230
- if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && ts.isIdentifier(node.expression.expression) && mappedProps.has(node.expression.expression.text)) {
2415
+ const collection = ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" ? unwrapExpression(node.expression.expression) : undefined
2416
+ const root = collection && ts.isPropertyAccessExpression(collection) ? unwrapExpression(collection.expression) : collection
2417
+ if (root && ts.isIdentifier(root) && mappedProps.has(root.text) && (!propertyOnly || ts.isPropertyAccessExpression(collection))) {
2231
2418
  found = true
2232
2419
  return
2233
2420
  }
@@ -2237,6 +2424,75 @@ function isStateBackedListComponentCall(call, component, setters) {
2237
2424
  return found
2238
2425
  }
2239
2426
 
2427
+ function referencesComponentProperty(expression, locals) {
2428
+ let found = false
2429
+ const visit = node => {
2430
+ if (found) return
2431
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(unwrapExpression(node.expression)) && locals.has(unwrapExpression(node.expression).text)) {
2432
+ found = true
2433
+ return
2434
+ }
2435
+ ts.forEachChild(node, visit)
2436
+ }
2437
+ visit(expression)
2438
+ return found
2439
+ }
2440
+
2441
+ function componentPropertyMutation(component, locals) {
2442
+ let invalid
2443
+ const root = expression => {
2444
+ let value = unwrapExpression(expression)
2445
+ while (ts.isPropertyAccessExpression(value) || ts.isElementAccessExpression(value)) value = unwrapExpression(value.expression)
2446
+ return ts.isIdentifier(value) ? value.text : undefined
2447
+ }
2448
+ const visit = node => {
2449
+ if (invalid) return
2450
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment && locals.has(root(node.left))) invalid = node
2451
+ else if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node)) && locals.has(root(node.operand))) invalid = node
2452
+ else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && mutatingListMethods.has(node.expression.name.text) && locals.has(root(node.expression.expression))) invalid = node
2453
+ if (!invalid) ts.forEachChild(node, visit)
2454
+ }
2455
+ visit(component)
2456
+ return invalid
2457
+ }
2458
+
2459
+ function prependJsxChild(root, child, factory) {
2460
+ if (ts.isJsxElement(root)) return factory.updateJsxElement(root, root.openingElement, [child, ...root.children], root.closingElement)
2461
+ const opening = factory.createJsxOpeningElement(root.tagName, root.typeArguments, root.attributes)
2462
+ const closing = factory.createJsxClosingElement(root.tagName)
2463
+ return factory.createJsxElement(opening, [child], closing)
2464
+ }
2465
+
2466
+ function componentPropertyUses(component, returned, effectCalls) {
2467
+ const locals = new Set(component.parameters[0].name.elements.filter(element => !element.dotDotDotToken && ts.isIdentifier(element.name)).map(element => element.name.text))
2468
+ const uses = new Map()
2469
+ const add = (local, field, consumer) => {
2470
+ if (["__proto__", "constructor", "prototype"].includes(field)) return
2471
+ const fields = uses.get(local) ?? new Map()
2472
+ const consumers = fields.get(field) ?? new Set()
2473
+ consumers.add(consumer)
2474
+ fields.set(field, consumers)
2475
+ uses.set(local, fields)
2476
+ }
2477
+ for (const call of effectCalls) {
2478
+ const dependencies = call.arguments[1]
2479
+ if (!ts.isArrayLiteralExpression(dependencies)) continue
2480
+ for (const dependency of dependencies.elements) {
2481
+ const value = unwrapExpression(dependency)
2482
+ if (ts.isPropertyAccessExpression(value) && ts.isIdentifier(unwrapExpression(value.expression)) && locals.has(unwrapExpression(value.expression).text)) add(unwrapExpression(value.expression).text, value.name.text, "effect")
2483
+ }
2484
+ }
2485
+ const visit = node => {
2486
+ if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(unwrapExpression(node.expression)) && locals.has(unwrapExpression(node.expression).text)) {
2487
+ const map = ts.isPropertyAccessExpression(node.parent) && node.parent.expression === node && node.parent.name.text === "map" && ts.isCallExpression(node.parent.parent)
2488
+ add(unwrapExpression(node.expression).text, node.name.text, map ? "list" : "binding")
2489
+ }
2490
+ ts.forEachChild(node, visit)
2491
+ }
2492
+ visit(returned)
2493
+ return new Map([...uses].map(([local, fields]) => [local, [...fields].map(([field, consumers]) => ({ path: [field], consumers: ["binding", "effect", "list"].filter(consumer => consumers.has(consumer)) }))]))
2494
+ }
2495
+
2240
2496
  function jsxCallHasDirectStateProp(call, setters) {
2241
2497
  const attributes = ts.isJsxElement(call) ? call.openingElement.attributes : call.attributes
2242
2498
  const stateNames = new Set(setters.values())
@@ -2264,10 +2520,6 @@ function componentHasDirectPropStateInitializer(component) {
2264
2520
  return component.body.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState" && declaration.initializer.arguments.length === 1 && ts.isIdentifier(declaration.initializer.arguments[0]) && props.has(declaration.initializer.arguments[0].text)))
2265
2521
  }
2266
2522
 
2267
- function componentHasDirectPrimitiveState(component, state) {
2268
- return Boolean(component && ts.isBlock(component.body) && component.body.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => ts.isArrayBindingPattern(declaration.name) && ts.isIdentifier(declaration.name.elements[0]?.name) && declaration.name.elements[0].name.text === state && declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState" && declaration.initializer.arguments.length === 1 && isPrimitiveDefaultLiteral(unwrapExpression(declaration.initializer.arguments[0])))))
2269
- }
2270
-
2271
2523
  function componentHasDirectObjectRef(component, ref) {
2272
2524
  if (!component || !ts.isBlock(component.body)) return false
2273
2525
  const declaration = component.body.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(entry => ts.isIdentifier(entry.name) && entry.name.text === ref && entry.initializer && ts.isCallExpression(entry.initializer) && ts.isIdentifier(entry.initializer.expression) && entry.initializer.expression.text === "useRef" && entry.initializer.arguments.length === 1 && entry.initializer.arguments[0].kind === ts.SyntaxKind.NullKeyword))
@@ -2294,10 +2546,6 @@ function directSetterLiteralCallback(node, setters) {
2294
2546
  return { setter: expression.expression.text, value: unwrapExpression(expression.arguments[0]) }
2295
2547
  }
2296
2548
 
2297
- function componentHasDirectArrayState(component, state) {
2298
- return Boolean(component && ts.isBlock(component.body) && component.body.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => ts.isArrayBindingPattern(declaration.name) && ts.isIdentifier(declaration.name.elements[0]?.name) && declaration.name.elements[0].name.text === state && declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useState" && declaration.initializer.arguments.length === 1 && ts.isArrayLiteralExpression(unwrapExpression(declaration.initializer.arguments[0])))))
2299
- }
2300
-
2301
2549
  function directSetterPropEffect(component, setterProp) {
2302
2550
  if (!component || component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name) || !ts.isBlock(component.body)) return undefined
2303
2551
  const props = component.parameters[0].name.elements.filter(element => !element.dotDotDotToken && ts.isIdentifier(element.name))
@@ -2649,6 +2897,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2649
2897
  }
2650
2898
  const substitutions = new Map()
2651
2899
  const acceptedProps = new Set()
2900
+ const propLocals = new Set()
2652
2901
  let rest
2653
2902
  const elements = component.parameters[0].name.elements
2654
2903
  for (const [index, element] of elements.entries()) {
@@ -2661,10 +2910,15 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2661
2910
  if (element.initializer && !isSerializableStateLiteral(element.initializer)) fail(element.initializer, `${label} component prop defaults must be directly serializable primitive, plain-object, or array literals`)
2662
2911
  const prop = (element.propertyName ?? element.name).text
2663
2912
  acceptedProps.add(prop)
2913
+ propLocals.add(element.name.text)
2664
2914
  substitutions.set(element.name.text, props.has(prop) ? props.get(prop) : element.initializer ?? factory.createIdentifier("undefined"))
2665
2915
  }
2666
2916
  const restEntries = [...props].filter(([prop]) => !acceptedProps.has(prop))
2667
2917
  if (!rest) for (const [prop] of restEntries) fail(call, `Unknown ${label.toLowerCase()} component prop "${prop}"`)
2918
+ if (label === "Object-property component") {
2919
+ const mutation = componentPropertyMutation(component, propLocals)
2920
+ if (mutation) fail(mutation, "Object-property component props must remain immutable")
2921
+ }
2668
2922
  const propAnalysis = elements.map(element => ({
2669
2923
  name: (element.propertyName ?? element.name).getText(),
2670
2924
  local: element.name.getText(),
@@ -2702,7 +2956,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2702
2956
  const substitutedProp = propReceiver ? substitutions.get(propReceiver.text) : undefined
2703
2957
  const substitutedState = substitutedProp && ts.isIdentifier(unwrapExpression(substitutedProp)) ? unwrapExpression(substitutedProp).text : undefined
2704
2958
  const directProp = ts.isIdentifier(initialArgument)
2705
- const parentInitializer = substitutedState ? directStateInitializer(call, substitutedState) : undefined
2959
+ const parentInitializer = substitutedState ? directStateInitializer(nearestFunction(call), substitutedState) : undefined
2706
2960
  const propInitializer = ordinaryHooks && substitutedState && ordinaryStateNames.has(substitutedState) && parentInitializer && (isPrimitiveDefaultLiteral(parentInitializer) || directProp && (ts.isObjectLiteralExpression(parentInitializer) || ts.isArrayLiteralExpression(parentInitializer)))
2707
2961
  const rowItemProp = propReceiver && elements.find(element => !element.dotDotDotToken && ts.isIdentifier(element.name) && element.name.text === propReceiver.text)
2708
2962
  const rowItemInitializer = !ordinaryHooks && directProp && substitutedState && rowItemProp && directProps.has((rowItemProp.propertyName ?? rowItemProp.name).text)
@@ -2754,6 +3008,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2754
3008
  continue
2755
3009
  }
2756
3010
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
3011
+ if (label === "Object-property component" && referencesComponentProperty(declaration.initializer, propLocals)) fail(declaration.initializer, "Object-property component property aliases are not supported; read the static prop.field path directly at each consumer")
2757
3012
  const calculation = substituteClone(declaration.initializer, substitutions, factory, context)
2758
3013
  calculations.push({ name: declaration.name.text, expression: calculation })
2759
3014
  substitutions.set(declaration.name.text, calculation)
@@ -2792,6 +3047,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2792
3047
  ordinaryStates,
2793
3048
  ordinaryRefs,
2794
3049
  ordinaryIds,
3050
+ propertyUses: componentPropertyUses(component, returned, effectCalls),
2795
3051
  propExpressions: props,
2796
3052
  props: propAnalysis,
2797
3053
  usesComponentId: ordinaryIds.length > 0
@@ -2806,8 +3062,7 @@ function isSerializableStateLiteral(node) {
2806
3062
  return value.properties.every(property => ts.isPropertyAssignment(property) && !ts.isComputedPropertyName(property.name) && property.name.text !== "__proto__" && isSerializableStateLiteral(property.initializer))
2807
3063
  }
2808
3064
 
2809
- function directStateInitializer(call, name) {
2810
- const owner = nearestFunction(call)
3065
+ function directStateInitializer(owner, name) {
2811
3066
  if (!owner || !ts.isBlock(owner.body)) return
2812
3067
  for (const statement of owner.body.statements) {
2813
3068
  if (!ts.isVariableStatement(statement)) continue
@@ -3138,10 +3393,34 @@ function importedSerializableCollectionNames(sourceFile, file, sourceFiles, sour
3138
3393
  return new Set(importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex).keys())
3139
3394
  }
3140
3395
 
3141
- function importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex) {
3396
+ function importedSerializableCollections(sourceFile, file, sourceFiles, sourceIndex, onlyMapped = false) {
3142
3397
  const collections = new Map()
3398
+ const mapped = new Set()
3399
+ const memoNames = new Set(["useMemo"])
3400
+ for (const statement of sourceFile.statements) if (ts.isImportDeclaration(statement) && ["react", "@kudzujs/core"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings)) {
3401
+ for (const entry of statement.importClause.namedBindings.elements) if ((entry.propertyName ?? entry.name).text === "useMemo") memoNames.add(entry.name.text)
3402
+ }
3403
+ const collectRoot = node => {
3404
+ if (ts.isPropertyAccessExpression(node) && ["filter", "flatMap", "slice", "toSorted", "map"].includes(node.name.text)) {
3405
+ let expression = node.expression
3406
+ while (ts.isCallExpression(expression) || ts.isPropertyAccessExpression(expression)) expression = expression.expression
3407
+ if (ts.isIdentifier(expression)) mapped.add(expression.text)
3408
+ }
3409
+ ts.forEachChild(node, collectRoot)
3410
+ }
3411
+ const visit = node => {
3412
+ if (ts.isPropertyAccessExpression(node) && node.name.text === "map") {
3413
+ collectRoot(node)
3414
+ }
3415
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && memoNames.has(node.expression.text) && node.arguments[0]) collectRoot(node.arguments[0])
3416
+ ts.forEachChild(node, visit)
3417
+ }
3418
+ if (onlyMapped) {
3419
+ visit(sourceFile)
3420
+ if (!mapped.size) return collections
3421
+ }
3143
3422
  for (const [name, binding] of clientImportBindings(sourceFile, file, sourceFiles)) {
3144
- if (binding.kind !== "named") continue
3423
+ if (binding.kind !== "named" || onlyMapped && !mapped.has(name)) continue
3145
3424
  const imported = parseSourceFile(binding.target, sourceIndex.get(binding.target))
3146
3425
  for (const statement of imported.statements) {
3147
3426
  if (!ts.isVariableStatement(statement) || !(statement.declarationList.flags & ts.NodeFlags.Const) || !statement.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.ExportKeyword)) continue
@@ -3279,7 +3558,9 @@ function orderSourceStyles(entryFiles, sourceFiles, sourceIndex, staticFiles) {
3279
3558
  const visit = file => {
3280
3559
  if (seenSources.has(file)) return
3281
3560
  seenSources.add(file)
3282
- const sourceFile = parseSourceFile(file, sourceIndex.get(file))
3561
+ const source = sourceIndex.get(file)
3562
+ if (importFreeTypeScriptModule(file, source)) return
3563
+ const sourceFile = parseSourceFile(file, source)
3283
3564
  for (const statement of sourceFile.statements) {
3284
3565
  if ((!ts.isImportDeclaration(statement) && !ts.isExportDeclaration(statement)) || !runtimeModuleReference(statement) || !statement.moduleSpecifier || !ts.isStringLiteral(statement.moduleSpecifier) || !statement.moduleSpecifier.text.startsWith(".")) continue
3285
3566
  const specifier = statement.moduleSpecifier.text
@@ -3390,7 +3671,7 @@ const printHandlerModule = createHandlerCodegen({
3390
3671
  const { normalizeReactMigrationSyntax, validateUseIdSyntax } = createReactMigrationPass({ cloneAst, jsxTagName })
3391
3672
  const normalizeReactRouterSyntax = createRouterPass({ withBase })
3392
3673
 
3393
- return { collectClientModules, compileClientModule, compiledPath, compileSource, layoutExportError, orderSourceStyles, reachableSourceFiles, safeStaticFiles }
3674
+ return { collectClientModules, compileClientModule, compiledPath, compileSource, compileSourceAsync, layoutExportError, orderSourceStyles, reachableSourceFiles, safeStaticFiles }
3394
3675
  }
3395
3676
 
3396
3677
  const currentCompiler = sourceIndex => createSourceCompiler(createProjectSession(process.cwd(), { sourceIndex }))