@vidact/runtime 0.2.0-beta.6 → 0.2.0-beta.8

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/actions.js +1 -1
  3. package/dist/compiled/core.d.ts +17 -5
  4. package/dist/compiled/core.d.ts.map +1 -1
  5. package/dist/compiled/core.js +545 -155
  6. package/dist/compiled/core.js.map +1 -1
  7. package/dist/direct-dom.d.ts.map +1 -1
  8. package/dist/direct-dom.js +102 -11
  9. package/dist/direct-dom.js.map +1 -1
  10. package/dist/dom/events.d.ts +4 -1
  11. package/dist/dom/events.d.ts.map +1 -1
  12. package/dist/dom/events.js +66 -1
  13. package/dist/dom/events.js.map +1 -1
  14. package/dist/dom/namespace.js +1 -1
  15. package/dist/hydration-bridge.d.ts +4 -4
  16. package/dist/hydration-bridge.d.ts.map +1 -1
  17. package/dist/hydration-bridge.js.map +1 -1
  18. package/dist/hydration.d.ts +2 -2
  19. package/dist/hydration.d.ts.map +1 -1
  20. package/dist/hydration.js.map +1 -1
  21. package/dist/index.d.ts +3 -3
  22. package/dist/index.js +3 -3
  23. package/dist/keyed-list.d.ts +6 -2
  24. package/dist/keyed-list.d.ts.map +1 -1
  25. package/dist/keyed-list.js +122 -11
  26. package/dist/keyed-list.js.map +1 -1
  27. package/dist/metadata.js +1 -1
  28. package/dist/renderable.d.ts +2 -1
  29. package/dist/renderable.d.ts.map +1 -1
  30. package/dist/renderable.js +12 -1
  31. package/dist/renderable.js.map +1 -1
  32. package/dist/source-mask.d.ts +2 -1
  33. package/dist/source-mask.d.ts.map +1 -1
  34. package/dist/source-mask.js +16 -1
  35. package/dist/source-mask.js.map +1 -1
  36. package/dist/spread.js +2 -2
  37. package/dist/state-slot.d.ts +12 -1
  38. package/dist/state-slot.d.ts.map +1 -1
  39. package/dist/state-slot.js +54 -24
  40. package/dist/state-slot.js.map +1 -1
  41. package/package.json +3 -3
  42. package/src/compiled/core.ts +785 -179
  43. package/src/direct-dom.ts +136 -10
  44. package/src/dom/events.ts +86 -0
  45. package/src/hydration-bridge.ts +10 -4
  46. package/src/hydration.ts +5 -3
  47. package/src/index.ts +2 -0
  48. package/src/keyed-list.ts +168 -22
  49. package/src/renderable.ts +30 -0
  50. package/src/source-mask.ts +20 -0
  51. package/src/state-slot.ts +83 -27
package/src/direct-dom.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  isCompiledBinding,
3
+ isCompiledEventHandler,
3
4
  isStructuralBinding,
4
5
  adoptCompiledRoot,
5
6
  constructCompiledComponent,
@@ -17,11 +18,13 @@ import type {
17
18
  StructuralBinding,
18
19
  } from './compiled/types.ts'
19
20
  import { hasInvalidChild } from './compiled/validation.ts'
