@kudzujs/core 0.7.12 → 0.7.14

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/GOAL_B.md CHANGED
@@ -102,6 +102,12 @@ The focused fixture emitted a 907 B raw / 477 B gzip Worker graph and an 11,388
102
102
 
103
103
  These numbers are dated conformance evidence. They do not define a dashboard product target.
104
104
 
105
+ ## Reproducible 0.7.12 Rerun
106
+
107
+ At commit `05e5cc2` on Apple M3 / macOS 26.5.2 / Node 25.6.1, `npm run benchmark` measured clean build times of 404.2, 401.3, 408.2, 404.4, 399.9, 408.8, and 402.0 ms after one warm-up, for a 404.2 ms median. The current Worker graph is 907 B raw / 475 B gzip; the complete dashboard window graph is 11,960 B raw / 5,365 B aggregate gzip.
108
+
109
+ Chrome 150.0.7871.187 passed the tracked throughput, cadence, stale-write, bounded-history, and 30-cycle start/termination checks through the focused Worker browser test. These current values differ from the historical completion snapshot because shared window capabilities and the local toolchain changed; the historical values above remain release provenance, not current artifact claims.
110
+
105
111
  ## Non-Goals
106
112
 
107
113
  - Device, alarm, tenant, transport, chart, map, or widget product features.
package/README.md CHANGED
@@ -10,13 +10,14 @@ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTM
10
10
 
11
11
  > Experimental `0.7.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **Latest release: 0.7.12 - Exported row reuse.** Directly exported same-file row components can be reused across static and keyed JSX sites while every call still lowers to intrinsic DOM. Read the [release notes](./RELEASES.md#0712---exported-row-reuse) or open the [release page](https://kudzujs.cloud/releases/0.7.12).
13
+ **Latest release: 0.7.14 - Intrinsic forwardRef.** Ordinary React-shaped components can retain direct `forwardRef()` wrappers while Kudzu separates ref from props and erases the wrapper into intrinsic output. Read the [release notes](./RELEASES.md#0714---intrinsic-forwardref) or open the [release page](https://kudzujs.cloud/releases/0.7.14).
14
14
 
15
15
  - [Documentation](https://kudzujs.cloud/docs)
16
16
  - [Installation guide](https://kudzujs.cloud/docs#install)
17
17
  - [Components and migration support](https://kudzujs.cloud/docs#components)
18
18
  - [Current limits](https://kudzujs.cloud/docs#limits)
19
19
  - [Benchmarks](https://kudzujs.cloud/docs#benchmarks)
20
+ - [Raw performance records](./PERFORMANCE.md)
20
21
  - [React migration roadmap](./MIGRATION_ROADMAP.md)
21
22
  - [Release history](./RELEASES.md)
22
23
 
@@ -77,7 +78,7 @@ ordinary React-shaped TSX
77
78
  ```
78
79
 
79
80
  - Function components execute at build time and do not survive as browser components.
80
- - `useState` and reduced `useReducer` compile to synchronous logical state and batched direct DOM writes.
81
+ - `useState` and reduced `useReducer` compile to synchronous logical state and batched direct DOM writes; top-level `useId` and direct intrinsic `forwardRef` authoring erase to static HTML without a component runtime.
81
82
  - Conditions, keyed collections, attributes, events, refs, effects, and supported component boundaries compile to route-specific capabilities.
82
83
  - Build-known data and routes become complete HTML through async components and `getStaticPaths()`.
83
84
  - Native document navigation is the default; static routes do not load a client runtime.
