@kudzujs/core 0.7.10 → 0.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,19 +6,21 @@
6
6
 
7
7
  HTML-first TSX framework with synchronous state semantics and no virtual DOM.
8
8
 
9
- Kudzu is designed so ordinary common React-shaped TSX can migrate with minimal source restructuring. It keeps familiar function components, props, children, collection rendering, conditions, event handlers, `useState`, reduced `useReducer`, refs, and effects, preferring compiler specialization over imperative DOM rewrites. This is a general migration model, not compatibility for one application. Static components compile to HTML; interactions compile to direct DOM capabilities and external ESM only where used.
9
+ Kudzu compiles ordinary React-shaped TypeScript and TSX into complete static HTML, CSS, and only the route-specific ESM capabilities actually used. Static pages ship zero JavaScript. React, hydration, a VDOM, and a retained browser component tree are not part of the output.
10
10
 
11
11
  > Experimental `0.7.x`: the compiler API and supported TSX surface may change.
12
12
 
13
- **0.7.10:** Component composition. Existing specialized collection wrappers and keyed rows now retain direct analyzable prop spreads and forwarded JSX children without adding a browser component runtime. See [release notes](./RELEASES.md#0710---component-composition).
13
+ **Latest release: 0.7.11 - Serializable defaults and rest props.** Specialized collection components retain directly serializable object/array defaults and direct intrinsic rest forwarding without adding a component runtime. Read the [release notes](./RELEASES.md#0711---serializable-defaults-and-rest-props) or open the [release page](https://kudzujs.cloud/releases/0.7.11).
14
14
 
15
- Documentation: [kudzujs.cloud/docs](https://kudzujs.cloud/docs)
15
+ - [Documentation](https://kudzujs.cloud/docs)
16
+ - [Installation guide](https://kudzujs.cloud/docs#install)
17
+ - [Components and migration support](https://kudzujs.cloud/docs#components)
18
+ - [Current limits](https://kudzujs.cloud/docs#limits)
19
+ - [Benchmarks](https://kudzujs.cloud/docs#benchmarks)
20
+ - [React migration roadmap](./MIGRATION_ROADMAP.md)
21
+ - [Release history](./RELEASES.md)
16
22
 
17
- Development direction: [React migration roadmap](./MIGRATION_ROADMAP.md)
18
-
19
- ## Install
20
-
21
- Create a new project:
23
+ ## Quick Start
22
24
 
23
25
  ```bash
24
26
  npm create kudzu@latest my-app
@@ -26,25 +28,14 @@ cd my-app
26
28
  npm run dev
27
29
  ```
28
30
 
29
- Or add Kudzu to an existing project:
31
+ The generated project includes reusable components, an interactive state example, a zero-JavaScript static route, metadata, and responsive CSS.
32
+
33
+ To add Kudzu to an existing project:
30
34
 
31
35
  ```bash
32
36
  npm install @kudzujs/core
33
37
  ```
34
38
 
35
- Add scripts:
36
-
37
- ```json
38
- {
39
- "scripts": {
40
- "dev": "kudzu dev",
41
- "build": "kudzu build"
42
- }
43
- }
44
- ```
45
-
46
- Configure TypeScript:
47
-
48
39
  ```json
49
40
  {
50
41
  "compilerOptions": {
@@ -58,29 +49,7 @@ Configure TypeScript:
58
49
  }
59
50
  ```
60
51
 
61
- Make sure application TSX files are included by this `tsconfig.json`. Files outside its `include` may fall into an editor-inferred React project and incorrectly report a missing `react/jsx-runtime` or React event-type errors.
62
-
63
- Existing React migration source may retain conventional imports while components are moved under `src`:
64
-
65
- ```tsx
66
- import React, { useState } from "react"
67
-
68
- export default function Header() {
69
- const [open, setOpen] = useState(false)
70
- return <React.Fragment>
71
- <button onClick={() => setOpen(!open)}>{open ? "Close" : "Menu"}</button>
72
- {open && <nav>Navigation</nav>}
73
- </React.Fragment>
74
- }
75
- ```
76
-
77
- Kudzu rewrites supported React imports to its compile-time APIs before evaluating the module; neither the React package nor a compatibility runtime enters the deploy output. Named or aliased `useState`, `useReducer`, `useEffect`, `useRef`, `createContext`, and `useContext` imports compile to their canonical forms. Default and namespace imports may call those APIs as direct members such as `React.useState`, and default, namespace, or named `Fragment` also works. `memo(Component)` is erased to a same-file component. Inline `useCallback(function, literalDependencies)` is erased to its function, while inline synchronous `useMemo` callbacks may return one expression over primitive literals/direct local state or an analyzable `filter`, `map`, `flatMap`, and `Array.from` collection pipeline. Collection pipelines may start from local array state or a named relative import whose source is an exported JSON-safe `const` array; imported static collections may be filtered by direct local state listed in the dependency array. Scalar expressions inline into existing bindings; collection pipelines lower to existing keyed-list selectors and preserve row identity. Both hooks require inert literal dependency arrays and complete captured-state dependencies; memo locals cannot be duplicated or captured by nested functions. React classes and side-effect or dynamic React imports remain unsupported. A static route using these forms still emits zero JavaScript.
78
-
79
- Direct default or named `clsx` imports compile away for string/number literals, literal arrays, literal object conditions, and conditional expressions. Kudzu lowers those calls to ordinary class expressions, so reactive classes reuse existing bindings without shipping `clsx`; spreads, computed object keys, arbitrary calls, and indirect references remain unsupported.
80
-
81
- Migration source may also retain a reduced Zustand store declared as one exported `const` initialized by a named `create` import. The initializer accepts `set`, returns exactly one directly serializable data property plus synchronous actions, and components select one direct property with `state => state.property`. A shared layout must select the store before its routes use it; Kudzu then owns the data as layout state, inlines action updates into existing handler ESM, and ships neither React nor Zustand. Derived selectors, multiple data properties, middleware, `get`, subscriptions, equality functions, persist/devtools wrappers, async actions, helper captures, replacement updates, and indirect action forwarding remain unsupported.
82
-
83
- Create `src/pages/index.tsx`:
52
+ Put routes in `src/pages`; `src/pages/index.tsx` maps to `/`.
84
53
 
85
54
  ```tsx
86
55
  import { useState } from "@kudzujs/core"
@@ -88,765 +57,63 @@ import { useState } from "@kudzujs/core"
88
57
  export default function HomePage() {
89
58
  const [count, setCount] = useState(0)
90
59
 
91
- function increaseTwice() {
92
- setCount(count + 1)
93
- setCount(count + 1)
94
- }
95
-
96
- return <button onClick={increaseTwice}>Count: {count}</button>
60
+ return <button onClick={() => setCount(count + 1)}>
61
+ Grown {count} times
62
+ </button>
97
63
  }
98
64
  ```
99
65
 
100
66
  ```bash
101
67
  npm run dev
68
+ npm run build
102
69
  ```
103
70
 
104
- Pages live in `src/pages`; `index.tsx` maps to `/`. `npm run dev` serves locally on `127.0.0.1`, reloads the browser after successful rebuilds, and shows build failures in an error overlay. Across that full-page reload, compatible Kudzu logical state is briefly preserved by route-unique state variable name for the current pathname, query, and hash, including controlled properties, conditions, and keyed-list arrays. Renamed, removed, and duplicate-named state is skipped. Uncontrolled DOM state, focus, selection, and imperative DOM mutations are not preserved. The server starts at `PORT` or `3000` and increments until it finds an available port. Set `HOST=0.0.0.0` when a local reverse proxy or container must reach the server. The development client and state snapshot are dev-only; production output in `dist/` is unaffected.
105
-
106
- Dynamic static pages use bracket parameters and `getStaticPaths()`:
107
-
108
- ```tsx
109
- // src/pages/posts/[slug].tsx
110
- export async function getStaticPaths() {
111
- return [
112
- { params: { slug: "oak" }, props: { title: "Oak" } },
113
- { params: { slug: "pine" }, props: { title: "Pine" } }
114
- ]
115
- }
116
-
117
- export default function Post({ title }: { title: string }) {
118
- return <h1>{title}</h1>
119
- }
120
- ```
121
-
122
- This emits `/posts/oak` and `/posts/pine`. Parameter values must be safe single path segments; missing, unsafe, and duplicate routes fail the build.
123
-
124
- When a bracket value exists only in the request URL, opt into one static fallback document and read it with `useParams()`:
125
-
126
- ```tsx
127
- // src/pages/items/[id].tsx
128
- import { useEffect, useParams } from "@kudzujs/core"
129
-
130
- export const runtimeParams = true
131
-
132
- export default function ItemPage() {
133
- const { id } = useParams<{ id: string }>()
134
-
135
- useEffect(() => {
136
- fetch(`/api/items/${encodeURIComponent(id)}`)
137
- }, [])
138
-
139
- return <h1>Item {id}</h1>
140
- }
141
- ```
142
-
143
- This emits `dist/items/[id]/index.html` and a route-specific pathname matcher. `getStaticPaths()` and `runtimeParams` are mutually exclusive. Runtime parameters occupy complete path segments, decode once, and reject empty, malformed, separator, control, and traversal-like values. The development server resolves deep links automatically. Production static hosts must try exact files first, then internally rewrite matching paths to the fallback file while preserving the browser URL; `.kudzu/kudzu-plan.json` and `afterBuild()` expose ordered `rewrites` for host adapters. Navigation remains ordinary `<a>` document navigation, not an SPA router.
144
-
145
- Static trusted HTML can be rendered without a transform layer:
146
-
147
- ```tsx
148
- <article dangerouslySetInnerHTML={{ __html: renderedNotionHtml }} />
149
- ```
150
-
151
- The HTML is intentionally not sanitized. Use only trusted or previously sanitized build-time content. Reactive raw HTML, children on the same element, void elements, and keyed-list raw HTML are rejected.
152
-
153
- Every CSS file under `src` is emitted to the same relative path under `dist/assets` and linked in deterministic order. Relative side-effect CSS imports are erased after validation. Default `.module.css` imports become deterministic scoped class maps at build time, while default imports of `.avif`, `.gif`, `.ico`, `.jpeg`, `.jpg`, `.otf`, `.png`, `.svg`, `.ttf`, `.webp`, `.woff`, or `.woff2` become base-prefixed asset URL strings; the same files accept `?url`. Relative CSS `url(...)` references are rewritten to base-prefixed URLs, preserve query/hash suffixes, and copy the referenced bytes under their source-relative `dist/assets` path. Data, fragment, root-relative, protocol-relative, and absolute CSS URLs remain unchanged. Other import queries, import hashes, attributes, named/namespace asset imports, and CSS Module `composes` are rejected. Configured root-relative URLs receive `base`; absolute HTTP URLs are preserved. A source style entry reads CSS, optionally transforms it, writes its declared output, and links it without an `afterBuild` file pipeline. `publicDir` defaults to `public` and may point elsewhere. Global or page `metadata` may be an object or a function of `{ route, params, props }`, so route props can set document language and head resources before rendering:
154
-
155
- ```js
156
- export default {
157
- base: "/newsletter",
158
- publicDir: "../public",
159
- styles: [{
160
- source: "../src/styles/global.css",
161
- output: "/assets/styles.css",
162
- transform: css => transformCss(css)
163
- }],
164
- metadata: ({ props }) => ({ lang: props.locale, manifest: "/manifest.json" }),
165
- async afterBuild({ outDir, routes, plans, rewrites, base }) {
166
- // Write host rewrites, RSS, sitemap, or other non-document artifacts.
167
- }
168
- }
169
- ```
170
-
171
- The transform may return CSS text or an object with a `css` string, matching common CSS processor results. Page-exported `metadata` takes precedence over config metadata and may use the same function form.
172
-
173
- Do not render `<link rel="stylesheet">` from page or component JSX. Kudzu rejects direct static body stylesheets with a source location and catches computed JSX stylesheet output during rendering. Trusted `dangerouslySetInnerHTML` remains unparsed and is responsible for its own resource tags.
174
-
175
- ## State Semantics
176
-
177
- Kudzu intentionally differs from React's state snapshot behavior:
178
-
179
- ```tsx
180
- function increaseTwice() {
181
- setCount(count + 1)
182
- setCount(count + 1)
183
- }
184
- ```
185
-
186
- - A setter updates logical state immediately.
187
- - The next statement reads the latest logical state.
188
- - Setters execute in source order.
189
- - DOM writes batch at synchronous-turn boundaries.
190
- - The same input produces the same execution plan.
191
-
192
- The handler above increments by two and patches its bound DOM once. Inspect the generated plan at `.kudzu/kudzu-plan.json`.
193
-
194
- State may also hold serializable plain objects. Property expressions in JSX text update directly:
195
-
196
- ```tsx
197
- const [weather, setWeather] = useState({ temperature: 28, label: "Warm" })
198
-
199
- return <p>{weather.temperature}° {weather.label}</p>
200
- ```
201
-
202
- Derived text uses comment-bounded text nodes rather than wrapper elements, so table cells, options, SVG text, layout, and element selectors keep their authored structure.
203
-
204
- Reducer state uses the same immediate logical updates and batched DOM commit:
205
-
206
- ```tsx
207
- import todoReducer from "../todoReducer"
208
-
209
- const [todos, dispatch] = useReducer(todoReducer, [])
210
- dispatch({ type: "add", title: "Ship" })
211
- ```
212
-
213
- The reduced migration form requires `[state, dispatch]`, exactly two hook arguments, and a synchronous two-parameter reducer exported as a default or named value from a relative TypeScript module. Dispatches in compiled handlers lower to functional state updates. A dispatch may cross one direct prop boundary into a same-file or relative-imported synchronous component whose intrinsic root contains the compiled handler:
214
-
215
- ```tsx
216
- function Controls({ dispatch }: { dispatch: Dispatch<TodoAction> }) {
217
- return <button onClick={() => dispatch({ type: "add", title: "Ship" })}>Add</button>
218
- }
219
-
220
- return <Controls dispatch={dispatch} />
221
- ```
222
-
223
- Kudzu specializes that call at build time; no function prop or child component survives in the browser. Reducers follow React's pure reducer contract. The direct child may also be a keyed row such as `todos.map(todo => <Item key={todo.id} todo={todo} dispatch={dispatch} />)`. Its inline or simple `const` event handler receives the latest keyed item, and it has the same multiple serializable state, effect, condition, and object-ref support as other keyed rows. The list key path owns those hooks across item updates and reorder; removal cleans up and releases them. Relative TypeScript constants and helpers used inside the handler are renamed for call-site safety and bundled into the parent handler graph. Lazy state or reducer initializers, non-keyed specialized local state, package, namespace, local, async, and generator reducers, package imports or child imports used outside event handlers, further dispatch forwarding, and reducer dispatch through context remain unsupported.
224
-
225
- That specialized component may pass one inline or simple `const` callback containing dispatch to one relative-imported synchronous child with an intrinsic root:
226
-
227
- ```tsx
228
- const add = (title: string) => dispatch({ type: "add", title })
229
- return <Input onSubmit={add} />
230
- ```
231
-
232
- Kudzu substitutes the callback into the child's compiled event handler at build time. An inline React `useCallback` wrapper is erased before this analysis. This is not general function-prop serialization: only one nested specialized callback boundary is supported, and further forwarding, effects, component roots, and callback use outside event handlers are rejected.
233
-
234
- Reducer dispatch and callback components may use destructured string, finite-number, boolean, or `null` defaults. A missing prop is replaced during specialization; object, array, computed, and function-call defaults remain unsupported:
235
-
236
- ```tsx
237
- function Input({ onSubmit, editing = false }) {
238
- // ...
239
- }
240
- ```
241
-
242
- ## Reactive Attributes
243
-
244
- `className`, `disabled`, controlled `value`, and controlled `checked` accept normal state-dependent TSX expressions. The same `value` binding works for inputs and selects:
245
-
246
- ```tsx
247
- <div className={active ? "active" : "idle"} />
248
- <button disabled={loading}>Save</button>
249
- <input value={name} onInput={event => setName(event.currentTarget.value)} />
250
- <input type="checkbox" checked={subscribed} onChange={event => setSubscribed(event.currentTarget.checked)} />
251
- <select value={theme} onChange={event => setTheme(event.currentTarget.value)} />
252
- <div style={{ opacity: open ? 1 : 0, width: open ? 240 : 0 }} />
253
- ```
254
-
255
- Regular attributes use the same expressions without an allowlist:
256
-
257
- ```tsx
258
- <button
259
- aria-expanded={open}
260
- data-state={open ? "open" : "closed"}
261
- hidden={!visible}
262
- title={open ? "Close menu" : "Open menu"}
263
- />
264
- ```
265
-
266
- Kudzu compiles derived expressions to external ESM and patches only the bound DOM attribute or property. `aria-*` and `data-*` boolean values serialize as `"true"` or `"false"`; ordinary false values remove the attribute. Object `style` values use React-shaped camelCase properties, add `px` to nonzero dimensional numbers, and preserve unitless properties and CSS custom properties. Reactive `dangerouslySetInnerHTML` remains unsupported.
267
-
268
- Inline SVG accepts React-shaped presentation props for static and reactive values. Kudzu preserves native camelCase SVG names such as `viewBox` while mapping common aliases such as `fillRule`, `clipRule`, `strokeWidth`, `strokeLinecap`, `strokeLinejoin`, opacity/color props, `textAnchor`, and `vectorEffect` to their SVG attribute names:
269
-
270
- ```tsx
271
- <svg viewBox="0 0 24 24">
272
- <path fillRule="evenodd" strokeWidth={active ? 2 : 1} strokeLinecap="round" />
273
- </svg>
274
- ```
275
-
276
- ## DOM Refs
277
-
278
- Use an object ref to access an element from a normal event handler:
279
-
280
- ```tsx
281
- const inputRef = useRef<HTMLInputElement>(null)
282
-
283
- return <>
284
- <input ref={inputRef} />
285
- <button onClick={() => inputRef.current?.focus()}>Focus</button>
286
- </>
287
- ```
288
-
289
- Kudzu resolves `current` when the handler reads it, so removed conditional elements return `null` without a component runtime. Keyed row components may also declare object refs; the row key path scopes the ref and removal releases it. Refs must initialize directly with `null`; callback refs and mutable value refs are not supported.
290
-
291
- ## Context
292
-
293
- Create a context to pass static or reactive values through component layers without prop drilling:
294
-
295
- ```tsx
296
- type ThemeValue = { theme: string; setTheme: (theme: string) => void }
297
- const ThemeContext = createContext<ThemeValue | null>(null)
298
-
299
- function Toolbar() {
300
- const value = useContext(ThemeContext)
301
- if (!value) return null
302
- return <button className={`theme-${value.theme}`} onClick={() => value.setTheme("light")}>{value.theme}</button>
303
- }
304
-
305
- function App() {
306
- const [theme, setTheme] = useState("dark")
307
- return <ThemeContext.Provider value={{ theme, setTheme }}><Toolbar /></ThemeContext.Provider>
308
- }
309
- ```
310
-
311
- Context values may contain state, setters, arrays, nested plain objects, and static serializable fields. Consumers can read reactive properties, destructure or rename them, and call setters from normal handlers. Kudzu serializes only state and setter IDs, then materializes live browser getters and batched setters; no function source, Provider tree, component tree, or hydration is shipped. The default applies outside a Provider and nested Providers resolve to independent concrete state IDs at build time. Arbitrary functions, accessors, cycles, symbols, and non-plain objects remain rejected at the browser capture boundary.
312
-
313
- ## Conditional DOM
314
-
315
- Inline child `&&` and ternary expressions insert and remove bounded DOM ranges directly. A menu bar needs only state setters:
316
-
317
- ```tsx
318
- function MenuBar() {
319
- return <nav><a href="/docs">Docs</a></nav>
320
- }
321
-
322
- const [open, setOpen] = useState(false)
323
-
324
- {open
325
- ? <button onClick={() => setOpen(false)}>Close menu</button>
326
- : <button onClick={() => setOpen(true)}>Open menu</button>}
327
- {open && <MenuBar />}
328
- ```
329
-
330
- Logical state persists across branch switches, while uncontrolled DOM state resets on remount. Both branches are materialized in inert templates at build time, so conditional rendering is not an authorization boundary and dormant branches must not contain secrets.
331
-
332
- Reactive conditional DOM currently targets the HTML namespace and is rejected inside SVG or MathML.
333
-
334
- Top-level or block-scoped immutable JSX locals can hold static or state-dependent branches:
335
-
336
- ```tsx
337
- const menu = open ? <MenuBar /> : <p>Menu dormant</p>
338
- const content = open && menu
339
-
340
- return <main>{content}</main>
341
- ```
342
-
343
- Kudzu compiles the local initializer to the same bounded DOM ranges as an inline condition. Terminal early returns and one adjacent exhaustive `let` assignment normalize to the same representation:
344
-
345
- ```tsx
346
- if (loading) return <Loading />
347
- if (failed) return <ErrorView />
348
- return <Content />
349
-
350
- let view
351
- if (open) view = <Menu />
352
- else view = <p>Closed</p>
353
- return view
354
- ```
355
-
356
- Branches may contain only the return or assignment being normalized. Effectful statements, non-exhaustive assignments, later reassignment, loops, `switch`, and `try` remain ordinary JavaScript and state-dependent render forms are rejected rather than evaluated against signal-object truthiness. Reactive branches are still both rendered into inert templates at build time.
357
-
358
- A 1,000-component A/B build compared direct ternaries with an even mix of block locals, early returns, and exhaustive assignment. Both emitted 1,000 conditions and byte-identical runtime assets. The mixed source added 18 B gzip for three equivalent evaluator exports instead of one and built in 604 ms versus 590 ms (+2.24%).
359
-
360
- ## Keyed Lists
361
-
362
- Map local array state directly to one keyed JSX element per item:
363
-
364
- ```tsx
365
- const [items, setItems] = useState([
366
- { id: 1, name: "Oak", done: false },
367
- { id: 2, name: "Pine", done: true }
368
- ])
369
-
370
- const rows = items.map(item =>
371
- <li
372
- key={item.id}
373
- className={item.done ? "done" : "active"}
374
- aria-label={`${item.name} item`}
375
- style={{ opacity: item.done ? 0.5 : 1 }}
376
- >
377
- {item.name.toUpperCase()}
378
- {item.done ? <strong>Complete</strong> : <span>Pending</span>}
379
- <button onClick={() => setItems(items.filter(entry => entry.id !== item.id))}>Remove</button>
380
- </li>
381
- )
382
-
383
- return <ul>{rows}</ul>
384
- ```
385
-
386
- The root may also be a top-level row component declared in the same file or imported from a relative TypeScript module. Default, named, aliased, and named re-export imports are resolved at build time. Kudzu specializes each call, so projected props, callback props, and simple local calculations compile to the same intrinsic list template:
387
-
388
- ```tsx
389
- function ItemRow({ name, done, onRemove }: {
390
- name: string
391
- done: boolean
392
- onRemove: () => void
393
- }) {
394
- const className = done ? "done" : "active"
395
- return <li className={className}>
396
- {name}
397
- <button onClick={() => onRemove()}>Remove</button>
398
- </li>
399
- }
400
-
401
- const rows = items.map(item => <ItemRow
402
- key={item.id}
403
- name={item.name}
404
- done={item.done}
405
- onRemove={() => setItems(items.filter(entry => entry.id !== item.id))}
406
- />)
407
- ```
408
-
409
- The map may also stay inside a component that receives the local state array directly. The component may be declared in the page or imported by default or name from a relative TypeScript module; direct named re-exports are resolved at build time:
410
-
411
- ```tsx
412
- function ItemList({ items }: { items: Item[] }) {
413
- return <ul>{items.map(item => <li key={item.id}>{item.name}</li>)}</ul>
414
- }
415
-
416
- return <ItemList items={items} />
417
- ```
418
-
419
- The original row component remains reusable across multiple lists and ordinary JSX. State-backed list wrappers and row components are specialized to intrinsic JSX at build time; no component function or component runtime is shipped to the browser. Kudzu emits initial items as static HTML, then adds, removes, updates, styles, conditional branches, and moves keyed elements directly. The map may appear directly in JSX, in one top-level immutable `const` rendered once as a JSX child, or in one synchronous wrapper receiving the state identifier as a direct prop. Existing keys move without remounting, preserving uncontrolled descendant state. Direct `item.<field>` reads use compact markers; derived item expressions compile to external ESM evaluators. Nested item-local `&&` and ternary conditions patch bounded branches and mount or unmount their handlers. Item-local handlers and effects receive the latest JSON-safe item for their key. Effects mount after a row is connected and clean up when it is removed. A direct primitive item dependency such as `[item.name]`, optionally mixed with row or page state, reruns only rows whose selected values changed; unrelated fields and reorder do not rerun it, while removal cleans it up. The item remains stored once in shared list state; runtime descriptors carry a placeholder that the list runtime fills when mounting or updating the keyed root.
420
-
421
- Rendered collections may use one-use top-level aliases and analyzable pipelines over local array state or named relative imports of exported JSON-safe `const` arrays. Inline arrow callbacks accept `(item)` or `(item, index)`; `filter()` supports pure synchronous expressions and direct local-state reads, `flatMap()` projects one direct array property, `Array.from()` accepts an optional pure mapper, and the final `map()` may use `key={item.field}` or positional `key={index}`. Field keys preserve the matching DOM node through filtering and reorder. Positional keys deliberately preserve the DOM node at each position while its item changes, matching React key semantics.
422
-
423
- Compiler-owned static `filter` collections with structural keyed rows validate source items and key tokens once. Removed rows become detached prototypes; restoration clones fresh nodes and inserts only new runs, so retained keys keep identity while restored keys remount. In a 31-fresh-profile, 4x CPU-throttled Chrome benchmark over 1,000 alternating products, Kudzu measured 37.8 ms to visible rows, 8.4 ms to filter to 500, and 3.6 ms to restore 1,000. React measured 86.3/12.9/6.2 ms, Vue 54.1/8.4/4.3 ms, and Svelte 61.4/11.7/8.3 ms. This is a focused static-filter result, not a claim that rendering 100,000 or 1,000,000 DOM rows is appropriate; paginate or window large datasets so only the visible result set enters the document.
424
-
425
- A keyed row may contain multiple keyed maps over direct array properties of its item at any nesting depth. This supports recursively nested data populated after mount while preserving keyed DOM identity across updates and reorder:
426
-
427
- ```tsx
428
- function ItemCard({ item, onSelect }: {
429
- item: Item
430
- onSelect: () => void
431
- }) {
432
- return <li>
433
- <span>{item.title}</span>
434
- {item.available ? <strong>Available</strong> : <small>Unavailable</small>}
435
- <button onClick={() => onSelect()}>Select</button>
436
- <ul>{item.groups.map(group => <li key={group.id}>
437
- <strong>{group.title}</strong>
438
- {group.options.map(option => <button key={option.id} onClick={() => console.log(option.title)}>
439
- {option.title}
440
- </button>)}
441
- </li>)}</ul>
442
- </li>
443
- }
444
-
445
- {categories.map(category => <section key={category.id}>
446
- <h2>{category.title}</h2>
447
- <ul>{category.items.map(item => <ItemCard
448
- key={item.id}
449
- item={item}
450
- onSelect={() => setSelected(item)}
451
- />)}</ul>
452
- </section>)}
453
- ```
454
-
455
- Each nested collection must be `parent.<field>`, and a row may own multiple sibling child maps. There is no numeric nesting-depth limit. Nested rows may be intrinsic or recursively specialized through same-file and relative-imported components to one intrinsic root. They support nested item conditions, latest-item handlers, effects, object refs initialized with `null`, and multiple `useState` declarations whose initial values are directly serializable primitives, arrays, or plain objects. The structural list site plus ancestor key path owns each hook slot across updates and reorder; removal cleans up effects and releases state/ref ownership, so re-adding the key starts from its initial values. Computed nested collections, parent-item capture from a child row, component cycles, package or namespace row imports, and arbitrary collection callbacks remain unsupported.
456
-
457
- #### Nested list output
458
-
459
- Initial child rows remain complete HTML. Kudzu stores one child row prototype, including inert condition branches and descriptors, and reuses it across parent rows.
460
-
461
- - HTML: 339,601 B.
462
- - Total deploy output: 359,271 B.
463
- - Compressed-file sum: 24,173 B.
464
- - Initial JavaScript: 7,204 B gzip. Routes without nested lists compile out prototype and marker lookup.
465
-
466
- In the matched 100-parent/1,000-child fixture, Kudzu measured 1.3/0.4/5.0/0.7 ms for child update and condition change, child reverse, parent reverse, and parent removal. Hand-written Astro/native measured 0.5/0.4/3.9/0.2 ms, Svelte 2.7/1.2/6.7/1.3 ms, Vue 4.9/2.5/6.1/2.2 ms, and React 11.8/5.0/8.2/4.4 ms. Kudzu and Astro emit initial rows while the CSR targets do not, so artifact sizes are not architecture-equivalent.
467
-
468
- Each item must be an ordinary plain object with a unique string or finite-number key; nested data may contain only JSON-safe arrays, ordinary plain objects, and primitive values. Null-prototype objects are rejected to preserve JSON round-trip parity. Collections must remain anchored to local array state or a supported static named import; inline callbacks accept one or two identifier parameters, and row roots must be intrinsic JSX or supported same-file/relative components with `key={item.<field>}` or `key={index}`. Specialized wrappers and keyed rows accept forwarded JSX children and source-ordered prop spreads from inline object literals or one direct `const` object literal declared in the calling component. Specialized keyed row components accept missing destructured string, finite-number, boolean, or `null` props with literal defaults. State-backed list wrappers use one destructured props parameter, an intrinsic return root, no effects, and a direct local-state prop. Whole-item, computed, nested, derived, `__proto__`, `prototype`, and `constructor` effect dependencies are rejected. A collection alias may only be rendered once and cannot be read by other JavaScript. Collection callbacks and derived expressions must be pure and synchronous: supported reads, operators, templates, approved read-only methods, deterministic `Math`, and primitive conversion compile; imported callbacks, browser globals, promises, mutation, arbitrary calls, and prototype-sensitive properties fail. Lazy or dynamic keyed-row state initializers, non-`null` refs, callback refs, package/namespace/star row imports, same-file exported rows, reusable aliases, dynamic/computed prop spreads, rest props, non-primitive defaults, fragments, and `dangerouslySetInnerHTML` remain unsupported. Keyed rows must be placed inside an explicit `<tbody>`, `<thead>`, or `<tfoot>`.
469
-
470
- The focused wrapper fixture emits 1,393 B raw / 500 B gzip HTML and 10,719 B raw / 4,665 B gzip JavaScript across its route capabilities. After one warm-up, seven clean builds measured 314.1, 325.3, 322.3, 327.2, 336.1, 322.4, and 315.0 ms, with a 322.4 ms median.
471
-
472
- The three-wrapper relative-import fixture emits 2,279 B raw / 629 B gzip HTML and 11,370 B raw / 4,828 B gzip JavaScript, including one imported-wrapper item expression; its unused component handler module is not emitted. After one warm-up, seven clean builds measured 340.2, 349.4, 352.8, 335.0, 354.1, 364.3, and 349.5 ms, with a 349.5 ms median.
473
-
474
- The compact neutral integration fixture combines the ordinary migration shapes above: a `flatMap`/`filter` alias, `(item, index)`, stable and positional keys, sibling/deep same-file and relative component lists, three nested conditions, and keyed-row state/effect/ref lifecycles. It emits 13,898 B HTML and 39,387 B raw/14,655 B gzip JavaScript across 11 files (16,514 B total file-by-file gzip at level 9). Seven artifact-clean builds after one warm-up measured 515.185, 516.919, 486.594, 517.121, 436.251, 454.066, and 444.403 ms, with a 486.594 ms median. Seven fresh Chrome profiles measured 2.2 ms filter update, 1.2 ms flatMap reorder, 0.6 ms keyed-row state/effect rerun, 3.5 ms ref focus/read, 1.0 ms nested-condition re-entry, 0.8 ms removal cleanup, 2.1 ms re-add/reset, and 0.8/0.6/0.5 ms sibling-list update/add/reorder.
475
-
476
- ## Effects
477
-
478
- Browser-only initial work uses the familiar empty-dependency effect shape:
479
-
480
- ```tsx
481
- import { useEffect, useState } from "@kudzujs/core"
482
-
483
- const [items, setItems] = useState([])
484
-
485
- useEffect(async () => {
486
- const response = await fetch("/api/items")
487
- setItems(await response.json())
488
- }, [])
489
- ```
490
-
491
- Kudzu does not execute the effect during static rendering and does not ship the component. It emits one route-specific effect entry that invokes the compiled callback against existing logical state and direct DOM commit capabilities. Effects may update reactive text, attributes, conditions, and keyed lists. Multiple effects start independently in source order, and one synchronous or asynchronous failure is reported without suppressing later effects.
492
-
493
- An effect may directly return an inline cleanup function:
494
-
495
- ```tsx
496
- useEffect(() => {
497
- const onResize = () => console.log(window.innerWidth)
498
- window.addEventListener("resize", onResize)
499
-
500
- return () => window.removeEventListener("resize", onResize)
501
- }, [])
502
- ```
503
-
504
- Document-owned cleanup runs once when the document leaves outside the browser back-forward cache. An effect in a conditional branch or supported keyed row mounts only while its DOM owner is present and cleans up once when that owner is removed. Effect-local resources and component state read by nested cleanup closures retain their setup-time values. Cleanup failures are isolated so later cleanups still run.
505
-
506
- Literal arrays of direct primitive `useState` or `useParams` signal identifiers rerun after committed dependency changes:
507
-
508
- ```tsx
509
- const [event, setEvent] = useState("resize")
510
-
511
- useEffect(() => {
512
- const listener = () => console.log(event)
513
- window.addEventListener(event, listener)
514
- return () => window.removeEventListener(event, listener)
515
- }, [event])
516
- ```
517
-
518
- Dependency values are limited to JSON-safe strings, finite numbers, booleans, and `null`; direct signal aliases are accepted, while expressions, property reads, ordinary props or locals, objects, spreads, and dynamic arrays fail the build. Kudzu compares dependencies with `Object.is`, coalesces multiple commits in one turn, invokes every affected previous cleanup in declaration order, awaits asynchronous cleanup, and then runs the affected setups in declaration order. The component itself is not rerun.
519
-
520
- Effect callbacks must be inline and block-bodied. Named or dynamically obtained cleanup functions, cleanup parameters or generators, other return values, callback parameters, and non-serializable captures are rejected. Async effects cannot return cleanup functions; the cleanup itself may be async. Pages without effects receive no effect entry. Empty-dependency effects retain their smaller output, and dependency-only capability code is isolated to the routes that use `kudzu-deps.js` unless another capability already requires the shared runtime.
521
-
522
- An inline effect may own an exact relative TypeScript module Worker:
523
-
524
- ```tsx
525
- useEffect(() => {
526
- const worker = new Worker(
527
- new URL("../telemetry.worker.ts", import.meta.url),
528
- { type: "module" },
529
- )
530
- return () => worker.terminate()
531
- }, [])
532
- ```
533
-
534
- Kudzu resolves the path from the callback source, bundles the Worker and its relative TypeScript imports separately as content-hashed ESM under `assets/workers`, and rewrites the constructor to the base-prefixed same-origin asset URL. The Worker is fetched only when the effect mounts; it is not a capability script, preload, or window import. Unrendered effect handlers do not cause their Worker root to be emitted. This slice requires unshadowed global `Worker` and `URL`, exact `import.meta.url`, a relative `.worker.ts` string literal, and exactly `{ type: "module" }`. Worker graphs reject JSX, package runtime imports, TypeScript import-equals declarations, dynamic imports, `require()`, missing files, and paths outside `src`. Worker source cannot be imported or re-exported as an ordinary runtime module. Construction in event handlers, imported helpers, or imported keyed-row effects is rejected; move keyed-row Worker ownership to a directly compiled page or local component effect. Public or absolute JavaScript Workers remain ordinary browser code and are not transformed.
535
-
536
- Route-owned browser requests use the same dependency-effect cleanup rather than a request runtime. Keep the effect callback synchronous, create an `AbortController` and timeout inside it, start the promise chain, and directly return cleanup that clears the timer and aborts the request. A command-only handler can update primitive command/revision state; the dependency effect then owns the request. Replacement or route disposal runs cleanup before the next setup and invalidates the old effect's setters. Applications must still check `response.ok`, distinguish timeout from other failures, and guard any imperative DOM writes themselves.
537
-
538
- A matched mount-fetch benchmark renders a title and two keyed rows from local JSON. With one warm-up and seven rotating clean builds, Kudzu shipped initial HTML, 3.4 KB initial JS gzip, 8.1 KB total output, and built in 374 ms. React CSR shipped no initial content, 59.3 KB initial JS gzip, 189.2 KB total output, and built in 992 ms. Hand-written ESM shipped 534 B initial JS gzip, 1.2 KB total output, and built in 210 ms. Fresh-profile Chrome medians to loaded data were 157.9 ms, 166.5 ms, and 153.4 ms respectively.
539
-
540
- A matched resize-listener cleanup fixture, measured with the same warm-up and seven rotating clean builds, shipped 1.2 KB JavaScript gzip and built in 402 ms with Kudzu. Svelte shipped 10.1 KB and built in 861 ms, Vue shipped 23.6 KB and built in 768 ms, and React shipped 59.1 KB and built in 1,058 ms. Kudzu and the 127 B hand-written Astro baseline emitted initial HTML; the CSR fixtures did not.
541
-
542
- In the matched dependency-rerun fixture, Kudzu shipped 1.5 KB JavaScript gzip and built in 429 ms. Svelte shipped 9.7 KB in 995 ms, Vue 23.8 KB in 943 ms, React 59.2 KB in 1,172 ms, and the hand-written Astro baseline 196 B in 969 ms. Kudzu and Astro emitted initial HTML; the CSR fixtures did not.
543
-
544
- ## Normal JavaScript
545
-
546
- Command-only setters use the smallest optimized path. Conditions, local variables, browser globals, events, and `async`/`await` compile to external ESM without `eval`, `new Function`, or inline executable code.
547
-
548
- ```tsx
549
- async function load() {
550
- setStatus("loading")
551
-
552
- try {
553
- const response = await fetch("/api/status")
554
- const result = await response.json()
555
- setStatus(result.status)
556
- } catch {
557
- setStatus("failed")
558
- }
559
- }
560
- ```
561
-
562
- Native handlers may call default, named, or namespace helpers imported from relative TypeScript modules. Kudzu bundles the reachable helper graph into handler ESM and shared chunks; helper runtime imports must remain relative, and dynamic imports or JSX helpers are rejected. Imported functions cannot be used directly as JSX event callbacks.
563
-
564
- ```tsx
565
- import { normalizeStatus } from "../lib/status"
566
-
567
- async function load() {
568
- const response = await fetch("/api/status")
569
- setStatus(normalizeStatus(await response.json()))
570
- }
571
- ```
572
-
573
- Primitive values, arrays, plain objects, and destructured props can be captured by client handlers. Functions, symbols, bigints, cycles, and class instances are not supported as captures.
574
-
575
- Native handlers use direct DOM listeners with normal `currentTarget`, bubbling, default-action, and propagation semantics. Handler modules load before listener registration, so `preventDefault`, `stopPropagation`, and `stopImmediatePropagation` work synchronously as expected.
576
-
577
- ## Rendering
71
+ ## How It Works
578
72
 
579
73
  ```text
580
- TSX
581
- ├─ static component → HTML
582
- ├─ ordered state setter → behavior command
583
- ├─ conditional child → bounded DOM range
584
- ├─ keyed state map → keyed DOM moves
585
- └─ normal JS handler → external ESM
586
- ```
587
-
588
- - Static pages ship no client JavaScript.
589
- - Interactive pages receive only the runtime capabilities they use.
590
- - Interactive route modules are discovered in the document head and retain deferred execution after HTML parsing, overlapping cold downloads with document transfer.
591
- - Production JavaScript is minified; development output stays readable.
592
- - Components are authoring units; no component tree is retained in the browser.
593
- - There is no VDOM, hydration pass, retained component tree, default router, or general client application runtime.
594
-
595
- ## Application Navigation
596
-
597
- Pages may export one shared layout while continuing to emit complete standalone documents:
598
-
599
- ```tsx
600
- export { Shell as layout } from "../components/Shell"
601
-
602
- export default function ProductPage() {
603
- return <main><h1>Product</h1></main>
604
- }
605
- ```
606
-
607
- Opt emitted exact or runtime-parameter routes into same-document navigation:
608
-
609
- ```js
610
- export default {
611
- navigation: { routes: ["/product", "/items/[id]"] }
612
- }
613
- ```
614
-
615
- The legacy single-group form remains supported. Applications with multiple shared layouts use mutually exclusive `groups`:
616
-
617
- ```js
618
- export default {
619
- navigation: { groups: [
620
- { routes: ["/product", "/items/[id]"] },
621
- { routes: ["/account", "/settings"] }
622
- ] }
623
- }
74
+ ordinary React-shaped TSX
75
+ -> Kudzu compiler specialization
76
+ -> complete HTML + CSS + capability-specific ESM
624
77
  ```
625
78
 
626
- Every configured identity must be a unique emitted exact route or `runtimeParams` bracket pattern. Routes within each group must export the same layout function identity; different groups may export different layouts. Kudzu emits one deterministic, route-set-hashed navigation asset per group containing only that group's records and capabilities. Path domains may overlap within a group, where exact and more-specific matching wins, but overlapping exact/runtime or runtime/runtime domains across groups fail the build.
627
-
628
- The layout DOM, state, and effects persist within its group; route state, parameters, and effects reset after cleanup on each transition. Conditional effects mount only while their DOM is connected. Keyed row effects mount per connected row, survive reorder, rerun only rows whose selected direct primitive item dependency changed, receive the latest complete item, and clean up on removal. Cached route modules create fresh route owner records and subscriptions on every revisit. Eligible same-group anchors prefetch validated complete documents into a finite memory cache. Cross-group links, ungrouped routes, direct requests, reloads, malformed runtime paths, JavaScript failures, and unsupported links retain native document navigation.
629
-
630
- This produces fast same-document route changes, but it does not add a coordinated transition animation. CSS entry animations can style newly inserted route content; exit and shared-element View Transitions are not integrated yet.
631
-
632
- Example Nginx configuration:
633
-
634
- ```nginx
635
- location / {
636
- try_files $uri $uri/ $uri/index.html =404;
637
- }
638
- ```
639
-
640
- ## Current Scope
641
-
642
- Supported:
643
-
644
- - Function components, props, children, fragments, and TSX
645
- - File-based static routes
646
- - Build-time async components
647
- - Dynamic static routes with build-time props
648
- - Runtime bracket parameters with static fallback documents and host rewrite metadata
649
- - Static trusted `dangerouslySetInnerHTML`
650
- - Base-path deployments, multiple CSS files, and `afterBuild`
651
- - `useState` and relative-imported `useReducer` bindings
652
- - Mount-only `useEffect(fn, [])` compiled to route-specific ESM
653
- - Relative TypeScript module Workers owned by inline effects
654
- - Conditional and keyed-row effect ownership with cleanup on DOM removal
655
- - Synchronous and async event handlers
656
- - Relative imported helpers in native handlers
657
- - Serializable component-local captures
658
- - Direct text DOM patches
659
- - Reactive standard, `aria-*`, and `data-*` attributes
660
- - Reactive object `style` attributes
661
- - Static and reactive React-shaped SVG presentation attributes
662
- - Object DOM refs in native event handlers
663
- - Default, nested, and reactive context providers
664
- - Controlled `value` and `checked` form properties
665
- - Conditional child `&&` and ternary DOM patches
666
- - Top-level and block-scoped JSX locals, terminal early returns, and exhaustive JSX assignment
667
- - Direct keyed local-state lists
668
- - Analyzable `filter`/direct-property `flatMap`/`Array.from` collection pipelines with item or positional keys
669
- - Multiple sibling and recursively deep direct-property child lists
670
- - Recursive same-file/relative keyed-row specialization with nested conditions and latest-item handlers
671
- - Keyed-row multiple serializable state slots, effects, and `null`-initialized object refs
672
- - Page-exported shared layouts with layout/route state lifetimes
673
- - Opt-in exact/runtime-route navigation with complete-document prefetch and native fallback
674
- - Layout- and route-lifetime effect mounts in navigation groups
675
- - Conditional/keyed DOM-owned effects in navigation groups
676
-
677
- Selected current migration limits:
678
-
679
- - Non-primitive prop defaults/rest, exported reusable specialized rows, `forwardRef`, and `useId` still need fixture-driven component specialization. Direct analyzable prop spreads and forwarded JSX children are supported across existing specialized collection boundaries.
680
- - Hookful non-keyed imported components and pure lazy state/reducer initializers remain narrower than ordinary React.
681
- - Reusable collection aliases, computed child collections, imported pure transforms, and common immutable pagination/sorting forms need failing fixtures before expansion.
682
- - Effect dependencies remain limited to directly analyzable primitive signals and supported keyed-item properties.
683
- - Conditional/keyed ranges inside SVG and router-shaped package source remain separate compatibility work.
684
- - Arbitrary, mutating, asynchronous, or package collection callbacks remain deliberate static-analysis boundaries.
685
- - Server actions, request-time SSR, a React runtime/ecosystem layer, React islands, and a default SPA router are non-goals.
686
-
687
- See the [active fixture queue](./MIGRATION_ROADMAP.md#active-fixture-queue) for development order. Unsupported syntax is not automatically backlog work; a real React migration fixture must fail first.
688
-
689
- ## Benchmarks
690
-
691
- ### Goal A Validation Fixture: Commerce Journey
692
-
693
- The matched fixture covers home, category, product, cart, checkout, and account routes with complete initial HTML, shared application layout state, product options, optimistic cart success and rejection, accessible errors, rollback, and product-to-cart navigation. Kudzu, React + Vite, Next.js, Nuxt, and SvelteKit render the same tested content and interactions.
694
-
695
- Browser medians use seven rotating fresh Chrome profiles per target with 4x CPU slowdown, 100 ms latency, and 200 KiB/s throughput. Product JavaScript includes the initial static import graph; cold transfer includes the complete initial page transfer. Lower is better.
696
-
697
- | Target | Product JS gzip | Cold transfer | Cold LCP | Warm LCP | Startup task | Heap | Interaction | Product → cart |
698
- |---|---:|---:|---:|---:|---:|---:|---:|---:|
699
- | Kudzu | **7,334 B** | **35,260 B** | 332 ms | **156 ms** | **122.6 ms** | **650,708 B** | **4.8 ms** | **5.6 ms** |
700
- | React + Vite | 61,464 B | 202,842 B | 332 ms | 264 ms | 179.9 ms | 1,062,520 B | 10.2 ms | 9.9 ms |
701
- | Next.js | 190,090 B | 547,615 B | **324 ms** | 176 ms | 434.4 ms | 2,168,412 B | 14.0 ms | 30.0 ms |
702
- | Nuxt | 67,620 B | 195,953 B | **324 ms** | 224 ms | 247.9 ms | 1,721,348 B | **4.5 ms** | 29.6 ms |
703
- | SvelteKit | 32,474 B | 90,939 B | 376 ms | 184 ms | 143.8 ms | 999,496 B | 6.3 ms | 21.8 ms |
704
-
705
- The current Kudzu application emits 35,355 deploy bytes. Its 7,334 B gzip product graph includes the 2,425 B navigation capability; all three sizes are unchanged by conditional/keyed navigation effects because this top-level-only fixture retains the smaller specialized path. The first implementation paid a 128.7 ms HTML round trip during product-to-cart navigation; validated near-viewport document prefetch measured 5.6 ms in the current run while preserving complete documents and native fallback.
706
-
707
- The mobile row is retained from the previous matched run using a 390x844 viewport, 6x CPU slowdown, 150 ms latency, and 150 KiB/s throughput:
79
+ - 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
+ - Conditions, keyed collections, attributes, events, refs, effects, and supported component boundaries compile to route-specific capabilities.
82
+ - Build-known data and routes become complete HTML through async components and `getStaticPaths()`.
83
+ - Native document navigation is the default; static routes do not load a client runtime.
84
+ - Unsupported nearby patterns fail during the build with a source location and actionable boundary.
708
85
 
709
- | Profile | Cold LCP | Warm LCP | Interaction | Product cart | Reject feedback | Rollback/error | CLS |
710
- |---|---:|---:|---:|---:|---:|---:|---:|
711
- | Desktop | 332 ms | 156 ms | 4.8 ms | 5.6 ms | 4.0 ms | 112.1 ms | 0 |
712
- | Mobile | 420 ms | 220 ms | 5.6 ms | 8.7 ms | 4.1 ms | 158 ms | 0 |
86
+ Migration input may retain supported imports from `react`; Kudzu erases those references and never emits or executes React. New Kudzu source should import framework APIs from `@kudzujs/core`.
713
87
 
714
- Initial runs found a repeatable 6–7% small-build loss from TypeScript and esbuild module startup. Kudzu now enables Node's native module compile cache before lazily loading the compiler. The current seven-run matched commerce build measured Kudzu at 486.8 ms and React at 545.4 ms, making Kudzu 10.7% faster in that run. Kudzu also shipped 88.1% less product JavaScript, used 38.8% less measured heap, and measured 52.9% faster interaction and 43.4% faster product-to-cart navigation than React. Disabling the cache preserves byte-for-byte output. Attempts to replace generated-handler lowering or share one TypeScript Program did not improve the combined median and were not retained.
88
+ See the [complete guide](https://kudzujs.cloud/docs), [interactive features](https://kudzujs.cloud/docs#state), and [current limits](https://kudzujs.cloud/docs#limits) instead of relying on this README as an API reference.
715
89
 
716
- These results describe this six-route fixture on one machine, not framework ecosystem size or every rendering mode. Prefetch improves an eligible warm application transition; it does not hide cold transfer, and direct loads remain complete standalone documents.
90
+ ## Architecture
717
91
 
718
- ### Capability Microbenchmarks
92
+ Kudzu intentionally does not provide:
719
93
 
720
- The measurements below isolate individual compiler capabilities. They were produced on the same machine from production builds. Each framework received one warm-up followed by seven clean builds in rotating order; the table reports the median. Initial JavaScript includes inline scripts, root script references, and their static import graph, compressed file-by-file with gzip level 9. Total output is the raw size of every deploy artifact.
94
+ - React runtime compatibility
95
+ - Virtual DOM or hydration
96
+ - Retained browser component instances
97
+ - A default SPA router
98
+ - Request-time SSR or server actions
99
+ - A general client state or effect runtime
721
100
 
722
- ### Interactive Counter
101
+ Browser code is a compiler-generated capability module, included only when a route uses that capability.
723
102
 
724
- Same counter with initial value `7` and increment/decrement buttons:
103
+ ## Packages
725
104
 
726
- | Framework | Initial content | Initial JS gzip | Total output | Clean build |
727
- |---|---:|---:|---:|---:|
728
- | Kudzu | Yes | 393 B | 1.1 KB | **409 ms** |
729
- | Astro | Yes | **158 B** | **365 B** | 893 ms |
730
- | Svelte CSR | No | 10.5 KB | 26.9 KB | 867 ms |
731
- | Qwik CSR | No | 20.6 KB | 57.8 KB | 600 ms |
732
- | Vue CSR | No | 24.0 KB | 60.3 KB | 785 ms |
733
- | React CSR | No | 59.2 KB | 189.0 KB | 1032 ms |
734
- | Next.js | Yes | 182.1 KB | 652.2 KB | 3082 ms |
735
-
736
- Astro produces the smallest hand-authored counter. Kudzu's advantage in this fixture is React-shaped state code with a sub-1 KB runtime, not the smallest possible JavaScript.
737
-
738
- #### Imported Helper Cost
739
-
740
- The same native counter calculation was measured inline and through one relative TypeScript helper. Click medians are per state update from five 20,000-click batches in each of seven fresh Chrome sessions.
741
-
742
- | Kudzu variant | Files | Initial JS gzip | Total output | Clean build | Click |
743
- |---|---:|---:|---:|---:|---:|
744
- | Inline native handler | 5 | 1,827 B | 3,923 B | **426 ms** | **3.78 µs** |
745
- | Imported helper | 5 | 1,845 B | 3,949 B | 446 ms | 4.47 µs |
746
-
747
- Bundling removes the helper file boundary, leaving 26 raw bytes and 18 gzip bytes for the function definition and calls. The measured call adds 0.69 µs per state update. The smaller 393 B command-only counter above uses a different optimized runtime path and is not the helper overhead baseline.
748
-
749
- #### Context Object Cost
750
-
751
- The same native counter was measured with local state access and through `value={{ count, setCount }}`. Context uses live object properties in both derived text and handlers, so this measures the complete recursive capture and generic binding capability.
752
-
753
- | Kudzu variant | Files | Initial JS gzip | Total output | Clean build | Click |
754
- |---|---:|---:|---:|---:|---:|
755
- | Local native state | 5 | **1,890 B** | **4,064 B** | **421 ms** | **3.96 µs** |
756
- | Context object | 7 | 4,991 B | 11,829 B | 441 ms | 7.54 µs |
757
-
758
- Context adds 3,101 B gzip and 3.59 µs per update only on pages using nested reactive capture descriptors. It preserves immediate logical reads across repeated setter calls and batches DOM writes once per synchronous turn. Capability specialization removes the recursive state/setter branches from pages that do not use them.
759
-
760
- #### Wrapper-Free Derived Text
761
-
762
- The same object-state counter was built with the legacy span target and the current comment-bounded text range. Browser medians use five 20,000-update batches in each of seven fresh Chrome sessions.
763
-
764
- | Text target | Files | JS gzip | Total output | Clean build | Update |
765
- |---|---:|---:|---:|---:|---:|
766
- | Legacy span target | 7 | **4,453 B** | **10,065 B** | **404 ms** | **4.83 µs** |
767
- | Comment range | 7 | 4,763 B | 10,922 B | 426 ms | 5.03 µs |
768
-
769
- The range costs 310 B gzip only on pages using derived reactive text. It removes wrapper elements and preserves authored structure across table cells, options, SVG text, selectors, and conditional remounts; ordinary attribute and condition pages tree-shake the range code entirely.
770
-
771
- ### 123-Page Newsletter Build
772
-
773
- The migration fixture emits the same 123 static detail pages, two stylesheets, base-prefixed URLs, and post-build feed with no browser JavaScript. Seven clean builds compare generated page files with one dynamic page module.
774
-
775
- | Build model | TSX source files | Pages | JS gzip | Total output | Clean build |
776
- |---|---:|---:|---:|---:|---:|
777
- | Generated TSX workaround | 123 | 123 | 0 B | 52.0 KB | 882 ms |
778
- | `getStaticPaths` | **1** | 123 | 0 B | 52.0 KB | **454 ms** |
779
-
780
- `getStaticPaths` removes 122 generated source files and cuts clean build time by 48.5% without changing deploy output or runtime cost.
781
-
782
- ### Static Journal Page
783
-
784
- Same content and CSS across every fixture:
785
-
786
- | Framework | Initial content | Initial JS gzip | Total output | Clean build |
787
- |---|---:|---:|---:|---:|
788
- | Kudzu | Yes | **0 B** | 3.2 KB | **422 ms** |
789
- | Astro | Yes | **0 B** | **3.0 KB** | 1081 ms |
790
- | Svelte CSR | No | 10.2 KB | 27.2 KB | 902 ms |
791
- | Qwik CSR | No | 20.2 KB | 59.6 KB | 633 ms |
792
- | Vue CSR | No | 24.2 KB | 62.3 KB | 810 ms |
793
- | React CSR | No | 59.8 KB | 192.3 KB | 1110 ms |
794
- | Next.js | Yes | 182.6 KB | 663.6 KB | 3126 ms |
795
-
796
- ### 1,000-item Keyed List
797
-
798
- The list starts with 1,000 keyed items, then updates every label, reverses the order, removes odd IDs, and adds 500 items. Kudzu, Next, React, Vue, and Svelte browser timings are medians from 31 rotating fresh headless Chrome profiles, measured when a DOM observer sees each expected result rather than at the next animation frame. The Astro and Qwik rows retain their earlier seven-profile measurements.
799
-
800
- | Framework | Initial content | Initial JS gzip | Total output | Build | Update | Reverse | Remove | Add | Operations total |
801
- |---|---:|---:|---:|---:|---:|---:|---:|---:|---:|
802
- | Astro | Yes | **324 B** | **43.6 KB** | 920 ms | **4.4 ms** | **4.1 ms** | **1.5 ms** | **3.6 ms** | **13.6 ms** |
803
- | Kudzu | Yes | 6.7 KB | 65.9 KB | **380 ms** | **5.7 ms** | **7.3 ms** | **2.8 ms** | **5.6 ms** | **21.4 ms** |
804
- | Next.js | Yes | 182.2 KB | 695.2 KB | 3142 ms | 8.0 ms | 13.0 ms | 4.4 ms | 7.4 ms | 32.8 ms |
805
- | React CSR | No | 59.3 KB | 189.4 KB | 1074 ms | 9.7 ms | 12.6 ms | 4.3 ms | 5.9 ms | 32.5 ms |
806
- | Vue CSR | No | 24.3 KB | 61.3 KB | 791 ms | 11.0 ms | 9.7 ms | 4.4 ms | 6.7 ms | 31.8 ms |
807
- | Svelte CSR | No | 12.9 KB | 33.1 KB | 910 ms | 6.0 ms | 42.3 ms | 4.5 ms | 6.2 ms | 59.0 ms |
808
- | Qwik CSR | No | 22.2 KB | 64.1 KB | 634 ms | 10.6 ms | 25.6 ms | 36.5 ms | 22.1 ms | 94.8 ms |
809
-
810
- An intrinsic-root versus projected-prop row-component A/B build produced byte-for-byte identical `dist` output: 6,817 B JS gzip and 67,495 B total. Seven rotating clean builds measured 380 ms and 384 ms. In 31 paired fresh-profile rounds, no browser operation differed significantly; component specialization adds no deployed runtime overhead.
811
-
812
- Astro is the hand-authored native DOM baseline in the interactive fixtures. React, Vue, Svelte, and Qwik used client-rendered fixtures, while Kudzu and Astro emitted initial HTML; Qwik therefore did not exercise its SSR resumability advantage. Kudzu has the lowest median for every operation among the 31-profile framework targets, and every exact paired sign test is significant.
813
-
814
- ### 1,000-item Keyed Effect
815
-
816
- Each keyed row owns one effect depending on `item.name`. The measured actions rename only row 500 and wait for exactly one cleanup/setup, change an unrelated detail and require no lifecycle work, then reverse all rows and again require no lifecycle work. Medians use seven fresh Chrome profiles; builds use one warm-up and seven rotating clean runs.
817
-
818
- | Framework | Initial rows | Initial JS gzip | Total output | Build | Selected update | Unrelated update | Reverse |
819
- |---|---:|---:|---:|---:|---:|---:|---:|
820
- | Astro native | Yes | **381 B** | **90,734 B** | 998 ms | **0.4 ms** | **0.2 ms** | **5.7 ms** |
821
- | Kudzu | Yes | 8,264 B | 225,248 B | **426 ms** | 3.6 ms | 2.4 ms | 8.8 ms |
822
- | Vue CSR | No | 25,091 B | 63,368 B | 935 ms | 5.8 ms | 2.4 ms | 10.5 ms |
823
- | Svelte CSR | No | 12,848 B | 33,222 B | 1,040 ms | 5.7 ms | 4.1 ms | 58.1 ms |
824
- | React CSR | No | 60,921 B | 194,301 B | 1,198 ms | 9.8 ms | 5.9 ms | 16.8 ms |
825
-
826
- This is a post-initialization runtime microbenchmark, not an architecture-equivalent loading comparison. Kudzu and Astro emit all 1,000 rows in HTML while React, Vue, and Svelte use empty CSR shells, so their JavaScript, output, and build columns are observations rather than framework-size or startup claims. Once every target has 1,000 rows and effects ready, Kudzu's targeted changed-root path measures 3.6 ms versus Vue at 5.8 ms, Svelte at 5.7 ms, and React at 9.8 ms. List reconciliation remains O(n); Kudzu and Vue both measure 2.4 ms for the unrelated detail update. Astro is the hand-written direct-DOM lower bound.
827
-
828
- ### 1,000-item Keyed Row State
829
-
830
- Row 500 enters local edit state, the list reverses while preserving that row and its input DOM identity, then the row is removed and the same key is re-added with fresh non-editing state. Framework browser timings use 31 rotating fresh profiles; the native baseline retains its latest seven-profile run. Timings start at click and stop only after row order, unique IDs, labels, local state, and DOM identity match.
831
-
832
- | Framework | Initial rows | Initial JS gzip | Total output | Build | Edit | Reverse | Remove | Re-add |
833
- |---|---:|---:|---:|---:|---:|---:|---:|---:|
834
- | Astro native | Yes | **373 B** | **83.7 KB** | 903 ms | **1.6 ms** | **5.7 ms** | **1.2 ms** | **1.3 ms** |
835
- | Kudzu | Yes | 9.2 KB | 572.9 KB | **434 ms** | **2.7 ms** | 10.8 ms | 3.0 ms | 3.7 ms |
836
- | Vue CSR | No | 24.4 KB | 61.6 KB | 793 ms | 3.0 ms | 12.1 ms | 4.2 ms | 4.1 ms |
837
- | Svelte CSR | No | 13.1 KB | 33.8 KB | 911 ms | 2.8 ms | 50.4 ms | 4.6 ms | 5.9 ms |
838
- | React CSR | No | 59.4 KB | 189.5 KB | 1,054 ms | 6.2 ms | 25.8 ms | 9.1 ms | 6.7 ms |
839
-
840
- Kudzu builds fastest and has the lowest framework median for every operation. The 0.1 ms displayed edit lead over Svelte is not statistically significant (p = 0.572); every other framework comparison is significant. Stable identity fast paths preserve direct keyed DOM identity across reorder, removal, and append. Astro remains the hand-written native lower bound. Kudzu's 572.9 KB output includes complete initial HTML plus per-row direct-patch descriptors; React, Vue, and Svelte ship CSR shells, so deploy size and loading architecture are not equivalent comparisons.
841
-
842
- The general benchmark snapshot was collected on July 22, 2026, the keyed-effect comparison on July 27, and the 31-profile keyed-list and keyed-row-state comparisons on July 30 with Node 24.14.0 on an Intel i5-9500. These results compare the selected one-page fixtures, not ecosystem maturity, browser interaction speed beyond the listed operations, or each framework's full rendering options. Build times vary with machine load and filesystem cache.
105
+ - [`@kudzujs/core`](https://www.npmjs.com/package/@kudzujs/core): compiler, CLI, JSX runtime, and framework APIs
106
+ - [`create-kudzu`](https://www.npmjs.com/package/create-kudzu): project generator and working showcase
843
107
 
844
108
  ## Development
845
109
 
846
110
  ```bash
847
- npm install
848
111
  npm run check
849
112
  npm test
850
113
  ```
851
114
 
852
- License: MIT
115
+ Read `AGENTS.md` and `MIGRATION_ROADMAP.md` before extending migration syntax or browser capabilities.
116
+
117
+ ## License
118
+
119
+ MIT
package/RELEASES.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Kudzu Releases
2
2
 
3
+ ## 0.7.11 - Serializable defaults and rest props
4
+
5
+ Kudzu 0.7.11 preserves common non-primitive defaults and direct rest forwarding across existing compiler-specialized component boundaries.
6
+
7
+ ### New in 0.7.11
8
+
9
+ - Specialized collection wrappers, keyed rows, and reducer components accept directly serializable primitive, plain-object, and array literal prop defaults.
10
+ - One final identifier rest binding may be forwarded exactly once to the component's direct intrinsic root.
11
+ - Rest props expand into ordinary JSX attributes before existing event, binding, style, and keyed-row analysis, preserving source-order overrides without a runtime rest object.
12
+ - Calling-component `const` prop spreads now resolve through lexical function scopes, including keyed row calls nested inside `map` callbacks.
13
+ - Dynamic defaults, indirect or repeated rest use, rest-forwarded children, and prototype-sensitive rest properties fail with source-located diagnostics.
14
+ - A React-shaped keyed row fixture proves object and array defaults, ARIA and event rest props, style output, and component erasure.
15
+ - The repository README is now a concise project entry point; detailed APIs, limits, and benchmarks link to the maintained web documentation.
16
+
17
+ ### Boundary
18
+
19
+ Defaults must be directly serializable literals. Rest must be one final identifier binding used exactly once as a spread on the direct intrinsic root. State collections still cross specialized wrapper boundaries as direct props. Exported reusable specialized rows, `forwardRef`, and `useId` remain fixture-driven work.
20
+
21
+ ### Upgrade
22
+
23
+ ```bash
24
+ npm install @kudzujs/core@^0.7.11
25
+ ```
26
+
3
27
  ## 0.7.10 - Component composition
4
28
 
5
29
  Kudzu 0.7.10 preserves common component composition across existing compiler-specialized collection boundaries.
@@ -31,11 +31,11 @@ Page `metadata` can emit description, canonical, favicon, manifest, Open Graph,
31
31
 
32
32
  Inline SVG rendering normalizes an explicit set of common React presentation aliases before static serialization and binding descriptor creation. Reactive aliases therefore use the existing generic `setAttribute` path; static SVG adds no JavaScript and reactive SVG adds no SVG-specific runtime.
33
33
 
34
- Same-file and relative-imported component chains receiving a direct local-state array or keyed item are recursively specialized to intrinsic JSX before keyed-list analysis, so their component functions are not retained in the browser. Missing destructured string, finite-number, boolean, or `null` props use their literal defaults during specialization. Rows may own multiple direct-property child maps recursively, nested conditions, latest-item handlers, multiple directly serializable state slots, effects, and `null`-initialized object refs. Structural list sites and ancestor key paths scope hooks across updates and reorder and release them on removal. Handler modules are emitted only when a rendered descriptor references them. Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal and unrelated fields do nothing. Builds without item dependencies emit no item reader or list-state subscription code.
34
+ Same-file and relative-imported component chains receiving a direct local-state array or keyed item are recursively specialized to intrinsic JSX before keyed-list analysis, so their component functions are not retained in the browser. Missing destructured props use directly serializable primitive, plain-object, or array literal defaults during specialization. One final identifier rest binding may be expanded exactly once at the direct intrinsic root. Rows may own multiple direct-property child maps recursively, nested conditions, latest-item handlers, multiple directly serializable state slots, effects, and `null`-initialized object refs. Structural list sites and ancestor key paths scope hooks across updates and reorder and release them on removal. Handler modules are emitted only when a rendered descriptor references them. Direct JSON-safe primitive keyed-item dependencies subscribe each row record to its owning list commit and compare selected fields after `list-runtime.js` synchronously refreshes the row marker. Only changed rows rerun with the complete latest item; reorder compares equal and unrelated fields do nothing. Builds without item dependencies emit no item reader or list-state subscription code.
35
35
 
36
36
  Rendered collection selectors compile one-use aliases and inline `(item)` or `(item, index)` pipelines over local array state or supported static named imports. Supported selectors are pure `filter` with direct local-state reads, direct-property `flatMap`, and `Array.from` before a final keyed `map`; dependency commits re-evaluate the selector against the immutable build-time collection while field keys retain item identity and `key={index}` retains positional identity. Compiler-owned static filters over structural keyed rows validate source references and keys once, retain removed rows as detached prototypes, clone fresh restoration nodes, and insert only new runs without moving retained DOM. Specialized collection wrappers and keyed rows inline direct object-literal or calling-component `const` object prop spreads in source order and forward JSX children into intrinsic output. This route-specific path is compiled out elsewhere. Compiler-owned collection state is excluded from development snapshot restoration. Dynamic/computed prop spreads, arbitrary callbacks, mutation, asynchronous selectors, imported callback functions, prototype-sensitive reads, lazy/dynamic row state initializers, non-`null` or callback refs, and recursive component cycles fail during compilation.
37
37
 
38
- The reduced `useReducer` form reuses ordinary state slots and React's pure reducer contract. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. Pure reducer-owned keyed lists reuse unchanged item identities for reorder, one removal, and append fast paths; ordinary `useState` lists retain full validation. One direct dispatch prop into a same-file or relative-imported synchronous component, including a direct keyed row, is specialized to intrinsic JSX at the call site, so its handler retains the parent reducer scope and no dispatch capture or child handler asset is emitted. A reducer row reads the latest item through the existing list scope and uses the same multiple serializable state, effect, condition, and object-ref specialization as other keyed rows. Relative TypeScript imports referenced inside that child handler receive collision-free call-site aliases and join the parent handler graph. One nested relative-imported intrinsic child may receive an inline or simple `const` callback containing dispatch; the compiler recursively substitutes that callback once and omits the nested child handler asset. Missing primitive literal defaults in these reducer specializations are substituted at the same call site. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
38
+ The reduced `useReducer` form reuses ordinary state slots and React's pure reducer contract. A direct dispatch in a compiled handler becomes a functional `set` whose reducer is bundled from a relative TypeScript module into that handler graph. Pure reducer-owned keyed lists reuse unchanged item identities for reorder, one removal, and append fast paths; ordinary `useState` lists retain full validation. One direct dispatch prop into a same-file or relative-imported synchronous component, including a direct keyed row, is specialized to intrinsic JSX at the call site, so its handler retains the parent reducer scope and no dispatch capture or child handler asset is emitted. A reducer row reads the latest item through the existing list scope and uses the same multiple serializable state, effect, condition, and object-ref specialization as other keyed rows. Relative TypeScript imports referenced inside that child handler receive collision-free call-site aliases and join the parent handler graph. One nested relative-imported intrinsic child may receive an inline or simple `const` callback containing dispatch; the compiler recursively substitutes that callback once and omits the nested child handler asset. Missing directly serializable literal defaults and direct intrinsic rest props in these reducer specializations are substituted at the same call site. Reducer-free routes and shared runtimes are unchanged; no reducer runtime or browser component instance exists.
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
 
@@ -3358,34 +3358,40 @@ function validateKeyedList(parts, sourceFile, listValues, listEventItems, listCo
3358
3358
  visit(root)
3359
3359
  }
3360
3360
 
3361
- function directConstObjectLiteral(expression, call, sourceFile) {
3361
+ function directConstObjectLiteral(expression, call) {
3362
3362
  expression = unwrapExpression(expression)
3363
3363
  if (ts.isObjectLiteralExpression(expression)) return expression
3364
3364
  if (!ts.isIdentifier(expression)) return
3365
- const owner = nearestFunction(call)
3366
- const scope = owner?.body ?? sourceFile
3367
- if (!scope || !ts.isBlock(scope) && !ts.isSourceFile(scope)) return
3368
- const declarations = []
3369
- for (const statement of scope.statements) {
3370
- if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0) continue
3371
- for (const declaration of statement.declarationList.declarations) {
3372
- if (ts.isIdentifier(declaration.name) && declaration.name.text === expression.text && declaration.initializer && declaration.end < call.pos) declarations.push(declaration)
3365
+ const scopes = []
3366
+ for (let current = call.parent; current; current = current.parent) {
3367
+ if (isFunctionLike(current) && ts.isBlock(current.body)) scopes.push(current.body)
3368
+ if (ts.isSourceFile(current)) scopes.push(current)
3369
+ }
3370
+ for (const scope of scopes) {
3371
+ const declarations = []
3372
+ for (const statement of scope.statements) {
3373
+ if (!ts.isVariableStatement(statement)) continue
3374
+ for (const declaration of statement.declarationList.declarations) {
3375
+ if (ts.isIdentifier(declaration.name) && declaration.name.text === expression.text) declarations.push({ declaration, constant: (statement.declarationList.flags & ts.NodeFlags.Const) !== 0 })
3376
+ }
3373
3377
  }
3378
+ if (!declarations.length) continue
3379
+ if (declarations.length !== 1 || !declarations[0].constant || !declarations[0].declaration.initializer || declarations[0].declaration.end >= call.pos) return
3380
+ const initializer = unwrapExpression(declarations[0].declaration.initializer)
3381
+ if (ts.isObjectLiteralExpression(initializer)) return initializer
3382
+ return
3374
3383
  }
3375
- if (declarations.length !== 1) return
3376
- const initializer = unwrapExpression(declarations[0].initializer)
3377
- if (ts.isObjectLiteralExpression(initializer)) return initializer
3378
3384
  }
3379
3385
 
3380
- function specializedSpreadEntries(expression, call, sourceFile, fail, label, seen = new Set()) {
3381
- const object = directConstObjectLiteral(expression, call, sourceFile)
3386
+ function specializedSpreadEntries(expression, call, fail, label, seen = new Set()) {
3387
+ const object = directConstObjectLiteral(expression, call)
3382
3388
  if (!object) fail(expression, `${label} component prop spreads must use an inline object literal or one direct const object literal declared in the calling component`)
3383
3389
  if (seen.has(object)) fail(expression, `${label} component prop spreads cannot be circular`)
3384
3390
  seen.add(object)
3385
3391
  const entries = []
3386
3392
  for (const property of object.properties) {
3387
3393
  if (ts.isSpreadAssignment(property)) {
3388
- entries.push(...specializedSpreadEntries(property.expression, call, sourceFile, fail, label, seen))
3394
+ entries.push(...specializedSpreadEntries(property.expression, call, fail, label, seen))
3389
3395
  continue
3390
3396
  }
3391
3397
  if (ts.isShorthandPropertyAssignment(property)) {
@@ -3447,6 +3453,37 @@ function flattenForwardedComponentChildren(root, factory, context) {
3447
3453
  return ts.visitNode(root, visit)
3448
3454
  }
3449
3455
 
3456
+ function expandSpecializedRest(root, returned, component, rest, entries, factory, context, fail, label) {
3457
+ const sourceRoot = unwrapExpression(returned)
3458
+ const sourceTag = jsxTagName(sourceRoot)
3459
+ if (!sourceTag || !ts.isIdentifier(sourceTag) || sourceTag.text[0] !== sourceTag.text[0].toLowerCase()) {
3460
+ fail(returned, `${label} component rest props must be forwarded exactly once to the direct intrinsic root`)
3461
+ }
3462
+ const sourceAttributes = ts.isJsxElement(sourceRoot) ? sourceRoot.openingElement.attributes : sourceRoot.attributes
3463
+ const spreads = sourceAttributes.properties.filter(attribute => ts.isJsxSpreadAttribute(attribute) && ts.isIdentifier(unwrapExpression(attribute.expression)) && unwrapExpression(attribute.expression).text === rest.name)
3464
+ const references = referenceIdentifiers(component.body, rest.name)
3465
+ if (spreads.length !== 1 || references.length !== 1 || unwrapExpression(spreads[0].expression) !== references[0]) {
3466
+ fail(rest.node, `${label} component rest props must be forwarded exactly once to the direct intrinsic root`)
3467
+ }
3468
+ for (const [name] of entries) {
3469
+ if (["__proto__", "constructor", "prototype"].includes(name)) fail(rest.node, `${label} component rest prop ${JSON.stringify(name)} is not supported`)
3470
+ if (name === "children") fail(rest.node, `${label} component rest props cannot forward children; destructure children explicitly`)
3471
+ }
3472
+ const attributes = ts.isJsxElement(root) ? root.openingElement.attributes : root.attributes
3473
+ const expanded = attributes.properties.flatMap(attribute => {
3474
+ if (!ts.isJsxSpreadAttribute(attribute) || !ts.isIdentifier(unwrapExpression(attribute.expression)) || unwrapExpression(attribute.expression).text !== rest.name) return [attribute]
3475
+ return entries.map(([name, value]) => factory.createJsxAttribute(factory.createIdentifier(name), factory.createJsxExpression(undefined, cloneAst(value, factory, context))))
3476
+ })
3477
+ const last = new Map()
3478
+ expanded.forEach((attribute, index) => {
3479
+ if (ts.isJsxAttribute(attribute)) last.set(attribute.name.text, index)
3480
+ })
3481
+ const properties = expanded.filter((attribute, index) => !ts.isJsxAttribute(attribute) || last.get(attribute.name.text) === index)
3482
+ if (ts.isJsxSelfClosingElement(root)) return factory.updateJsxSelfClosingElement(root, root.tagName, root.typeArguments, factory.updateJsxAttributes(attributes, properties))
3483
+ const opening = factory.updateJsxOpeningElement(root.openingElement, root.openingElement.tagName, root.openingElement.typeArguments, factory.updateJsxAttributes(attributes, properties))
3484
+ return factory.updateJsxElement(root, opening, root.children, root.closingElement)
3485
+ }
3486
+
3450
3487
  function specializeComponentCall(call, component, sourceFile, factory, context, fail, label = "Keyed list", allowComponentRoot = false) {
3451
3488
  if (component.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) || component.asteriskToken) fail(component, `${label} components must be synchronous`)
3452
3489
  if (component.parameters.length !== 1 || !ts.isObjectBindingPattern(component.parameters[0].name)) fail(component, `${label} components must use one destructured props parameter`)
@@ -3456,7 +3493,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3456
3493
  let key
3457
3494
  for (const attribute of callAttributes.properties) {
3458
3495
  if (ts.isJsxSpreadAttribute(attribute)) {
3459
- for (const [name, value, property] of specializedSpreadEntries(attribute.expression, call, sourceFile, fail, label)) {
3496
+ for (const [name, value, property] of specializedSpreadEntries(attribute.expression, call, fail, label)) {
3460
3497
  if (["__proto__", "constructor", "prototype"].includes(name)) fail(property, `${label} component prop spread property ${JSON.stringify(name)} is not supported`)
3461
3498
  if (name === "key") fail(property, `${label} component prop spreads cannot declare key`)
3462
3499
  props.set(name, value)
@@ -3485,14 +3522,22 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3485
3522
  }
3486
3523
  const substitutions = new Map()
3487
3524
  const acceptedProps = new Set()
3488
- for (const element of component.parameters[0].name.elements) {
3489
- if (element.dotDotDotToken || !ts.isIdentifier(element.name)) fail(element, `${label} component props cannot use rest or nested destructuring`)
3490
- if (element.initializer && !isPrimitiveDefaultLiteral(element.initializer)) fail(element.initializer, `${label} component prop defaults must be primitive literals`)
3525
+ let rest
3526
+ const elements = component.parameters[0].name.elements
3527
+ for (const [index, element] of elements.entries()) {
3528
+ if (element.dotDotDotToken) {
3529
+ if (!ts.isIdentifier(element.name) || element.propertyName || element.initializer || index !== elements.length - 1) fail(element, `${label} component rest props must be one final identifier binding`)
3530
+ rest = { name: element.name.text, node: element }
3531
+ continue
3532
+ }
3533
+ if (!ts.isIdentifier(element.name)) fail(element, `${label} component props cannot use nested destructuring`)
3534
+ if (element.initializer && !isSerializableStateLiteral(element.initializer)) fail(element.initializer, `${label} component prop defaults must be directly serializable primitive, plain-object, or array literals`)
3491
3535
  const prop = (element.propertyName ?? element.name).text
3492
3536
  acceptedProps.add(prop)
3493
3537
  substitutions.set(element.name.text, props.has(prop) ? props.get(prop) : element.initializer ?? factory.createIdentifier("undefined"))
3494
3538
  }
3495
- for (const prop of props.keys()) if (!acceptedProps.has(prop)) fail(call, `Unknown ${label.toLowerCase()} component prop "${prop}"`)
3539
+ const restEntries = [...props].filter(([prop]) => !acceptedProps.has(prop))
3540
+ if (!rest) for (const [prop] of restEntries) fail(call, `Unknown ${label.toLowerCase()} component prop "${prop}"`)
3496
3541
 
3497
3542
  let returned
3498
3543
  const calculations = []
@@ -3559,6 +3604,7 @@ function specializeComponentCall(call, component, sourceFile, factory, context,
3559
3604
  for (const calculation of calculations) findUnsupportedHook(calculation.expression)
3560
3605
  if (unsupportedHook) throw sourceNodeError(unsupportedHook, component.getSourceFile(), `Keyed row ${unsupportedHook.expression.text}() must be one top-level const declaration`)
3561
3606
  let root = unwrapExpression(flattenForwardedComponentChildren(substituteClone(returned, substitutions, factory, context), factory, context))
3607
+ if (rest) root = expandSpecializedRest(root, returned, component, rest, restEntries, factory, context, fail, label)
3562
3608
  if (!ts.isJsxElement(root) && !ts.isJsxSelfClosingElement(root)) fail(returned, `${label} component must return one JSX element`)
3563
3609
  const tag = jsxTagName(root)
3564
3610
  if (!ts.isIdentifier(tag) || !allowComponentRoot && tag.text[0] !== tag.text[0].toLowerCase()) fail(returned, `${label} component must directly return an intrinsic JSX element`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kudzujs/core",
3
- "version": "0.7.10",
3
+ "version": "0.7.11",
4
4
  "description": "HTML-first TSX framework with synchronous state semantics and no virtual DOM",
5
5
  "type": "module",
6
6
  "license": "MIT",