@abide/abide 0.40.2 → 0.41.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/AGENTS.md CHANGED
@@ -159,7 +159,7 @@ Control flow — native `<template>`:
159
159
  | Directive | Shape |
160
160
  | --- | --- |
161
161
  | `<template if={c}>` … `<template elseif={c2}>` … `<template else>` | conditional chain (`else` must be last) |
162
- | `<template each={list} as="x" key="x.id">` | keyed list |
162
+ | `<template each={list} as="x" key="x.id" index="i">` | keyed list (`index` binds the row's reactive position) |
163
163
  | `<template await={p}>` `<template then="v">` `<template catch="e">` `<template finally>` | promise (streams; branch value bound by `then`/`catch`) |
164
164
  | `<template switch={s}>` `<template case={v}>` … `<template default>` | first strict-`===` match |
165
165
  | `<template try>` `<template catch="e">` `<template finally>` | synchronous error boundary |
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # abide
2
2
 
3
+ ## 0.41.0
4
+
5
+ ### Minor Changes
6
+
7
+ - [`ba08127`](https://github.com/briancray/abide/commit/ba081270c4721d64655457681e4ebcc79681f1d4) - `index="i"` on `<template each>` — bind the row's reactive position
8
+
9
+ `<template each={list} as="item" key="item.id" index="i">` binds the iteration index to a name. In a keyed `each` the index is reactive: a reorder/insert/remove that shifts a surviving row repaints its `{i}` in place (same DOM, no rebuild), riding the same `Object.is` cell-write path as the item binding. SSR renders the index via `entries()` so hydration stays congruent; async `each await` carries the stream arrival ordinal.
10
+
11
+ - [`ba08127`](https://github.com/briancray/abide/commit/ba081270c4721d64655457681e4ebcc79681f1d4) - In-place reactive updates for `<template await>` `then` values and keyed `each` items
12
+
13
+ A re-settling `await` block and a re-keyed `each` row no longer rebuild their subtree — they update through a reactive value cell instead, so a live cache patch updates only what changed and never flashes the surrounding DOM.
14
+
15
+ - `awaitBlock` keeps the mounted `then`-branch across a re-run, setting a reactive value cell rather than detaching + rebuilding. A revalidation now keeps the stale branch visible and patches in place; the branch is rebuilt only across a pending/catch ↔ then kind change.
16
+ - The `then` binding is lowered to read that cell reactively — both a single identifier (`then="value"`) and a destructure (`then="[a, b]"` / `{ x, y }`), where each leaf is derived per-read so only the leaves whose value changed propagate.
17
+ - Keyed `each` holds each row's item in a reactive cell; a re-key with a changed value (same key, new object) writes the cell through `Object.is`, re-running only that row's effects with no DOM rebuild. The row `render` now receives `State<T>` (the compiler binds the `as` name to read it).
18
+ - A destructuring `each … as="[a, b]"` (or `{ x, y }`) with no explicit `key` now defaults the key to the row's raw item identity, like a plain `as`. Previously the default key re-emitted the destructure pattern, allocating a fresh array/object per reconcile, so keys never matched and every row rebuilt on any list change.
19
+ - A block value binding (`then="x"` / `each … as="x"`) now correctly shadows a same-named component `state`/`computed`/`derived`: it is a nearer lexical scope, so `{x}` in the block reads the resolved/row cell rather than the component signal. A destructure pattern's default/computed-key initializer is also lowered, so `then="{ id, label = fallback }"` resolves `fallback` against the component scope instead of emitting it raw.
20
+
3
21
  ## 0.40.2
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@abide/abide",
3
- "version": "0.40.2",
3
+ "version": "0.41.0",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "description": "Isomorphic multimodal HTTP framework built for humans and machines in a single Bun runtime",
@@ -0,0 +1,38 @@
1
+ import ts from 'typescript'
2
+
3
+ /*
4
+ The leaf binding names a destructuring pattern introduces, in source order —
5
+ `[a, b]` → `a, b`; `{ x, y: z }` → `x, z`; `[a, ...rest]` → `a, rest`; nested
6
+ patterns flatten. Used to re-bind an `await` `then` destructure as per-leaf
7
+ reactive reads of the resolved-value cell, so a re-settle updates each leaf in
8
+ place instead of rebuilding the branch.
9
+ */
10
+ export function destructureBindingNames(pattern: string): string[] {
11
+ const source = ts.createSourceFile(
12
+ 'pattern.ts',
13
+ `const ${pattern} = $;`,
14
+ ts.ScriptTarget.Latest,
15
+ true,
16
+ )
17
+ const names: string[] = []
18
+ const collect = (name: ts.BindingName): void => {
19
+ if (ts.isIdentifier(name)) {
20
+ names.push(name.text)
21
+ return
22
+ }
23
+ // Object/array pattern: each element binds; array holes are OmittedExpression, not BindingElement.
24
+ for (const element of name.elements) {
25
+ if (ts.isBindingElement(element)) {
26
+ collect(element.name)
27
+ }
28
+ }
29
+ }
30
+ for (const statement of source.statements) {
31
+ if (ts.isVariableStatement(statement)) {
32
+ for (const declaration of statement.declarationList.declarations) {
33
+ collect(declaration.name)
34
+ }
35
+ }
36
+ }
37
+ return names
38
+ }
@@ -4,6 +4,7 @@ import { OUTLET_TAG } from '../runtime/OUTLET_TAG.ts'
4
4
  import { asOutlet } from './asOutlet.ts'
5
5
  import { bindListenEvent } from './bindListenEvent.ts'
6
6
  import { composeProps } from './composeProps.ts'
7
+ import { destructureBindingNames } from './destructureBindingNames.ts'
7
8
  import { groupBindParts } from './groupBindParts.ts'
8
9
  import { isControlFlow } from './isControlFlow.ts'
9
10
  import { isWhitespaceText } from './isWhitespaceText.ts'
@@ -85,10 +86,50 @@ export function generateBuild(
85
86
  expression: lowerExpression,
86
87
  statement: lowerStatement,
87
88
  withNestedScripts,
89
+ withLocalDerived,
88
90
  bindRead,
89
91
  bindWrite,
90
92
  } = lowerContext(stateNames, derivedNames, computedNames)
91
93
 
94
+ /* A value binding the runtime can update in place (an `await` `then` value, a keyed `each`
95
+ item) is passed as a reactive `.value` cell; the consuming branch/row reads its NAME(s)
96
+ as a deref so it re-runs in place when a re-settle/re-key sets the cell. */
97
+ const isPlainIdentifier = (name: string | undefined): name is string =>
98
+ name !== undefined && /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)
99
+
100
+ /* How a reactive value param binds: `param` is the thunk's value parameter (the cell),
101
+ `prefix` declares any per-leaf readers, `localNames` enter the deref scope for the body.
102
+ A plain identifier reads the cell directly (`item` → `item.value`); a destructure
103
+ re-applies over the cell per read so each leaf stays reactive (JS handles
104
+ defaults/rest/rename/nesting). The caller lowers the body with `localNames` derived. */
105
+ function reactiveBinding(authorParam: string): {
106
+ param: string
107
+ prefix: string
108
+ localNames: string[]
109
+ } {
110
+ if (isPlainIdentifier(authorParam)) {
111
+ return { param: authorParam, prefix: '', localNames: [authorParam] }
112
+ }
113
+ const cellParam = nextVar('aw')
114
+ const deriveVar = nextVar('ad')
115
+ const leaves = destructureBindingNames(authorParam)
116
+ /* Lower the destructure declaration so a default/computed-key initializer that
117
+ references a component signal (`{ label = fallback }`, `{ [key]: v }`) is rewritten
118
+ to its `model`/cell form — the bound leaf names are name-slots and stay untouched.
119
+ A pattern with no such initializer lowers to itself, so the common case is unchanged
120
+ and SSR (which lowers the same declaration) stays congruent. */
121
+ const declaration = lowerStatement(`const ${authorParam} = ${cellParam}.value`)
122
+ const prefix =
123
+ `const ${deriveVar} = { get value() { ${declaration} return { ${leaves.join(', ')} }; } };\n` +
124
+ leaves
125
+ .map(
126
+ (leaf) =>
127
+ `const ${leaf} = { get value() { return ${deriveVar}.value.${leaf}; } };\n`,
128
+ )
129
+ .join('')
130
+ return { param: cellParam, prefix, localNames: leaves }
131
+ }
132
+
92
133
  /* Emits the wiring for one non-static attribute against an already-obtained skeleton
93
134
  element var — reactive `attr`, `on` listener, `attach`, or a two-way `bind`. */
94
135
  function dynamicAttr(
@@ -498,13 +539,21 @@ export function generateBuild(
498
539
  const pending = node.blocking
499
540
  ? []
500
541
  : node.children.filter((child) => child.kind !== 'branch')
542
+ /* The resolved value is reactive: a re-settle updates it in place rather than
543
+ rebuilding the branch (see awaitBlock). The branch reads it as a `.value` cell. */
501
544
  const thenThunk = node.blocking
502
545
  ? branchThunk(
503
546
  node.children.filter((child) => child.kind !== 'branch'),
504
547
  node.as ?? '_value',
505
548
  finallyChildren,
549
+ true,
550
+ )
551
+ : branchThunk(
552
+ thenBranch?.children ?? [],
553
+ thenBranch?.as ?? '_value',
554
+ finallyChildren,
555
+ true,
506
556
  )
507
- : branchThunk(thenBranch?.children ?? [], thenBranch?.as ?? '_value', finallyChildren)
508
557
  /* Neither catch nor finally → pass `undefined` so awaitBlock re-throws the
509
558
  rejection (surfacing it) instead of rendering an empty branch. A finally-only
510
559
  block keeps a catch thunk that renders just finally. */
@@ -536,18 +585,29 @@ export function generateBuild(
536
585
  children: TemplateNode[],
537
586
  valueParam?: string,
538
587
  finallyChildren: TemplateNode[] = [],
588
+ reactiveValue = false,
539
589
  ): string {
540
590
  const parentParam = nextVar('p')
541
- const head =
542
- valueParam === undefined ? `(${parentParam})` : `(${parentParam}, ${valueParam})`
543
- const body = withNestedScripts(children, () => generateChildren(children, parentParam))
591
+ /* A reactive value (an `await` `then`) arrives as a `.value` cell the runtime can set
592
+ in place, so the branch re-runs in place on a re-settle instead of being rebuilt. */
593
+ const binding =
594
+ reactiveValue && valueParam !== undefined ? reactiveBinding(valueParam) : undefined
595
+ const param = binding?.param ?? valueParam
596
+ const prefix = binding?.prefix ?? ''
597
+ const localNames = binding?.localNames ?? []
598
+ const head = param === undefined ? `(${parentParam})` : `(${parentParam}, ${param})`
599
+ const body = withNestedScripts(children, () =>
600
+ localNames.length === 0
601
+ ? generateChildren(children, parentParam)
602
+ : withLocalDerived(localNames, () => generateChildren(children, parentParam)),
603
+ )
544
604
  const finallyBody =
545
605
  finallyChildren.length > 0
546
606
  ? withNestedScripts(finallyChildren, () =>
547
607
  generateChildren(finallyChildren, parentParam),
548
608
  )
549
609
  : ''
550
- return `${head} => {\n${body}${finallyBody}}`
610
+ return `${head} => {\n${prefix}${body}${finallyBody}}`
551
611
  }
552
612
 
553
613
  /* True when a branch has content worth a render thunk — vs an absent/empty branch
@@ -626,13 +686,30 @@ export function generateBuild(
626
686
  before: string,
627
687
  ): string {
628
688
  const rowParam = nextVar('p')
689
+ /* The item is a reactive `.value` cell so a re-key with a changed value updates the row
690
+ in place (no rebuild). `keyOf` receives the RAW item; the key expression is lowered
691
+ with the author name plain — derive it BEFORE the row body puts that name in the
692
+ deref scope. With no explicit `key`, default the key to the item's own identity: a
693
+ plain `as` returns its name; a destructuring `as` binds a fresh param and returns
694
+ THAT, not the pattern re-wrapped (`[i,crumb]` → `[i,crumb]` would allocate a fresh
695
+ array per reconcile, so keys never match and every row rebuilds). An explicit `key`
696
+ destructures the item via `node.as` to read its leaves. */
697
+ const rawItemParam = isPlainIdentifier(node.as) ? node.as : nextVar('k')
698
+ const keyParam = node.key === undefined ? rawItemParam : node.as
699
+ const keyExpression = node.key === undefined ? rawItemParam : lowerExpression(node.key)
700
+ const binding = reactiveBinding(node.as)
701
+ /* `index="i"` binds the row's position as a third reactive cell param (the runtime
702
+ always passes it). It is a plain identifier — read as `i.value` — so it enters the
703
+ body's deref scope alongside the item's leaf names; an unnamed param when absent. */
704
+ const indexParam = node.index === undefined ? '' : `, ${node.index}`
705
+ const bodyLocalNames =
706
+ node.index === undefined ? binding.localNames : [...binding.localNames, node.index]
629
707
  /* The row body builds its children (a `<script>` declares per-row local signals,
630
708
  emitted in document order) into the row parent. A `<template catch>` child is
631
709
  consumed by the async-each, not the row — `generateChildren` skips it. */
632
710
  const rowBody = withNestedScripts(node.children, () =>
633
- generateChildren(node.children, rowParam),
711
+ withLocalDerived(bodyLocalNames, () => generateChildren(node.children, rowParam)),
634
712
  )
635
- const keyExpression = node.key === undefined ? node.as : lowerExpression(node.key)
636
713
  /* `await` → the AsyncIterable runtime, drained row-by-row on the client, with an
637
714
  optional `<template catch>` branch rendered (after the streamed rows) when the
638
715
  iterator rejects. Absent → `undefined`, so the rejection surfaces instead. */
@@ -643,7 +720,7 @@ export function generateBuild(
643
720
  : ''
644
721
  return (
645
722
  `${fn}(${parentVar}, () => (${lowerExpression(node.items)}), ` +
646
- `(${node.as}) => (${keyExpression}), (${rowParam}, ${node.as}) => {\n${rowBody}}${catchArg}, ${before});\n`
723
+ `(${keyParam}) => (${keyExpression}), (${rowParam}, ${binding.param}${indexParam}) => {\n${binding.prefix}${rowBody}}${catchArg}, ${before});\n`
647
724
  )
648
725
  }
649
726
 
@@ -269,7 +269,15 @@ export function generateSSR(
269
269
  if (node.async) {
270
270
  return anchor
271
271
  }
272
- return `${anchor}for (const ${node.as} of (${lowerExpression(node.items)})) {\n${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}}\n`
272
+ const rowBody = `${openRange(target)}${branchContent(node.children, target)}${closeRange(target)}`
273
+ /* `index="i"` binds the row position. SSR reads it as a plain number from
274
+ `entries()` over a materialized array; the client reads the same number from a
275
+ cell, so first paint is congruent. No index → a plain `for…of` over the items. */
276
+ const header =
277
+ node.index === undefined
278
+ ? `for (const ${node.as} of (${lowerExpression(node.items)}))`
279
+ : `for (const [${node.index}, ${node.as}] of [...(${lowerExpression(node.items)})].entries())`
280
+ return `${anchor}${header} {\n${rowBody}}\n`
273
281
  }
274
282
  if (node.kind === 'await') {
275
283
  return `${anchor}${generateAwait(node, target)}`
@@ -22,20 +22,21 @@ export function lowerContext(
22
22
  derivedNames: ReadonlySet<string>,
23
23
  computedNames: ReadonlySet<string> = new Set(),
24
24
  ) {
25
- /* Branch-scoped signal bindings (from nested `<script>`s) they deref to
26
- `.value` like a `computed`. Pushed while a branch's script + markup compile,
27
- popped after, so they shadow only within that subtree. */
25
+ /* Branch-scoped signal bindings (from nested `<script>`s, and the block value params
26
+ pushed by `withLocalDerived`) — they deref to `.value` like a `computed`, and as a
27
+ nearer lexical scope they SHADOW a same-named component signal. Pushed while a
28
+ branch's script + markup compile, popped after, so they shadow only within that
29
+ subtree. */
28
30
  const localDerived = new Set<string>()
29
- const derefScope = (): ReadonlySet<string> =>
30
- localDerived.size === 0 ? derivedNames : new Set([...derivedNames, ...localDerived])
31
31
 
32
32
  /* Parse `code` once and chain the reference rename and doc-access lowering over the
33
- one tree — the two string passes would each parse + reprint. `derefScope()` is read
34
- per call so a nested-`<script>` binding pushed mid-compile is honoured. */
33
+ one tree — the two string passes would each parse + reprint. `localDerived` is
34
+ snapshotted per call (as the transformer's block-local shadow set) so a binding
35
+ pushed mid-compile is honoured AND shadows a same-named component signal. */
35
36
  function lowerOnce(code: string): string {
36
37
  const source = ts.createSourceFile('expr.ts', code, ts.ScriptTarget.Latest, true)
37
38
  const result = ts.transform(source, [
38
- signalRefsTransformer(stateNames, derefScope(), computedNames),
39
+ signalRefsTransformer(stateNames, derivedNames, computedNames, new Set(localDerived)),
39
40
  docAccessTransformer('model'),
40
41
  ])
41
42
  const output = TS_PRINTER.printFile(result.transformed[0] as ts.SourceFile).trim()
@@ -113,5 +114,24 @@ export function lowerContext(
113
114
  return result
114
115
  }
115
116
 
116
- return { expression, statement, withNestedScripts, bindRead, bindWrite }
117
+ /* Pushes explicit names into the deref scope for `body` then pops them — the
118
+ programmatic counterpart to `withNestedScripts`, used to bind a block's value param
119
+ (an `await` `then` value, a keyed `each` item) as a reactive `.value` cell so the
120
+ branch reads it reactively and re-runs in place when the block sets the cell. */
121
+ function withLocalDerived<T>(names: string[], body: () => T): T {
122
+ const added: string[] = []
123
+ for (const name of names) {
124
+ if (!localDerived.has(name)) {
125
+ localDerived.add(name)
126
+ added.push(name)
127
+ }
128
+ }
129
+ const result = body()
130
+ for (const name of added) {
131
+ localDerived.delete(name)
132
+ }
133
+ return result
134
+ }
135
+
136
+ return { expression, statement, withNestedScripts, withLocalDerived, bindRead, bindWrite }
117
137
  }
@@ -560,11 +560,13 @@ function toControlFlow(attrs: TemplateAttr[], children: TemplateNode[]): Templat
560
560
  }
561
561
  const as = find('as')
562
562
  const key = find('key')
563
+ const index = find('index')
563
564
  return {
564
565
  kind: 'each',
565
566
  items: itemsCode,
566
567
  as: (as === undefined ? undefined : attrText(as)) ?? '_item',
567
568
  key: key === undefined ? undefined : attrText(key),
569
+ index: index === undefined ? undefined : attrText(index),
568
570
  async: find('await') !== undefined, // `<template each await>` over an AsyncIterable
569
571
  children,
570
572
  loc: attrLoc(items),
@@ -43,10 +43,19 @@ export function signalRefsTransformer(
43
43
  stateNames: ReadonlySet<string>,
44
44
  derivedNames: ReadonlySet<string>,
45
45
  computedNames: ReadonlySet<string> = new Set(),
46
+ /* Block-local signal bindings (an `await` `then` / keyed `each` value param, a nested
47
+ `<script>` declaration) — a nearer lexical scope than the component signals, so a
48
+ name here shadows a same-named `state`/`computed`/`derived` and derefs as a cell. */
49
+ blockLocal: ReadonlySet<string> = new Set(),
46
50
  ): ts.TransformerFactory<ts.SourceFile> {
47
51
  /* The signal names that a nested binding can shadow — only these matter for
48
52
  scope tracking, so we ignore every other local binding. */
49
- const signalNames = new Set<string>([...stateNames, ...derivedNames, ...computedNames])
53
+ const signalNames = new Set<string>([
54
+ ...stateNames,
55
+ ...derivedNames,
56
+ ...computedNames,
57
+ ...blockLocal,
58
+ ])
50
59
  return (context) => (root) => {
51
60
  /* The identifier nodes that are names, not value reads — collected by walking
52
61
  each parent and recording its name children (`parent.name`, a label, a
@@ -88,6 +97,7 @@ export function signalRefsTransformer(
88
97
  stateNames,
89
98
  derivedNames,
90
99
  computedNames,
100
+ blockLocal,
91
101
  )
92
102
  if (replacement !== undefined) {
93
103
  return ts.factory.createPropertyAssignment(node.name.text, replacement)
@@ -99,6 +109,7 @@ export function signalRefsTransformer(
99
109
  stateNames,
100
110
  derivedNames,
101
111
  computedNames,
112
+ blockLocal,
102
113
  )
103
114
  if (replacement !== undefined) {
104
115
  return replacement
@@ -305,13 +316,19 @@ function functionParameters(node: ts.Node): ts.NodeArray<ts.ParameterDeclaration
305
316
 
306
317
  /* `model.<name>` for a state binding, `<name>()` for a computed doc-slot (the
307
318
  string-free reader `scope().derive` returns), `<name>.value` for a runtime cell
308
- (linked / lens / transform-state), else undefined. */
319
+ (linked / lens / transform-state), else undefined. A `blockLocal` binding shadows
320
+ any same-named component signal — it is a nearer lexical scope — so it derefs as a
321
+ cell (`<name>.value`) regardless of a colliding `state`/`computed`/`derived`. */
309
322
  function referenceFor(
310
323
  name: string,
311
324
  stateNames: ReadonlySet<string>,
312
325
  derivedNames: ReadonlySet<string>,
313
326
  computedNames: ReadonlySet<string>,
327
+ blockLocal: ReadonlySet<string> = new Set(),
314
328
  ): ts.Expression | undefined {
329
+ if (blockLocal.has(name)) {
330
+ return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier(name), 'value')
331
+ }
315
332
  if (stateNames.has(name)) {
316
333
  return ts.factory.createPropertyAccessExpression(ts.factory.createIdentifier('model'), name)
317
334
  }
@@ -34,6 +34,8 @@ export type TemplateNode =
34
34
  items: string
35
35
  as: string
36
36
  key: string | undefined
37
+ /* `index="i"` → the row's reactive position, bound to this name; absent → unbound. */
38
+ index: string | undefined
37
39
  /* `await` on the tag → `items` is an AsyncIterable, drained on the client. */
38
40
  async: boolean
39
41
  children: TemplateNode[]
@@ -6,6 +6,8 @@ import type { ResumeEntry } from '../runtime/RESUME.ts'
6
6
  import { RESUME } from '../runtime/RESUME.ts'
7
7
  import { scope } from '../runtime/scope.ts'
8
8
  import { scopeGroup } from '../runtime/scopeGroup.ts'
9
+ import type { State } from '../runtime/types/State.ts'
10
+ import { state } from '../state.ts'
9
11
  import { discardBoundary } from './discardBoundary.ts'
10
12
  import { enterNamespace } from './enterNamespace.ts'
11
13
 
@@ -52,6 +54,15 @@ export function awaitBlock(
52
54
  let first = true
53
55
  /* Bumped each run so a prior run's in-flight promise can't clobber a newer one. */
54
56
  let generation = 0
57
+ /* The resolved value, held as a reactive cell so the then-branch reads it through its
58
+ own effects. A re-run that resolves to a NEW value SETS this cell instead of rebuilding
59
+ the branch — the branch (and any keyed `each` inside it) survives and updates in place,
60
+ so a live cache patch no longer flashes the whole subtree. The branch is rebuilt only
61
+ across a kind change (pending/catch ↔ then), where it has to be. */
62
+ let valueCell: State<unknown> | undefined
63
+ /* Which branch is currently mounted, so a settle knows whether it can update the cell in
64
+ place (then→then) or must build a fresh branch. */
65
+ let activeKind: 'pending' | 'then' | 'catch' | undefined
55
66
 
56
67
  const detach = (): void => {
57
68
  if (active !== undefined) {
@@ -81,33 +92,61 @@ export function awaitBlock(
81
92
  active = { nodes, dispose }
82
93
  }
83
94
 
95
+ /* Settle to a resolved value. then→then updates the cell in place — the branch and its
96
+ inner each survive (no flash); any other prior kind builds a fresh then-branch around
97
+ a new cell. renderThen receives the CELL (not the raw value), so the branch reads it
98
+ reactively and re-runs its own effects when a later settle sets it. */
99
+ const settleThen = (value: unknown): void => {
100
+ if (activeKind === 'then' && valueCell !== undefined) {
101
+ valueCell.value = value
102
+ return
103
+ }
104
+ const cell = state(value)
105
+ valueCell = cell
106
+ place((host) => renderThen(host, cell))
107
+ activeKind = 'then'
108
+ }
109
+
110
+ /* Settle to a rejection: surface it with no catch branch, else swap to the catch branch. */
111
+ const settleError = (error: unknown): void => {
112
+ if (renderCatch === undefined) {
113
+ throw error
114
+ }
115
+ valueCell = undefined
116
+ place((host) => renderCatch(host, error))
117
+ activeKind = 'catch'
118
+ }
119
+
84
120
  /* Render a settled-or-pending result into the current generation. */
85
121
  const render = (result: unknown): void => {
86
122
  const gen = generation
87
123
  if (!isThenable(result)) {
88
- place((host) => renderThen(host, result)) // warm-sync → resolved now, no flash
124
+ settleThen(result) // warm-sync → resolved now, no flash
89
125
  return
90
126
  }
91
- if (renderPending !== undefined) {
92
- place((host) => renderPending(host))
93
- } else {
94
- detach()
127
+ /* A then-branch is already mounted (a revalidation): keep it visible and update in
128
+ place when the new value settles, instead of blanking to pending and rebuilding —
129
+ this is the no-flash live-update path. A first load (or a prior pending/catch)
130
+ shows the pending branch (or detaches) while the promise is in flight. */
131
+ if (activeKind !== 'then') {
132
+ if (renderPending !== undefined) {
133
+ place((host) => renderPending(host))
134
+ activeKind = 'pending'
135
+ } else {
136
+ detach()
137
+ activeKind = undefined
138
+ }
95
139
  }
96
140
  result.then(
97
141
  (value) => {
98
142
  if (gen === generation) {
99
- place((host) => renderThen(host, value))
143
+ settleThen(value)
100
144
  }
101
145
  },
102
146
  (error) => {
103
- if (gen !== generation) {
104
- return
105
- }
106
- /* No catch branch → surface the rejection instead of an empty branch. */
107
- if (renderCatch === undefined) {
108
- throw error
147
+ if (gen === generation) {
148
+ settleError(error)
109
149
  }
110
- place((host) => renderCatch(host, error))
111
150
  },
112
151
  )
113
152
  }
@@ -176,11 +215,21 @@ export function awaitBlock(
176
215
  }
177
216
  }
178
217
  if (entry !== undefined) {
218
+ /* Build the adopted branch around a value CELL (then) so a later re-run updates
219
+ it in place, exactly like a fresh mount. The `throw` for a catch-less rejection
220
+ stays OUTSIDE the adopt try/catch so it surfaces rather than triggering the
221
+ cold-rebuild fallback. */
179
222
  let build: (host: Node) => void
223
+ let cell: State<unknown> | undefined
224
+ let kind: 'then' | 'catch'
180
225
  if (entry.ok) {
181
- build = (host) => renderThen(host, entry.value)
226
+ cell = state(entry.value)
227
+ const resolved = cell
228
+ build = (host) => renderThen(host, resolved)
229
+ kind = 'then'
182
230
  } else if (renderCatch !== undefined) {
183
231
  build = (host) => renderCatch(host, entry.error)
232
+ kind = 'catch'
184
233
  } else {
185
234
  /* A resumed rejection with no catch branch surfaces (mirrors the cold
186
235
  path); in practice the server 500s such a block, so none resumes. */
@@ -188,14 +237,19 @@ export function awaitBlock(
188
237
  }
189
238
  try {
190
239
  adopt(open, build)
240
+ valueCell = cell
241
+ activeKind = kind
191
242
  } catch {
192
243
  rebuildCold(open)
193
244
  }
194
245
  return
195
246
  }
196
247
  if (!isThenable(result)) {
248
+ const cell = state(result)
197
249
  try {
198
- adopt(open, (host) => renderThen(host, result))
250
+ adopt(open, (host) => renderThen(host, cell))
251
+ valueCell = cell
252
+ activeKind = 'then'
199
253
  } catch {
200
254
  rebuildCold(open)
201
255
  }
@@ -4,6 +4,8 @@ import { claimExpected } from '../runtime/claimExpected.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
6
  import { scopeGroup } from '../runtime/scopeGroup.ts'
7
+ import type { State } from '../runtime/types/State.ts'
8
+ import { state } from '../state.ts'
7
9
  import { enterNamespace } from './enterNamespace.ts'
8
10
  import { moveRange } from './moveRange.ts'
9
11
  import { removeRange } from './removeRange.ts'
@@ -32,7 +34,11 @@ export function each<T>(
32
34
  parent: Node,
33
35
  items: () => Iterable<T>,
34
36
  keyOf: (item: T) => string,
35
- render: (parent: Node, item: T) => void,
37
+ /* The row receives its item and its position as reactive cells (not raw snapshots): a
38
+ re-key with a changed value, or a reorder that shifts the row, sets the matching cell
39
+ and re-runs the row's effects in place. The compiler binds the `as`/`index` names to
40
+ read them; a direct caller reads `item.value` / `index.value`. */
41
+ render: (parent: Node, item: State<T>, index: State<number>) => void,
36
42
  before: Node | null = null,
37
43
  ): void {
38
44
  const rows = new Map<string, EachRow>()
@@ -49,15 +55,20 @@ export function each<T>(
49
55
  (`scope` builds untracked), so a raw reactive read in the row content — e.g. a
50
56
  nested `<script>` body — can't re-reconcile the whole list. Only `items()` drives
51
57
  the each; each row's own interpolations track through their own effects. */
52
- const buildRow = (item: T): EachRow => {
58
+ const buildRow = (item: T, position: number): EachRow => {
59
+ /* Item and position are held in reactive cells the row reads, so a later re-key with
60
+ a changed value, or a reorder that shifts the row, updates it in place (see the
61
+ reconcile below) instead of rebuilding it. */
62
+ const cell = state(item) as State<unknown>
63
+ const indexCell = state(position)
53
64
  const hydration = RENDER.hydration
54
65
  if (hydration !== undefined) {
55
66
  const start = claimExpected(hydration, parent, 'each row start marker')
56
67
  hydration.next.set(parent, start.nextSibling)
57
- const dispose = group.track(scope(() => render(parent, item)))
68
+ const dispose = group.track(scope(() => render(parent, cell as State<T>, indexCell)))
58
69
  const end = claimExpected(hydration, parent, 'each row end marker')
59
70
  hydration.next.set(parent, end.nextSibling)
60
- return { start, end, dispose }
71
+ return { start, end, dispose, cell, indexCell }
61
72
  }
62
73
  const start = document.createComment('[')
63
74
  const end = document.createComment(']')
@@ -66,10 +77,10 @@ export function each<T>(
66
77
  /* Build under `parent`'s foreign namespace so foreign row elements (svg/math)
67
78
  built into the detached fragment are namespaced, not built as HTML. */
68
79
  const dispose = group.track(
69
- enterNamespace(parent, () => scope(() => render(pending, item))),
80
+ enterNamespace(parent, () => scope(() => render(pending, cell as State<T>, indexCell))),
70
81
  )
71
82
  pending.appendChild(end)
72
- return { start, end, dispose, pending }
83
+ return { start, end, dispose, cell, indexCell, pending }
73
84
  }
74
85
 
75
86
  /* Place a row so its range ends just before `cursor`: insert a fresh row's
@@ -94,8 +105,10 @@ export function each<T>(
94
105
  let adopting = false
95
106
  const hydration = RENDER.hydration
96
107
  if (hydration !== undefined) {
108
+ let position = 0
97
109
  for (const item of items()) {
98
- rows.set(keyOf(item), buildRow(item)) // claims the SSR row where it sits
110
+ rows.set(keyOf(item), buildRow(item, position)) // claims the SSR row where it sits
111
+ position += 1
99
112
  }
100
113
  anchor = document.createTextNode('')
101
114
  parent.insertBefore(anchor, claimChild(hydration, parent))
@@ -145,8 +158,15 @@ export function each<T>(
145
158
  const key = keys[index] as string
146
159
  let row = rows.get(key)
147
160
  if (row === undefined) {
148
- row = buildRow(list[index] as T)
161
+ row = buildRow(list[index] as T, index)
149
162
  rows.set(key, row)
163
+ } else {
164
+ /* Surviving key: push the (possibly new) item and position into the row's
165
+ cells. Both write through `Object.is`, so an unchanged item/position is a
166
+ no-op and a changed one re-runs only the row's own effects — the row's DOM
167
+ is never rebuilt. A reorder thus repaints only the moved rows' `index`. */
168
+ row.cell.value = list[index]
169
+ row.indexCell.value = index
150
170
  }
151
171
  placeBefore(row, cursor)
152
172
  cursor = row.start
@@ -4,6 +4,8 @@ import { OWNER } from '../runtime/OWNER.ts'
4
4
  import { RENDER } from '../runtime/RENDER.ts'
5
5
  import { scope } from '../runtime/scope.ts'
6
6
  import { scopeGroup } from '../runtime/scopeGroup.ts'
7
+ import type { State } from '../runtime/types/State.ts'
8
+ import { state } from '../state.ts'
7
9
  import { enterNamespace } from './enterNamespace.ts'
8
10
  import { removeRange } from './removeRange.ts'
9
11
  import type { EachRow } from './types/EachRow.ts'
@@ -27,7 +29,10 @@ export function eachAsync<T>(
27
29
  parent: Node,
28
30
  items: () => AsyncIterable<T>,
29
31
  keyOf: (item: T) => string,
30
- render: (parent: Node, item: T) => void,
32
+ /* The row receives its item and position as reactive cells — same contract as sync
33
+ `each`; the streaming runtime rebuilds the row on a re-yield rather than patching, and
34
+ the position is the stream arrival ordinal (a stream only appends, never reorders). */
35
+ render: (parent: Node, item: State<T>, index: State<number>) => void,
31
36
  /* Absent → an iterator rejection surfaces instead of rendering a catch branch. */
32
37
  renderCatch: ((parent: Node, error: unknown) => void) | undefined,
33
38
  before: Node | null = null,
@@ -44,8 +49,11 @@ export function eachAsync<T>(
44
49
  parent.insertBefore(anchor, before) // `before` places rows before a static suffix
45
50
  }
46
51
 
47
- /* Build a content range and insert it just before the anchor (arrival order). */
48
- const insertRange = (build: (into: Node) => void): EachRow => {
52
+ /* Build a content range and insert it just before the anchor (arrival order). A bare
53
+ range (no item cell) the data-row site adds the cell, the error branch needs none. */
54
+ const insertRange = (
55
+ build: (into: Node) => void,
56
+ ): { start: Node; end: Node; dispose: () => void } => {
49
57
  const start = document.createComment('[')
50
58
  const end = document.createComment(']')
51
59
  const fragment = document.createDocumentFragment()
@@ -62,7 +70,7 @@ export function eachAsync<T>(
62
70
  }
63
71
 
64
72
  /* The mounted `<template catch>` range, disposed when a fresh run re-streams. */
65
- let errorRange: EachRow | undefined
73
+ let errorRange: { start: Node; end: Node; dispose: () => void } | undefined
66
74
  const clearError = (): void => {
67
75
  if (errorRange !== undefined) {
68
76
  errorRange.dispose()
@@ -82,6 +90,7 @@ export function eachAsync<T>(
82
90
  clearError() // a fresh run drops a prior error branch
83
91
  const iterable = items() // read (subscribe) synchronously
84
92
  const present = new Set<string>()
93
+ let arrivals = 0 // stream arrival ordinal → each row's index
85
94
  const drain = async (): Promise<void> => {
86
95
  const active = iterable[Symbol.asyncIterator]()
87
96
  iterator = active
@@ -96,14 +105,18 @@ export function eachAsync<T>(
96
105
  }
97
106
  const key = keyOf(result.value)
98
107
  present.add(key)
99
- /* A re-yielded key rebuilds the row from the new value, swapping the old
100
- range out (v1 has no in-place field patchrows bind plain snapshots). */
108
+ /* A re-yielded key rebuilds the row from the new value (arrival order), swapping
109
+ the old range out. The item rides in a cellthe row reads it reactively, same
110
+ contract as sync `each` — but the streaming runtime rebuilds rather than patches. */
101
111
  const stale = rows.get(key)
102
- const item = result.value
103
- rows.set(
104
- key,
105
- insertRange((host) => render(host, item)),
106
- )
112
+ const cell = state(result.value) as State<unknown>
113
+ const indexCell = state(arrivals)
114
+ arrivals += 1
115
+ rows.set(key, {
116
+ ...insertRange((host) => render(host, cell as State<T>, indexCell)),
117
+ cell,
118
+ indexCell,
119
+ })
107
120
  if (stale !== undefined) {
108
121
  stale.dispose()
109
122
  removeRange(stale.start, stale.end)
@@ -1,10 +1,17 @@
1
+ import type { State } from '../../runtime/types/State.ts'
2
+
1
3
  /* A live row in a keyed list: a content RANGE bounded by two comment markers (so a
2
4
  row holds any content, not just one node), plus the disposer for the bindings
3
- created in its ownership scope. `pending` holds a freshly built row's nodes in a
4
- fragment until first placement inserts them. */
5
+ created in its ownership scope. `cell` holds the row's item as a reactive value, so a
6
+ re-key with a changed value (same key, new object) updates the row in place instead of
7
+ leaving it frozen. `indexCell` holds the row's reactive position, so a reorder repaints
8
+ its `index` binding in place without a rebuild. `pending` holds a freshly built row's
9
+ nodes in a fragment until first placement inserts them. */
5
10
  export type EachRow = {
6
11
  start: Node
7
12
  end: Node
8
13
  dispose: () => void
14
+ cell: State<unknown>
15
+ indexCell: State<number>
9
16
  pending?: DocumentFragment
10
17
  }