package/RELEASES.md CHANGED
@@ -1,5 +1,52 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.14 - Intrinsic forwardRef
4
+
5
+ Kudzu 0.7.14 preserves conventional direct `forwardRef()` component authoring while erasing the wrapper into build-time intrinsic output.
6
+
7
+ ### New in 0.7.14
8
+
9
+ - `forwardRef` may be imported directly or with an alias from `react`, or called as a direct default/namespace React member.
10
+ - One top-level `const` component may wrap one inline synchronous `(props, ref)` render function.
11
+ - The compiler removes `ref` from ordinary props and rest bindings before supplying it as the render function's second parameter.
12
+ - The forwarded object ref must appear exactly once on the direct intrinsic root and reuses Kudzu's existing deterministic ref marker.
13
+ - Components remain valid when the optional ref prop is omitted; `null` and `undefined` intrinsic refs emit no marker.
14
+ - Same-file and relative-imported components compile without React, a wrapper function, hydration, or a browser component runtime.
15
+ - Indirect callbacks, async/generator renders, callback or composed refs, fragments, component roots, nested targets, repeated forwarding, and `memo(forwardRef(...))` fail with source diagnostics.
16
+
17
+ ### Boundary
18
+
19
+ This release intentionally supports one direct object-ref boundary. It does not add React ref objects, imperative handles, generic ref composition, or browser component instances. Existing keyed-row ownership checks still require refs used inside keyed lists to originate from that row.
20
+
21
+ ### Upgrade
22
+
23
+ ```bash
24
+ npm install @kudzujs/core@^0.7.14
25
+ ```
26
+
27
+ ## 0.7.13 - Deterministic useId
28
+
29
+ Kudzu 0.7.13 preserves conventional top-level React `useId()` authoring while emitting deterministic static HTML IDs with no browser runtime.
30
+
31
+ ### New in 0.7.13
32
+
33
+ - `useId` may be imported directly or with an alias from `react`, or called as a direct default/namespace React member.
34
+ - Each top-level `const id = useId()` receives a stable build-time ID that can be reused by `id`, `htmlFor`, and ARIA ID-reference attributes.
35
+ - Repeated component calls receive distinct IDs, while unchanged clean builds reproduce the same output.
36
+ - Shared layouts and route content use separate ID namespaces during complete-document and enhanced navigation builds.
37
+ - Static routes remain JavaScript-free; no hook dispatcher, hydration metadata, or browser component function is emitted.
38
+ - Calls with arguments, non-top-level forms, and keyed-row ownership fail with source-located diagnostics.
39
+
40
+ ### Boundary
41
+
42
+ `useId()` accepts no arguments and must initialize one top-level `const` identifier in an ordinary component. Keyed rows remain unsupported because cloned row templates require key-scoped rewriting of `id`, `for`, ARIA IDREF, and fragment-reference attributes; Kudzu rejects the unsafe shape instead of emitting duplicate IDs.
43
+
44
+ ### Upgrade
45
+
46
+ ```bash
47
+ npm install @kudzujs/core@^0.7.13
48
+ ```
49
+
3
50
  ## 0.7.12 - Exported row reuse
4
51
 
5
52
  Kudzu 0.7.12 lets directly exported same-file row components remain ordinary reusable source while every supported call still specializes to intrinsic DOM.
@@ -319,7 +366,7 @@ This is source migration support, not a React compatibility runtime. Aliased hoo
319
366
 
320
367
  ### Measured fixture
321
368
 
322
- The two-route landing fixture retains React imports across relative components. Its static route has no script, while its interactive mobile-menu route emits 10,245 B raw / 5,030 B aggregate gzip JavaScript across seven capability files. Seven clean builds after one warm-up measured a 310.0 ms median on the development machine described in `MIGRATION_ROADMAP.md`.
369
+ The two-route landing fixture retains React imports across relative components. Its static route has no script, while its interactive mobile-menu route emitted 10,245 B raw / 5,030 B aggregate gzip JavaScript across seven capability files in the 0.7.0 release snapshot. Seven clean builds after one warm-up measured a 310.0 ms median in that historical development environment; the original runner and raw array are not tracked in the current repository.
323
370
 
324
371
  ### Upgrade
325
372
 