20
- import { attachEventProp, isEventProp } from './dom/events.ts'
21
+ import { attachCompiledEventProp, attachEventProp, isEventProp } from './dom/events.ts'
21
22
  import {
23
+ type IntrinsicNamespace,
22
24
  INTERNAL_NAMESPACE_PROP,
23
25
  createComponentProps,
24
26
  createIntrinsicElement,
27
+ currentIntrinsicNamespace,
25
28
  intrinsicChildrenNamespace,
26
29
  readIntrinsicNamespace,
27
30
  resolveIntrinsicNamespace,
@@ -71,6 +74,37 @@ export type DirectComponent = (props: Record<string, unknown>) => CompiledRender
71
74
 
72
75
  export const Fragment: symbol = Symbol.for('vidact.v1.Fragment')
73
76
  let frameworkMetadataHandler: ((element: Element, props: DirectProps) => Node) | undefined
77
+ const staticIntrinsicShells = new Map<string, Element>()
78
+ const cloneSafeHtmlTags = new Set([
79
+ 'a',
80
+ 'article',
81
+ 'aside',
82
+ 'button',
83
+ 'div',
84
+ 'footer',
85
+ 'h1',
86
+ 'h2',
87
+ 'h3',
88
+ 'h4',
89
+ 'h5',
90
+ 'h6',
91
+ 'header',
92
+ 'li',
93
+ 'main',
94
+ 'nav',
95
+ 'ol',
96
+ 'p',
97
+ 'section',
98
+ 'span',
99
+ 'table',
100
+ 'tbody',
101
+ 'td',
102
+ 'tfoot',
103
+ 'th',
104
+ 'thead',
105
+ 'tr',
106
+ 'ul',
107
+ ])
74
108
 
75
109
  /** @internal */
76
110
  export function installFrameworkMetadata(
@@ -134,14 +168,26 @@ export function h(
134
168
 
135
169
  const namespace = resolveIntrinsicNamespace(type, readIntrinsicNamespace(props))
136
170
  const namespaceUrl = intrinsicNamespaceUrl(namespace)
137
- const element =
138
- claimHydrationElement(type, namespaceUrl, (candidate) =>
139
- matchesHydrationElement(candidate, props, children),
140
- ) ?? createIntrinsicElement(document, type, namespace)
141
- const restoreAfterChildren = applyProps(element, props, children)
142
- withIntrinsicNamespace(intrinsicChildrenNamespace(type, namespace), () =>
143
- appendChildren(element, children),
171
+ const hydratedElement = claimHydrationElement(type, namespaceUrl, (candidate) =>
172
+ matchesHydrationElement(candidate, props, children),
144
173
  )
174
+ const shellKey =
175
+ hydratedElement === undefined ? staticIntrinsicShellKey(type, namespace, props) : undefined
176
+ let shell = shellKey === undefined ? undefined : staticIntrinsicShells.get(shellKey)
177
+ if (shellKey !== undefined && shell === undefined) {
178
+ shell = createIntrinsicElement(document, type, namespace)
179
+ applyStaticShellProps(shell, props)
180
+ if (staticIntrinsicShells.size >= 128) staticIntrinsicShells.clear()
181
+ staticIntrinsicShells.set(shellKey, shell)
182
+ }
183
+ const element =
184
+ hydratedElement ??
185
+ (shell?.cloneNode(false) as Element | undefined) ??
186
+ createIntrinsicElement(document, type, namespace)
187
+ const restoreAfterChildren = applyProps(element, props, children, shell !== undefined)
188
+ const childrenNamespace = intrinsicChildrenNamespace(type, namespace)
189
+ if (currentIntrinsicNamespace() === childrenNamespace) appendChildren(element, children)
190
+ else withIntrinsicNamespace(childrenNamespace, () => appendChildren(element, children))
145
191
  if (restoreAfterChildren) restoreControlledFormState(element)
146
192
  if (frameworkMetadataHandler !== undefined && namespace === 'html') {
147
193
  return frameworkMetadataHandler(element, props)
@@ -149,6 +195,57 @@ export function h(
149
195
  return element
150
196
  }
151
197
 
198
+ function staticIntrinsicShellKey(
199
+ type: string,
200
+ namespace: IntrinsicNamespace,
201
+ props: DirectProps,
202
+ ): string | undefined {
203
+ if (namespace !== 'html' || !cloneSafeHtmlTags.has(type)) return undefined
204
+ let key = type
205
+ if (props === null) return key
206
+ for (const name in props) {
207
+ if (!Object.hasOwn(props, name)) continue
208
+ if (name === 'key' || name === 'children' || name === INTERNAL_NAMESPACE_PROP) continue
209
+ const value = props[name]
210
+ if (!isStaticShellProp(name, value)) continue
211
+ key += `\0${name}\0${typeof value}\0${String(value)}`
212
+ }
213
+ return key
214
+ }
215
+
216
+ function isStaticShellProp(name: string, value: unknown): boolean {
217
+ if (
218
+ name !== 'className' &&
219
+ name !== 'id' &&
220
+ name !== 'href' &&
221
+ name !== 'rel' &&
222
+ name !== 'role' &&
223
+ name !== 'target' &&
224
+ name !== 'title' &&
225
+ name !== 'type' &&
226
+ !name.startsWith('aria-') &&
227
+ !name.startsWith('data-')
228
+ ) {
229
+ return false
230
+ }
231
+ return (
232
+ value === null ||
233
+ value === undefined ||
234
+ typeof value === 'string' ||
235
+ typeof value === 'number' ||
236
+ typeof value === 'boolean'
237
+ )
238
+ }
239
+
240
+ function applyStaticShellProps(element: Element, props: DirectProps): void {
241
+ if (props === null) return
242
+ for (const name in props) {
243
+ if (!Object.hasOwn(props, name)) continue
244
+ const value = props[name]
245
+ if (isStaticShellProp(name, value)) applyDomProp(element, name, value)
246
+ }
247
+ }
248
+
152
249
  export function createElement(
153
250
  type: string | typeof Fragment | DirectComponent,
154
251
  props: DirectProps,
@@ -231,6 +328,7 @@ function applyProps(
231
328
  element: Element,
232
329
  props: DirectProps,
233
330
  children: readonly DirectChild[],
331
+ skipStaticShellProps = false,
234
332
  ): boolean {
235
333
  if (props === null) return false
236
334
  const rawHtml = props.dangerouslySetInnerHTML
@@ -245,6 +343,7 @@ function applyProps(
245
343
  }
246
344
  if (name === 'key' || name === 'children' || name === INTERNAL_NAMESPACE_PROP) continue
247
345
  if (name === 'dangerouslySetInnerHTML') continue
346
+ if (skipStaticShellProps && isStaticShellProp(name, value)) continue
248
347
  if (!hasControlledRestoration && isControlledFormProp(element, name)) {
249
348
  registerCompiledCleanup(ensureControlledFormRestoration(element))
250
349
  hasControlledRestoration = true
@@ -264,7 +363,7 @@ function applyProps(
264
363
  }
265
364
  if (isCompiledBinding(value)) {
266
365
  if (isEventProp(name)) {
267
- mountCompiledProp(value, (next) => attachEventProp(element, name, next))
366
+ mountCompiledProp(value, (next) => attachCompiledEventProp(element, name, next))
268
367
  continue
269
368
  }
270
369
  if (element instanceof HTMLSelectElement && name === 'multiple') {
@@ -286,10 +385,14 @@ function applyProps(
286
385
  )
287
386
  continue
288
387
  }
289
- mountCompiledProp(value, (next) => applyDomProp(element, name, next))
388
+ mountCompiledProp(value, applyDomProp, element, name)
290
389
  continue
291
390
  }
292
391
  if (isEventProp(name)) {
392
+ if (isCompiledEventHandler(value)) {
393
+ attachCompiledEventProp(element, name, value)
394
+ continue
395
+ }
293
396
  registerCompiledCleanup(attachEventProp(element, name, value))
294
397
  continue
295
398
  }
@@ -311,6 +414,29 @@ function applyProps(
311
414
  }
312
415
 
313
416
  function appendChildren(parent: Node, children: readonly DirectChild[]): void {
417
+ if (!isHydrating()) {
418
+ if (children.length === 0) return
419
+ if (children.length === 1) {
420
+ const child = children[0]
421
+ if (child === null || child === undefined || typeof child === 'boolean') return
422
+ if (child instanceof Node) {
423
+ parent.appendChild(child)
424
+ return
425
+ }
426
+ if (typeof child === 'string' || typeof child === 'number' || typeof child === 'bigint') {
427
+ parent.appendChild(document.createTextNode(String(child)))
428
+ return
429
+ }
430
+ }
431
+ if (
432
+ children.length > 1 &&
433
+ parent instanceof Element &&
434
+ children.every((child): child is Node => child instanceof Node)
435
+ ) {
436
+ parent.append(...children)
437
+ return
438
+ }
439
+ }
314
440
  if (hasInvalidChild(children)) {
315
441
  throw new TypeError(
316
442
  DEV ? 'unsupported direct child value; expected a DOM node or owned block' : 'V103',
package/src/dom/events.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { isReactFormChangeEvent, restoreControlledFormState } from './properties.ts'
2
2
 
3
3
  const DEV = typeof __VIDACT_DEV__ === 'undefined' || __VIDACT_DEV__
4
+ const delegatedClick = Symbol('vidact.click')
5
+ const delegatedClickCursor = Symbol('vidact.click.cursor')
6
+ const delegatedClickRoots = new WeakMap<EventTarget, number>()
7
+ export const COMPILED_DELEGATED_EVENT_INVOKE = Symbol('vidact.compiled-event.invoke')
8
+
9
+ type DelegatedEventListener = EventListener & {
10
+ [COMPILED_DELEGATED_EVENT_INVOKE]?: EventListener
11
+ }
12
+ type DelegatedClickElement = Element & { [delegatedClick]?: DelegatedEventListener }
13
+ type DelegatedClickEvent = Event & { [delegatedClickCursor]?: EventTarget }
4
14
 
5
15
  function isSupportedReactEventName(name: string): boolean {
6
16
  return '|Abort|AnimationEnd|AnimationIteration|AnimationStart|AuxClick|BeforeInput|BeforeToggle|Blur|CanPlay|CanPlayThrough|Cancel|Change|Click|Close|CompositionEnd|CompositionStart|CompositionUpdate|ContextMenu|Copy|Cut|DoubleClick|Drag|DragEnd|DragEnter|DragExit|DragLeave|DragOver|DragStart|Drop|DurationChange|Emptied|Encrypted|Ended|Error|Focus|GotPointerCapture|Input|Invalid|KeyDown|KeyPress|KeyUp|Load|LoadedData|LoadedMetadata|LoadStart|LostPointerCapture|MouseDown|MouseEnter|MouseLeave|MouseMove|MouseOut|MouseOver|MouseUp|Paste|Pause|Play|Playing|PointerCancel|PointerDown|PointerEnter|PointerLeave|PointerMove|PointerOut|PointerOver|PointerUp|Progress|RateChange|Reset|Resize|Scroll|ScrollEnd|Seeked|Seeking|Select|Stalled|Submit|Suspend|TimeUpdate|Toggle|TouchCancel|TouchEnd|TouchMove|TouchStart|TransitionCancel|TransitionEnd|TransitionRun|TransitionStart|VolumeChange|Waiting|Wheel|'.includes(
@@ -38,6 +48,43 @@ export function attachEventProp(element: Element, name: string, value: unknown):
38
48
  return () => element.removeEventListener(eventName, dispatch, capture)
39
49
  }
40
50
 
51
+ export function attachCompiledEventProp(
52
+ element: Element,
53
+ name: string,
54
+ value: unknown,
55
+ ): () => void {
56
+ if (name !== 'onClick') return attachEventProp(element, name, value)
57
+ if (value === null || value === undefined) return () => {}
58
+ if (typeof value !== 'function') {
59
+ throw new TypeError(DEV ? `event prop ${name} must be a function, null, or undefined` : 'V201')
60
+ }
61
+
62
+ const target = element as DelegatedClickElement
63
+ const listener = value as DelegatedEventListener
64
+ target[delegatedClick] = listener
65
+ return () => {
66
+ if (target[delegatedClick] === listener) delete target[delegatedClick]
67
+ }
68
+ }
69
+
70
+ export function retainCompiledEventRoot(root: EventTarget): () => void {
71
+ const references = delegatedClickRoots.get(root) ?? 0
72
+ if (references === 0) root.addEventListener('click', dispatchDelegatedClick)
73
+ delegatedClickRoots.set(root, references + 1)
74
+ let retained = true
75
+ return () => {
76
+ if (!retained) return
77
+ retained = false
78
+ const nextReferences = (delegatedClickRoots.get(root) ?? 1) - 1
79
+ if (nextReferences > 0) {
80
+ delegatedClickRoots.set(root, nextReferences)
81
+ return
82
+ }
83
+ delegatedClickRoots.delete(root)
84
+ root.removeEventListener('click', dispatchDelegatedClick)
85
+ }
86
+ }
87
+
41
88
  export function isEventProp(name: string): boolean {
42
89
  return /^on[A-Z]/.test(name)
43
90
  }
@@ -85,6 +132,45 @@ function reactShapedEvent(event: Event): Event {
85
132
  return shaped
86
133
  }
87
134
 
135
+ function dispatchDelegatedClick(event: Event): void {
136
+ const root = event.currentTarget
137
+ if (root === null) return
138
+ const path = event.composedPath()
139
+ const previousRoot = (event as DelegatedClickEvent)[delegatedClickCursor]
140
+ const start = previousRoot === undefined ? 0 : path.indexOf(previousRoot) + 1
141
+ for (let index = start; index < path.length; index += 1) {
142
+ const target = path[index]
143
+ if (target instanceof Element) {
144
+ const listener = (target as DelegatedClickElement)[delegatedClick]
145
+ if (listener !== undefined) invokeDelegatedClick(target, listener, event)
146
+ }
147
+ if (event.cancelBubble) break
148
+ if (target === root) break
149
+ }
150
+ ;(event as DelegatedClickEvent)[delegatedClickCursor] = root
151
+ }
152
+
153
+ function invokeDelegatedClick(
154
+ element: Element,
155
+ listener: DelegatedEventListener,
156
+ event: Event,
157
+ ): void {
158
+ const currentTarget = Object.getOwnPropertyDescriptor(event, 'currentTarget')
159
+ Object.defineProperty(event, 'currentTarget', { configurable: true, value: element })
160
+ try {
161
+ const shapedEvent = reactShapedEvent(event)
162
+ const compiledInvoke = listener[COMPILED_DELEGATED_EVENT_INVOKE]
163
+ if (compiledInvoke === undefined) listener(shapedEvent)
164
+ else compiledInvoke.call(listener, shapedEvent)
165
+ } finally {
166
+ if (currentTarget === undefined) {
167
+ Reflect.deleteProperty(event, 'currentTarget')
168
+ } else {
169
+ Object.defineProperty(event, 'currentTarget', currentTarget)
170
+ }
171
+ }
172
+ }
173
+
88
174
  function nativeEventName(reactEventName: string): string {
89
175
  if (reactEventName === 'DoubleClick') return 'dblclick'
90
176
  if (reactEventName === 'Focus') return 'focusin'
@@ -30,8 +30,12 @@ export interface HydrationOperations {
30
30
  readonly claimSuspenseFallback: (parent: Node) => Comment | undefined
31
31
  readonly skipRange: (start: Comment, end: Comment) => void
32
32
  readonly withoutHydration: <Result>(operation: () => Result) => Result
33
- readonly withInsertion: <Result>(parent: Node, before: Node, operation: () => Result) => Result
34
- readonly insertionPoint: () => readonly [parent: Node, before: Node] | undefined
33
+ readonly withInsertion: <Result>(
34
+ parent: Node,
35
+ before: Node | null,
36
+ operation: () => Result,
37
+ ) => Result
38
+ readonly insertionPoint: () => readonly [parent: Node, before: Node | null] | undefined
35
39
  readonly cursor: (parent: Node) => Node | null | undefined
36
40
  readonly rangeParent: (start: Comment, end: Comment) => Node | undefined
37
41
  }
@@ -169,13 +173,15 @@ export function withoutHydration<Result>(operation: () => Result): Result {
169
173
 
170
174
  export function withHydrationInsertion<Result>(
171
175
  parent: Node,
172
- before: Node,
176
+ before: Node | null,
173
177
  operation: () => Result,
174
178
  ): Result {
175
179
  return hydration === undefined ? operation() : hydration.withInsertion(parent, before, operation)
176
180
  }
177
181
 
178
- export function hydrationInsertionPoint(): readonly [parent: Node, before: Node] | undefined {
182
+ export function hydrationInsertionPoint():
183
+ | readonly [parent: Node, before: Node | null]
184
+ | undefined {
179
185
  return hydration?.insertionPoint()
180
186
  }
181
187
 
package/src/hydration.ts CHANGED
@@ -21,7 +21,7 @@ interface HydrationState {
21
21
  }
22
22
 
23
23
  let activeHydration: HydrationState | undefined
24
- let activeInsertionPoint: readonly [parent: Node, before: Node] | undefined
24
+ let activeInsertionPoint: readonly [parent: Node, before: Node | null] | undefined
25
25
  const hydrationFragments = new WeakMap<DocumentFragment, readonly unknown[]>()
26
26
 
27
27
  export function beginHydration(host: ParentNode): () => void {
@@ -400,7 +400,7 @@ export function withoutHydration<Result>(operation: () => Result): Result {
400
400
 
401
401
  export function withHydrationInsertion<Result>(
402
402
  parent: Node,
403
- before: Node,
403
+ before: Node | null,
404
404
  operation: () => Result,
405
405
  ): Result {
406
406
  if (activeHydration === undefined) return operation()
@@ -413,7 +413,9 @@ export function withHydrationInsertion<Result>(
413
413
  }
414
414
  }
415
415
 
416
- export function hydrationInsertionPoint(): readonly [parent: Node, before: Node] | undefined {
416
+ export function hydrationInsertionPoint():
417
+ | readonly [parent: Node, before: Node | null]
418
+ | undefined {
417
419
  return activeInsertionPoint
418
420
  }
419
421
 
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ export {
5
5
  binding,
6
6
  choose,
7
7
  compiledEvent,
8
+ compiledInlineEvent,
8
9
  compiledEffect,
9
10
  compiledImperativeHandle,
10
11
  compiledInsertionEffect,
@@ -71,6 +72,7 @@ export { createRef, useRef, type MutableRef } from './ref.ts'
71
72
  export {
72
73
  cloneRenderable,
73
74
  cloneRenderableComponent,
75
+ createComponentRenderable,
74
76
  createReactElement,
75
77
  createReactElement as createElement,
76
78
  createRenderable,
package/src/keyed-list.ts CHANGED
@@ -1,7 +1,13 @@
1
+ export interface KeyedDisposeTarget {
2
+ readonly [3]: () => void
3
+ }
4
+
5
+ export type KeyedDisposer = (() => void) | KeyedDisposeTarget
6
+
1
7
  export type KeyedItem<T> = readonly [
2
8
  nodes: readonly Node[],
3
9
  update?: ((value: T, index: number) => void) | undefined,
4
- dispose?: (() => void) | undefined,
10
+ dispose?: KeyedDisposer | undefined,
5
11
  ]
6
12
 
7
13
  export type KeyedRenderResult<T> = Node | readonly Node[] | KeyedItem<T>
@@ -21,7 +27,7 @@ type RecordState<T, K> = readonly [
21
27
  key: K,
22
28
  nodes: readonly Node[],
23
29
  update: ((value: T, index: number) => void) | undefined,
24
- dispose: (() => void) | undefined,
30
+ dispose: KeyedDisposer | undefined,
25
31
  ]
26
32
 
27
33
  const DEV = typeof __VIDACT_DEV__ === 'undefined' || __VIDACT_DEV__
@@ -54,10 +60,55 @@ export function createKeyedList<T, K>(
54
60
  assertValidKeys(keys)
55
61
  assertUniqueKeys(keys)
56
62
 
57
- const previousByKey = new Map<K, RecordState<T, K>>()
58
- for (const record of records) previousByKey.set(record[0], record)
63
+ if (!hydrationPending && values.length === 0 && records.length > 0) {
64
+ const removed = records
65
+ records = []
66
+ deleteRangeContents(start, end)
67
+ const cleanup = disposeRecordOwners(removed)
68
+ if (cleanup[1]) throw cleanup[0]
69
+ return []
70
+ }
71
+
72
+ if (!hydrationPending && hasRetainedPrefix(records, keys)) {
73
+ const retainedCount = Math.min(records.length, values.length)
74
+ const created: RecordState<T, K>[] = []
75
+ try {
76
+ for (let index = records.length; index < values.length; index += 1) {
77
+ const value = values[index] as T
78
+ const item = normalizeRenderResult(
79
+ withHydrationInsertion(parent, end, () => options.render(value, index)),
80
+ )
81
+ created.push([keys[index] as K, item[0], item[1], item[2]])
82
+ }
83
+ for (let index = 0; index < retainedCount; index += 1) {
84
+ records[index]![2]?.(values[index] as T, index)
85
+ }
86
+ } catch (error) {
87
+ disposeRecords(currentParent, created)
88
+ throw error
89
+ }
90
+
91
+ const cleanup = disposeRecords(currentParent, records.slice(values.length))
92
+ const fragment = document.createDocumentFragment()
93
+ for (const record of created) {
94
+ for (const node of record[1]) fragment.append(node)
95
+ }
96
+ currentParent.insertBefore(fragment, end)
97
+ if (records.length !== values.length) {
98
+ records = retainedCount === 0 ? created : [...records.slice(0, retainedCount), ...created]
99
+ }
100
+ if (cleanup[1]) throw cleanup[0]
101
+ return collectRecordNodes(created)
102
+ }
103
+
104
+ const previousByKey = new Map<K, readonly [record: RecordState<T, K>, index: number]>()
105
+ for (let index = 0; index < records.length; index += 1) {
106
+ const record = records[index] as RecordState<T, K>
107
+ previousByKey.set(record[0], [record, index])
108
+ }
59
109
  const created: RecordState<T, K>[] = []
60
110
  const retained: Array<readonly [RecordState<T, K>, T, number]> = []
111
+ const previousIndexes: number[] = []
61
112
  let nextRecords: RecordState<T, K>[]
62
113
  try {
63
114
  nextRecords = values.map((value, index): RecordState<T, K> => {
@@ -65,8 +116,9 @@ export function createKeyedList<T, K>(
65
116
  const previous = previousByKey.get(key)
66
117
  if (previous !== undefined) {
67
118
  previousByKey.delete(key)
68
- retained.push([previous, value, index])
69
- return previous
119
+ retained.push([previous[0], value, index])
120
+ previousIndexes.push(previous[1])
121
+ return previous[0]
70
122
  }
71
123
 
72
124
  const item = normalizeRenderResult(
@@ -74,6 +126,7 @@ export function createKeyedList<T, K>(
74
126
  )
75
127
  const record: RecordState<T, K> = [key, item[0], item[1], item[2]]
76
128
  created.push(record)
129
+ previousIndexes.push(-1)
77
130
  return record
78
131
  })
79
132
  for (const [record, value, index] of retained) record[2]?.(value, index)
@@ -82,13 +135,18 @@ export function createKeyedList<T, K>(
82
135
  throw error
83
136
  }
84
137
 
85
- const cleanup = disposeRecords(currentParent, previousByKey.values())
138
+ const cleanup = disposeRecords(currentParent, mapRecords(previousByKey.values()))
86
139
 
87
- const activeElement =
88
- document.activeElement instanceof HTMLElement &&
89
- currentParent.contains(document.activeElement)
140
+ const stableRecords = longestIncreasingSubsequence(previousIndexes)
141
+ const hasRetainedMoves = previousIndexes.some(
142
+ (previousIndex, index) => previousIndex >= 0 && stableRecords[index] === 0,
143
+ )
144
+ const activeElement = hasRetainedMoves
145
+ ? document.activeElement instanceof HTMLElement &&
146
+ currentParent.contains(document.activeElement)
90
147
  ? document.activeElement
91
148
  : null
149
+ : null
92
150
  const selectionControl =
93
151
  activeElement instanceof HTMLInputElement || activeElement instanceof HTMLTextAreaElement
94
152
  ? activeElement
@@ -100,10 +158,14 @@ export function createKeyedList<T, K>(
100
158
  let cursor: Node = end
101
159
  for (let recordIndex = nextRecords.length - 1; recordIndex >= 0; recordIndex -= 1) {
102
160
  const record = nextRecords[recordIndex] as RecordState<T, K>
103
- for (let nodeIndex = record[1].length - 1; nodeIndex >= 0; nodeIndex -= 1) {
104
- const node = record[1][nodeIndex] as Node
105
- if (node.nextSibling !== cursor) moveBefore(currentParent, node, cursor)
106
- cursor = node
161
+ if (previousIndexes[recordIndex] === -1 || stableRecords[recordIndex] === 0) {
162
+ for (let nodeIndex = record[1].length - 1; nodeIndex >= 0; nodeIndex -= 1) {
163
+ const node = record[1][nodeIndex] as Node
164
+ if (node.nextSibling !== cursor) moveBefore(currentParent, node, cursor)
165
+ cursor = node
166
+ }
167
+ } else {
168
+ cursor = record[1][0] as Node
107
169
  }
108
170
  }
109
171
  if (
@@ -128,7 +190,7 @@ export function createKeyedList<T, K>(
128
190
  finishHydrationArrayRange(currentParent, end)
129
191
  }
130
192
  if (cleanup[1]) throw cleanup[0]
131
- return created.flatMap((record) => record[1])
193
+ return collectRecordNodes(created)
132
194
  }
133
195
 
134
196
  const dispose = (): void => {
@@ -148,6 +210,55 @@ export function createKeyedList<T, K>(
148
210
  return { dispose, parent: () => end.parentNode, update }
149
211
  }
150
212
 
213
+ function* mapRecords<T, K>(
214
+ entries: Iterable<readonly [record: RecordState<T, K>, index: number]>,
215
+ ): Iterable<RecordState<T, K>> {
216
+ for (const [record] of entries) yield record
217
+ }
218
+
219
+ function collectRecordNodes<T, K>(records: readonly RecordState<T, K>[]): Node[] {
220
+ const nodes: Node[] = []
221
+ for (const record of records) nodes.push(...record[1])
222
+ return nodes
223
+ }
224
+
225
+ function longestIncreasingSubsequence(indexes: readonly number[]): Uint8Array {
226
+ const predecessors = new Int32Array(indexes.length).fill(-1)
227
+ const tails: number[] = []
228
+ for (let index = 0; index < indexes.length; index += 1) {
229
+ const value = indexes[index] as number
230
+ if (value < 0) continue
231
+ let low = 0
232
+ let high = tails.length
233
+ while (low < high) {
234
+ const middle = (low + high) >>> 1
235
+ if ((indexes[tails[middle] as number] as number) < value) low = middle + 1
236
+ else high = middle
237
+ }
238
+ if (low > 0) predecessors[index] = tails[low - 1] as number
239
+ tails[low] = index
240
+ }
241
+
242
+ const stable = new Uint8Array(indexes.length)
243
+ let index = tails.at(-1) ?? -1
244
+ while (index >= 0) {
245
+ stable[index] = 1
246
+ index = predecessors[index] as number
247
+ }
248
+ return stable
249
+ }
250
+
251
+ function hasRetainedPrefix<T, K>(
252
+ records: readonly RecordState<T, K>[],
253
+ keys: readonly K[],
254
+ ): boolean {
255
+ const retainedCount = Math.min(records.length, keys.length)
256
+ for (let index = 0; index < retainedCount; index += 1) {
257
+ if (records[index]![0] !== keys[index]) return false
258
+ }
259
+ return true
260
+ }
261
+
151
262
  function moveBefore(parent: Node, node: Node, before: Node): void {
152
263
  const statePreservingParent = parent as Node & {
153
264
  moveBefore?: (node: Node, before: Node | null) => void
@@ -184,14 +295,21 @@ function normalizeRenderResult<T>(result: KeyedRenderResult<T>): KeyedItem<T> {
184
295
  }
185
296
 
186
297
  function normalizeNodes(nodes: readonly Node[]): readonly Node[] {
187
- const normalized: Node[] = []
188
- for (const node of nodes) {
189
- if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
190
- normalized.push(...node.childNodes)
191
- } else {
192
- normalized.push(node)
298
+ if (nodes.length === 0) return [document.createComment(DEV ? 'vidact:empty' : '')]
299
+ let firstFragment = -1
300
+ for (let index = 0; index < nodes.length; index += 1) {
301
+ if (nodes[index]!.nodeType === Node.DOCUMENT_FRAGMENT_NODE) {
302
+ firstFragment = index
303
+ break
193
304
  }
194
305
  }
306
+ if (firstFragment === -1) return nodes
307
+ const normalized = nodes.slice(0, firstFragment) as Node[]
308
+ for (let index = firstFragment; index < nodes.length; index += 1) {
309
+ const node = nodes[index]!
310
+ if (node.nodeType === Node.DOCUMENT_FRAGMENT_NODE) normalized.push(...node.childNodes)
311
+ else normalized.push(node)
312
+ }
195
313
  return normalized.length === 0 ? [document.createComment(DEV ? 'vidact:empty' : '')] : normalized
196
314
  }
197
315
 
@@ -218,7 +336,23 @@ function disposeRecords<T, K>(
218
336
  for (const record of records) {
219
337
  removeNodes(parent, record[1])
220
338
  try {
221
- record[3]?.()
339
+ disposeRecord(record[3])
340
+ } catch (error) {
341
+ if (!failed) firstError = error
342
+ failed = true
343
+ }
344
+ }
345
+ return [firstError, failed]
346
+ }
347
+
348
+ function disposeRecordOwners<T, K>(
349
+ records: Iterable<RecordState<T, K>>,
350
+ ): readonly [error: unknown, failed: boolean] {
351
+ let firstError: unknown
352
+ let failed = false
353
+ for (const record of records) {
354
+ try {
355
+ disposeRecord(record[3])
222
356
  } catch (error) {
223
357
  if (!failed) firstError = error
224
358
  failed = true
@@ -226,6 +360,18 @@ function disposeRecords<T, K>(
226
360
  }
227
361
  return [firstError, failed]
228
362
  }
363
+
364
+ function disposeRecord(disposer: KeyedDisposer | undefined): void {
365
+ if (typeof disposer === 'function') disposer()
366
+ else disposer?.[3]()
367
+ }
368
+
369
+ function deleteRangeContents(start: Node, end: Node): void {
370
+ const range = start.ownerDocument?.createRange() ?? document.createRange()
371
+ range.setStartAfter(start)
372
+ range.setEndBefore(end)
373
+ range.deleteContents()
374
+ }
229
375
  import {
230
376
  claimHydrationArrayRange,
231
377
  finishHydrationArrayRange,