@kudzujs/core 0.8.8 → 0.8.9

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 CHANGED
@@ -10,7 +10,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
10
10
 
11
11
  > Experimental `0.8.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **Latest release: 0.8.8 - Conditional keyed map roots.** Expression-bodied keyed maps may return `condition && <Row />` or `condition ? <Row /> : null`; Kudzu lowers the condition to its existing filter path so omitted rows clean up and re-entry starts fresh. Read the [release notes](./RELEASES.md#088---conditional-keyed-map-roots) or open the [release page](https://kudzujs.cloud/releases/0.8.8).
13
+ **Latest release: 0.8.9 - Context-backed CRUD actions.** A relative custom hook may directly return `useContext(Context)` for one analyzable Provider whose state-backed actions compile into existing route handler ESM, without a browser Context tree. Read the [release notes](./RELEASES.md#089---context-backed-crud-actions) or open the [release page](https://kudzujs.cloud/releases/0.8.9).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
package/RELEASES.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.8.9 - Context-backed CRUD actions
4
+
5
+ Kudzu 0.8.9 specializes state-backed actions exposed through one conventional Context Provider and relative custom hook.
6
+
7
+ ### New in 0.8.9
8
+
9
+ - A relative zero-argument custom hook may directly return `useContext(ContextIdentifier)` from one local or named relative Context module.
10
+ - One Provider may expose direct shorthand `useState` pairs and synchronous actions that capture only those exposed pairs.
11
+ - Consumers may select state and actions across ordinary component boundaries; direct action calls inline into existing route handler ESM and concrete state operations.
12
+ - CRUD actions over object arrays compose with keyed rows, reactive selection, conditional editors, and guarded local storage effects in the React Notes migration.
13
+ - Unsupported private captures, hidden state dependencies, indirect action references, dynamic Provider values, multiple Providers, and consumer binding collisions fail with source diagnostics.
14
+ - Static sibling routes remain JavaScript-free. No action function, Context runtime, Provider tree, callback registry, VDOM, or hydration is emitted.
15
+ - The complete suite passes 139/139 tests, including Chrome coverage for create, rename, select, and delete behavior.
16
+
17
+ ### Boundary
18
+
19
+ The hook must directly return `useContext(ContextIdentifier)`. The Context and exactly one Provider must be declared together in one local or named relative module, and its value must be one direct shorthand object. Actions must be synchronous, called directly in intrinsic handlers, and capture only exposed Provider state pairs.
20
+
21
+ ### Upgrade
22
+
23
+ ```bash
24
+ npm install @kudzujs/core@^0.8.9
25
+ ```
26
+
3
27
  ## 0.8.8 - Conditional keyed map roots
4
28
 
5
29
  Kudzu 0.8.8 compiles ordinary expression-bodied conditional keyed maps through the existing pure collection selector path.
@@ -2843,6 +2843,78 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2843
2843
  let usesComponentRef = false
2844
2844
  let usesComponentEffects = false
2845
2845
 
2846
+ const resolveContextHook = (returned, hookSource) => {
2847
+ if (!hasFrameworkImport(hookSource, "useContext")) throw sourceNodeError(returned.expression, hookSource, "Relative Context hooks must call useContext imported from react or @kudzujs/core")
2848
+ if (returned.arguments.length !== 1 || !ts.isIdentifier(returned.arguments[0])) throw sourceNodeError(returned, hookSource, "Relative Context hooks must directly return useContext(ContextIdentifier)")
2849
+ const contextName = returned.arguments[0].text
2850
+ let providerSource = hookSource
2851
+ let providerContextName = contextName
2852
+ const hookImports = clientImportBindings(hookSource, hookSource.fileName, sourceFiles)
2853
+ if (hookImports.has(contextName)) {
2854
+ const binding = hookImports.get(contextName)
2855
+ if (binding.kind === "namespace" || binding.kind === "default") throw sourceNodeError(returned.arguments[0], hookSource, "Relative Context hooks require a named Context import")
2856
+ providerSource = importedSource(binding.target)
2857
+ providerContextName = binding.imported
2858
+ }
2859
+ const hasContext = hasFrameworkImport(providerSource, "createContext") && providerSource.statements.some(statement => ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => ts.isIdentifier(declaration.name) && declaration.name.text === providerContextName && declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "createContext"))
2860
+ if (!hasContext) throw sourceNodeError(returned.arguments[0], hookSource, "Relative Context hooks require a local or named relative createContext() declaration")
2861
+
2862
+ const providers = []
2863
+ const findProviders = node => {
2864
+ if (ts.isJsxAttribute(node) && node.name.text === "value") {
2865
+ const element = node.parent?.parent
2866
+ const tag = ts.isJsxOpeningElement(element) || ts.isJsxSelfClosingElement(element) ? element.tagName : undefined
2867
+ if (ts.isPropertyAccessExpression(tag) && tag.name.text === "Provider" && ts.isIdentifier(tag.expression) && tag.expression.text === providerContextName) providers.push(node)
2868
+ }
2869
+ ts.forEachChild(node, findProviders)
2870
+ }
2871
+ findProviders(providerSource)
2872
+ if (providers.length !== 1) throw sourceNodeError(returned.arguments[0], hookSource, "Relative Context hooks require exactly one Provider value in the Context module")
2873
+ const provider = providers[0]
2874
+ const value = provider.initializer && ts.isJsxExpression(provider.initializer) && provider.initializer.expression ? unwrapExpression(provider.initializer.expression) : undefined
2875
+ if (!value || !ts.isObjectLiteralExpression(value)) throw sourceNodeError(provider, providerSource, "Context Provider value must be one direct object literal")
2876
+ const owner = nearestFunction(provider)
2877
+ if (!owner) throw sourceNodeError(provider, providerSource, "Context Provider value must be returned by a component")
2878
+
2879
+ const states = new Map()
2880
+ const callbacks = new Map()
2881
+ const hasUseState = hasFrameworkImport(providerSource, "useState")
2882
+ const collectProviderBindings = node => {
2883
+ if (ts.isVariableDeclaration(node) && nearestFunction(node) === owner) {
2884
+ if (hasUseState && ts.isArrayBindingPattern(node.name) && node.initializer && ts.isCallExpression(node.initializer) && ts.isIdentifier(node.initializer.expression) && node.initializer.expression.text === "useState") {
2885
+ const [state, setter] = node.name.elements
2886
+ if (node.name.elements.length === 2 && state && setter && ts.isBindingElement(state) && ts.isBindingElement(setter) && ts.isIdentifier(state.name) && ts.isIdentifier(setter.name)) states.set(setter.name.text, state.name.text)
2887
+ }
2888
+ if (ts.isIdentifier(node.name) && node.initializer && (ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))) callbacks.set(node.name.text, node.initializer)
2889
+ }
2890
+ ts.forEachChild(node, collectProviderBindings)
2891
+ }
2892
+ collectProviderBindings(owner.body)
2893
+
2894
+ const fields = new Set()
2895
+ const stateFields = new Set([...states].flat())
2896
+ for (const property of value.properties) {
2897
+ if (!ts.isShorthandPropertyAssignment(property)) throw sourceNodeError(property, providerSource, "Context Provider values must use direct shorthand state, setter, or action fields")
2898
+ const name = property.name.text
2899
+ if (!stateFields.has(name) && !callbacks.has(name)) throw sourceNodeError(property, providerSource, `Context Provider field ${JSON.stringify(name)} must be a direct provider-owned state, setter, or action`)
2900
+ fields.add(name)
2901
+ }
2902
+ for (const [setter, state] of states) {
2903
+ if (fields.has(setter) !== fields.has(state)) throw sourceNodeError(value, providerSource, `Context Provider state ${JSON.stringify(state)} and setter ${JSON.stringify(setter)} must be exposed together`)
2904
+ }
2905
+ for (const [name, callback] of callbacks) {
2906
+ if (!fields.has(name)) continue
2907
+ if (callback.asteriskToken || callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(callback, providerSource, `Context action ${JSON.stringify(name)} must be synchronous`)
2908
+ const capture = nativeCaptureNames(callback, states).values().next().value
2909
+ if (capture) throw sourceNodeError(callback, providerSource, `Context action ${JSON.stringify(name)} cannot capture private binding ${JSON.stringify(capture)}`)
2910
+ for (const state of referencedStateNames(callback.body, states, callback)) {
2911
+ const setter = [...states].find(([, candidate]) => candidate === state)?.[0]
2912
+ if (!setter || !fields.has(state) || !fields.has(setter)) throw sourceNodeError(callback, providerSource, `Context action ${JSON.stringify(name)} requires exposed state and setter fields for ${JSON.stringify(state)}`)
2913
+ }
2914
+ }
2915
+ return { callbacks: new Map([...callbacks].filter(([name]) => fields.has(name))), context: true, fields, privateStates: new Set(), states }
2916
+ }
2917
+
2846
2918
  const resolveCustomHook = (binding, call) => {
2847
2919
  const exportName = binding.kind === "default" ? "default" : binding.imported
2848
2920
  const key = `${binding.target}:${exportName}`
@@ -2852,7 +2924,12 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2852
2924
  if (hook.parameters.length || hook.asteriskToken || hook.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || !ts.isBlock(hook.body)) throw sourceNodeError(hook, hookSource, "Relative custom hooks must be synchronous zero-argument functions with a block body")
2853
2925
  const returns = hook.body.statements.filter(ts.isReturnStatement)
2854
2926
  const returned = returns.length === 1 && returns[0] === hook.body.statements.at(-1) && returns[0].expression ? unwrapExpression(returns[0].expression) : undefined
2855
- if (!returned || !ts.isObjectLiteralExpression(returned)) throw sourceNodeError(hook.body, hookSource, "Relative custom hooks must end with one direct object return")
2927
+ if (returned && ts.isCallExpression(returned) && ts.isIdentifier(returned.expression) && returned.expression.text === "useContext") {
2928
+ const analysis = resolveContextHook(returned, hookSource)
2929
+ customHooks.set(key, analysis)
2930
+ return analysis
2931
+ }
2932
+ if (!returned || !ts.isObjectLiteralExpression(returned)) throw sourceNodeError(hook.body, hookSource, "Relative custom hooks must end with one direct object return or direct useContext(ContextIdentifier)")
2856
2933
 
2857
2934
  const states = new Map()
2858
2935
  const callbacks = new Map()
@@ -2897,7 +2974,30 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2897
2974
  const owner = nearestFunction(node)
2898
2975
  if (!owner) throw sourceNodeError(node, sourceFile, "Relative custom hooks cannot be used outside a Kudzu component")
2899
2976
  const setters = settersByFunction.get(owner) ?? new Map()
2977
+ const requiredContextStates = new Set()
2978
+ if (hook.context) {
2979
+ for (const name of names) {
2980
+ const callback = hook.callbacks.get(name)
2981
+ if (callback) for (const state of referencedStateNames(callback.body, hook.states, callback)) requiredContextStates.add(state)
2982
+ }
2983
+ }
2900
2984
  for (const [setter, state] of hook.states) {
2985
+ if (hook.context) {
2986
+ 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`)
2987
+ if (!names.has(state) && !requiredContextStates.has(state)) continue
2988
+ setters.set(names.has(setter) || requiredContextStates.has(state) ? setter : `__kContextState_${state}`, state)
2989
+ if (requiredContextStates.has(state)) {
2990
+ const fields = customHookPrivateFields.get(node) ?? []
2991
+ for (const field of [state, setter]) {
2992
+ if (names.has(field) || fields.includes(field)) continue
2993
+ const conflict = owner.parameters.some(parameter => bindingNames(parameter.name).includes(field)) || owner.body.statements.some(statement => statement !== node.parent.parent && statementDeclaresName(statement, field))
2994
+ if (conflict) throw sourceNodeError(node.name, sourceFile, `Context action state field ${JSON.stringify(field)} conflicts with a consumer binding`)
2995
+ fields.push(field)
2996
+ }
2997
+ customHookPrivateFields.set(node, fields)
2998
+ }
2999
+ continue
3000
+ }
2901
3001
  if (hook.privateStates.has(state)) {
2902
3002
  setters.set(setter, state)
2903
3003
  const fields = customHookPrivateFields.get(node) ?? []
@@ -2914,6 +3014,11 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2914
3014
  const callbacks = customHookFunctionsByOwner.get(owner) ?? new Map()
2915
3015
  callbacks.set(name, hook.callbacks.get(name))
2916
3016
  customHookFunctionsByOwner.set(owner, callbacks)
3017
+ if (hook.context) {
3018
+ const reducers = reducersByFunction.get(owner) ?? new Map()
3019
+ reducers.set(name, { contextAction: hook.callbacks.get(name), states: hook.states })
3020
+ reducersByFunction.set(owner, reducers)
3021
+ }
2917
3022
  }
