@kudzujs/core 0.4.0 → 0.4.1

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
@@ -150,16 +150,25 @@ Map local array state directly to one keyed JSX element per item:
150
150
 
151
151
  ```tsx
152
152
  const [items, setItems] = useState([
153
- { id: 1, name: "Oak" },
154
- { id: 2, name: "Pine" }
153
+ { id: 1, name: "Oak", done: false },
154
+ { id: 2, name: "Pine", done: true }
155
155
  ])
156
156
 
157
- <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
157
+ <ul>{items.map(item =>
158
+ <li
159
+ key={item.id}
160
+ className={item.done ? "done" : "active"}
161
+ aria-label={`${item.name} item`}
162
+ >
163
+ {item.name.toUpperCase()}
164
+ <button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
165
+ </li>
166
+ )}</ul>
158
167
  ```
159
168
 
160
- Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Each item must be a plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, plain objects, and primitive values.
169
+ Kudzu emits initial items as static HTML, then adds, removes, updates, and moves keyed elements directly. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Item-local handlers use delegated events and receive the latest JSON-safe item for their key, including after updates, additions, and reorders. The item remains stored once in shared list state; handler descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
161
170
 
162
- The MVP requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Item data may appear only as direct `item.<field>` text or attributes. Item-derived expressions, item-local handlers, nested conditions or lists, component tags, and fragments are rejected at build time. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
171
+ Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. The current syntax requires a direct local-state `.map`, one identifier callback parameter, one intrinsic JSX root, and `key={item.<field>}`. Derived expressions must be pure and synchronous: item reads, literals, operators, templates, approved read-only string/array methods, deterministic `Math` methods, and `String`/`Number`/`Boolean` conversion are supported. Component state, locals, imported helpers, browser globals, Promise values, mutation, arbitrary calls, and prototype-sensitive properties are rejected. Nested conditions or lists, item spreads, component tags, fragments, and reactive `style`, `ref`, or `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
163
172
 
164
173
  ## Normal JavaScript
165
174
 
@@ -181,6 +190,8 @@ async function load() {
181
190
 
182
191
  Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, class instances, and imported helper functions are not yet supported as captures.
183
192
 
193
+ Native handlers are delegated after normal event bubbling and run from the target toward matching Kudzu ancestors in deterministic order. Delegated handlers cannot call or reference `preventDefault`, `stopPropagation`, or `stopImmediatePropagation`; the compiler rejects those methods because external ESM cannot apply them with correct synchronous DOM semantics.
194
+
184
195
  ## Rendering
185
196
 
186
197
  ```text
@@ -6,7 +6,7 @@
6
6
  - `runtime.js`: command-only runtime for direct state-to-text patches.
7
7
  - `shared-runtime.js`: command runtime with capability commit and DOM lifecycle hooks, emitted only when needed.
8
8
  - `binding-runtime.js`: optional generic attributes, form properties, and conditional range patches.
9
- - `list-runtime.js`: optional keyed list validation, updates, moves, and cleanup.
9
+ - `list-runtime.js`: optional keyed list validation, external item-expression evaluation, dynamic item-handler scopes, moves, and cleanup.
10
10
  - `serialization.js`: capture deserialization shared by binding and native handlers.
11
11
  - `native-runtime.js`: optional runtime for normal synchronous and asynchronous ESM handlers.
12
12
  - `*.d.ts`: public TypeScript and JSX declarations.
@@ -150,6 +150,7 @@ async function compile(file) {
150
150
  const source = await readFile(file, "utf8")
151
151
  const nativeHandlers = []
152
152
  const reactiveBindings = []
153
+ const listExpressions = []
153
154
  const handlerPath = `handlers/${relative(sourceDirectory, file).replaceAll(sep, "/").replace(/\.(?:ts|tsx)$/, ".js")}`
154
155
  const result = ts.transpileModule(source, {
155
156
  fileName: file,
@@ -159,7 +160,7 @@ async function compile(file) {
159
160
  jsx: ts.JsxEmit.ReactJSX,
160
161
  jsxImportSource: "@kudzujs/core"
161
162
  },
162
- transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, `/assets/${handlerPath}`)] },
163
+ transformers: { before: [createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, `/assets/${handlerPath}`)] },
163
164
  reportDiagnostics: true
164
165
  })
165
166
 