@@ -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`, and default, namespace, or named `Fragment`. Collection memos may start from local array state or a named relative import of an exported JSON-safe `const` array and may read direct local state declared in their dependency array. `build.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`. `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 and may read direct local state declared in their dependency array. `build.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
  Direct `clsx` calls over literal strings, numbers, arrays, object conditions, and conditional expressions are similarly lowered to ordinary concatenation and conditional expressions. The package import is erased, and dynamic classes continue through the existing binding compiler without serializing or shipping the `clsx` function.
8
8
 
@@ -39,8 +39,4 @@ The reduced `useReducer` form reuses ordinary state slots and React's pure reduc
39
39
 
40
40
  `kudzu.config` may opt one emitted shared-layout group into same-document navigation with legacy `navigation: { routes: ["/product", "/items/[id]"] }`, or multiple groups with `navigation: { groups: [{ routes: [...] }, { routes: [...] }] }`. The forms are mutually exclusive. Identities are globally unique emitted exact paths or `runtimeParams` patterns; each group uses one page-exported layout function identity. Runtime records securely match concrete pathnames under `base`, and their cache-safe parameter initializer runs before route DOM/effects mount on every transition. Each group receives a deterministic route-hashed asset specialized to only its records, pattern decoder, and effect/parameter lifecycle needs. Cross-group and ungrouped anchors remain native and are not prefetched; overlapping path domains across groups fail the build. Route effect entries export cache-safe layout and route mount functions: layout effects, including conditional/keyed DOM-owned effects, persist for the group session; route effects receive a fresh owner registry after each route insertion; and non-persisted page disposal cleans route before layout. Direct primitive state, runtime parameter, and keyed-item property dependencies and cleanup are supported. Fragment payloads and coordinated View Transitions are not implemented.
41
41
 
42
- The current matched commerce profile emits 35,355 deploy bytes and loads 7,334 B gzip of product-route JavaScript, including 2,425 B for navigation. These sizes are unchanged because its top-level-only navigation effects retain the smaller specialized path. Validated prefetch reduced the original 128.7 ms product-to-cart navigation to 5.6 ms in the current run. Seven interleaved artifact-clean builds after warm-up measured Kudzu at 486.8 ms and React at 545.4 ms, making Kudzu 10.7% faster. Cache-disabled output is byte-for-byte identical.
43
-
44
- In matched state-only and item-property keyed-row builds, the minified route effect entry changes from 3,829 B raw/1,667 B gzip to 4,392 B raw/1,823 B gzip, the shared runtime from 1,291 B raw/671 B gzip to 1,503 B raw/751 B gzip, and the list runtime from 6,606 B raw/2,474 B gzip to 6,652 B raw/2,493 B gzip. The complete targeted-notification capability costs +821 B raw/+255 B gzip and remains absent from builds without item dependencies. Seven clean builds of the expanded three-route fixture measured 420-440 ms with a 430 ms median; this records current build cost rather than claiming a cross-version speed change.
45
-
46
- The matched 1,000-row cross-framework effect fixture measured Kudzu at 8,264 B initial JavaScript gzip, 426 ms build, 3.6 ms selected-row cleanup/update/setup, 2.4 ms unrelated-field update, and 8.8 ms reorder. React CSR measured 60,921 B, 1,198 ms, 9.8 ms, 5.9 ms, and 16.8 ms respectively; Vue measured 5.8, 2.4, and 10.5 ms for the browser operations, and Svelte measured 5.7, 4.1, and 58.1 ms. Browser operations begin only after all targets have 1,000 rows and effects ready. Kudzu emits those rows in HTML while the framework CSR fixtures begin from empty shells, so JavaScript, output, and build values are not architecture-equivalent comparisons. Targeted changed-root notification avoids an extra O(n) effect-record scan; list validation, serialization, and reconciliation remain O(n).
42
+ Cross-framework performance tables are historical snapshots from an excluded local benchmark workspace; the current checkout does not contain their competitor fixtures, runners, or raw arrays. The maintained provenance warning and tables live in the web docs. `npm run benchmark` is the tracked reproducible Worker fixture measurement, while the focused Chrome Worker test verifies throughput, cadence, bounded history, stale-write isolation, and route lifecycle behavior.
@@ -1882,8 +1882,8 @@ function normalizeZustandMigrationSyntax(sourceFile, factory, context) {
1882
1882
  }
1883
1883
 
1884
1884
  function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections = new Set()) {
1885
- const supported = new Set(["createContext", "useContext", "useEffect", "useReducer", "useRef", "useState"])
1886
- const erased = new Set(["memo", "useCallback", "useMemo"])
1885
+ const supported = new Set(["createContext", "useContext", "useEffect", "useId", "useReducer", "useRef", "useState"])
1886
+ const erased = new Set(["forwardRef", "memo", "useCallback", "useMemo"])
1887
1887
  const aliases = new Map()
1888
1888
  const reactObjects = new Set()
1889
1889
  for (const statement of sourceFile.statements) {
@@ -1988,6 +1988,7 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCol
1988
1988
  }
1989
1989
  if (ts.isCallExpression(node)) {
1990
1990
  const name = migrationCallName(node)
1991
+ if (name === "forwardRef") return ts.visitNode(lowerReactForwardRef(node, sourceFile, factory), visitor)
1991
1992
  if (name === "memo") {
1992
1993
  if (node.arguments.length !== 1 || !(ts.isArrowFunction(node.arguments[0]) || ts.isFunctionExpression(node.arguments[0]) || ts.isIdentifier(node.arguments[0]))) throw sourceNodeError(node, sourceFile, "React memo() requires exactly one function component or component identifier")
1993
1994
  if (ts.isIdentifier(node.arguments[0]) && isShadowedIdentifier(node.arguments[0], sourceFile)) throw sourceNodeError(node.arguments[0], sourceFile, "React memo() component identifiers must resolve to an unshadowed same-file top-level function")
@@ -2074,6 +2075,78 @@ function normalizeReactMigrationSyntax(sourceFile, factory, context, importedCol
2074
2075
  return normalized
2075
2076
  }
2076
2077
 
2078
+ function lowerReactForwardRef(call, sourceFile, factory) {
2079
+ const declaration = call.parent
2080
+ const statement = declaration?.parent?.parent
2081
+ if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== call || !ts.isIdentifier(declaration.name) || !statement || !ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || statement.parent !== sourceFile) {
2082
+ throw sourceNodeError(call, sourceFile, "React forwardRef() must directly initialize one top-level const component")
2083
+ }
2084
+ if (call.arguments.length !== 1 || !ts.isArrowFunction(call.arguments[0]) && !ts.isFunctionExpression(call.arguments[0])) throw sourceNodeError(call, sourceFile, "React forwardRef() requires exactly one inline render function")
2085
+ const callback = call.arguments[0]
2086
+ if (callback.asteriskToken || callback.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword)) throw sourceNodeError(callback, sourceFile, "React forwardRef() render function must be synchronous and cannot be a generator")
2087
+ if (callback.parameters.length !== 2) throw sourceNodeError(callback, sourceFile, "React forwardRef() render function must declare exactly (props, ref)")
2088
+ const [props, ref] = callback.parameters
2089
+ if (props.dotDotDotToken || props.initializer || !ts.isIdentifier(props.name) && !ts.isObjectBindingPattern(props.name)) throw sourceNodeError(props, sourceFile, "React forwardRef() props must use one identifier or a flat object binding")
2090
+ if (ref.dotDotDotToken || ref.initializer || !ts.isIdentifier(ref.name)) throw sourceNodeError(ref, sourceFile, "React forwardRef() ref parameter must be one identifier")
2091
+
2092
+ let elements
2093
+ if (ts.isIdentifier(props.name)) {
2094
+ elements = [
2095
+ factory.createBindingElement(undefined, factory.createIdentifier("ref"), factory.createIdentifier(ref.name.text)),
2096
+ factory.createBindingElement(factory.createToken(ts.SyntaxKind.DotDotDotToken), undefined, factory.createIdentifier(props.name.text))
2097
+ ]
2098
+ } else {
2099
+ for (const element of props.name.elements) {
2100
+ const property = (element.propertyName ?? element.name)
2101
+ if (!ts.isIdentifier(element.name) || property.text === "ref") throw sourceNodeError(element, sourceFile, property.text === "ref" ? "React forwardRef() props must not declare ref; Kudzu supplies ref through the second parameter" : "React forwardRef() props must use one identifier or a flat object binding")
2102
+ }
2103
+ const rest = props.name.elements.findIndex(element => Boolean(element.dotDotDotToken))
2104
+ elements = [...props.name.elements]
2105
+ elements.splice(rest < 0 ? elements.length : rest, 0, factory.createBindingElement(undefined, factory.createIdentifier("ref"), factory.createIdentifier(ref.name.text)))
2106
+ }
2107
+
2108
+ const last = ts.isBlock(callback.body) ? callback.body.statements.at(-1) : undefined
2109
+ let returnCount = 0
2110
+ const countReturns = node => {
2111
+ if (node !== callback.body && isFunctionLike(node)) return
2112
+ if (ts.isReturnStatement(node)) returnCount++
2113
+ ts.forEachChild(node, countReturns)
2114
+ }
2115
+ countReturns(callback.body)
2116
+ const returned = ts.isBlock(callback.body)
2117
+ ? last && ts.isReturnStatement(last) ? last.expression : undefined
2118
+ : callback.body
2119
+ const root = returned && unwrapExpression(returned)
2120
+ const tag = root && jsxTagName(root)
2121
+ if ((ts.isBlock(callback.body) && returnCount !== 1) || !root || !ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root) || !ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) throw sourceNodeError(callback.body, sourceFile, "React forwardRef() render function must directly return one intrinsic JSX element")
2122
+ const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
2123
+ const forwarded = attributes.properties.filter(attribute => ts.isJsxAttribute(attribute) && attribute.name.text === "ref" && ts.isJsxExpression(attribute.initializer) && ts.isIdentifier(attribute.initializer.expression) && attribute.initializer.expression.text === ref.name.text)
2124
+ if (forwarded.length !== 1 || referenceIdentifiers(callback.body, ref.name.text).length !== 1) throw sourceNodeError(ref, sourceFile, "React forwardRef() ref must be forwarded exactly once as ref={ref} on the direct intrinsic root")
2125
+
2126
+ const parameter = factory.updateParameterDeclaration(props, props.modifiers, undefined, factory.createObjectBindingPattern(elements), props.questionToken, props.type, undefined)
2127
+ return ts.isArrowFunction(callback)
2128
+ ? factory.updateArrowFunction(callback, callback.modifiers, callback.typeParameters, [parameter], callback.type, callback.equalsGreaterThanToken, callback.body)
2129
+ : factory.updateFunctionExpression(callback, callback.modifiers, undefined, callback.name, callback.typeParameters, [parameter], callback.type, callback.body)
2130
+ }
2131
+
2132
+ function validateUseIdSyntax(sourceFile) {
2133
+ const imported = sourceFile.statements.some(statement => ts.isImportDeclaration(statement) && ["@kudzujs/core", "react"].includes(statement.moduleSpecifier.text) && statement.importClause?.namedBindings && ts.isNamedImports(statement.importClause.namedBindings) && statement.importClause.namedBindings.elements.some(entry => !entry.propertyName && entry.name.text === "useId"))
2134
+ if (!imported) return
2135
+ const visit = node => {
2136
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useId" && !isShadowedIdentifier(node.expression, sourceFile)) {
2137
+ if (node.arguments.length) throw sourceNodeError(node, sourceFile, "useId() does not accept arguments")
2138
+ const declaration = node.parent
2139
+ const statement = declaration?.parent?.parent
2140
+ const owner = nearestFunction(node)
2141
+ if (!ts.isVariableDeclaration(declaration) || declaration.initializer !== node || !ts.isIdentifier(declaration.name) || !statement || !ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0 || !owner || !ts.isBlock(owner.body) || statement.parent !== owner.body) {
2142
+ throw sourceNodeError(node, sourceFile, "useId() must be assigned to one top-level const identifier in a component")
2143
+ }
2144
+ }
2145
+ ts.forEachChild(node, visit)
2146
+ }
2147
+ visit(sourceFile)
2148
+ }
2149
+
2077
2150
  function importDeclarationNames(statement) {
2078
2151
  const names = []
2079
2152
  if (statement.importClause?.name) names.push(statement.importClause.name.text)
@@ -2158,6 +2231,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2158
2231
  ts.setParentRecursive(sourceFile, false)
2159
2232
  sourceFile = normalizeReactMigrationSyntax(sourceFile, factory, context, importedCollections)
2160
2233
  ts.setParentRecursive(sourceFile, false)
2234
+ validateUseIdSyntax(sourceFile)
2161
2235
  sourceFile = normalizeZustandMigrationSyntax(sourceFile, factory, context)
2162
2236
  ts.setParentRecursive(sourceFile, false)
2163
2237
  sourceFile = normalizeRenderControlFlow(sourceFile, factory, context)
@@ -2173,6 +2247,7 @@ function createKudzuTransformer(nativeHandlers, effectHandlers, reactiveBindings
2173
2247
  ts.setParentRecursive(imported, false)
2174
2248
  imported = normalizeReactMigrationSyntax(imported, factory, context, importedSerializableCollectionNames(imported, target, sourceFiles, sourceIndex))
2175
2249
  ts.setParentRecursive(imported, false)
2250
+ validateUseIdSyntax(imported)
2176
2251
  imported = normalizeZustandMigrationSyntax(imported, factory, context)
2177
2252
  ts.setParentRecursive(imported, false)
2178
2253
  imported = normalizeRenderControlFlow(imported, factory, context)
@@ -3281,6 +3356,7 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
3281
3356
  if (!ts.isIdentifier(tag) || tag.text[0] !== tag.text[0].toLowerCase()) fail(node, "Keyed list items must use intrinsic JSX elements")
3282
3357
  }
3283
3358
  const visit = node => {
3359
+ if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "useId") fail(node, "useId() is not supported in keyed rows")
3284
3360
  if (ts.isJsxFragment(node)) fail(node, "Fragments are not supported in keyed lists")
3285
3361
  if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) validateElement(node)
3286
3362
  if (node !== root && ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === "map" && containsJsx(node)) fail(node, nestedDiagnostic)
@@ -3586,6 +3662,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3586
3662
  rowRefs.push({ name })
3587
3663
  continue
3588
3664
  }
3665
+ if (declaration.initializer && ts.isCallExpression(declaration.initializer) && ts.isIdentifier(declaration.initializer.expression) && declaration.initializer.expression.text === "useId") throw sourceNodeError(declaration.initializer, component.getSourceFile(), "useId() is not supported in keyed row components")
3589
3666
  if (!ts.isIdentifier(declaration.name) || !declaration.initializer) fail(declaration, `${label} component locals must be initialized identifiers`)
3590
3667
  const calculation = substituteClone(declaration.initializer, substitutions, factory, context)
3591
3668
  calculations.push({ name: declaration.name.text, expression: calculation })
@@ -5,6 +5,7 @@ export type EffectCleanup = () => void | Promise<void>
5
5
  export type EffectDependency = string | number | boolean | null
6
6
  export const Fragment: unique symbol
7
7
 
8
+ export function useId(): string
8
9
  export function useState<T>(initialValue: T): [T, StateSetter<T>]
9
10
  export function useReducer<State, Action>(reducer: Reducer<State, Action>, initialValue: State): [State, Dispatch<Action>]
10
11
  export function useEffect(effect: () => void | EffectCleanup | Promise<void>, dependencies: readonly EffectDependency[]): void
@@ -47,6 +47,12 @@ const svgAttributeAliases = {
47
47
 
48
48
  let renderContext
49
49
 
50
+ export function useId() {
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")}`
54
+ }
55
+
50
56
  export function useState(initialValue, name) {
51
57
  if (!renderContext) {
52
58
  throw new Error("useState() can only run while rendering a Kudzu component")
@@ -406,7 +412,7 @@ function serializeCapture(name, value, seen) {
406
412
  }
407
413
 
408
414
  export async function renderPage(component, metadata = {}, props = {}, layout) {
409
- 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, listRowRoot: undefined, listTemplate: false, listInitialMarkers: false, listConditionalBranch: false, listFields: undefined, listEffectOwners: [], listRowStates: [], listRowRefs: [], listRowConditions: [], listRowLists: [], effectOwners: [], contexts: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
415
+ renderContext = { scoped: Boolean(layout), renderScope: layout ? "layout" : "route", 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: [], stores: new Map(), states: {}, textStates: new Set(), conditionStates: new Set(), events: [], effects: [], bindings: [], textBindings: [], conditions: [], lists: [], handlerModules: new Set(), runtimeParamNames: metadata.runtimeParams, paramEntries: [], params: undefined, hasBehaviors: false, hasNativeBehaviors: false, hasEffects: false, hasParams: false, hasBindings: false, hasLists: false, hasListStyles: false }
410
416
 
411
417
  try {
412
418
  const page = { [routeScopeMarker]: true, component, props }
@@ -702,6 +708,7 @@ async function renderNode(node, namespace, selectValue = noSelectValue) {
702
708
  for (const [rawName, value] of Object.entries(props)) {
703
709
  if (rawName === "children" || rawName === "key") continue
704
710
  if (rawName === "ref") {
711
+ if (value == null) continue
705
712
  if (!value?.[refMarker]) throw new Error("ref must be created by useRef(null)")
706
713
  if (renderContext.listDepth && !value.row) throw new Error("Refs in keyed lists must be declared by the keyed row component")
707
714
  attributes += ` data-k-ref="${value.id}"`
@@ -910,7 +917,7 @@ function nextRowList() {
910
917
 
911
918
  function nextRenderId(kind) {
912
919
  if (renderContext.scoped) return `${renderContext.renderScope === "layout" ? "l" : "r"}${kind}${renderContext.counters[renderContext.renderScope][kind]++}`
913
- const counters = { s: "nextState", r: "nextRef", c: "nextCondition", l: "nextList", e: "nextEffect", p: "nextParam" }
920
+ const counters = { s: "nextState", r: "nextRef", c: "nextCondition", l: "nextList", e: "nextEffect", p: "nextParam", i: "nextId" }
914
921
  return `${kind}${renderContext[counters[kind]]++}`
915
922
  }
916
923
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.12",
3
+ "version": "0.7.14",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -51,6 +51,7 @@
51
51
  "dev": "node ./bin/kudzu.mjs dev",
52
52
  "check": "tsc --noEmit && tsc -p test/fixtures/tsconfig.json --noEmit && node ./bin/kudzu.mjs build",
53
53
  "test": "node --test test/*.test.mjs",
54
+ "benchmark": "node test/performance.mjs",
54
55
  "prepublishOnly": "npm run check && npm test",
55
56
  "deploy": "wrangler deploy",
56
57
  "preview": "wrangler dev"