@kudzujs/core 0.5.8 → 0.6.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.
@@ -1,8 +1,9 @@
1
1
  export type StateSetter<T> = (value: T | ((previous: T) => T)) => void
2
2
  export type EffectCleanup = () => void | Promise<void>
3
+ export type EffectDependency = string | number | boolean | null
3
4
 
4
5
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
5
- export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly []): void
6
+ export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
6
7
  export function useParams<Params extends Record<string, string> = Record<string, string>>(): Readonly<Params>
7
8
 
8
9
  export interface RefObject<T> {
@@ -49,11 +50,16 @@ export function renderPage<Props = Record<string, never>>(
49
50
  manifest?: string
50
51
  styles?: boolean | string[]
51
52
  base?: string
53
+ runtimeAsset?: string
52
54
  effectAsset?: string
53
55
  paramAsset?: string
54
56
  runtimeParams?: string[]
57
+ navigationAsset?: string
58
+ applicationId?: string
59
+ layoutId?: string
55
60
  },
56
- props?: Props
61
+ props?: Props,
62
+ layout?: (props: { children: unknown }) => unknown | Promise<unknown>
57
63
  ): Promise<{
58
64
  html: string
59
65
  hasBehaviors: boolean
@@ -64,14 +70,14 @@ export function renderPage<Props = Record<string, never>>(
64
70
  hasListStyles: boolean
65
71
  hasStateSeed: boolean
66
72
  plan: {
67
- states: Array<{ id: string; name: string; initialValue: unknown }>
73
+ states: Array<{ id: string; name: string; initialValue: unknown; lifetime?: "layout" | "route" }>
68
74
  params: Array<{ name: string; id: string }>
69
75
  events: Array<{
70
76
  event: string
71
77
  commands?: Array<[string, string, unknown]>
72
78
  native?: { module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown> }
73
79
  }>
74
- effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; cleanup?: true }>
80
+ effects: Array<{ module: string; handler: string; states: Record<string, string>; scope: Record<string, unknown>; lifetime?: "layout" | "route"; dependencies?: string[]; cleanup?: true; owner?: string; list?: true }>
75
81
  bindings: Array<{
76
82
  target: string
77
83
  state?: string
@@ -14,6 +14,7 @@ const listConditionalMarker = Symbol("kudzu.listConditional")
14
14
  const refMarker = Symbol("kudzu.ref")
15
15
  const contextMarker = Symbol("kudzu.context")
16
16
  const contextProviderMarker = Symbol("kudzu.contextProvider")
17
+ const routeScopeMarker = Symbol("kudzu.routeScope")
17
18
  const noSelectValue = Symbol("kudzu.no-select-value")
18
19
 
19
20
  let renderContext
@@ -23,23 +24,24 @@ export function useState(initialValue, name) {
23
24
  throw new Error("useState() can only run while rendering a Kudzu component")
24
25
  }
25
26
 
26
- const id = `s${renderContext.nextState++}`
27
+ const id = nextRenderId("s")
27
28
  const signal = createSignal(id, initialValue)
28
29
 
29
30
  const setter = () => {
30
31
  throw new Error("State setters are compiled into ordered browser behaviors")
31
32
  }
32
33
  Object.defineProperty(setter, setterMarker, { value: id })
33
- renderContext.states[id] = { name: name ?? id, initialValue }
34
+ renderContext.states[id] = { name: name ?? id, initialValue, ...(renderContext.scoped ? { lifetime: renderContext.renderScope } : {}) }
34
35
  return [signal, setter]
35
36
  }
36
37
 
37
38
  export function useParams() {
39
+ if (renderContext?.renderScope === "layout") throw new Error("useParams() is only supported in route scope")
38
40
  if (!renderContext?.runtimeParamNames?.length) throw new Error("useParams() requires export const runtimeParams = true on a bracket page")
39
41
  if (!renderContext.params) {
40
42
  const params = Object.create(null)
41
- renderContext.paramEntries = renderContext.runtimeParamNames.map((name, index) => {
42
- const id = `p${index}`
43
+ renderContext.paramEntries = renderContext.runtimeParamNames.map(name => {
44
+ const id = nextRenderId("p")
43
45
  params[name] = createSignal(id, "")
44
46
  return { name, id }
45
47
  })
@@ -66,18 +68,45 @@ function createSignal(id, value) {
66
68
 
67
69
  export function useEffect(callback, dependencies, module, handler, states, scope, source, cleanup) {
68
70
  if (!renderContext) throw new Error("useEffect() can only run while rendering a Kudzu component")
69
- if (typeof callback !== "function" || !Array.isArray(dependencies) || dependencies.length || !module || !handler) {
70
- throw new Error("useEffect() must be compiled with a literal empty dependency array")
71
- }
72
- renderContext.effects.push({ module, handler, states, scope, source, ...(cleanup ? { cleanup: true } : {}) })
71
+ if (typeof callback !== "function" || !Array.isArray(dependencies) || !module || !handler) throw new Error("useEffect() must be compiled with a literal dependency array")
72
+ const dependencyIds = dependencies.map(dependency => {
73
+ if (!dependency?.[signalMarker] || !validEffectDependency(dependency.value)) throw new Error(`${source} useEffect() dependencies must be primitive Kudzu state or runtime parameter identifiers`)
74
+ return dependency.id
75
+ })
76
+ let owner
77
+ let list = false
78
+ if (renderContext.listDepth) {
79
+ const effects = renderContext.listRoot?.effects
80
+ if (!effects) throw new Error(`${source} useEffect() inside keyed lists must belong to the direct row component`)
81
+ const index = effects.length
82
+ if (renderContext.listTemplate) {
83
+ owner = nextRenderId("e")
84
+ renderContext.listEffectOwners.push(owner)
85
+ list = true
86
+ } else {
87
+ owner = renderContext.listEffectOwners[index]
88
+ if (!owner) throw new Error(`${source} Keyed row effects must have the same hook order for every item`)
89
+ }
90
+ effects.push(owner)
91
+ } else if (renderContext.conditionDepth) {
92
+ const owners = renderContext.effectOwners.at(-1)
93
+ if (!owners) throw new Error(`${source} useEffect() inside conditional DOM must belong to a rendered function component`)
94
+ owner = nextRenderId("e")
95
+ owners.push(owner)
96
+ }
97
+ if (!renderContext.listDepth || list) renderContext.effects.push({ module, handler, states, scope, source, renderScope: renderContext.renderScope, ...(dependencyIds.length ? { dependencies: dependencyIds } : {}), ...(cleanup ? { cleanup: true } : {}), ...(owner ? { owner } : {}), ...(list ? { list: true } : {}) })
73
98
  renderContext.hasBehaviors = true
74
99
  renderContext.hasEffects = true
75
100
  }
76
101
 
102
+ function validEffectDependency(value) {
103
+ return value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value) && !Object.is(value, -0)
104
+ }
105
+
77
106
  export function useRef(initialValue) {
78
107
  if (!renderContext) throw new Error("useRef() can only run while rendering a Kudzu component")
79
108
  if (initialValue !== null) throw new Error("Kudzu DOM refs must initialize with null")
80
- return { [refMarker]: true, id: `r${renderContext.nextRef++}`, current: null }
109
+ return { [refMarker]: true, id: nextRenderId("r"), current: null }
81
110
  }
82
111
 
83
112
  export function createContext(defaultValue) {
@@ -261,18 +290,26 @@ function serializeCapture(name, value, seen) {
261
290
  }
262
291
  }
263
292
 
264
- export async function renderPage(component, metadata = {}, props = {}) {
265
- renderContext = { nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
293
+ export async function renderPage(component, metadata = {}, props = {}, layout) {
294
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], effectOwners: [], contexts: [], states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
266
295
 
267
296
  try {
268
- const body = await renderNode({ type: component, props })
297
+ const page = { [routeScopeMarker]: true, component, props }
298
+ const body = await renderNode(layout ? { type: layout, props: { children: page } } : { type: component, props })
269
299
  renderContext.effects = renderContext.effects.map(effect => {
270
300
  try {
301
+ if (metadata.navigationAsset && effect.owner) throw new Error("useEffect() inside conditional or keyed DOM is not supported in a configured navigation group yet; move the effect to the layout or route component body, or remove the route from navigation")
302
+ const descriptor = nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, typeof read === "function" ? read() : read]))
271
303
  return {
272
304
  module: effect.module,
273
305
  handler: effect.handler,
306
+ ...(effect.dependencies ? { dependencies: effect.dependencies } : {}),
274
307
  ...(effect.cleanup ? { cleanup: true } : {}),
275
- ...nativeDescriptor(effect.states.map(([name, read]) => [name, read()]), effect.scope.map(([name, read]) => [name, read()]))
308
+ ...(effect.owner ? { owner: effect.owner } : {}),
309
+ ...(effect.list ? { list: true } : {}),
310
+ ...(renderContext.scoped ? { lifetime: effect.renderScope } : {}),
311
+ states: descriptor.states,
312
+ scope: descriptor.scope
276
313
  }
277
314
  } catch (error) {
278
315
  throw new Error(`${effect.source} ${error.message}`)
@@ -280,26 +317,30 @@ export async function renderPage(component, metadata = {}, props = {}) {
280
317
  })
281
318
  const title = escapeHtml(metadata.title ?? "Kudzu")
282
319
  const head = renderMetadata(metadata)
320
+ const capability = metadata.navigationAsset ? " data-k-capability" : ""
283
321
  const styles = metadata.styles === false
284
322
  ? ""
285
323
  : (Array.isArray(metadata.styles) ? metadata.styles : [assetPath(metadata.base, "assets/style.css")]).map(href => `<link rel="stylesheet" href="${escapeAttribute(href)}">`).join("")
286
324
  const runtime = renderContext.hasBehaviors
287
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu.js")}"></script>`
325
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.runtimeAsset ?? assetPath(metadata.base, "assets/kudzu.js"))}"></script>`
288
326
  : ""
289
327
  const nativeRuntime = renderContext.hasNativeBehaviors
290
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
328
+ ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-native.js")}"></script>`
291
329
  : ""
292
330
  const paramRuntime = renderContext.hasParams
293
- ? `<script type="module" src="${escapeAttribute(metadata.paramAsset)}"></script>`
331
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.paramAsset)}"></script>`
294
332
  : ""
295
333
  const bindingRuntime = renderContext.hasBindings
296
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
334
+ ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-binding.js")}"></script>`
297
335
  : ""
298
336
  const listRuntime = renderContext.hasLists
299
- ? `<script type="module" src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
337
+ ? `<script type="module"${capability} src="${assetPath(metadata.base, "assets/kudzu-list.js")}"></script>`
300
338
  : ""
301
339
  const effectRuntime = renderContext.hasEffects
302
- ? `<script type="module" src="${escapeAttribute(metadata.effectAsset)}"></script>`
340
+ ? `<script type="module"${capability} src="${escapeAttribute(metadata.effectAsset)}"></script>`
341
+ : ""
342
+ const navigationRuntime = metadata.navigationAsset
343
+ ? `<script type="module" data-k-capability src="${escapeAttribute(metadata.navigationAsset)}"></script>`
303
344
  : ""
304
345
  const listStates = new Set(renderContext.lists.map(list => list.state))
305
346
  const seededListStates = new Set(renderContext.lists.filter(list => list.seed && !renderContext.textStates.has(list.state) && !renderContext.conditionStates.has(list.state)).map(list => list.state))
@@ -317,7 +358,7 @@ export async function renderPage(component, metadata = {}, props = {}) {
317
358
  : ""
318
359
 
319
360
  return {
320
- html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}</head><body${state}${textBindings}>${body}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}</body></html>`,
361
+ html: `<!doctype html><html lang="${escapeAttribute(metadata.lang ?? "en")}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${title}</title>${head}${styles}${runtime}${paramRuntime}${bindingRuntime}${listRuntime}${nativeRuntime}${effectRuntime}${navigationRuntime}</head><body${state}${textBindings}${metadata.applicationId ? ` data-k-application="${escapeAttribute(metadata.applicationId)}" data-k-layout="${escapeAttribute(metadata.layoutId)}"` : ""}>${body}</body></html>`,
321
362
  hasBehaviors: renderContext.hasBehaviors,
322
363
  hasEffects: renderContext.hasEffects,
323
364
  hasParams: renderContext.hasParams,
@@ -342,16 +383,17 @@ export async function renderPage(component, metadata = {}, props = {}) {
342
383
 
343
384
  function renderMetadata(metadata) {
344
385
  const tags = []
386
+ const owned = metadata.navigationAsset ? " data-k-head" : ""
345
387
  const meta = (name, content, property = false) => {
346
- if (content) tags.push(`<meta ${property ? "property" : "name"}="${escapeAttribute(name)}" content="${escapeAttribute(content)}">`)
388
+ if (content) tags.push(`<meta${owned} ${property ? "property" : "name"}="${escapeAttribute(name)}" content="${escapeAttribute(content)}">`)
347
389
  }
348
390
 
349
391
  if (metadata.description) meta("description", metadata.description)
350
392
  if (metadata.themeColor) meta("theme-color", metadata.themeColor)
351
- if (metadata.url) tags.push(`<link rel="canonical" href="${escapeAttribute(metadata.url)}">`)
352
- if (metadata.icon) tags.push(`<link rel="icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.icon))}">`)
353
- if (metadata.appleTouchIcon) tags.push(`<link rel="apple-touch-icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.appleTouchIcon))}">`)
354
- if (metadata.manifest) tags.push(`<link rel="manifest" href="${escapeAttribute(baseUrl(metadata.base, metadata.manifest))}">`)
393
+ if (metadata.url) tags.push(`<link${owned} rel="canonical" href="${escapeAttribute(metadata.url)}">`)
394
+ if (metadata.icon) tags.push(`<link${owned} rel="icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.icon))}">`)
395
+ if (metadata.appleTouchIcon) tags.push(`<link${owned} rel="apple-touch-icon" href="${escapeAttribute(baseUrl(metadata.base, metadata.appleTouchIcon))}">`)
396
+ if (metadata.manifest) tags.push(`<link${owned} rel="manifest" href="${escapeAttribute(baseUrl(metadata.base, metadata.manifest))}">`)
355
397
 
356
398
  meta("og:title", metadata.title, true)
357
399
  meta("og:description", metadata.description, true)
@@ -397,6 +439,16 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
397
439
  return escapeHtml(node)
398
440
  }
399
441
  if (node instanceof Promise) return renderNode(await node, namespace, selectValue)
442
+ if (node?.[routeScopeMarker]) {
443
+ const previousScope = renderContext.renderScope
444
+ renderContext.renderScope = "route"
445
+ try {
446
+ const html = await renderNode({ type: node.component, props: node.props }, namespace, selectValue)
447
+ return `<template data-k-route-start></template>${html}<template data-k-route-end></template>`
448
+ } finally {
449
+ renderContext.renderScope = previousScope
450
+ }
451
+ }
400
452
  if (node?.[contextProviderMarker]) {
401
453
  renderContext.contexts.push([node.context, node.value])
402
454
  try {
@@ -411,7 +463,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
411
463
  if (!stateIds.size) return renderNode(node.value ? node.truthy() : node.falsy(), namespace, selectValue)
412
464
  if (namespace) throw new Error(`Reactive conditional DOM is not supported inside ${namespace}`)
413
465
 
414
- const id = `c${renderContext.nextCondition++}`
466
+ const id = nextRenderId("c")
415
467
  renderContext.conditionDepth++
416
468
  const truthy = await renderNode(node.truthy(), namespace, selectValue)
417
469
  const falsy = await renderNode(node.falsy(), namespace, selectValue)
@@ -472,10 +524,29 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
472
524
  }
473
525
 
474
526
  if (node.type === Symbol.for("kudzu.fragment")) return renderNode(node.props.children, namespace, selectValue)
475
- if (typeof node.type === "function") return renderNode(await node.type(node.props), namespace, selectValue)
527
+ if (typeof node.type === "function") {
528
+ if (!renderContext.conditionDepth) return renderNode(await node.type(node.props), namespace, selectValue)
529
+ const owners = []
530
+ renderContext.effectOwners.push(owners)
531
+ let result
532
+ try {
533
+ result = await node.type(node.props)
534
+ } finally {
535
+ renderContext.effectOwners.pop()
536
+ }
537
+ const html = await renderNode(result, namespace, selectValue)
538
+ return owners.map(owner => `<template data-k-effect="${owner}"></template>`).join("") + html
539
+ }
476
540
 
477
541
  const tag = node.type
478
542
  const props = node.props ?? {}
543
+ if (typeof tag === "string" && tag.toLowerCase() === "link") {
544
+ const rel = Object.entries(props).find(([name]) => name.toLowerCase() === "rel")?.[1]
545
+ const value = rel?.[signalMarker] || rel?.[bindingMarker] ? rel.value : rel
546
+ if (typeof value === "string" && value.toLowerCase().split(/\s+/).includes("stylesheet")) {
547
+ throw new Error("Stylesheets must be placed under src/ or declared in kudzu.config styles so Kudzu can emit them in <head>")
548
+ }
549
+ }
479
550
  const directListText = props.children?.[listFieldMarker] ? props.children : undefined
480
551
  const childSelectValue = tag === "select"
481
552
  ? Object.hasOwn(props, "value") ? bindingValue(props.value) : noSelectValue
@@ -494,6 +565,10 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
494
565
  const root = renderContext.listRoot
495
566
  renderContext.listRoot = undefined
496
567
  if (root.template) attributes += ` data-k-list-root="${root.id}"`
568
+ if (root.effects.length) {
569
+ attributes += ` data-k-effects='${escapeJsonAttribute(root.effects)}'`
570
+ if (!root.template) attributes += ` data-k-effect-item='${escapeJsonAttribute(root.item)}'`
571
+ }
497
572
  }
498
573
 
499
574
  for (const [rawName, value] of Object.entries(props)) {
@@ -506,7 +581,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
506
581
  }
507
582
  if (rawName === "selected" && selectValue !== noSelectValue) continue
508
583
  if (/^on/i.test(rawName) && !/^on[A-Z]/.test(rawName)) throw new Error(`${rawName} must use a camelCase event handler`)
509
- if (rawName.toLowerCase().startsWith("data-k-")) throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
584
+ if (rawName.toLowerCase().startsWith("data-k-") && rawName.toLowerCase() !== "data-k-native") throw new Error(`${rawName} uses Kudzu's reserved data-k-* prefix`)
510
585
  if (["ref", "dangerouslysetinnerhtml"].includes(rawName.toLowerCase()) && (value?.[signalMarker] || value?.[bindingMarker])) {
511
586
  throw new Error(`Reactive ${rawName} is not supported`)
512
587
  }
@@ -599,16 +674,19 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
599
674
 
600
675
  async function renderList(node, namespace, selectValue) {
601
676
  if (namespace) throw new Error(`Reactive keyed lists are not supported inside ${namespace}`)
602
- const id = `l${renderContext.nextList++}`
677
+ const id = nextRenderId("l")
603
678
  const descriptor = { id, state: node.items.id, key: node.keyField, keys: node.items.value.map(item => item[node.keyField]) }
604
679
  renderContext.listDepth++
605
680
  const previousListFields = renderContext.listFields
681
+ const previousListEffectOwners = renderContext.listEffectOwners
606
682
  try {
607
683
  renderContext.listTemplate = true
684
+ renderContext.listEffectOwners = []
608
685
  renderContext.listFields = new Set([node.keyField])
609
- renderContext.listRoot = { id, template: true }
686
+ renderContext.listRoot = { id, template: true, effects: [], item: {} }
610
687
  const template = await renderNode(node.render({}), namespace, selectValue)
611
- if (template.includes("data-k-native-")) descriptor.mount = true
688
+ if (template.includes("data-k-native-") || template.includes("data-k-effects=")) descriptor.mount = true
689
+ if (template.includes("data-k-effects=")) descriptor.effects = true
612
690
  if (template.includes("data-k-list-condition")) descriptor.conditions = true
613
691
  if (template.includes("data-k-list-text-end")) descriptor.textRanges = true
614
692
  if (template.includes("data-k-list-attrs")) descriptor.attributes = true
@@ -621,7 +699,7 @@ async function renderList(node, namespace, selectValue) {
621
699
  renderContext.listTemplate = false
622
700
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
623
701
  for (const item of node.items.value) {
624
- renderContext.listRoot = { id, key: item[node.keyField], template: false }
702
+ renderContext.listRoot = { id, key: item[node.keyField], template: false, effects: [], item }
625
703
  current += await renderNode(node.render(item), namespace, selectValue)
626
704
  }
627
705
  renderContext.lists.push(descriptor)
@@ -633,10 +711,17 @@ async function renderList(node, namespace, selectValue) {
633
711
  renderContext.listTemplate = false
634
712
  renderContext.listInitialMarkers = false
635
713
  renderContext.listFields = previousListFields
714
+ renderContext.listEffectOwners = previousListEffectOwners
636
715
  renderContext.listDepth--
637
716
  }
638
717
  }
639
718
 
719
+ function nextRenderId(kind) {
720
+ if (renderContext.scoped) return `${renderContext.renderScope === "layout" ? "l" : "r"}${kind}${renderContext.counters[renderContext.renderScope][kind]++}`
721
+ const counters = { s: "nextState", r: "nextRef", c: "nextCondition", l: "nextList", e: "nextEffect", p: "nextParam" }
722
+ return `${kind}${renderContext[counters[kind]]++}`
723
+ }
724
+
640
725
  function optionValue(props) {
641
726
  if (props.value != null) return bindingValue(props.value)
642
727
  return Array.isArray(props.children) ? props.children.join("") : props.children ?? ""
@@ -0,0 +1,36 @@
1
+ export function applyCommands(state, commands, commit, log = console.log) {
2
+ const changed = new Set()
3
+ for (const [operation, id, operand] of commands) {
4
+ const current = state.get(id)
5
+ if (operation === "log") log(operand, current)
6
+ else {
7
+ state.set(id, operation === "add" ? current + operand : operand)
8
+ changed.add(id)
9
+ }
10
+ }
11
+ for (const id of changed) commit(id, state.get(id))
12
+ }
13
+
14
+ export const browserState = new Map()
15
+ const committers = []
16
+
17
+ export function registerCommitter(commit) {
18
+ committers.push(commit)
19
+ }
20
+
21
+ export function commitDom(id, value) {
22
+ for (const node of document.querySelectorAll(`[data-k-text="${id}"]`)) node.textContent = value
23
+ for (const commit of committers) commit(id)
24
+ }
25
+
26
+ if (typeof document !== "undefined") {
27
+ const initialState = document.body.dataset.kState
28
+ if (initialState) for (const [id, value] of JSON.parse(initialState)) browserState.set(id, value)
29
+ for (const node of document.querySelectorAll("[data-k-text]")) browserState.set(node.dataset.kText, JSON.parse(node.dataset.kValue))
30
+
31
+ const eventNames = ["click", "input", "change"]
32
+ for (const eventName of eventNames) document.addEventListener(eventName, event => {
33
+ const target = event.target.closest(`[data-k-on-${eventName}]`)
34
+ if (target) applyCommands(browserState, JSON.parse(target.getAttribute(`data-k-on-${eventName}`)), commitDom)
35
+ })
36
+ }
@@ -1,17 +1,19 @@
1
1
  import { deserialize } from "./serialization.js"
2
2
 
3
- export function createEffectContext(state, stateIds, commit, serializedScope = {}) {
3
+ export function createEffectContext(state, stateIds, commit, serializedScope = {}, active = () => true) {
4
4
  const changed = new Set()
5
5
  let scheduled = false
6
6
 
7
7
  const flush = () => {
8
8
  scheduled = false
9
+ if (!active()) return changed.clear()
9
10
  const ids = [...changed]
10
11
  changed.clear()
11
12
  for (const id of ids) commit(id, state.get(id))
12
13
  }
13
14
 
14
15
  const setId = (id, value) => {
16
+ if (!active()) return
15
17
  const current = state.get(id)
16
18
  state.set(id, typeof value === "function" ? value(current) : value)
17
19
  changed.add(id)
@@ -7,7 +7,7 @@ const imports = __KUDZU_LIST_ASYNC_PARTS__ ? new Map() : undefined
7
7
  const revisions = __KUDZU_LIST_ASYNC_PARTS__ ? new WeakMap() : undefined
8
8
  const itemParts = new WeakMap()
9
9
  const conditionOwners = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
10
- const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}`
10
+ const itemPartsSelector = `[data-k-list-text]${__KUDZU_LIST_ATTRIBUTES__ ? ",[data-k-list-attrs]" : ""}${__KUDZU_LIST_EVENTS__ ? ",[data-k-list-events]" : ""}${__KUDZU_LIST_EXPRESSIONS__ ? ",[data-k-list-expression]" : ""}${__KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? ",[data-k-list-expression-attrs]" : ""}${__KUDZU_LIST_CONDITIONS__ ? ",[data-k-list-condition]" : ""}${__KUDZU_LIST_EFFECTS__ ? ",[data-k-effects]" : ""}`
11
11
 
12
12
  function commitLists(id) {
13
13
  const lists = listTargets.get(id)
@@ -114,8 +114,11 @@ function updateList(list) {
114
114
  } else node.remove()
115
115
  }
116
116
  if (added) {
117
- if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) mountDom(additions)
118
- parent.insertBefore(additions, list.boundary)
117
+ if (__KUDZU_LIST_MOUNTS__ && list.descriptor.mount) {
118
+ const addedNodes = [...additions.childNodes]
119
+ parent.insertBefore(additions, list.boundary)
120
+ for (const node of addedNodes) mountDom(node)
121
+ } else parent.insertBefore(additions, list.boundary)
119
122
  list.container ??= parent
120
123
  }
121
124
  let anchor = list.boundary
@@ -149,6 +152,7 @@ function fillListItem(root, item) {
149
152
  }
150
153
 
151
154
  function fillListParts(root, parts, item, revision) {
155
+ if (__KUDZU_LIST_EFFECTS__) for (const node of parts.effects) node.dataset.kEffectItem = JSON.stringify(item)
152
156
  for (const [node, field] of parts.directTexts) {
153
157
  const text = item?.[field]
154
158
  const value = text == null ? "" : String(text)
@@ -203,7 +207,7 @@ function fillListParts(root, parts, item, revision) {
203
207
  function listItemParts(root) {
204
208
  let parts = itemParts.get(root)
205
209
  if (parts) return parts
206
- parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [] }
210
+ parts = { directTexts: [], texts: [], attributes: [], events: [], expressions: [], expressionAttributes: [], conditions: [], effects: [] }
207
211
  for (const node of matching(root, itemPartsSelector)) {
208
212
  if (node.hasAttribute("data-k-list-text")) (node.tagName === "TEMPLATE" ? parts.texts : parts.directTexts).push([node, node.dataset.kListText])
209
213
  if (__KUDZU_LIST_ATTRIBUTES__ && node.hasAttribute("data-k-list-attrs")) parts.attributes.push([node, JSON.parse(node.dataset.kListAttrs)])
@@ -214,6 +218,7 @@ function listItemParts(root) {
214
218
  parts.conditions.push([node, JSON.parse(node.dataset.kListCondition)])
215
219
  conditionOwners.set(node, root)
216
220
  }
221
+ if (__KUDZU_LIST_EFFECTS__ && node.hasAttribute("data-k-effects")) parts.effects.push(node)
217
222
  }
218
223
  itemParts.set(root, parts)
219
224
  return parts
@@ -230,7 +235,8 @@ function listItemPartPlan(template) {
230
235
  events: __KUDZU_LIST_EVENTS__ ? parts.events.map(([node, events]) => [indexes.get(node), events]) : [],
231
236
  expressions: __KUDZU_LIST_EXPRESSIONS__ ? parts.expressions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : [],
232
237
  expressionAttributes: __KUDZU_LIST_EXPRESSION_ATTRIBUTES__ ? parts.expressionAttributes.map(([node, attributes]) => [indexes.get(node), attributes]) : [],
233
- conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : []
238
+ conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [indexes.get(node), descriptor]) : [],
239
+ effects: __KUDZU_LIST_EFFECTS__ ? parts.effects.map(node => indexes.get(node)) : []
234
240
  }
235
241
  }
236
242
 
@@ -246,7 +252,8 @@ function mapListItemParts(parts, root) {
246
252
  conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([index, descriptor]) => {
247
253
  conditionOwners.set(target[index], root)
248
254
  return [target[index], descriptor]
249
- }) : []
255
+ }) : [],
256
+ effects: __KUDZU_LIST_EFFECTS__ ? parts.effects.map(index => target[index]) : []
250
257
  })
251
258
  }
252
259