@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.
Files changed (70) hide show
  1. package/dist/advanced.cjs +10 -8
  2. package/dist/advanced.cjs.map +1 -1
  3. package/dist/advanced.d.cts +4 -3
  4. package/dist/advanced.d.ts +4 -3
  5. package/dist/advanced.js +10 -8
  6. package/dist/advanced.js.map +1 -1
  7. package/dist/{chunk-TWELIZRY.js → chunk-5AA7HP4S.js} +5 -3
  8. package/dist/{chunk-TWELIZRY.js.map → chunk-5AA7HP4S.js.map} +1 -1
  9. package/dist/chunk-6SOPF5LZ.cjs +2363 -0
  10. package/dist/chunk-6SOPF5LZ.cjs.map +1 -0
  11. package/dist/{chunk-SO6X7G5S.js → chunk-BQG7VEBY.js} +501 -1880
  12. package/dist/chunk-BQG7VEBY.js.map +1 -0
  13. package/dist/chunk-FKDMDAUR.js +2363 -0
  14. package/dist/chunk-FKDMDAUR.js.map +1 -0
  15. package/dist/{chunk-L4DIV3RC.cjs → chunk-GHUV2FLD.cjs} +9 -7
  16. package/dist/chunk-GHUV2FLD.cjs.map +1 -0
  17. package/dist/{chunk-XLIZJMMJ.js → chunk-KKKYW54Z.js} +8 -6
  18. package/dist/{chunk-XLIZJMMJ.js.map → chunk-KKKYW54Z.js.map} +1 -1
  19. package/dist/{chunk-M2TSXZ4C.cjs → chunk-KYLNC4CD.cjs} +18 -16
  20. package/dist/chunk-KYLNC4CD.cjs.map +1 -0
  21. package/dist/chunk-TKWN42TA.cjs +2259 -0
  22. package/dist/chunk-TKWN42TA.cjs.map +1 -0
  23. package/dist/{context-B25xyQrJ.d.cts → context-CTBE00S_.d.cts} +1 -1
  24. package/dist/{context-CGdP7_Jb.d.ts → context-lkLhbkFJ.d.ts} +1 -1
  25. package/dist/{effect-D6kaLM2-.d.cts → effect-BpSNEJJz.d.cts} +7 -67
  26. package/dist/{effect-D6kaLM2-.d.ts → effect-BpSNEJJz.d.ts} +7 -67
  27. package/dist/index.cjs +40 -38
  28. package/dist/index.cjs.map +1 -1
  29. package/dist/index.d.cts +5 -4
  30. package/dist/index.d.ts +5 -4
  31. package/dist/index.dev.js +92 -4
  32. package/dist/index.dev.js.map +1 -1
  33. package/dist/index.js +19 -17
  34. package/dist/index.js.map +1 -1
  35. package/dist/internal.cjs +189 -202
  36. package/dist/internal.cjs.map +1 -1
  37. package/dist/internal.d.cts +13 -23
  38. package/dist/internal.d.ts +13 -23
  39. package/dist/internal.js +195 -208
  40. package/dist/internal.js.map +1 -1
  41. package/dist/loader.cjs +280 -0
  42. package/dist/loader.cjs.map +1 -0
  43. package/dist/loader.d.cts +57 -0
  44. package/dist/loader.d.ts +57 -0
  45. package/dist/loader.js +280 -0
  46. package/dist/loader.js.map +1 -0
  47. package/dist/{props-BIfromL0.d.cts → props-XTHYD19o.d.cts} +13 -2
  48. package/dist/{props-BEgIVMRx.d.ts → props-x-HbI-jX.d.ts} +13 -2
  49. package/dist/resume-BrAkmSTY.d.cts +79 -0
  50. package/dist/resume-Dx8_l72o.d.ts +79 -0
  51. package/dist/{scope-CzNkn587.d.ts → scope-CdbGmsFf.d.ts} +1 -1
  52. package/dist/{scope-Cx_3CjIZ.d.cts → scope-DfcP9I-A.d.cts} +1 -1
  53. package/dist/signal-C4ISF17w.d.cts +66 -0
  54. package/dist/signal-C4ISF17w.d.ts +66 -0
  55. package/package.json +8 -3
  56. package/src/binding.ts +254 -5
  57. package/src/dom.ts +103 -5
  58. package/src/hooks.ts +15 -2
  59. package/src/hydration.ts +75 -0
  60. package/src/internal.ts +34 -2
  61. package/src/list-helpers.ts +113 -12
  62. package/src/loader.ts +437 -0
  63. package/src/node-ops.ts +65 -0
  64. package/src/resume.ts +517 -0
  65. package/src/store.ts +8 -0
  66. package/dist/chunk-ID3WBWNO.cjs +0 -3638
  67. package/dist/chunk-ID3WBWNO.cjs.map +0 -1
  68. package/dist/chunk-L4DIV3RC.cjs.map +0 -1
  69. package/dist/chunk-M2TSXZ4C.cjs.map +0 -1
  70. 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
+ }