@kudzujs/core 0.14.3 → 0.15.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/PERFORMANCE.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  Reproducibility classes: `npm run benchmark`, `npm run benchmark:keyed`, `npm run benchmark:native`, `npm run benchmark:module-cache`, `npm run benchmark:project-navigation`, `npm run benchmark:project-state`, and `npm run benchmark:source-scale` are maintained in this repository; `npm run benchmark:commerce` is a maintained paired runner over the public external storefront; older excluded-workspace sections are historical provenance only and are not current framework rankings.
4
4
 
5
+ ## 0.15.1 Key-Scoped Native Popover Intake
6
+
7
+ Measured 2026-08-27 on macOS arm64 with Node 24.14.0 and Chrome
8
+ 151.0.7922.174. The maintained project fixture uses native Popover with one
9
+ key-scoped `useId()` relationship per project row. Required Chrome verifies
10
+ Escape, light dismiss, trigger focus restoration, reorder identity, inserted
11
+ and remounted IDs, and enhanced-navigation cleanup.
12
+
13
+ The application emits 44 files totaling 171,202 raw / 53,629 aggregate gzip
14
+ bytes with deploy SHA-256
15
+ `46ba45b4a423b9627c6ede5f8dd3a667f593cb5401454ae42ecc66207b07b323`.
16
+ The maintained two-route session contains 17 JavaScript files totaling 77,779
17
+ raw / 27,050 aggregate gzip bytes. `/help` remains 0 B JavaScript.
18
+
19
+ Seven fresh Chrome profiles record table update samples of `[0.4, 0.4, 0.5,
20
+ 0.4, 0.5, 0.5, 0.5]` ms, a 0.5 ms median and 0.4/0.5 ms range. Navigation
21
+ samples are `[4, 2.4, 2.6, 2.5, 2.5, 2.8, 2.4]` ms, a 2.5 ms median and 2.4/4
22
+ ms range. These ranges overlap prior retained-path evidence; no timing
23
+ improvement or regression is claimed.
24
+
5
25
  ## 0.14.3 10,000-Item Browser Decision Intake
6
26
 
7
27
  Measured 2026-08-27 on Linux x64 with Node 24.14.0 and Chrome
package/README.md CHANGED
@@ -14,7 +14,7 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
14
14
 
15
15
  > Experimental `0.14.x`: the compiler API and supported TSX surface may change.
16
16
 