@@ -172,10 +173,11 @@ async function compile(file) {
172
173
  await mkdir(resolve(output, ".."), { recursive: true })
173
174
  await writeFile(output, result.outputText)
174
175
 
175
- if (!nativeHandlers.length && !reactiveBindings.length) return undefined
176
+ if (!nativeHandlers.length && !reactiveBindings.length && !listExpressions.length) return undefined
176
177
  const moduleSource = [
177
178
  ...nativeHandlers.map(handler => printNativeHandler(handler)),
178
- ...reactiveBindings.map(entry => printReactiveBinding(entry))
179
+ ...reactiveBindings.map(entry => printReactiveBinding(entry)),
180
+ ...listExpressions.map(entry => printListExpression(entry))
179
181
  ].join("\n")
180
182
  const moduleResult = ts.transpileModule(moduleSource, {
181
183
  compilerOptions: { target: ts.ScriptTarget.ES2022, module: ts.ModuleKind.ESNext },
@@ -186,12 +188,13 @@ async function compile(file) {
186
188
  return { path: handlerPath, code: moduleResult.outputText, hasNativeHandlers: nativeHandlers.length > 0 }
187
189
  }
188
190
 
189
- function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
191
+ function createKudzuTransformer(nativeHandlers, reactiveBindings, listExpressions, handlerUrl) {
190
192
  return context => sourceFile => {
191
193
  const factory = context.factory
192
194
  const settersByFunction = new Map()
193
195
  const functions = new Map()
194
- const listFieldExpressions = new WeakSet()
196
+ const listValues = new WeakMap()
197
+ const listEventItems = new WeakMap()
195
198
  let usesBehavior = false
196
199
  let usesBinding = false
197
200
  let usesConditional = false
@@ -237,13 +240,20 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
237
240
  return factory.updateVariableDeclaration(node, node.name, node.exclamationToken, node.type, initializer)
238
241
  }
239
242
 
243
+ if (ts.isJsxExpression(node) && node.expression && listValues.has(node.expression)) {
244
+ return factory.updateJsxExpression(node, compileListValue(node.expression, listValues.get(node.expression), factory, listExpressions, handlerUrl))
245
+ }
246
+
247
+ if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && listValues.has(node.initializer.expression)) {
248
+ return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, compileListValue(node.initializer.expression, listValues.get(node.initializer.expression), factory, listExpressions, handlerUrl)))
249
+ }
250
+
240
251
  if (ts.isJsxExpression(node) && node.initializer === undefined && node.expression && (ts.isJsxElement(node.parent) || ts.isJsxFragment(node.parent))) {
241
252
  const listParts = keyedListParts(node.expression, settersForNode(node, settersByFunction))
242
253
  if (listParts) {
243
254
  if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
244
- validateKeyedList(listParts, sourceFile, listFieldExpressions)
255
+ validateKeyedList(listParts, sourceFile, settersForNode(node, settersByFunction), listValues, listEventItems)
245
256
  usesBehavior = true
246
- usesBinding = true
247
257
  usesList = true
248
258
  return factory.updateJsxExpression(node, factory.createCallExpression(factory.createIdentifier("__kList"), undefined, [
249
259
  listParts.state,
@@ -269,7 +279,6 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
269
279
 
270
280
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && !/^on/i.test(node.name.getText()) && !["style", "key", "ref", "dangerouslysetinnerhtml"].includes(node.name.getText().toLowerCase())) {
271
281
  const expression = node.initializer.expression
272
- if (listFieldExpressions.has(expression)) return node
273
282
  const setters = settersForNode(node, settersByFunction)
274
283
  const usedStates = referencedStateNames(expression, setters)
275
284
  const captures = captureNames(expression, expression, setters)
@@ -283,7 +292,7 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
283
292
 
284
293
  if (ts.isJsxAttribute(node) && node.initializer && ts.isJsxExpression(node.initializer) && node.initializer.expression && /^on[A-Z]/.test(node.name.getText())) {
285
294
  const setters = settersForNode(node, settersByFunction)
286
- const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl)
295
+ const event = compileEvent(node.initializer.expression, setters, functions, factory, nativeHandlers, handlerUrl, listEventItems.get(node))
287
296
  if (event) {
288
297
  usesBehavior = true
289
298
  return factory.updateJsxAttribute(node, node.name, factory.createJsxExpression(undefined, event))
@@ -302,7 +311,12 @@ function createKudzuTransformer(nativeHandlers, reactiveBindings, handlerUrl) {
302
311
  if (nativeHandlers.length) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("nativeBehavior"), factory.createIdentifier("__kNativeBehavior")))
303
312
  if (usesBinding) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("binding"), factory.createIdentifier("__kBinding")))
304
313
  if (usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("conditional"), factory.createIdentifier("__kConditional")))
305
- if (usesList) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
314
+ if (usesList) {
315
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("list"), factory.createIdentifier("__kList")))
316
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listExpression"), factory.createIdentifier("__kListExpression")))
317
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listField"), factory.createIdentifier("__kListField")))
318
+ behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("listItem"), factory.createIdentifier("__kListItem")))
319
+ }
306
320
  if (usesBinding || usesConditional) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("bindingValue"), factory.createIdentifier("__kBindingValue")))
