@fictjs/runtime 0.3.0 → 0.5.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/dist/advanced.cjs +10 -8
- package/dist/advanced.cjs.map +1 -1
- package/dist/advanced.d.cts +4 -3
- package/dist/advanced.d.ts +4 -3
- package/dist/advanced.js +10 -8
- package/dist/advanced.js.map +1 -1
- package/dist/{chunk-TWELIZRY.js → chunk-5AA7HP4S.js} +5 -3
- package/dist/{chunk-TWELIZRY.js.map → chunk-5AA7HP4S.js.map} +1 -1
- package/dist/chunk-6SOPF5LZ.cjs +2363 -0
- package/dist/chunk-6SOPF5LZ.cjs.map +1 -0
- package/dist/{chunk-SO6X7G5S.js → chunk-BQG7VEBY.js} +501 -1880
- package/dist/chunk-BQG7VEBY.js.map +1 -0
- package/dist/chunk-FKDMDAUR.js +2363 -0
- package/dist/chunk-FKDMDAUR.js.map +1 -0
- package/dist/{chunk-L4DIV3RC.cjs → chunk-GHUV2FLD.cjs} +9 -7
- package/dist/chunk-GHUV2FLD.cjs.map +1 -0
- package/dist/{chunk-XLIZJMMJ.js → chunk-KKKYW54Z.js} +8 -6
- package/dist/{chunk-XLIZJMMJ.js.map → chunk-KKKYW54Z.js.map} +1 -1
- package/dist/{chunk-M2TSXZ4C.cjs → chunk-KYLNC4CD.cjs} +18 -16
- package/dist/chunk-KYLNC4CD.cjs.map +1 -0
- package/dist/chunk-TKWN42TA.cjs +2259 -0
- package/dist/chunk-TKWN42TA.cjs.map +1 -0
- package/dist/{context-B25xyQrJ.d.cts → context-CTBE00S_.d.cts} +1 -1
- package/dist/{context-CGdP7_Jb.d.ts → context-lkLhbkFJ.d.ts} +1 -1
- package/dist/{effect-D6kaLM2-.d.cts → effect-BpSNEJJz.d.cts} +7 -67
- package/dist/{effect-D6kaLM2-.d.ts → effect-BpSNEJJz.d.ts} +7 -67
- package/dist/index.cjs +40 -38
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +5 -4
- package/dist/index.d.ts +5 -4
- package/dist/index.dev.js +92 -4
- package/dist/index.dev.js.map +1 -1
- package/dist/index.js +19 -17
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +189 -202
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +13 -23
- package/dist/internal.d.ts +13 -23
- package/dist/internal.js +195 -208
- package/dist/internal.js.map +1 -1
- package/dist/loader.cjs +280 -0
- package/dist/loader.cjs.map +1 -0
- package/dist/loader.d.cts +57 -0
- package/dist/loader.d.ts +57 -0
- package/dist/loader.js +280 -0
- package/dist/loader.js.map +1 -0
- package/dist/{props-BIfromL0.d.cts → props-XTHYD19o.d.cts} +13 -2
- package/dist/{props-BEgIVMRx.d.ts → props-x-HbI-jX.d.ts} +13 -2
- package/dist/resume-BrAkmSTY.d.cts +79 -0
- package/dist/resume-Dx8_l72o.d.ts +79 -0
- package/dist/{scope-CzNkn587.d.ts → scope-CdbGmsFf.d.ts} +1 -1
- package/dist/{scope-Cx_3CjIZ.d.cts → scope-DfcP9I-A.d.cts} +1 -1
- package/dist/signal-C4ISF17w.d.cts +66 -0
- package/dist/signal-C4ISF17w.d.ts +66 -0
- package/package.json +8 -3
- package/src/binding.ts +254 -5
- package/src/dom.ts +103 -5
- package/src/hooks.ts +15 -2
- package/src/hydration.ts +75 -0
- package/src/internal.ts +34 -2
- package/src/list-helpers.ts +113 -12
- package/src/loader.ts +437 -0
- package/src/node-ops.ts +65 -0
- package/src/resume.ts +517 -0
- package/src/store.ts +8 -0
- package/dist/chunk-ID3WBWNO.cjs +0 -3638
- package/dist/chunk-ID3WBWNO.cjs.map +0 -1
- package/dist/chunk-L4DIV3RC.cjs.map +0 -1
- package/dist/chunk-M2TSXZ4C.cjs.map +0 -1
- package/dist/chunk-SO6X7G5S.js.map +0 -1
package/src/loader.ts
ADDED
|
@@ -0,0 +1,437 @@
|
|
|
1
|
+
import { DelegatedEvents } from './constants'
|
|
2
|
+
import {
|
|
3
|
+
__fictEnableResumable,
|
|
4
|
+
__fictEnsureScope,
|
|
5
|
+
__fictGetResume,
|
|
6
|
+
__fictGetSSRScope,
|
|
7
|
+
__fictSetSSRState,
|
|
8
|
+
__fictUseLexicalScope,
|
|
9
|
+
} from './resume'
|
|
10
|
+
|
|
11
|
+
// ============================================================================
|
|
12
|
+
// Module Resolution
|
|
13
|
+
// ============================================================================
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve a module URL through the manifest if available.
|
|
17
|
+
* In production, virtual module URLs (virtual:fict-handler:...) are mapped
|
|
18
|
+
* to their built chunk URLs through the manifest.
|
|
19
|
+
*/
|
|
20
|
+
function resolveModuleUrl(url: string): string {
|
|
21
|
+
const manifest = (globalThis as Record<string, unknown>).__FICT_MANIFEST__ as
|
|
22
|
+
| Record<string, string>
|
|
23
|
+
| undefined
|
|
24
|
+
|
|
25
|
+
if (manifest) {
|
|
26
|
+
// Check if the URL (without #fragment) is in the manifest
|
|
27
|
+
const resolved = manifest[url]
|
|
28
|
+
if (resolved) {
|
|
29
|
+
return resolved
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return url
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// ============================================================================
|
|
37
|
+
// Types
|
|
38
|
+
// ============================================================================
|
|
39
|
+
|
|
40
|
+
export interface PrefetchStrategy {
|
|
41
|
+
/**
|
|
42
|
+
* Enable visibility-based prefetch using IntersectionObserver.
|
|
43
|
+
* Prefetches modules when interactive elements come into view.
|
|
44
|
+
* @default true
|
|
45
|
+
*/
|
|
46
|
+
visibility?: boolean
|
|
47
|
+
/**
|
|
48
|
+
* Root margin for IntersectionObserver (e.g., '200px' to prefetch earlier).
|
|
49
|
+
* @default '200px'
|
|
50
|
+
*/
|
|
51
|
+
visibilityMargin?: string
|
|
52
|
+
/**
|
|
53
|
+
* Enable hover-based prefetch using pointerover events.
|
|
54
|
+
* Prefetches modules when user hovers over interactive elements.
|
|
55
|
+
* @default true
|
|
56
|
+
*/
|
|
57
|
+
hover?: boolean
|
|
58
|
+
/**
|
|
59
|
+
* Delay in ms before prefetching on hover (debounce rapid movements).
|
|
60
|
+
* @default 50
|
|
61
|
+
*/
|
|
62
|
+
hoverDelay?: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ResumableLoaderOptions {
|
|
66
|
+
document?: Document
|
|
67
|
+
snapshotScriptId?: string
|
|
68
|
+
events?: string[]
|
|
69
|
+
/**
|
|
70
|
+
* Prefetch strategy configuration.
|
|
71
|
+
* Set to false to disable all prefetching.
|
|
72
|
+
* @default { visibility: true, hover: true }
|
|
73
|
+
*/
|
|
74
|
+
prefetch?: PrefetchStrategy | false
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ============================================================================
|
|
78
|
+
// State
|
|
79
|
+
// ============================================================================
|
|
80
|
+
|
|
81
|
+
const hydratedScopes = new Set<string>()
|
|
82
|
+
const prefetchedUrls = new Set<string>()
|
|
83
|
+
let prefetchCleanup: (() => void) | null = null
|
|
84
|
+
let eventListenerCleanup: (() => void) | null = null
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Reset the hydrated scopes set. Useful for testing.
|
|
88
|
+
*/
|
|
89
|
+
export function resetHydratedScopes(): void {
|
|
90
|
+
hydratedScopes.clear()
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Reset the prefetched URLs set. Useful for testing.
|
|
95
|
+
*/
|
|
96
|
+
export function resetPrefetchedUrls(): void {
|
|
97
|
+
prefetchedUrls.clear()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Set of pending handler promises. Used for testing to wait for all handlers to complete.
|
|
102
|
+
*/
|
|
103
|
+
const pendingHandlers = new Set<Promise<void>>()
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Wait for all pending event handlers to complete. Useful for testing.
|
|
107
|
+
*/
|
|
108
|
+
export async function waitForPendingHandlers(): Promise<void> {
|
|
109
|
+
if (pendingHandlers.size === 0) return
|
|
110
|
+
await Promise.allSettled([...pendingHandlers])
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Clean up all registered event listeners. Useful for testing.
|
|
115
|
+
*/
|
|
116
|
+
export function cleanupEventListeners(): void {
|
|
117
|
+
if (eventListenerCleanup) {
|
|
118
|
+
eventListenerCleanup()
|
|
119
|
+
eventListenerCleanup = null
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// ============================================================================
|
|
124
|
+
// Main Entry Point
|
|
125
|
+
// ============================================================================
|
|
126
|
+
|
|
127
|
+
export function installResumableLoader(options: ResumableLoaderOptions = {}): void {
|
|
128
|
+
const doc = options.document ?? window.document
|
|
129
|
+
const scriptId = options.snapshotScriptId ?? '__FICT_SNAPSHOT__'
|
|
130
|
+
|
|
131
|
+
// Reset hydrated scopes for fresh loader installation
|
|
132
|
+
hydratedScopes.clear()
|
|
133
|
+
prefetchedUrls.clear()
|
|
134
|
+
|
|
135
|
+
// Clean up previous event listeners
|
|
136
|
+
if (eventListenerCleanup) {
|
|
137
|
+
eventListenerCleanup()
|
|
138
|
+
eventListenerCleanup = null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Clean up previous prefetch handlers
|
|
142
|
+
if (prefetchCleanup) {
|
|
143
|
+
prefetchCleanup()
|
|
144
|
+
prefetchCleanup = null
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const snapshotEl = doc.getElementById(scriptId)
|
|
148
|
+
if (snapshotEl?.textContent) {
|
|
149
|
+
try {
|
|
150
|
+
const state = JSON.parse(snapshotEl.textContent)
|
|
151
|
+
__fictSetSSRState(state)
|
|
152
|
+
} catch {
|
|
153
|
+
// Ignore parse errors
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
__fictEnableResumable()
|
|
158
|
+
|
|
159
|
+
const events = options.events ?? Array.from(DelegatedEvents)
|
|
160
|
+
for (const eventName of events) {
|
|
161
|
+
doc.addEventListener(eventName, handleResumableEvent, true)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Store cleanup function for event listeners
|
|
165
|
+
eventListenerCleanup = () => {
|
|
166
|
+
for (const eventName of events) {
|
|
167
|
+
doc.removeEventListener(eventName, handleResumableEvent, true)
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Setup prefetch if enabled
|
|
172
|
+
if (options.prefetch !== false) {
|
|
173
|
+
prefetchCleanup = setupPrefetch(doc, options.prefetch ?? {})
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// ============================================================================
|
|
178
|
+
// Prefetch Implementation
|
|
179
|
+
// ============================================================================
|
|
180
|
+
|
|
181
|
+
function setupPrefetch(doc: Document, strategy: PrefetchStrategy): () => void {
|
|
182
|
+
const cleanupFns: (() => void)[] = []
|
|
183
|
+
|
|
184
|
+
// Visibility-based prefetch
|
|
185
|
+
if (strategy.visibility !== false) {
|
|
186
|
+
const cleanup = setupVisibilityPrefetch(doc, strategy.visibilityMargin ?? '200px')
|
|
187
|
+
cleanupFns.push(cleanup)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Hover-based prefetch
|
|
191
|
+
if (strategy.hover !== false) {
|
|
192
|
+
const cleanup = setupHoverPrefetch(doc, strategy.hoverDelay ?? 50)
|
|
193
|
+
cleanupFns.push(cleanup)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return () => {
|
|
197
|
+
for (const cleanup of cleanupFns) {
|
|
198
|
+
cleanup()
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function setupVisibilityPrefetch(doc: Document, rootMargin: string): () => void {
|
|
204
|
+
// Check if IntersectionObserver is available
|
|
205
|
+
if (typeof IntersectionObserver === 'undefined') {
|
|
206
|
+
return () => {}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const observer = new IntersectionObserver(
|
|
210
|
+
entries => {
|
|
211
|
+
for (const entry of entries) {
|
|
212
|
+
if (entry.isIntersecting) {
|
|
213
|
+
const el = entry.target as Element
|
|
214
|
+
prefetchElementQrls(el)
|
|
215
|
+
// Stop observing after prefetch
|
|
216
|
+
observer.unobserve(el)
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
{ rootMargin },
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
// Observe all elements with on:* attributes
|
|
224
|
+
const interactiveElements = doc.querySelectorAll(
|
|
225
|
+
'[on\\:click], [on\\:input], [on\\:change], [on\\:submit], [on\\:keydown], [on\\:keyup]',
|
|
226
|
+
)
|
|
227
|
+
interactiveElements.forEach(el => observer.observe(el))
|
|
228
|
+
|
|
229
|
+
// Also observe elements with data-fict-h (resumable components)
|
|
230
|
+
const resumableHosts = doc.querySelectorAll('[data-fict-h]')
|
|
231
|
+
resumableHosts.forEach(el => observer.observe(el))
|
|
232
|
+
|
|
233
|
+
return () => {
|
|
234
|
+
observer.disconnect()
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function setupHoverPrefetch(doc: Document, delay: number): () => void {
|
|
239
|
+
let hoverTimeout: ReturnType<typeof setTimeout> | null = null
|
|
240
|
+
let lastHoveredElement: Element | null = null
|
|
241
|
+
|
|
242
|
+
const handlePointerOver = (event: Event) => {
|
|
243
|
+
const target = event.target
|
|
244
|
+
if (!(target instanceof Element)) return
|
|
245
|
+
|
|
246
|
+
// Find the closest element with interactive attributes
|
|
247
|
+
const interactiveEl =
|
|
248
|
+
target.closest('[on\\:click]') ||
|
|
249
|
+
target.closest('[on\\:input]') ||
|
|
250
|
+
target.closest('[on\\:change]') ||
|
|
251
|
+
target.closest('[on\\:submit]') ||
|
|
252
|
+
target.closest('[data-fict-h]')
|
|
253
|
+
|
|
254
|
+
if (!interactiveEl || interactiveEl === lastHoveredElement) return
|
|
255
|
+
|
|
256
|
+
lastHoveredElement = interactiveEl
|
|
257
|
+
|
|
258
|
+
// Clear previous timeout
|
|
259
|
+
if (hoverTimeout) {
|
|
260
|
+
clearTimeout(hoverTimeout)
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// Debounce prefetch
|
|
264
|
+
hoverTimeout = setTimeout(() => {
|
|
265
|
+
prefetchElementQrls(interactiveEl)
|
|
266
|
+
}, delay)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const handlePointerOut = () => {
|
|
270
|
+
if (hoverTimeout) {
|
|
271
|
+
clearTimeout(hoverTimeout)
|
|
272
|
+
hoverTimeout = null
|
|
273
|
+
}
|
|
274
|
+
lastHoveredElement = null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
doc.addEventListener('pointerover', handlePointerOver, { passive: true })
|
|
278
|
+
doc.addEventListener('pointerout', handlePointerOut, { passive: true })
|
|
279
|
+
|
|
280
|
+
return () => {
|
|
281
|
+
doc.removeEventListener('pointerover', handlePointerOver)
|
|
282
|
+
doc.removeEventListener('pointerout', handlePointerOut)
|
|
283
|
+
if (hoverTimeout) {
|
|
284
|
+
clearTimeout(hoverTimeout)
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function prefetchElementQrls(el: Element): void {
|
|
290
|
+
// Prefetch event handler QRLs
|
|
291
|
+
const eventAttrs = ['on:click', 'on:input', 'on:change', 'on:submit', 'on:keydown', 'on:keyup']
|
|
292
|
+
for (const attr of eventAttrs) {
|
|
293
|
+
const qrl = el.getAttribute(attr)
|
|
294
|
+
if (qrl) {
|
|
295
|
+
prefetchQrl(qrl)
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Prefetch resume handler QRL
|
|
300
|
+
const resumeQrl = el.getAttribute('data-fict-h')
|
|
301
|
+
if (resumeQrl) {
|
|
302
|
+
prefetchQrl(resumeQrl)
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// Also check children for nested QRLs
|
|
306
|
+
const children = el.querySelectorAll(
|
|
307
|
+
'[on\\:click], [on\\:input], [on\\:change], [on\\:submit], [data-fict-h]',
|
|
308
|
+
)
|
|
309
|
+
children.forEach(child => {
|
|
310
|
+
for (const attr of eventAttrs) {
|
|
311
|
+
const qrl = child.getAttribute(attr)
|
|
312
|
+
if (qrl) {
|
|
313
|
+
prefetchQrl(qrl)
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
const childResumeQrl = child.getAttribute('data-fict-h')
|
|
317
|
+
if (childResumeQrl) {
|
|
318
|
+
prefetchQrl(childResumeQrl)
|
|
319
|
+
}
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function prefetchQrl(qrl: string): void {
|
|
324
|
+
const { url } = parseQrl(qrl)
|
|
325
|
+
if (!url || prefetchedUrls.has(url)) return
|
|
326
|
+
|
|
327
|
+
prefetchedUrls.add(url)
|
|
328
|
+
|
|
329
|
+
// Resolve through manifest for production builds
|
|
330
|
+
const resolvedUrl = resolveModuleUrl(url)
|
|
331
|
+
|
|
332
|
+
// Use modulepreload link for best browser support
|
|
333
|
+
if (typeof document !== 'undefined') {
|
|
334
|
+
const link = document.createElement('link')
|
|
335
|
+
link.rel = 'modulepreload'
|
|
336
|
+
link.href = resolvedUrl
|
|
337
|
+
link.crossOrigin = 'anonymous'
|
|
338
|
+
document.head.appendChild(link)
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ============================================================================
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Wrapper that tracks the async handler promise for testing.
|
|
346
|
+
*/
|
|
347
|
+
function handleResumableEvent(event: Event): void {
|
|
348
|
+
const promise = handleResumableEventAsync(event)
|
|
349
|
+
pendingHandlers.add(promise)
|
|
350
|
+
promise.finally(() => {
|
|
351
|
+
pendingHandlers.delete(promise)
|
|
352
|
+
})
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleResumableEventAsync(event: Event): Promise<void> {
|
|
356
|
+
const path =
|
|
357
|
+
typeof event.composedPath === 'function' ? event.composedPath() : buildEventPath(event)
|
|
358
|
+
|
|
359
|
+
for (const node of path) {
|
|
360
|
+
if (!(node instanceof Element)) continue
|
|
361
|
+
const qrl = node.getAttribute(`on:${event.type}`)
|
|
362
|
+
if (!qrl) continue
|
|
363
|
+
|
|
364
|
+
const host = node.closest('[data-fict-s]') as Element | null
|
|
365
|
+
if (!host) continue
|
|
366
|
+
const scopeId = host.getAttribute('data-fict-s')
|
|
367
|
+
if (!scopeId) continue
|
|
368
|
+
|
|
369
|
+
const snapshot = __fictGetSSRScope(scopeId)
|
|
370
|
+
if (snapshot) {
|
|
371
|
+
__fictEnsureScope(scopeId, host, snapshot)
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const { url, exportName } = parseQrl(qrl)
|
|
375
|
+
|
|
376
|
+
// Pre-emptively prevent default on navigations/forms while we await modules
|
|
377
|
+
if (event.cancelable && (event.type === 'click' || event.type === 'submit')) {
|
|
378
|
+
const tag = node.tagName.toLowerCase()
|
|
379
|
+
if (tag === 'a' || tag === 'form') {
|
|
380
|
+
event.preventDefault()
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Resume FIRST to set up reactive bindings BEFORE the handler runs
|
|
385
|
+
if (!hydratedScopes.has(scopeId)) {
|
|
386
|
+
const resumeQrl = host.getAttribute('data-fict-h')
|
|
387
|
+
if (resumeQrl) {
|
|
388
|
+
const { url: resumeUrl, exportName: resumeExport } = parseQrl(resumeQrl)
|
|
389
|
+
const resolvedResumeUrl = resolveModuleUrl(resumeUrl)
|
|
390
|
+
// Load the module to ensure resume functions are registered
|
|
391
|
+
await import(/* @vite-ignore */ resolvedResumeUrl)
|
|
392
|
+
// Get resume function from registry (not module exports)
|
|
393
|
+
const resumeFn = __fictGetResume(resumeExport)
|
|
394
|
+
if (typeof resumeFn === 'function') {
|
|
395
|
+
await (resumeFn as (scopeId: string, host: Element) => unknown)(scopeId, host)
|
|
396
|
+
hydratedScopes.add(scopeId)
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// THEN run the handler - now signal updates will trigger DOM updates
|
|
402
|
+
const resolvedUrl = resolveModuleUrl(url)
|
|
403
|
+
const mod = await import(/* @vite-ignore */ resolvedUrl)
|
|
404
|
+
const handler = (mod as Record<string, unknown>)[exportName]
|
|
405
|
+
if (typeof handler === 'function') {
|
|
406
|
+
await (handler as (scopeId: string, ev: Event, el: Element) => unknown)(scopeId, event, node)
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function parseQrl(qrl: string): { url: string; exportName: string } {
|
|
414
|
+
const [ref] = qrl.split('[')
|
|
415
|
+
if (!ref) {
|
|
416
|
+
return { url: '', exportName: 'default' }
|
|
417
|
+
}
|
|
418
|
+
const hashIndex = ref.lastIndexOf('#')
|
|
419
|
+
if (hashIndex === -1) {
|
|
420
|
+
return { url: ref, exportName: 'default' }
|
|
421
|
+
}
|
|
422
|
+
return { url: ref.slice(0, hashIndex), exportName: ref.slice(hashIndex + 1) }
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function buildEventPath(event: Event): EventTarget[] {
|
|
426
|
+
const path: EventTarget[] = []
|
|
427
|
+
let node: EventTarget | null = event.target
|
|
428
|
+
while (node) {
|
|
429
|
+
path.push(node)
|
|
430
|
+
node = (node as Node).parentNode
|
|
431
|
+
}
|
|
432
|
+
path.push(window)
|
|
433
|
+
return path
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// Re-export for handler authors (optional)
|
|
437
|
+
export { __fictUseLexicalScope } from './resume'
|
package/src/node-ops.ts
CHANGED
|
@@ -183,3 +183,68 @@ export function removeNodes(nodes: Node[]): void {
|
|
|
183
183
|
node.parentNode?.removeChild(node)
|
|
184
184
|
}
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
const SLOT_START = 'fict:slot:start'
|
|
188
|
+
const SLOT_END = 'fict:slot:end'
|
|
189
|
+
|
|
190
|
+
function isSlotStart(node: Node | null): node is Comment {
|
|
191
|
+
return !!(node && node.nodeType === 8 && (node as Comment).data === SLOT_START)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function isSlotEnd(node: Node | null): node is Comment {
|
|
195
|
+
return !!(node && node.nodeType === 8 && (node as Comment).data === SLOT_END)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function getSlotEnd(start: Comment): Comment {
|
|
199
|
+
let depth = 1
|
|
200
|
+
let cursor: Node | null = start.nextSibling
|
|
201
|
+
while (cursor) {
|
|
202
|
+
if (isSlotStart(cursor)) {
|
|
203
|
+
depth++
|
|
204
|
+
} else if (isSlotEnd(cursor)) {
|
|
205
|
+
depth--
|
|
206
|
+
if (depth === 0) {
|
|
207
|
+
return cursor
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
cursor = cursor.nextSibling
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const owner = start.ownerDocument ?? document
|
|
214
|
+
const end = owner.createComment(SLOT_END)
|
|
215
|
+
if (start.parentNode) {
|
|
216
|
+
start.parentNode.insertBefore(end, start.nextSibling)
|
|
217
|
+
}
|
|
218
|
+
return end
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function resolvePath(root: Node, path: number[]): Node | null {
|
|
222
|
+
let current: Node | null = root
|
|
223
|
+
for (const index of path) {
|
|
224
|
+
if (!current) return null
|
|
225
|
+
let child: Node | null = current.firstChild
|
|
226
|
+
let currentIndex = 0
|
|
227
|
+
while (child) {
|
|
228
|
+
if (isSlotStart(child)) {
|
|
229
|
+
if (currentIndex === index) {
|
|
230
|
+
current = child
|
|
231
|
+
break
|
|
232
|
+
}
|
|
233
|
+
const end = getSlotEnd(child as Comment)
|
|
234
|
+
child = end.nextSibling
|
|
235
|
+
currentIndex++
|
|
236
|
+
continue
|
|
237
|
+
}
|
|
238
|
+
if (currentIndex === index) {
|
|
239
|
+
current = child
|
|
240
|
+
break
|
|
241
|
+
}
|
|
242
|
+
currentIndex++
|
|
243
|
+
child = child.nextSibling
|
|
244
|
+
}
|
|
245
|
+
if (!child) {
|
|
246
|
+
return null
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return current
|
|
250
|
+
}
|