2918
3023
  else if (![...hook.states].some(([setter, state]) => name === setter || name === state)) throw sourceNodeError(node.name, sourceFile, `Relative custom hook result ${JSON.stringify(name)} must be a direct useState value, setter, or callback`)
2919
3024
  }
@@ -5251,9 +5356,13 @@ function compileNativeCallback(expression, setters, reducers, factory, entries,
5251
5356
  const usedReducers = referencedReducerDispatches(expression.body, reducers, expression)
5252
5357
  const imports = [...referencedImportedBindings(expression, importBindings)].map(name => importBindings.get(name))
5253
5358
  imports.push(...[...usedReducers].map(name => reducers.get(name).import).filter(Boolean))
5254
- const captures = new Set([...allCaptures].filter(name => !importBindings.has(name)))
5359
+ const captures = new Set([...allCaptures].filter(name => !importBindings.has(name) && !usedReducers.has(name)))
5255
5360
  for (const entry of imports) if (!entry.package) clientImports.add(entry.target)
5256
5361
  const usedStates = nativeStateNames(expression, setters)
5362
+ for (const name of usedReducers) {
5363
+ const reducer = reducers.get(name)
5364
+ if (reducer.contextAction) for (const state of referencedStateNames(reducer.contextAction.body, reducer.states, reducer.contextAction)) usedStates.add(state)
5365
+ }
5257
5366
  const exportName = `${prefix}${entries.length}`
5258
5367
  entries.push({ exportName, expression, captures, imports, liveStates, setters: new Map([...setters].filter(([, state]) => usedStates.has(state))), reducers: new Map([...reducers].filter(([name]) => usedReducers.has(name))), snapshotNested })
5259
5368
  const value = name => deferValues
@@ -5572,6 +5681,14 @@ function clientImportBindings(sourceFile, file, sourceFiles) {
5572
5681
  return bindings
5573
5682
  }
5574
5683
 
5684
+ function hasFrameworkImport(sourceFile, name) {
5685
+ return sourceFile.statements.some(node => {
5686
+ if (!ts.isImportDeclaration(node) || node.importClause?.isTypeOnly || !ts.isStringLiteral(node.moduleSpecifier) || !["react", "@kudzujs/core"].includes(node.moduleSpecifier.text)) return false
5687
+ const bindings = node.importClause?.namedBindings
5688
+ return bindings && ts.isNamedImports(bindings) && bindings.elements.some(entry => !entry.isTypeOnly && entry.name.text === name && (entry.propertyName ?? entry.name).text === name)
5689
+ })
5690
+ }
5691
+
5575
5692
  function packageImportBindings(sourceFile) {
5576
5693
  const bindings = new Map()
5577
5694
  const rejectDynamic = node => {
@@ -6136,15 +6253,23 @@ function printNativeHandler({ exportName, expression, captures, setters, reducer
6136
6253
  const visitor = node => {
6137
6254
  if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && reducers.has(node.expression.text) && !isShadowedIdentifier(node.expression, expression)) {
6138
6255
  const reducer = reducers.get(node.expression.text)
6256
+ if (reducer.contextAction) {
6257
+ const action = synthesizeTree(cloneAst(reducer.contextAction, factory, context))
6258
+ const call = factory.createCallExpression(action, undefined, node.arguments)
6259
+ ts.setParentRecursive(call, false)
6260
+ return ts.visitNode(call, visitor)
6261
+ }
6139
6262
  if (reducer.store) return zustandActionDispatch(factory, reducer, node.arguments.map(argument => ts.visitNode(argument, visitor)))
6140
6263
  if (node.arguments.length !== 1) throw sourceNodeError(node, expression.getSourceFile(), "Reducer dispatches require exactly one action")
6141
6264
  return reducerDispatch(factory, reducer, ts.visitNode(node.arguments[0], visitor))
6142
6265
  }
6143
6266
  if (ts.isShorthandPropertyAssignment(node) && reducers.has(node.name.text) && !isShadowedIdentifier(node.name, expression)) {
6267
+ if (reducers.get(node.name.text).contextAction) throw sourceNodeError(node, expression.getSourceFile(), "Context actions must be called directly inside an event handler")
6144
6268
  if (reducers.get(node.name.text).store) throw sourceNodeError(node, expression.getSourceFile(), "Zustand actions must be called directly inside an event handler")
6145
6269
  return factory.createPropertyAssignment(node.name, reducerReference(factory, reducers.get(node.name.text)))
6146
6270
  }
6147
6271
  if (ts.isIdentifier(node) && reducers.has(node.text) && isReferenceIdentifier(node) && !isShadowedIdentifier(node, expression)) {
6272
+ if (reducers.get(node.text).contextAction) throw sourceNodeError(node, expression.getSourceFile(), "Context actions must be called directly inside an event handler")
6148
6273
  if (reducers.get(node.text).store) throw sourceNodeError(node, expression.getSourceFile(), "Zustand actions must be called directly inside an event handler")
6149
6274
  return reducerReference(factory, reducers.get(node.text))
6150
6275
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.8.8",
3
+ "version": "0.8.9",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",