307
321
  const behaviorImport = factory.createImportDeclaration(
308
322
  undefined,
@@ -331,7 +345,7 @@ function keyedListParts(expression, setters) {
331
345
  return { state, callback, root, item: callback.parameters[0].name.text, keyField: field }
332
346
  }
333
347
 
334
- function validateKeyedList(parts, sourceFile, listFieldExpressions) {
348
+ function validateKeyedList(parts, sourceFile, setters, listValues, listEventItems) {
335
349
  const fail = (node, message) => {
336
350
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
337
351
  throw new Error(`${sourceFile.fileName}:${position.line + 1}:${position.character + 1} ${message}`)
@@ -341,27 +355,112 @@ function validateKeyedList(parts, sourceFile, listFieldExpressions) {
341
355
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
342
356
  }
343
357
  const visit = node => {
358
+ if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
344
359
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
345
- if (node !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map") fail(node, "Nested keyed lists are not supported")
360
+ if (node !== parts.root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, "Nested keyed lists are not supported")
346
361
  if (ts.isJsxSpreadAttribute(node) && referencesIdentifier(node.expression, parts.item)) fail(node, "Keyed list item spreads are not supported")
347
- if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) fail(node, "Item-local handlers are not supported in keyed lists")
362
+ if (ts.isJsxAttribute(node) && /^on[A-Z]/.test(node.name.getText())) {
363
+ listEventItems.set(node, parts.item)
364
+ return
365
+ }
348
366
  if (ts.isJsxExpression(node) && node.expression) {
349
367
  const expression = unwrapExpression(node.expression)
368
+ if (conditionalParts(expression) && containsJsx(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
350
369
  const field = directProperty(expression, parts.item)
351
- const isRootKey = node.parent?.parent === parts.root && ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
370
+ const isRootKey = ts.isJsxAttribute(node.parent) && node.parent.name.getText() === "key"
371
+ if (field && ["__proto__", "constructor", "prototype"].includes(field)) fail(node, `Keyed list item property "${field}" is not supported`)
352
372
  if (field && ts.isJsxAttribute(node.parent) && ["style", "ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
353
- if (isRootKey || field) {
354
- if (field) listFieldExpressions.add(node.expression)
373
+ if (isRootKey) return
374
+ if (field) {
375
+ listValues.set(node.expression, { field })
376
+ return
377
+ }
378
+ if (referencesIdentifier(expression, parts.item)) {
379
+ validateListExpression(expression, parts.item, node, fail)
380
+ if (ts.isJsxAttribute(node.parent) && ["style", "ref", "dangerouslysetinnerhtml"].includes(node.parent.name.getText().toLowerCase())) fail(node, `Keyed list item ${node.parent.name.getText()} is not supported`)
381
+ listValues.set(node.expression, { item: parts.item })
355
382
  return
356
383
  }
357
- if (conditionalParts(expression)) fail(node, "Nested reactive conditions are not supported in keyed lists")
358
- if (referencesIdentifier(expression, parts.item)) fail(node, `Keyed list item expressions must be direct ${parts.item}.<field> reads`)
359
384
  }
360
385
  ts.forEachChild(node, visit)
361
386
  }
362
387
  visit(parts.root)
363
388
  }
364
389
 
390
+ const pureListMethods = new Set(["at", "charAt", "charCodeAt", "concat", "endsWith", "includes", "indexOf", "join", "lastIndexOf", "padEnd", "padStart", "repeat", "replace", "replaceAll", "slice", "startsWith", "substring", "toLowerCase", "toUpperCase", "trim", "trimEnd", "trimStart"])
391
+ const mutatingListMethods = new Set(["copyWithin", "fill", "pop", "push", "reverse", "shift", "sort", "splice", "unshift"])
392
+ const pureMathMethods = new Set(["abs", "ceil", "floor", "max", "min", "pow", "round", "sign", "sqrt", "trunc"])
393
+ const pureListGlobals = new Set(["Boolean", "Infinity", "Math", "NaN", "Number", "String", "undefined"])
394
+ const assignmentOperators = new Set([
395
+ ts.SyntaxKind.EqualsToken, ts.SyntaxKind.PlusEqualsToken, ts.SyntaxKind.MinusEqualsToken,
396
+ ts.SyntaxKind.AsteriskEqualsToken, ts.SyntaxKind.AsteriskAsteriskEqualsToken, ts.SyntaxKind.SlashEqualsToken,
397
+ ts.SyntaxKind.PercentEqualsToken, ts.SyntaxKind.LessThanLessThanEqualsToken, ts.SyntaxKind.GreaterThanGreaterThanEqualsToken,
398
+ ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, ts.SyntaxKind.AmpersandEqualsToken, ts.SyntaxKind.BarEqualsToken,
399
+ ts.SyntaxKind.CaretEqualsToken, ts.SyntaxKind.BarBarEqualsToken, ts.SyntaxKind.AmpersandAmpersandEqualsToken,
400
+ ts.SyntaxKind.QuestionQuestionEqualsToken
401
+ ])
402
+
403
+ function validateListExpression(expression, item, source, fail) {
404
+ const visit = node => {
405
+ if (ts.isElementAccessExpression(node) && referencesIdentifier(node.expression, item)) {
406
+ const key = node.argumentExpression
407
+ if (!ts.isStringLiteral(key) && !ts.isNumericLiteral(key)) fail(source, "Derived keyed list item computed properties require a direct string or numeric literal key")
408
+ if (ts.isStringLiteral(key) && ["__proto__", "constructor", "prototype"].includes(key.text)) fail(source, `Derived keyed list item property "${key.text}" is not supported`)
409
+ }
410
+ if (ts.isPropertyAccessExpression(node) && ["__proto__", "constructor", "prototype"].includes(node.name.text) || ts.isElementAccessExpression(node) && ts.isStringLiteral(node.argumentExpression) && ["__proto__", "constructor", "prototype"].includes(node.argumentExpression.text)) {
411
+ fail(source, "Derived keyed list item expressions cannot read __proto__, prototype, or constructor")
412
+ }
413
+ if (ts.isBinaryExpression(node) && assignmentOperators.has(node.operatorToken.kind) || ts.isPostfixUnaryExpression(node) || ts.isPrefixUnaryExpression(node) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(node.operator)) {
414
+ fail(source, "Derived keyed list item expressions must be pure; assignments and updates are not supported")
415
+ }
416
+ if (ts.isDeleteExpression(node) || ts.isAwaitExpression(node) || ts.isNewExpression(node) || ts.isYieldExpression(node)) {
417
+ fail(source, "Derived keyed list item expressions must be synchronous and side-effect free; delete, await, yield, and new are not supported")
418
+ }
419
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isTaggedTemplateExpression(node)) {
420
+ fail(source, "Derived keyed list item expressions cannot create or invoke arbitrary functions")
421
+ }
422
+ if (ts.isCallExpression(node)) {
423
+ if (ts.isPropertyAccessExpression(node.expression)) {
424
+ const method = node.expression.name.text
425
+ if (mutatingListMethods.has(method)) fail(source, `Derived keyed list item expressions cannot call mutating method "${method}"`)
426
+ const receiver = node.expression.expression
427
+ const mathCall = ts.isIdentifier(receiver) && receiver.text === "Math" && pureMathMethods.has(method)
428
+ if (!mathCall && !pureListMethods.has(method)) fail(source, `Derived keyed list item expressions cannot call arbitrary method "${method}"`)
429
+ } else if (!ts.isIdentifier(node.expression) || !["Boolean", "Number", "String"].includes(node.expression.text)) {
430
+ fail(source, "Derived keyed list item expressions cannot call arbitrary functions")
431
+ }
432
+ }
433
+ if (ts.isIdentifier(node) && isReferenceIdentifier(node) && node.text !== item && !pureListGlobals.has(node.text)) {
434
+ fail(source, `Derived keyed list item expression identifier "${node.text}" is not allowed`)
435
+ }
436
+ ts.forEachChild(node, visit)
437
+ }
438
+ visit(expression)
439
+ }
440
+
441
+ function containsJsx(root) {
442
+ let found = false
443
+ const visit = node => {
444
+ if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node) || ts.isJsxFragment(node)) found = true
445
+ if (!found) ts.forEachChild(node, visit)
446
+ }
447
+ visit(root)
448
+ return found
449
+ }
450
+
451
+ function compileListExpression(read, expression, item, factory, listExpressions, handlerUrl) {
452
+ const exportName = `listExpression${listExpressions.length}`
453
+ listExpressions.push({ exportName, expression, item })
454
+ return factory.createCallExpression(factory.createIdentifier("__kListExpression"), undefined, [read, factory.createStringLiteral(handlerUrl), factory.createStringLiteral(exportName)])
455
+ }
456
+
457
+ function compileListValue(expression, entry, factory, listExpressions, handlerUrl) {
458
+ const read = factory.createArrowFunction(undefined, undefined, [], undefined, factory.createToken(ts.SyntaxKind.EqualsGreaterThanToken), expression)
459
+ return entry.field
460
+ ? factory.createCallExpression(factory.createIdentifier("__kListField"), undefined, [read, factory.createStringLiteral(entry.field)])
461
+ : compileListExpression(read, expression, entry.item, factory, listExpressions, handlerUrl)
462
+ }
463
+
365
464
  function directProperty(expression, objectName) {
366
465
  const value = unwrapExpression(expression)
367
466
  if (!ts.isPropertyAccessExpression(value) || !ts.isIdentifier(value.expression)) return undefined
@@ -454,10 +553,11 @@ function factoryNull() {
454
553
  return ts.factory.createNull()
455
554
  }
456
555
 
457
- function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl) {
556
+ function compileEvent(expression, setters, functions, factory, nativeHandlers, handlerUrl, listItem) {
458
557
  if (ts.isIdentifier(expression)) expression = functions.get(expression.text)
459
558
  if (!expression || (!ts.isArrowFunction(expression) && !ts.isFunctionExpression(expression) && !ts.isFunctionDeclaration(expression))) return undefined
460
559
 
560
+ rejectNativeEventControls(expression)
461
561
  const optimized = compileOptimizedEvent(expression, setters, factory)
462
562
  if (optimized) return optimized
463
563
 
@@ -469,18 +569,45 @@ function compileEvent(expression, setters, functions, factory, nativeHandlers, h
469
569
  factory.createStringLiteral(name),
470
570
  factory.createIdentifier(name)
471
571
  ]))
472
- const scope = [...captures].map(name => factory.createArrayLiteralExpression([
473
- factory.createStringLiteral(name),
474
- factory.createIdentifier(name)
475
- ]))
476
572
  return factory.createCallExpression(factory.createIdentifier("__kNativeBehavior"), undefined, [
477
573
  factory.createStringLiteral(handlerUrl),
478
574
  factory.createStringLiteral(exportName),
479
575
  factory.createArrayLiteralExpression(states),
480
- factory.createArrayLiteralExpression(scope)
576
+ factory.createArrayLiteralExpression([...captures].map(name => factory.createArrayLiteralExpression([
577
+ factory.createStringLiteral(name),
578
+ name === listItem ? factory.createCallExpression(factory.createIdentifier("__kListItem"), undefined, []) : factory.createIdentifier(name)
579
+ ])))
481
580
  ])
482
581
  }
483
582
 
583
+ function rejectNativeEventControls(expression) {
584
+ const controls = new Set(["preventDefault", "stopPropagation", "stopImmediatePropagation"])
585
+ const found = new Set()
586
+ const eventAliases = new Set()
587
+ const parameter = expression.parameters[0]?.name
588
+ if (parameter && ts.isIdentifier(parameter)) eventAliases.add(parameter.text)
589
+ const visit = node => {
590
+ if (ts.isIdentifier(node) && controls.has(node.text)) found.add(node.text)
591
+ if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer && ts.isIdentifier(unwrapEventAlias(node.initializer)) && eventAliases.has(unwrapEventAlias(node.initializer).text)) {
592
+ eventAliases.add(node.name.text)
593
+ }
594
+ if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken && ts.isIdentifier(node.left) && ts.isIdentifier(unwrapEventAlias(node.right)) && eventAliases.has(unwrapEventAlias(node.right).text)) eventAliases.add(node.left.text)
595
+ if (ts.isElementAccessExpression(node) && ts.isIdentifier(unwrapEventAlias(node.expression)) && eventAliases.has(unwrapEventAlias(node.expression).text)) {
596
+ if (ts.isStringLiteral(node.argumentExpression) && controls.has(node.argumentExpression.text)) found.add(node.argumentExpression.text)
597
+ else if (!ts.isStringLiteral(node.argumentExpression)) for (const control of controls) found.add(control)
598
+ }
599
+ ts.forEachChild(node, visit)
600
+ }
601
+ for (const parameter of expression.parameters) visit(parameter)
602
+ visit(expression.body)
603
+ if (found.size) throw new Error(`Delegated native handlers do not support event control methods: ${[...found].sort().join(", ")}`)
604
+ }
605
+
606
+ function unwrapEventAlias(node) {
607
+ if (ts.isParenthesizedExpression(node) || ts.isAsExpression(node) || ts.isTypeAssertionExpression(node) || ts.isNonNullExpression(node) || ts.isSatisfiesExpression(node)) return unwrapEventAlias(node.expression)
608
+ return node
609
+ }
610
+
484
611
  function nativeStateNames(expression, setters) {
485
612
  return referencedStateNames(expression.body, setters, expression)
486
613
  }
@@ -681,6 +808,19 @@ function printReactiveBinding({ exportName, expression, captures, states }) {
681
808
  }
682
809
  }
683
810
 
811
+ function printListExpression({ exportName, expression, item }) {
812
+ const declaration = ts.factory.createFunctionDeclaration(
813
+ [ts.factory.createModifier(ts.SyntaxKind.ExportKeyword)],
814
+ undefined,
815
+ exportName,
816
+ undefined,
817
+ [ts.factory.createParameterDeclaration(undefined, undefined, item)],
818
+ undefined,
819
+ ts.factory.createBlock([ts.factory.createReturnStatement(expression)], true)
820
+ )
821
+ return ts.createPrinter().printNode(ts.EmitHint.Unspecified, declaration, expression.getSourceFile())
822
+ }
823
+
684
824
  function scopeRead(factory, name) {
685
825
  return factory.createCallExpression(
686
826
  factory.createPropertyAccessExpression(factory.createIdentifier("__k"), "scope"),
@@ -8,6 +8,9 @@ export function binding(value: unknown, module: string, handler: string, states:
8
8
  export function bindingValue(value: unknown): unknown
9
9
  export function conditional(kind: "and" | "ternary", value: unknown, truthy: () => unknown, falsy: () => unknown, module: string, handler: string, states: Array<[string, unknown]>, scope: Array<[string, unknown]>): unknown
10
10
  export function list(items: unknown, keyField: string, render: (item: unknown) => unknown): unknown
11
+ export function listField(read: () => unknown, field: string): unknown
12
+ export function listExpression(read: () => unknown, module: string, handler: string): unknown
13
+ export function listItem(): unknown
11
14
 
12
15
  export function renderPage(
13
16
  component: (props: Record<string, never>) => unknown | Promise<unknown>,
@@ -5,6 +5,8 @@ const bindingMarker = Symbol("kudzu.binding")
5
5
  const conditionalMarker = Symbol("kudzu.conditional")
6
6
  const listMarker = Symbol("kudzu.list")
7
7
  const listFieldMarker = Symbol("kudzu.listField")
8
+ const listExpressionMarker = Symbol("kudzu.listExpression")
9
+ const listItemMarker = Symbol("kudzu.listItem")
8
10
  const noSelectValue = Symbol("kudzu.no-select-value")
9
11
 
10
12
  let renderContext
@@ -79,13 +81,27 @@ export function list(items, keyField, render) {
79
81
  return { [listMarker]: true, items, keyField, render }
80
82
  }
81
83
 
84
+ export function listField(read, field) {
85
+ return { [listFieldMarker]: true, field, value: renderContext?.listTemplate ? undefined : read() }
86
+ }
87
+
88
+ export function listExpression(read, module, handler) {
89
+ const value = renderContext?.listTemplate ? undefined : read()
90
+ if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
91
+ return { [listExpressionMarker]: true, module, handler, value }
92
+ }
93
+
94
+ export function listItem() {
95
+ return { [listItemMarker]: true }
96
+ }
97
+
82
98
  function validListKey(key) {
83
99
  return typeof key === "string" || typeof key === "number" && Number.isFinite(key)
84
100
  }
85
101
 
86
102
  function assertListItem(item) {
87
103
  const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
88
- if (!item || Array.isArray(item) || prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must be plain objects")
104
+ if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
89
105
  }
90
106
 
91
107
  function assertListValue(value, seen) {
@@ -93,7 +109,7 @@ function assertListValue(value, seen) {
93
109
  if (!value || typeof value !== "object") throw new Error(`Keyed list items must contain only JSON-safe values`)
94
110
  if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
95
111
  const prototype = Object.getPrototypeOf(value)
96
- if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must contain only arrays and plain objects")
112
+ if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
97
113
  if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
98
114
  seen.add(value)
99
115
  const descriptors = Object.getOwnPropertyDescriptors(value)
@@ -139,6 +155,7 @@ function bindingDescriptor(value) {
139
155
  }
140
156
 
141
157
  function serializeCapture(name, value, seen) {
158
+ if (value?.[listItemMarker]) return { type: "list-item" }
142
159
  if (value === null || typeof value === "string" || typeof value === "boolean") return value
143
160
  if (typeof value === "number") {
144
161
  return Number.isFinite(value) && !Object.is(value, -0) ? value : { type: "number", value: String(value) }
@@ -170,7 +187,7 @@ function serializeCapture(name, value, seen) {
170
187
  }
171
188
 
172
189
  export async function renderPage(component, metadata = {}) {
173
- renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
190
+ renderContext = { nextState: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, states: {}, textStates: new Set(), conditionStates: new Set(), events: [], bindings: [], conditions: [], lists: [], hasBehaviors: false, hasNativeBehaviors: false, hasBindings: false, hasLists: false }
174
191
 
175
192
  try {
176
193
  const body = await renderNode({ type: component, props: {} })
@@ -290,6 +307,10 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
290
307
  if (node?.[listFieldMarker]) {
291
308
  return `<template data-k-list-text="${escapeAttribute(node.field)}"></template>${escapeHtml(node.value ?? "")}<template data-k-list-text-end></template>`
292
309
  }
310
+ if (node?.[listExpressionMarker]) {
311
+ const descriptor = { module: node.module, handler: node.handler }
312
+ return `<template data-k-list-expression='${escapeJsonAttribute(descriptor)}'></template>${escapeHtml(node.value ?? "")}<template data-k-list-expression-end></template>`
313
+ }
293
314
  if (!node || typeof node !== "object" || !("type" in node)) {
294
315
  throw new Error(`Cannot render ${String(node)}`)
295
316
  }
@@ -308,6 +329,8 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
308
329
  let attributes = ""
309
330
  const attributeBindings = []
310
331
  const listAttributes = []
332
+ const listExpressionAttributes = []
333
+ const listEvents = []
311
334
 
312
335
  if (renderContext.listRoot) {
313
336
  const root = renderContext.listRoot
@@ -327,15 +350,17 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
327
350
  }
328
351
 
329
352
  if (/^on[A-Z]/.test(rawName)) {
330
- const event = rawName.slice(2).toLowerCase()
353
+ const event = rawName.slice(2).toLowerCase()
331
354
  if (value?.[behaviorMarker]) {
332
355
  const commands = JSON.stringify(value.commands)
333
356
  attributes += ` data-k-on-${event}='${escapeJsonAttribute(value.commands)}'`
334
357
  renderContext.events.push({ event, commands: value.commands })
335
358
  } else if (value?.[nativeBehaviorMarker]) {
336
- const native = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
359
+ const template = { module: value.module, handler: value.handler, states: value.states, scope: value.scope }
360
+ const native = template
337
361
  attributes += ` data-k-native-${event}='${escapeJsonAttribute(native)}'`
338
362
  renderContext.events.push({ event, native })
363
+ if (renderContext.listDepth && Object.values(template.scope).some(entry => entry?.type === "list-item")) listEvents.push([event, template])
339
364
  renderContext.hasNativeBehaviors = true
340
365
  } else {
341
366
  throw new Error(`${rawName} must reference a compilable event handler`)
@@ -351,6 +376,11 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
351
376
  listAttributes.push([name, value.field])
352
377
  continue
353
378
  }
379
+ if (value?.[listExpressionMarker]) {
380
+ attributes += renderAttribute(name, value.value)
381
+ listExpressionAttributes.push([name, value.module, value.handler])
382
+ continue
383
+ }
354
384
  if (value?.[signalMarker] || value?.[bindingMarker]) {
355
385
  const initialValue = value[signalMarker] ? value.value : value.value
356
386
  const reactive = value[signalMarker] || Object.keys(value.states).length > 0 || Object.keys(value.scopeStates).length > 0 || Object.keys(value.scopeBindings).length > 0
@@ -382,6 +412,8 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
382
412
 
383
413
  if (attributeBindings.length) attributes += ` data-k-bind-attrs='${escapeJsonAttribute(attributeBindings)}'`
384
414
  if (listAttributes.length) attributes += ` data-k-list-attrs='${escapeJsonAttribute(listAttributes)}'`
415
+ if (listExpressionAttributes.length) attributes += ` data-k-list-expression-attrs='${escapeJsonAttribute(listExpressionAttributes)}'`
416
+ if (listEvents.length) attributes += ` data-k-list-events='${escapeJsonAttribute(listEvents)}'`
385
417
 
386
418
  if (tag === "option" && selectValue !== noSelectValue && String(optionValue(props)) === (selectValue == null ? "" : String(selectValue))) attributes += " selected"
387
419
 
@@ -394,17 +426,16 @@ async function renderList(node, namespace, selectValue) {
394
426
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
395
427
  const id = `l${renderContext.nextList++}`
396
428
  const descriptor = { id, state: node.items.id, key: node.keyField }
397
- const itemProxy = value => new Proxy({}, {
398
- get: (_, field) => ({ [listFieldMarker]: true, field: String(field), value: value?.[field] })
399
- })
400
429
  renderContext.listDepth++
401
430
  try {
431
+ renderContext.listTemplate = true
402
432
  renderContext.listRoot = { id, template: true }
403
- const template = await renderNode(node.render(itemProxy(undefined)), namespace, selectValue)
433
+ const template = await renderNode(node.render({}), namespace, selectValue)
404
434
  let current = ""
435
+ renderContext.listTemplate = false
405
436
  for (const item of node.items.value) {
406
437
  renderContext.listRoot = { id, key: item[node.keyField], template: false }
407
- current += await renderNode(node.render(itemProxy(item)), namespace, selectValue)
438
+ current += await renderNode(node.render(item), namespace, selectValue)
408
439
  }
409
440
  renderContext.lists.push(descriptor)
410
441
  renderContext.hasBehaviors = true
@@ -412,6 +443,7 @@ async function renderList(node, namespace, selectValue) {
412
443
  return `<template data-k-list='${escapeJsonAttribute(descriptor)}'>${template}</template>${current}<template data-k-list-end="${id}"></template>`
413
444
  } finally {
414
445
  renderContext.listRoot = undefined
446
+ renderContext.listTemplate = false
415
447
  renderContext.listDepth--
416
448
  }
417
449
  }
@@ -3,6 +3,8 @@ import { browserState, mountDom, registerCommitter, registerMountHook, registerU
3
3
  const listTargets = new Map()
4
4
  const listRegistrations = new WeakMap()
5
5
  const mountedLists = new WeakSet()
6
+ const imports = new Map()
7
+ const revisions = new WeakMap()
6
8
 
7
9
  function commitLists(id) {
8
10
  const lists = listTargets.get(id)
@@ -97,20 +99,62 @@ function updateList(list) {
97
99
  }
98
100
 
99
101
  function fillListItem(root, item) {
102
+ const revision = (revisions.get(root) ?? 0) + 1
103
+ revisions.set(root, revision)
100
104
  for (const marker of matching(root, "template[data-k-list-text]")) {
101
- const value = item?.[marker.dataset.kListText]
102
- let end = marker.nextSibling
103
- while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches("template[data-k-list-text-end]"))) end = end.nextSibling
104
- if (!end) throw new Error("Keyed list text marker has no end")
105
- const range = marker.ownerDocument.createRange()
106
- range.setStartAfter(marker)
107
- range.setEndBefore(end)
108
- range.deleteContents()
109
- end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
105
+ patchListText(marker, "template[data-k-list-text-end]", item?.[marker.dataset.kListText])
110
106
  }
111
107
  for (const node of matching(root, "[data-k-list-attrs]")) {
112
108
  for (const [target, field] of JSON.parse(node.dataset.kListAttrs)) patchBinding(node, target, item?.[field])
113
109
  }
110
+ for (const node of matching(root, "[data-k-list-events]")) {
111
+ for (const [event, native] of JSON.parse(node.dataset.kListEvents)) {
112
+ native.scope = Object.fromEntries(Object.entries(native.scope).map(([name, value]) => [name, value?.type === "list-item" ? serializeItem(item) : value]))
113
+ node.dataset[`kNative${capitalize(event)}`] = JSON.stringify(native)
114
+ }
115
+ }
116
+ for (const marker of matching(root, "template[data-k-list-expression]")) {
117
+ evaluate(JSON.parse(marker.dataset.kListExpression), item).then(value => {
118
+ if (revisions.get(root) === revision && root.isConnected) patchListText(marker, "template[data-k-list-expression-end]", value)
119
+ }).catch(error => console.error(error))
120
+ }
121
+ for (const node of matching(root, "[data-k-list-expression-attrs]")) {
122
+ for (const [target, module, handler] of JSON.parse(node.dataset.kListExpressionAttrs)) {
123
+ evaluate({ module, handler }, item).then(value => {
124
+ if (revisions.get(root) === revision && root.isConnected) patchBinding(node, target, value)
125
+ }).catch(error => console.error(error))
126
+ }
127
+ }
128
+ }
129
+
130
+ function patchListText(marker, endSelector, value) {
131
+ let end = marker.nextSibling
132
+ while (end && !(end.nodeType === Node.ELEMENT_NODE && end.matches(endSelector))) end = end.nextSibling
133
+ if (!end) throw new Error("Keyed list text marker has no end")
134
+ const range = marker.ownerDocument.createRange()
135
+ range.setStartAfter(marker)
136
+ range.setEndBefore(end)
137
+ range.deleteContents()
138
+ end.before(marker.ownerDocument.createTextNode(value == null ? "" : String(value)))
139
+ }
140
+
141
+ function evaluate(descriptor, item) {
142
+ let module = imports.get(descriptor.module)
143
+ if (!module) {
144
+ module = import(descriptor.module)
145
+ imports.set(descriptor.module, module)
146
+ }
147
+ return module.then(exports => {
148
+ const value = exports[descriptor.handler](item)
149
+ if (value && typeof value.then === "function") throw new Error("Derived keyed list item expressions must return synchronous values")
150
+ return value
151
+ })
152
+ }
153
+
154
+ function serializeItem(value) {
155
+ if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number") return value
156
+ if (Array.isArray(value)) return { type: "array", value: value.map(serializeItem) }
157
+ return { type: "object", nullPrototype: false, value: Object.entries(value).map(([key, entry]) => [key, serializeItem(entry)]) }
114
158
  }
115
159
 
116
160
  function patchBinding(node, target, value) {
@@ -142,7 +186,7 @@ function validListKey(key) {
142
186
 
143
187
  function assertListItem(item) {
144
188
  const prototype = item && typeof item === "object" ? Object.getPrototypeOf(item) : undefined
145
- if (!item || Array.isArray(item) || prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must be plain objects")
189
+ if (!item || Array.isArray(item) || prototype !== Object.prototype) throw new Error("Keyed list items must be ordinary plain objects")
146
190
  }
147
191
 
148
192
  function assertListValue(value, seen) {
@@ -150,7 +194,7 @@ function assertListValue(value, seen) {
150
194
  if (!value || typeof value !== "object") throw new Error("Keyed list items must contain only JSON-safe values")
151
195
  if (seen.has(value)) throw new Error("Keyed list items must not contain cycles")
152
196
  const prototype = Object.getPrototypeOf(value)
153
- if (!Array.isArray(value) && prototype !== Object.prototype && prototype !== null) throw new Error("Keyed list items must contain only arrays and plain objects")
197
+ if (!Array.isArray(value) && prototype !== Object.prototype) throw new Error("Keyed list items must contain only arrays and ordinary plain objects")
154
198
  if (Object.getOwnPropertySymbols(value).length) throw new Error("Keyed list items must not contain symbols")
155
199
  seen.add(value)
156
200
  const descriptors = Object.getOwnPropertyDescriptors(value)
@@ -183,3 +227,7 @@ function matching(root, selector) {
183
227
  function isStringBooleanAttribute(name) {
184
228
  return name.startsWith("aria-") || name.startsWith("data-")
185
229
  }
230
+
231
+ function capitalize(value) {
232
+ return value[0].toUpperCase() + value.slice(1)
233
+ }
@@ -39,19 +39,38 @@ if (typeof document !== "undefined") {
39
39
  const eventNames = ["click", "input", "change", "submit", "keydown", "keyup"]
40
40
  for (const eventName of eventNames) {
41
41
  document.addEventListener(eventName, event => {
42
- const target = event.target.closest(`[data-k-native-${eventName}]`)
43
- if (!target) return
44
-
45
- const native = JSON.parse(target.dataset[`kNative${capitalize(eventName)}`])
46
- let modulePromise = modules.get(native.module)
47
- if (!modulePromise) {
48
- modulePromise = import(native.module)
49
- modules.set(native.module, modulePromise)
42
+ try {
43
+ dispatchNative(event, snapshotNativeTargets(event, eventName), modules).catch(error => console.error(error))
44
+ } catch (error) {
45
+ console.error(error)
50
46
  }
51
- modulePromise
52
- .then(module => module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target)))
53
- .catch(error => console.error(error))
54
- }, true)
47
+ })
48
+ }
49
+ }
50
+
51
+ function snapshotNativeTargets(event, eventName) {
52
+ const selector = `[data-k-native-${eventName}]`
53
+ const targets = []
54
+ for (let target = event.target.closest(selector); target; target = target.parentElement?.closest(selector)) {
55
+ targets.push({ target, native: JSON.parse(target.dataset[`kNative${capitalize(eventName)}`]) })
56
+ }
57
+ return targets
58
+ }
59
+
60
+ async function dispatchNative(event, targets, modules) {
61
+ for (const { target, native } of targets) {
62
+ let modulePromise = modules.get(native.module)
63
+ if (!modulePromise) {
64
+ modulePromise = import(native.module)
65
+ modules.set(native.module, modulePromise)
66
+ }
67
+ try {
68
+ const module = await modulePromise
69
+ const result = module[native.handler](createNativeContext(browserState, native.states, commitDom, native.scope), delegatedEvent(event, target))
70
+ if (result && typeof result.then === "function") result.catch(error => console.error(error))
71
+ } catch (error) {
72
+ console.error(error)
73
+ }
55
74
  }
56
75
  }
57
76
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",