@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.
- package/CHANGELOG.md +13 -0
- package/dist/actions.js +1 -1
- package/dist/compiled/core.d.ts +17 -5
- package/dist/compiled/core.d.ts.map +1 -1
- package/dist/compiled/core.js +545 -155
- package/dist/compiled/core.js.map +1 -1
- package/dist/direct-dom.d.ts.map +1 -1
- package/dist/direct-dom.js +102 -11
- package/dist/direct-dom.js.map +1 -1
- package/dist/dom/events.d.ts +4 -1
- package/dist/dom/events.d.ts.map +1 -1
- package/dist/dom/events.js +66 -1
- package/dist/dom/events.js.map +1 -1
- package/dist/dom/namespace.js +1 -1
- package/dist/hydration-bridge.d.ts +4 -4
- package/dist/hydration-bridge.d.ts.map +1 -1
- package/dist/hydration-bridge.js.map +1 -1
- package/dist/hydration.d.ts +2 -2
- package/dist/hydration.d.ts.map +1 -1
- package/dist/hydration.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -3
- package/dist/keyed-list.d.ts +6 -2
- package/dist/keyed-list.d.ts.map +1 -1
- package/dist/keyed-list.js +122 -11
- package/dist/keyed-list.js.map +1 -1
- package/dist/metadata.js +1 -1
- package/dist/renderable.d.ts +2 -1
- package/dist/renderable.d.ts.map +1 -1
- package/dist/renderable.js +12 -1
- package/dist/renderable.js.map +1 -1
- package/dist/source-mask.d.ts +2 -1
- package/dist/source-mask.d.ts.map +1 -1
- package/dist/source-mask.js +16 -1
- package/dist/source-mask.js.map +1 -1
- package/dist/spread.js +2 -2
- package/dist/state-slot.d.ts +12 -1
- package/dist/state-slot.d.ts.map +1 -1
- package/dist/state-slot.js +54 -24
- package/dist/state-slot.js.map +1 -1
- package/package.json +3 -3
- package/src/compiled/core.ts +785 -179
- package/src/direct-dom.ts +136 -10
- package/src/dom/events.ts +86 -0
- package/src/hydration-bridge.ts +10 -4
- package/src/hydration.ts +5 -3
- package/src/index.ts +2 -0
- package/src/keyed-list.ts +168 -22
- package/src/renderable.ts +30 -0
- package/src/source-mask.ts +20 -0
- 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
|
|
138
|
-
|
|
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) =>
|
|
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,
|
|
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'
|
package/src/hydration-bridge.ts
CHANGED
|
@@ -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>(
|
|
34
|
-
|
|
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():
|
|
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():
|
|
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?:
|
|
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:
|
|
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
|
-
|
|
58
|
-
|
|
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
|
-
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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,
|