17
- **Latest release: 0.14.3 - 10,000-item browser decision.** Measured direct DOM, pagination, and scroll-window alternatives select native pagination for large project tables without adding virtualization or range runtime semantics. Read the [release notes](./RELEASES.md#0143---10000-item-browser-decision), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.14.3), or follow the [architecture packet](./docs/next-architecture/README.md).
17
+ **Latest release: 0.15.1 - Key-scoped native popovers.** Keyed row `useId()` now scopes native Popover triggers and intrinsic ID references by row key without adding an overlay runtime. Read the [release notes](./RELEASES.md#0151---key-scoped-native-popovers), open the [release page](https://github.com/kudzujs/kudzu/releases/tag/v0.15.1), or follow the [architecture packet](./docs/next-architecture/README.md).
18
18
 
19
19
  - [Documentation](https://kudzujs.cloud/docs)
20
20
  - [Installation guide](https://kudzujs.cloud/docs#install)
package/RELEASES.md CHANGED
@@ -1,5 +1,39 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.15.1 - Key-Scoped Native Popovers
4
+
5
+ Kudzu 0.15.1 lets ordinary keyed row components use `useId()` for native
6
+ Popover triggers and intrinsic ID relationships without duplicate cloned IDs.
7
+
8
+ ### Changed in 0.15.1
9
+
10
+ - Key-scoped keyed-row `useId()` through the existing list ownership path.
11
+ - Rewrites cloned `id`, `htmlFor`, static ARIA IDREF, `form`, `headers`, `list`,
12
+ and `popovertarget` attributes.
13
+ - Rejects keyed IDs used in text, handlers, aliases, arbitrary attributes, or
14
+ component prop forwarding.
15
+ - Proves native Popover Escape, light dismiss, trigger focus restoration,
16
+ retained reorder identity, inserted and remounted IDs, and route cleanup.
17
+ - Adds no overlay runtime, focus manager, positioning engine, Portal/Slot
18
+ reconciliation, retained component tree, or public API.
19
+ - Updates `create-kudzu@0.1.126` to generate projects on
20
+ `@kudzujs/core@^0.15.1`.
21
+
22
+ ### Output And Browser Evidence
23
+
24
+ - The maintained application emits 44 files totaling 171,202 raw / 53,629
25
+ aggregate gzip bytes; `/help` remains 0 B JavaScript.
26
+ - The two-route session remains 17 JavaScript files totaling 77,779 raw /
27
+ 27,050 aggregate gzip bytes.
28
+ - Seven Chrome profiles record a 0.5 ms table-update median and 2.5 ms
29
+ navigation median, within prior retained-path ranges.
30
+
31
+ ### Upgrade
32
+
33
+ ```sh
34
+ npm install @kudzujs/core@^0.15.1
35
+ ```
36
+
3
37
  ## 0.14.3 - 10,000-Item Browser Decision
4
38
 
5
39
  Kudzu 0.14.3 measures direct DOM, pagination, and scroll-window alternatives
@@ -1030,6 +1030,13 @@ virtual range ownership.
1030
1030
  - **Done condition:** range semantics are package-neutral, optional, measured,
1031
1031
  and absent from non-virtual routes.
1032
1032
 
1033
+ Closed by stop condition. The `0.14.3` decision selected pagination over both
1034
+ direct 10,000-row DOM and the authored fixed-height scroll window. No independent
1035
+ application fixture proves pagination or application-owned bounded ranges
1036
+ insufficient, against the required three. Kudzu therefore adds no visible-range
1037
+ owner, measurement policy, range primitive, or virtualization runtime and moves
1038
+ to the next evidence-ready application capability.
1039
+
1033
1040
  ## `0.15.x`: Overlay And Layer Ownership
1034
1041
 
1035
1042
  ### `0.15.0`: Native Dialog Baseline
@@ -1040,6 +1047,31 @@ virtual range ownership.
1040
1047
  - **Stop condition:** a custom modal/focus-trap runtime replaces `<dialog>`.
1041
1048
  - **Done condition:** project delete and edit confirmation pass accessibly.
1042
1049
 
1050
+ Implementation evidence is local and release gates remain pending. The maintained
1051
+ project application replaces immediate keyed project deletion with one shared
1052
+ native `<dialog>`, one route-owned object ref, and one pending project ID state.
1053
+ Existing row callback specialization opens the browser top layer; native cancel,
1054
+ explicit cancel, and confirmation handlers close it without a Portal, focus trap,
1055
+ overlay runtime, or retained component tree.
1056
+
1057
+ Required Chrome proves modal top-layer behavior, native initial focus, explicit
1058
+ cancel, Escape-equivalent `requestClose()`, trigger-focus restoration, confirmed
1059
+ keyed deletion with fallback focus, retained sibling identity, and exact top-layer
1060
+ release on enhanced route navigation. The static `/help` sibling remains 0 B
1061
+ JavaScript. The slice adds no semantic primitive, IR kind, compiler pass,
1062
+ production compiler/runtime line, normalization rule, adapter, runtime concept,
1063
+ or public API. The application emits 44 files totaling 169,778 raw / 53,294
1064
+ aggregate gzip bytes with deploy SHA-256
1065
+ `91c5517e69c341bbd2051f07b3f0ad7df627098ba8e1984561268ea8666f3407`.
1066
+ The maintained two-route session remains 17 JavaScript files totaling 77,453 raw
1067
+ / 26,905 aggregate gzip bytes.
1068
+
1069
+ Seven fresh macOS arm64 Chrome 151 profiles record table update samples of
1070
+ `[0.5, 0.6, 0.6, 0.6, 0.6, 0.4, 0.5]` ms, a 0.6 ms median and 0.4/0.6 ms range,
1071
+ plus navigation samples of `[2.3, 2.6, 2.9, 2.6, 2.5, 2.7, 2.5]` ms, a 2.6 ms
1072
+ median and 2.3/2.9 ms range. These retained-path ranges overlap the previous
1073
+ same-environment evidence, so no timing improvement or regression is claimed.
1074
+
1043
1075
  ### `0.15.1`: Popover, Dropdown, And Menu Behavior
1044
1076
 
1045
1077
  - **Purpose:** establish the native-first non-modal overlay boundary.
@@ -1050,6 +1082,38 @@ virtual range ownership.
1050
1082
  - **Stop condition:** package Portal/Slot reconciliation enters the runtime.
1051
1083
  - **Done condition:** supported native and custom boundaries are explicit.
1052
1084
 
1085
+ Completed with the maintained project application as the qualifying fixture. A
1086
+ keyed project row now authors one top-level `useId()`, a native
1087
+ `popovertarget`, and a native `popover="auto"` action group. The compiler scopes
1088
+ the generated ID by the existing keyed ownership path and the list runtime
1089
+ rewrites only intrinsic `id`, `for`, static ARIA IDREF, `form`, `headers`,
1090
+ `list`, and `popovertarget` attributes. Unsupported ID escapes receive a source
1091
+ diagnostic. Native Chrome behavior supplies the top layer, Escape, light
1092
+ dismiss, and trigger focus restoration; ordinary keyed ownership preserves IDs
1093
+ across reorder, scopes inserted rows, recreates deterministic IDs after
1094
+ remove/re-add, and releases an open popover during enhanced navigation. The
1095
+ authored overlay uses static anchor positioning and `role="group"`, so menu
1096
+ roving focus is not applicable.
1097
+
1098
+ The slice adds no semantic primitive, IR kind, compiler pass, runtime concept,
1099
+ public API, Portal/Slot reconciliation, focus manager, positioning engine, or
1100
+ retained overlay tree. It adds 52 and removes 24 production lines across the
1101
+ source compiler, RouteIR validation, capability projection, build renderer, and
1102
+ existing list runtime. The maintained application emits 44 files totaling
1103
+ 171,202 raw / 53,629 aggregate gzip bytes with deploy SHA-256
1104
+ `46ba45b4a423b9627c6ede5f8dd3a667f593cb5401454ae42ecc66207b07b323`.
1105
+ The two-route session remains 17 JavaScript files totaling 77,779 raw / 27,050
1106
+ aggregate gzip bytes, a 326 raw / 145 gzip byte increase from `0.15.0`; the
1107
+ static `/help` sibling remains 0 B JavaScript. Familiar React-shaped `useId()`
1108
+ authoring remains intact, so no application adapter or migration-only source
1109
+ structure is added.
1110
+
1111
+ Seven fresh macOS arm64 Chrome 151 profiles record table update samples of
1112
+ `[0.4, 0.4, 0.5, 0.4, 0.5, 0.5, 0.5]` ms, a 0.5 ms median and 0.4/0.5 ms range,
1113
+ plus navigation samples of `[4, 2.4, 2.6, 2.5, 2.5, 2.8, 2.4]` ms, a 2.5 ms
1114
+ median and 2.4/4 ms range. These retained-path ranges overlap prior evidence,
1115
+ so no timing improvement or regression is claimed.
1116
+
1053
1117
  ### `0.15.2`: Toast And Notification Ownership
1054
1118
 
1055
1119
  - **Purpose:** provide application feedback across route mutations.
@@ -1477,5 +1541,8 @@ release transaction where possible or document and publish a forward-fix patch.
1477
1541
  | `0.14.0` | Released | Preserve native table CRUD, retained identity, keyboard access, and measured update evidence. | None |
1478
1542
  | `0.14.1` | Released | Preserve immutable object-state replacement, nested keyed identity, latest handlers, and exact row release. | None |
1479
1543
  | `0.14.2` | Released | Preserve bounded cursor loading, duplicate suppression, retry/end behavior, retained identity, and route cleanup. | None |
1480
- | `0.14.3` | Release candidate | Complete commit, CI, tag, GitHub release, npm publication, and registry verification. | Release transaction |
1481
- | `0.14.4` onward | Blocked | Complete or explicitly close `0.14.3` first. | Ordered patch boundary |
1544
+ | `0.14.3` | Released | Preserve the published 10,000-item pagination decision and measurement evidence. | None |
1545
+ | `0.14.4` | Closed by stop condition | Preserve pagination as the selected strategy until three independent fixtures authorize virtual ownership review. | Evidence favors pagination; zero qualifying fixtures |
1546
+ | `0.15.0` | Closed by native composition | Preserve the native dialog, focus, cancellation, keyed deletion, and route cleanup evidence. | No production change; no release consumed |
1547
+ | `0.15.1` | Released | Preserve key-scoped IDs, native Popover behavior, retained keyed identity, and route cleanup evidence. | None |
1548
+ | `0.15.2` | Active | Begin with a real toast and notification ownership fixture. | None |
@@ -66,7 +66,7 @@ Keep each patch behavior-preserving and independently reviewable. If a boundary
66
66
 
67
67
  ## Generator Versions
68
68
 
69
- `create-kudzu@0.1.125` retains the explicit install instructions and generates projects with `@kudzujs/core@^0.14.3`.
69
+ `create-kudzu@0.1.126` retains the explicit install instructions and generates projects with `@kudzujs/core@^0.15.1`.
70
70
 
71
71
  ## Release Boundary
72
72
 
@@ -2,7 +2,7 @@
2
2
 
3
3
  Kudzu specializes ordinary common React-shaped TSX so migrations need minimal source restructuring. Declarative components, collection pipelines, conditions, hooks, and handlers should be lowered at build time rather than replaced with application-owned imperative DOM code. This principle applies across migrations and is not Stay-specific; it does not imply a React package, VDOM, hydration, or ecosystem runtime.
4
4
 
5
- Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. A named or default zero-argument custom hook imported from a relative TypeScript module may return direct shorthand state/setter pairs and callbacks that capture those states, including a callback that resets multiple states to direct primitive literals; callers use one top-level `const` object destructuring without aliases, defaults, or rest. Relative Context-hook actions may require exposed Provider state/setter fields without forcing those hidden fields into public consumer destructuring; compiler-owned collision-free aliases preserve same-named consumer locals. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows reject it because cloned row templates cannot safely duplicate HTML IDs. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `compiler/react-migration-pass.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
5
+ Migration source may retain conventional `react` imports for supported named or aliased hooks, direct members such as `React.useState`, same-file `memo`, inline `useCallback`, direct-state expression or analyzable collection-pipeline `useMemo`, direct intrinsic `forwardRef`, top-level `const` identifiers initialized by `useId()`, and default, namespace, or named `Fragment`. A named or default zero-argument custom hook imported from a relative TypeScript module may return direct shorthand state/setter pairs and callbacks that capture those states, including a callback that resets multiple states to direct primitive literals; callers use one top-level `const` object destructuring without aliases, defaults, or rest. Relative Context-hook actions may require exposed Provider state/setter fields without forcing those hidden fields into public consumer destructuring; compiler-owned collision-free aliases preserve same-named consumer locals. Kudzu's JSX declarations accept ReactNode-shaped component returns and contextually type common intrinsic DOM events, so strict React component props do not need migration-only `unknown` or explicit event annotations. `forwardRef()` accepts one inline synchronous `(props, ref)` function and requires the object ref exactly once on its direct intrinsic root; the compiler removes `ref` from props/rest and erases the wrapper. `useId()` becomes a deterministic build-time HTML ID and emits no browser capability; keyed rows key-scope direct intrinsic ID and ID-reference attributes through existing list ownership by row key. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array, including type-only `as const` and `satisfies` wrappers, and may read direct local state declared in their dependency array. `compiler/react-migration-pass.mjs` canonicalizes those forms and rewrites module references to `@kudzujs/core` before build-time evaluation. Memo wrappers are erased or inlined into existing bindings and keyed-list selectors because no browser component rerender or memo cache exists. Static routes remain JavaScript-free and emitted modules are checked for surviving React imports.
6
6
 
7
7
  Compilation begins from page entries and follows relative runtime imports, re-exports, and validated Worker references; unreachable TypeScript migration files are not transformed. Direct maps over imported immutable JSON-safe arrays fold to literals for zero-JavaScript static rows. Synchronous relative calculation functions may return objects whose direct static fields feed reactive JSX bindings; build rendering uses current signal values and route-specific binding ESM reevaluates the same helper after state commits. One direct array field may instead feed a keyed intrinsic map: its evaluator refreshes a compiler-owned array anchor before the existing list reconciler runs, preserving keyed DOM and SVG identity without a calculation runtime. That field must remain a JSON-safe array after every source-state commit. Package imports have a separate narrow boundary: direct references inside intrinsic JSX event callbacks are erased from build modules and bundled into route handler ESM, while render-time, effect, helper-indirect, and mixed package use fails.
8
8
 
@@ -68,7 +68,7 @@ export function planRouteCapabilities(records, { navigationRouteCount = 0 } = {}
68
68
  lists.expressionAttributes ||= Boolean(list.expressionAttributes)
69
69
  lists.seeds ||= Boolean(list.seed || list.valueSeed)
70
70
  lists.effects ||= Boolean(list.effects)
71
- lists.rowHooks ||= Boolean(list.rowStates?.length || list.rowRefs?.length)
71
+ lists.rowHooks ||= Boolean(list.rowStates?.length || list.rowRefs?.length || list.rowIds?.length)
72
72
  lists.rowRefs ||= Boolean(list.rowRefs?.length)
73
73
  lists.complexRowState ||= Boolean(list.rowStates?.some(state => state.initializer === "list-item" || state.initialValue !== null && typeof state.initialValue === "object"))
74
74
  lists.nested ||= Boolean(list.ownerField)
@@ -77,7 +77,7 @@ export function planRouteCapabilities(records, { navigationRouteCount = 0 } = {}
77
77
  lists.static ||= Boolean(list.static)
78
78
  lists.indexes ||= Boolean(list.indexed)
79
79
  lists.stableFastPaths ||= !list.children && !list.ownerField && list.key !== null && !list.indexed && !list.reducer && !list.selector
80
- lists.generalRowHooks ||= Boolean(list.ownerField && (list.rowStates?.length || list.rowRefs?.length))
80
+ lists.generalRowHooks ||= Boolean(list.rowIds?.length || list.ownerField && (list.rowStates?.length || list.rowRefs?.length))
81
81
  lists.mounts ||= Boolean(list.mount)
82
82
  }
83
83
  }
@@ -164,7 +164,7 @@ function assertList(list, index, ids, stateIds, listIds, parentByChild) {
164
164
  parentByChild.set(child.id, list.id)
165
165
  }
166
166
  for (const row of list.rowStates ?? []) if (!isRecord(row) || !rowTemplate(row.id) || !Object.hasOwn(row, "initialValue") || row.initializer !== undefined && row.initializer !== "list-item") throw new Error(`${label} has invalid row state`)
167
- for (const row of [...(list.rowRefs ?? []), ...(list.rowConditions ?? [])]) if (!rowTemplate(row)) throw new Error(`${label} has invalid row ownership ID`)
167
+ for (const row of [...(list.rowRefs ?? []), ...(list.rowIds ?? []), ...(list.rowConditions ?? [])]) if (!rowTemplate(row)) throw new Error(`${label} has invalid row ownership ID`)
168
168
  if (list.seed && list.valueSeed || list.seed !== undefined && !validSeed(list.seed) || list.valueSeed !== undefined && !validSeed(list.valueSeed)) throw new Error(`${label} has invalid seed`)
169
169
  }
170
170
 
@@ -517,6 +517,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
517
517
  let usesListItem = false
518
518
  let usesRowState = false
519
519
  let usesRowRef = false
520
+ let usesRowId = false
520
521
  let usesComponentState = false
521
522
  let usesComponentId = false
522
523
  let usesComponentRef = false
@@ -1199,7 +1200,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1199
1200
  ...result.ordinaryStates.map(({ state, setter, source }) => ({ name: state, setter, kind: "component", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))
1200
1201
  ],
1201
1202
  refs: [...result.rowRefs.map(({ name, source }) => ({ name, kind: "row", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) })), ...result.ordinaryRefs.map(({ name, source }) => ({ name, kind: "component", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))],
1202
- ids: result.ordinaryIds.map(({ name, source }) => ({ name, ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))
1203
+ ids: [...result.rowIds.map(({ name, source }) => ({ name, kind: "row", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) })), ...result.ordinaryIds.map(({ name, source }) => ({ name, kind: "component", ...(analysisSite(source, "hook") ? { site: analysisSite(source, "hook") } : {}), ...(analysisSource(source) ? { source: analysisSource(source) } : {}) }))]
1203
1204
  })
1204
1205
  result.propStateOwners = new Map(result.props.flatMap(prop => {
1205
1206
  const expression = result.propExpressions.get(prop.name)
@@ -1216,7 +1217,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1216
1217
  return result
1217
1218
  }
1218
1219
  const registerRowHooks = (call, specialization) => {
1219
- if (!specialization.rowStates.length && !specialization.rowRefs.length) return
1220
+ if (!specialization.rowStates.length && !specialization.rowRefs.length && !specialization.rowIds.length) return
1220
1221
  let owner
1221
1222
  for (let current = call.parent; current; current = current.parent) {
1222
1223
  if (isFunctionLike(current) && settersByFunction.has(current)) {
@@ -1236,6 +1237,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1236
1237
  rowHookCalls.push(call)
1237
1238
  usesRowState ||= specialization.rowStates.length > 0
1238
1239
  usesRowRef ||= specialization.rowRefs.length > 0
1240
+ usesRowId ||= specialization.rowIds.length > 0
1239
1241
  }
1240
1242
  const mergeSpecializedImports = (root, componentSource, call, effects = []) => {
1241
1243
  const componentImports = clientImportBindings(componentSource, componentSource.fileName, sourceFiles)
@@ -1716,7 +1718,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1716
1718
  let count = 0
1717
1719
  const visit = (node, currentAggregate = aggregate) => {
1718
1720
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) {
1719
- const nestedAggregate = { calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], specializations: [] }
1721
+ const nestedAggregate = { calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], rowIds: [], specializations: [] }
1720
1722
  for (const argument of node.arguments) visit(argument, nestedAggregate)
1721
1723
  if (nestedAggregate.hookDeclarations.length || nestedAggregate.effects.length) nestedRowSpecializations.set(`${node.pos}:${node.end}`, nestedAggregate)
1722
1724
  return
@@ -1749,6 +1751,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1749
1751
  currentAggregate.hookDeclarations.push(...specialization.hookDeclarations)
1750
1752
  currentAggregate.rowStates.push(...specialization.rowStates)
1751
1753
  currentAggregate.rowRefs.push(...specialization.rowRefs)
1754
+ currentAggregate.rowIds.push(...specialization.rowIds)
1752
1755
  }
1753
1756
  replacements.set(node, specialization.root)
1754
1757
  count++
@@ -1784,7 +1787,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1784
1787
  }
1785
1788
  for (const { node, parts: originalParts } of rawRenderedLists) {
1786
1789
  if (keyedListParentTag(node) === "table") throw new Error("Keyed table rows must be wrapped in <tbody>, <thead>, or <tfoot>")
1787
- const specialization = componentSpecializations.get(originalParts.root) ?? { root: originalParts.root, calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], ordinaryStates: [] }
1790
+ const specialization = componentSpecializations.get(originalParts.root) ?? { root: originalParts.root, calculations: [], effects: [], hookDeclarations: [], rowStates: [], rowRefs: [], rowIds: [], ordinaryStates: [] }
1788
1791
  const componentSource = specialization.componentSource ?? sourceFile
1789
1792
  specialization.root = expandKeyedComponents(specialization.root, componentSource, specialization.component ? [specialization.component] : [], specialization)
1790
1793
  if (specialization.imported) synthesizeTree(specialization.root = mergeSpecializedImports(specialization.root, componentSource, originalParts.root, specialization.effects))
@@ -1811,6 +1814,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
1811
1814
  specializations: [specialization.analysis?.slot, ...(specialization.specializations ?? [])].filter(slot => slot !== undefined),
1812
1815
  rowStates: [...specialization.rowStates, ...specialization.ordinaryStates],
1813
1816
  rowRefs: specialization.rowRefs,
1817
+ rowIds: specialization.rowIds,
1814
1818
  analysisStateOwners: new Map([...stateOwnersForNode(originalParts.root), ...(specialization.propStateOwners ?? []), ...[...specialization.rowStates, ...specialization.ordinaryStates].map(state => [state.state, state.analysisReference])])
1815
1819
  }
1816
1820
  for (const calculation of specialization.calculations) {
@@ -2297,6 +2301,7 @@ function createKudzuTransformer({ semantic, handlerUrl, file, sourceFiles, sourc
2297
2301
  if (usesListEffects) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useEffect"), factory.createIdentifier("__kListUseEffect")))
2298
2302
  if (usesRowState) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useState"), factory.createIdentifier("__kRowUseState")))
2299
2303
  if (usesRowRef) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useRef"), factory.createIdentifier("__kRowUseRef")))
2304
+ if (usesRowId) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useId"), factory.createIdentifier("__kRowUseId")))
2300
2305
  if (usesComponentState) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useState"), factory.createIdentifier("__kComponentUseState")))
2301
2306
  if (usesComponentId) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useId"), factory.createIdentifier("__kComponentUseId")))
2302
2307
  if (usesComponentRef) behaviorImports.push(factory.createImportSpecifier(false, factory.createIdentifier("useRef"), factory.createIdentifier("__kComponentUseRef")))
@@ -2656,7 +2661,6 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2656
2661
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
2657
2662
  }
2658
2663
  const visit = node => {
2659
- if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useId") fail(node, "useId() is not supported in keyed rows")
2660
2664
  if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
2661
2665
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
2662
2666
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, nestedDiagnostic)
@@ -2697,6 +2701,7 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2697
2701
  specializations: [specialization?.analysis?.slot, ...(specialization?.specializations ?? [])].filter(slot => slot !== undefined),
2698
2702
  rowStates: specializedStates,
2699
2703
  rowRefs: specialization?.rowRefs ?? [],
2704
+ rowIds: specialization?.rowIds ?? [],
2700
2705
  analysisStateOwners: new Map([...(parts.analysisStateOwners ?? []), ...(specialization?.propStateOwners ?? []), ...specializedStates.map(state => [state.state, state.analysisReference])])
2701
2706
  }
2702
2707
  for (const calculation of specialization?.calculations ?? []) {
@@ -2747,6 +2752,8 @@ function validateKeyedList(parts, sourceFile, setters, rowStates, componentSpeci
2747
2752
  return analysis
2748
2753
  }
2749
2754
 
2755
+ const keyedIdReferenceAttributes = new Set(["id", "htmlFor", "aria-activedescendant", "aria-controls", "aria-describedby", "aria-details", "aria-errormessage", "aria-flowto", "aria-labelledby", "aria-owns", "form", "headers", "list", "popovertarget"])
2756
+
2750
2757
  function directConstObjectLiteral(expression, call) {
2751
2758
  expression = unwrapExpression(expression)
2752
2759
  if (ts.isObjectLiteralExpression(expression)) return expression
@@ -2947,6 +2954,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
2947
2954
  const hookDeclarations = []
2948
2955
  const rowStates = []
2949
2956
  const rowRefs = []
2957
+ const rowIds = []
2950
2958
  const ordinaryStates = []
2951
2959
  const ordinaryRefs = []
2952
2960
  const ordinaryIds = []
@@ -3012,13 +3020,14 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3012
3020
  continue
3013
3021
  }
3014
3022
  if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useId") {
3015
- if (!ordinaryHooks) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "useId() is not supported in keyed row components")
3016
- if (declaration.initializer.arguments.length || !ts.isIdentifier(declaration.name)) throw sourceNodeError(declaration.initializer, component.getSourceFile(), "Setter-callback component useId() must initialize one top-level const identifier without arguments")
3017
- const name = `__kComponentId${Math.max(0, call.pos)}_${hookDeclarations.length}`
3023
+ const hookLabel = ordinaryHooks ? "Setter-callback component" : "Keyed row"
3024
+ if (declaration.initializer.arguments.length || !ts.isIdentifier(declaration.name)) throw sourceNodeError(declaration.initializer, component.getSourceFile(), `${hookLabel} useId() must initialize one top-level const identifier without arguments`)
3025
+ const ids = ordinaryHooks ? ordinaryIds : rowIds
3026
+ const name = `${ordinaryHooks ? "__kComponentId" : "__kRowId"}${Math.max(0, call.pos)}_${ids.length}`
3018
3027
  substitutions.set(declaration.name.text, factory.createIdentifier(name))
3019
- const initializer = factory.createCallExpression(factory.createIdentifier("__kComponentUseId"), undefined, [])
3028
+ const initializer = factory.createCallExpression(factory.createIdentifier(ordinaryHooks ? "__kComponentUseId" : "__kRowUseId"), undefined, [])
3020
3029
  hookDeclarations.push(factory.createVariableStatement(undefined, factory.createVariableDeclarationList([factory.createVariableDeclaration(factory.createIdentifier(name), undefined, undefined, initializer)], ts.NodeFlags.Const)))
3021
- ordinaryIds.push({ name, source: declaration })
3030
+ ids.push({ name, local: declaration.name.text, source: declaration })
3022
3031
  continue
3023
3032
  }
3024
3033
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
@@ -3029,6 +3038,10 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3029
3038
  }
3030
3039
  returned = last.expression
3031
3040
  }
3041
+ for (const id of rowIds) {
3042
+ for (const reference of referenceIdentifiers(returned, id.local)) if (!validKeyedIdReference(reference)) throw sourceNodeError(reference, component.getSourceFile(), "Keyed row useId() values may only be used in intrinsic id and ID-reference attributes")
3043
+ for (const calculation of calculations) if (referenceIdentifiers(calculation.expression, id.local).length) throw sourceNodeError(calculation.expression, component.getSourceFile(), "Keyed row useId() values may only be used directly in intrinsic id and ID-reference attributes")
3044
+ }
3032
3045
  let unsupportedHook
3033
3046
  const findUnsupportedHook = node => {
3034
3047
  if (unsupportedHook) return
@@ -3058,6 +3071,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3058
3071
  hookDeclarations,
3059
3072
  rowStates,
3060
3073
  rowRefs,
3074
+ rowIds,
3061
3075
  ordinaryStates,
3062
3076
  ordinaryRefs,
3063
3077
  ordinaryIds,
@@ -3068,6 +3082,16 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3068
3082
  }
3069
3083
  }
3070
3084
 
3085
+ function validKeyedIdReference(reference) {
3086
+ for (let current = reference.parent; current; current = current.parent) {
3087
+ if (!ts.isJsxAttribute(current)) continue
3088
+ const element = current.parent?.parent
3089
+ const tag = element?.tagName
3090
+ return keyedIdReferenceAttributes.has(current.name.text) && ts.isIdentifier(tag) && tag.text[0] === tag.text[0].toLowerCase()
3091
+ }
3092
+ return false
3093
+ }
3094
+
3071
3095
  function isSerializableStateLiteral(node) {
3072
3096
  const value = unwrapExpression(node)
3073
3097
  if (isPrimitiveDefaultLiteral(value)) return true
@@ -49,8 +49,7 @@ let renderContext
49
49
 
50
50
  export function useId() {
51
51
  if (!renderContext) throw new Error("useId() can only run while rendering a Kudzu component")
52
- if (renderContext.listRoot || renderContext.listRowRoot || renderContext.listTemplate) throw new Error("useId() is not supported in keyed rows")
53
- return `k-${nextRenderId("i")}`
52
+ return `k-${renderContext.listRoot || renderContext.listRowRoot ? nextRowRenderId("i") : nextRenderId("i")}`
54
53
  }
55
54
 
56
55
  export function useState(initialValue, name, initializer) {
@@ -484,7 +483,7 @@ function serializeCapture(name, value, seen) {
484
483
  }
485
484
 
486
485
  export async function renderPage(component, metadata = {}, props = {}, layout) {
487
- renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", applicationRoute: metadata.applicationRoute, counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], captureCache: new WeakMap(), sharedStates: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerReferences: new Map(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], searchParamsWritable: false, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
486
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", applicationRoute: metadata.applicationRoute, counters: { layout: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 }, route: { s: 0, r: 0, c: 0, l: 0, e: 0, p: 0, i: 0 } }, nextState: 0, nextRef: 0, nextCondition: 0, nextList: 0, nextEffect: 0, nextParam: 0, nextId: 0, conditionDepth: 0, listDepth: 0, listRoot: undefined, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowIds: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], captureCache: new WeakMap(), sharedStates: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), conditionOwnedStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerReferences: new Map(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, searchParams: new Map(), searchParamEntries: [], searchParamsWritable: false, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
488
487
 
489
488
  try {
490
489
  const page = { [routeScopeMarker]: true, component, props }
@@ -941,6 +940,7 @@ async function renderList(node, namespace, selectValue) {
941
940
  const previousListEffectOwners = renderContext.listEffectOwners
942
941
  const previousListRowStates = renderContext.listRowStates
943
942
  const previousListRowRefs = renderContext.listRowRefs
943
+ const previousListRowIds = renderContext.listRowIds
944
944
  const previousListRowConditions = renderContext.listRowConditions
945
945
  const previousListRowLists = renderContext.listRowLists
946
946
  try {
@@ -948,10 +948,11 @@ async function renderList(node, namespace, selectValue) {
948
948
  renderContext.listEffectOwners = rowList?.effectOwners ?? []
949
949
  renderContext.listRowStates = rowList?.rowStates ?? []
950
950
  renderContext.listRowRefs = rowList?.rowRefs ?? []
951
+ renderContext.listRowIds = rowList?.rowIds ?? []
951
952
  renderContext.listRowConditions = rowList?.rowConditions ?? []
952
953
  renderContext.listRowLists = rowList?.rowLists ?? []
953
954
  renderContext.listFields = new Set([node.keyField])
954
- const templateRoot = { id, state: node.items.id, descriptor, template: true, effects: [], item: {}, path: ownerRoot?.path ?? [], rowIndexes: { s: 0, r: 0, c: 0, l: 0 } }
955
+ const templateRoot = { id, state: node.items.id, descriptor, template: true, effects: [], item: {}, path: ownerRoot?.path ?? [], rowIndexes: { s: 0, r: 0, i: 0, c: 0, l: 0 } }
955
956
  renderContext.listRoot = templateRoot
956
957
  renderContext.listRowRoot = templateRoot
957
958
  const template = await renderNode(node.render({}, 0), namespace, selectValue)
@@ -974,6 +975,7 @@ async function renderList(node, namespace, selectValue) {
974
975
  descriptor.rowRefs = renderContext.listRowRefs.map(({ id }) => id)
975
976
  descriptor.mount = true
976
977
  }
978
+ if (renderContext.listRowIds.length) descriptor.rowIds = renderContext.listRowIds.map(({ id }) => id)
977
979
  if (descriptor.rowStates && !descriptor.effects && !descriptor.nested && !template.includes("data-k-text")) descriptor.fastRelease = true
978
980
  const valueSeed = node.ownerField || node.keyField === null ? undefined : listSeed(node.values, renderContext.listFields)
979
981
  const seed = node.selector.length ? undefined : valueSeed
@@ -984,7 +986,7 @@ async function renderList(node, namespace, selectValue) {
984
986
  renderContext.listInitialMarkers = Boolean(descriptor.conditions)
985
987
  for (const [index, item] of node.values.entries()) {
986
988
  const key = node.keyField === null ? index : item[node.keyField]
987
- renderContext.listRoot = { id, state: node.items.id, descriptor, key, template: false, effects: [], item, path: [...(ownerRoot?.path ?? []), `${id}=${typeof key}:${key}`], rowIndexes: { s: 0, r: 0, c: 0, l: 0 } }
989
+ renderContext.listRoot = { id, state: node.items.id, descriptor, key, template: false, effects: [], item, path: [...(ownerRoot?.path ?? []), `${id}=${typeof key}:${key}`], rowIndexes: { s: 0, r: 0, i: 0, c: 0, l: 0 } }
988
990
  renderContext.listRowRoot = renderContext.listRoot
989
991
  current += await renderNode(node.render(item, index), namespace, selectValue)
990
992
  }
@@ -1007,6 +1009,7 @@ async function renderList(node, namespace, selectValue) {
1007
1009
  renderContext.listEffectOwners = previousListEffectOwners
1008
1010
  renderContext.listRowStates = previousListRowStates
1009
1011
  renderContext.listRowRefs = previousListRowRefs
1012
+ renderContext.listRowIds = previousListRowIds
1010
1013
  renderContext.listRowConditions = previousListRowConditions
1011
1014
  renderContext.listRowLists = previousListRowLists
1012
1015
  renderContext.listDepth--
@@ -1017,7 +1020,7 @@ function nextRowList() {
1017
1020
  const root = renderContext.listRoot ?? renderContext.listRowRoot
1018
1021
  const index = root.rowIndexes.l++
1019
1022
  if (renderContext.listTemplate) {
1020
- const entry = renderContext.listRowLists[index] ?? { id: nextRenderId("l"), rowLists: [], rowStates: [], rowRefs: [], rowConditions: [], effectOwners: [], planned: false }
1023
+ const entry = renderContext.listRowLists[index] ?? { id: nextRenderId("l"), rowLists: [], rowStates: [], rowRefs: [], rowIds: [], rowConditions: [], effectOwners: [], planned: false }
1021
1024
  renderContext.listRowLists[index] = entry
1022
1025
  return entry
1023
1026
  }
@@ -1035,7 +1038,7 @@ function nextRenderId(kind) {
1035
1038
  function nextRowRenderId(kind, initialValue, initializer) {
1036
1039
  const root = renderContext.listRoot ?? renderContext.listRowRoot
1037
1040
  const index = root.rowIndexes[kind]++
1038
- const entries = kind === "s" ? renderContext.listRowStates : kind === "r" ? renderContext.listRowRefs : renderContext.listRowConditions
1041
+ const entries = kind === "s" ? renderContext.listRowStates : kind === "r" ? renderContext.listRowRefs : kind === "i" ? renderContext.listRowIds : renderContext.listRowConditions
1039
1042
  if (renderContext.listTemplate) {
1040
1043
  const entry = entries[index]
1041
1044
  if (entry) return entry.id
@@ -1044,7 +1047,7 @@ function nextRowRenderId(kind, initialValue, initializer) {
1044
1047
  return id
1045
1048
  }
1046
1049
  const entry = entries[index]
1047
- if (!entry) throw new Error(`Keyed row ${kind === "s" ? "state hooks" : kind === "r" ? "ref hooks" : "conditionals"} must have the same order for every item`)
1050
+ if (!entry) throw new Error(`Keyed row ${kind === "s" ? "state hooks" : kind === "r" ? "ref hooks" : kind === "i" ? "ID hooks" : "conditionals"} must have the same order for every item`)
1048
1051
  return rowRenderId(entry.id, root.descriptor.ownerField ? root.path : [`${typeof root.key}:${root.key}`])
1049
1052
  }
1050
1053
 
@@ -19,6 +19,7 @@ const conditionTemplates = __KUDZU_LIST_CONDITIONS__ ? new WeakMap() : undefined
19
19
  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]" : ""}`
20
20
  const childPrototypes = __KUDZU_NESTED_LISTS__ ? new WeakMap() : undefined
21
21
  const itemPartPlans = __KUDZU_NESTED_LISTS__ ? new WeakMap() : undefined
22
+ const rowIdAttributes = new Set(["id", "for", "aria-activedescendant", "aria-controls", "aria-describedby", "aria-details", "aria-errormessage", "aria-flowto", "aria-labelledby", "aria-owns", "form", "headers", "list", "popovertarget"])
22
23
 
23
24
  function commitLists(id) {
24
25
  const lists = listTargets.get(id)
@@ -742,7 +743,7 @@ function listItemPartPlan(template, nested = false, descriptor) {
742
743
  conditions: __KUDZU_LIST_CONDITIONS__ ? parts.conditions.map(([node, descriptor]) => [location(node), descriptor, node]) : [],
743
744
  effects: __KUDZU_LIST_EFFECTS__ ? parts.effects.map(location) : [],
744
745
  rowIds: structural && __KUDZU_GENERAL_ROW_HOOKS__ && hasRowHooks(descriptor) ? source.flatMap(node => {
745
- const attributes = [...node.attributes].filter(attribute => attribute.name.startsWith("data-k-") && attribute.value.includes("$k")).map(attribute => attribute.name)
746
+ const attributes = [...node.attributes].filter(attribute => (attribute.name.startsWith("data-k-") || rowIdAttributes.has(attribute.name)) && attribute.value.includes("$k")).map(attribute => attribute.name)
746
747
  return attributes.length ? [[location(node), attributes]] : []
747
748
  }) : []
748
749
  }
@@ -1086,7 +1087,7 @@ function replaceOwnedRowIds(root) {
1086
1087
  if (!directRowReplacements.delete(root)) return replaceRowIds(root, rowReplacements.get(root))
1087
1088
  const path = listRowPaths.get(root)
1088
1089
  const replace = node => {
1089
- for (const attribute of [...node.attributes]) if (attribute.name.startsWith("data-k-") && attribute.value.includes("$k")) attribute.value = attribute.value.replaceAll("$k", path)
1090
+ for (const attribute of [...node.attributes]) if ((attribute.name.startsWith("data-k-") || rowIdAttributes.has(attribute.name)) && attribute.value.includes("$k")) attribute.value = attribute.value.replaceAll("$k", path)
1090
1091
  for (const child of node.children) replace(child)
1091
1092
  for (const child of node.content?.children ?? []) replace(child)
1092
1093
  }
@@ -1095,7 +1096,7 @@ function replaceOwnedRowIds(root) {
1095
1096
  /* general-row-hooks-end */
1096
1097
 
1097
1098
  function hasRowHooks(descriptor) {
1098
- return Boolean(descriptor.rowStates?.length || descriptor.rowRefs?.length || descriptor.rowConditions?.length)
1099
+ return Boolean(descriptor.rowStates?.length || descriptor.rowRefs?.length || descriptor.rowIds?.length || descriptor.rowConditions?.length)
1099
1100
  }
1100
1101
 
1101
1102
  function listLifecycle(descriptor, template) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.14.3",
3
+ "version": "0.15.1",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",