@fictjs/runtime 0.5.1 → 0.6.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 (46) hide show
  1. package/dist/advanced.cjs +9 -9
  2. package/dist/advanced.js +4 -4
  3. package/dist/{chunk-AR6NSCZM.js → chunk-4NUHM77Z.js} +3 -3
  4. package/dist/{chunk-RY5CY4CI.js → chunk-D2IWOO4X.js} +2 -2
  5. package/dist/{chunk-LFMXNQZC.cjs → chunk-KNGHYGK4.cjs} +17 -17
  6. package/dist/{chunk-LFMXNQZC.cjs.map → chunk-KNGHYGK4.cjs.map} +1 -1
  7. package/dist/{chunk-7BO6P2KP.js → chunk-LRFMCJY3.js} +84 -1
  8. package/dist/chunk-LRFMCJY3.js.map +1 -0
  9. package/dist/{chunk-4ZPZM5IG.cjs → chunk-QB2UD62G.cjs} +8 -8
  10. package/dist/{chunk-4ZPZM5IG.cjs.map → chunk-QB2UD62G.cjs.map} +1 -1
  11. package/dist/{chunk-5OYBRKE4.js → chunk-SLFAEVKJ.js} +3 -3
  12. package/dist/{chunk-WJHXPF7M.cjs → chunk-Z6M3HKLG.cjs} +156 -156
  13. package/dist/{chunk-WJHXPF7M.cjs.map → chunk-Z6M3HKLG.cjs.map} +1 -1
  14. package/dist/{chunk-6RCEIWZL.cjs → chunk-ZR435MDC.cjs} +85 -2
  15. package/dist/chunk-ZR435MDC.cjs.map +1 -0
  16. package/dist/index.cjs +95 -45
  17. package/dist/index.cjs.map +1 -1
  18. package/dist/index.dev.js +87 -7
  19. package/dist/index.dev.js.map +1 -1
  20. package/dist/index.js +60 -10
  21. package/dist/index.js.map +1 -1
  22. package/dist/internal.cjs +51 -41
  23. package/dist/internal.cjs.map +1 -1
  24. package/dist/internal.d.cts +11 -2
  25. package/dist/internal.d.ts +11 -2
  26. package/dist/internal.js +13 -3
  27. package/dist/internal.js.map +1 -1
  28. package/dist/loader.cjs +60 -8
  29. package/dist/loader.cjs.map +1 -1
  30. package/dist/loader.d.cts +1 -1
  31. package/dist/loader.d.ts +1 -1
  32. package/dist/loader.js +53 -1
  33. package/dist/loader.js.map +1 -1
  34. package/dist/{resume-Dx8_l72o.d.ts → resume-CqeQ3v_q.d.ts} +5 -1
  35. package/dist/{resume-BrAkmSTY.d.cts → resume-i-A3EFox.d.cts} +5 -1
  36. package/package.json +1 -1
  37. package/src/internal.ts +4 -0
  38. package/src/loader.ts +58 -0
  39. package/src/resume.ts +55 -0
  40. package/src/ssr-stream.ts +38 -0
  41. package/src/suspense.ts +62 -7
  42. package/dist/chunk-6RCEIWZL.cjs.map +0 -1
  43. package/dist/chunk-7BO6P2KP.js.map +0 -1
  44. /package/dist/{chunk-AR6NSCZM.js.map → chunk-4NUHM77Z.js.map} +0 -0
  45. /package/dist/{chunk-RY5CY4CI.js.map → chunk-D2IWOO4X.js.map} +0 -0
  46. /package/dist/{chunk-5OYBRKE4.js.map → chunk-SLFAEVKJ.js.map} +0 -0
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/loader.ts"],"sourcesContent":["import { DelegatedEvents } from './constants'\nimport {\n __fictEnableResumable,\n __fictEnsureScope,\n __fictGetResume,\n __fictGetSSRScope,\n __fictSetSSRState,\n __fictUseLexicalScope,\n} from './resume'\n\n// ============================================================================\n// Module Resolution\n// ============================================================================\n\n/**\n * Resolve a module URL through the manifest if available.\n * In production, virtual module URLs (virtual:fict-handler:...) are mapped\n * to their built chunk URLs through the manifest.\n */\nfunction resolveModuleUrl(url: string): string {\n const manifest = (globalThis as Record<string, unknown>).__FICT_MANIFEST__ as\n | Record<string, string>\n | undefined\n\n if (manifest) {\n // Check if the URL (without #fragment) is in the manifest\n const resolved = manifest[url]\n if (resolved) {\n return resolved\n }\n }\n\n return url\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface PrefetchStrategy {\n /**\n * Enable visibility-based prefetch using IntersectionObserver.\n * Prefetches modules when interactive elements come into view.\n * @default true\n */\n visibility?: boolean\n /**\n * Root margin for IntersectionObserver (e.g., '200px' to prefetch earlier).\n * @default '200px'\n */\n visibilityMargin?: string\n /**\n * Enable hover-based prefetch using pointerover events.\n * Prefetches modules when user hovers over interactive elements.\n * @default true\n */\n hover?: boolean\n /**\n * Delay in ms before prefetching on hover (debounce rapid movements).\n * @default 50\n */\n hoverDelay?: number\n}\n\nexport interface ResumableLoaderOptions {\n document?: Document\n snapshotScriptId?: string\n events?: string[]\n /**\n * Prefetch strategy configuration.\n * Set to false to disable all prefetching.\n * @default { visibility: true, hover: true }\n */\n prefetch?: PrefetchStrategy | false\n}\n\n// ============================================================================\n// State\n// ============================================================================\n\nconst hydratedScopes = new Set<string>()\nconst prefetchedUrls = new Set<string>()\nlet prefetchCleanup: (() => void) | null = null\nlet eventListenerCleanup: (() => void) | null = null\n\n/**\n * Reset the hydrated scopes set. Useful for testing.\n */\nexport function resetHydratedScopes(): void {\n hydratedScopes.clear()\n}\n\n/**\n * Reset the prefetched URLs set. Useful for testing.\n */\nexport function resetPrefetchedUrls(): void {\n prefetchedUrls.clear()\n}\n\n/**\n * Set of pending handler promises. Used for testing to wait for all handlers to complete.\n */\nconst pendingHandlers = new Set<Promise<void>>()\n\n/**\n * Wait for all pending event handlers to complete. Useful for testing.\n */\nexport async function waitForPendingHandlers(): Promise<void> {\n if (pendingHandlers.size === 0) return\n await Promise.allSettled([...pendingHandlers])\n}\n\n/**\n * Clean up all registered event listeners. Useful for testing.\n */\nexport function cleanupEventListeners(): void {\n if (eventListenerCleanup) {\n eventListenerCleanup()\n eventListenerCleanup = null\n }\n}\n\n// ============================================================================\n// Main Entry Point\n// ============================================================================\n\nexport function installResumableLoader(options: ResumableLoaderOptions = {}): void {\n const doc = options.document ?? window.document\n const scriptId = options.snapshotScriptId ?? '__FICT_SNAPSHOT__'\n\n // Reset hydrated scopes for fresh loader installation\n hydratedScopes.clear()\n prefetchedUrls.clear()\n\n // Clean up previous event listeners\n if (eventListenerCleanup) {\n eventListenerCleanup()\n eventListenerCleanup = null\n }\n\n // Clean up previous prefetch handlers\n if (prefetchCleanup) {\n prefetchCleanup()\n prefetchCleanup = null\n }\n\n const snapshotEl = doc.getElementById(scriptId)\n if (snapshotEl?.textContent) {\n try {\n const state = JSON.parse(snapshotEl.textContent)\n __fictSetSSRState(state)\n } catch {\n // Ignore parse errors\n }\n }\n\n __fictEnableResumable()\n\n const events = options.events ?? Array.from(DelegatedEvents)\n for (const eventName of events) {\n doc.addEventListener(eventName, handleResumableEvent, true)\n }\n\n // Store cleanup function for event listeners\n eventListenerCleanup = () => {\n for (const eventName of events) {\n doc.removeEventListener(eventName, handleResumableEvent, true)\n }\n }\n\n // Setup prefetch if enabled\n if (options.prefetch !== false) {\n prefetchCleanup = setupPrefetch(doc, options.prefetch ?? {})\n }\n}\n\n// ============================================================================\n// Prefetch Implementation\n// ============================================================================\n\nfunction setupPrefetch(doc: Document, strategy: PrefetchStrategy): () => void {\n const cleanupFns: (() => void)[] = []\n\n // Visibility-based prefetch\n if (strategy.visibility !== false) {\n const cleanup = setupVisibilityPrefetch(doc, strategy.visibilityMargin ?? '200px')\n cleanupFns.push(cleanup)\n }\n\n // Hover-based prefetch\n if (strategy.hover !== false) {\n const cleanup = setupHoverPrefetch(doc, strategy.hoverDelay ?? 50)\n cleanupFns.push(cleanup)\n }\n\n return () => {\n for (const cleanup of cleanupFns) {\n cleanup()\n }\n }\n}\n\nfunction setupVisibilityPrefetch(doc: Document, rootMargin: string): () => void {\n // Check if IntersectionObserver is available\n if (typeof IntersectionObserver === 'undefined') {\n return () => {}\n }\n\n const observer = new IntersectionObserver(\n entries => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const el = entry.target as Element\n prefetchElementQrls(el)\n // Stop observing after prefetch\n observer.unobserve(el)\n }\n }\n },\n { rootMargin },\n )\n\n // Observe all elements with on:* attributes\n const interactiveElements = doc.querySelectorAll(\n '[on\\\\:click], [on\\\\:input], [on\\\\:change], [on\\\\:submit], [on\\\\:keydown], [on\\\\:keyup]',\n )\n interactiveElements.forEach(el => observer.observe(el))\n\n // Also observe elements with data-fict-h (resumable components)\n const resumableHosts = doc.querySelectorAll('[data-fict-h]')\n resumableHosts.forEach(el => observer.observe(el))\n\n return () => {\n observer.disconnect()\n }\n}\n\nfunction setupHoverPrefetch(doc: Document, delay: number): () => void {\n let hoverTimeout: ReturnType<typeof setTimeout> | null = null\n let lastHoveredElement: Element | null = null\n\n const handlePointerOver = (event: Event) => {\n const target = event.target\n if (!(target instanceof Element)) return\n\n // Find the closest element with interactive attributes\n const interactiveEl =\n target.closest('[on\\\\:click]') ||\n target.closest('[on\\\\:input]') ||\n target.closest('[on\\\\:change]') ||\n target.closest('[on\\\\:submit]') ||\n target.closest('[data-fict-h]')\n\n if (!interactiveEl || interactiveEl === lastHoveredElement) return\n\n lastHoveredElement = interactiveEl\n\n // Clear previous timeout\n if (hoverTimeout) {\n clearTimeout(hoverTimeout)\n }\n\n // Debounce prefetch\n hoverTimeout = setTimeout(() => {\n prefetchElementQrls(interactiveEl)\n }, delay)\n }\n\n const handlePointerOut = () => {\n if (hoverTimeout) {\n clearTimeout(hoverTimeout)\n hoverTimeout = null\n }\n lastHoveredElement = null\n }\n\n doc.addEventListener('pointerover', handlePointerOver, { passive: true })\n doc.addEventListener('pointerout', handlePointerOut, { passive: true })\n\n return () => {\n doc.removeEventListener('pointerover', handlePointerOver)\n doc.removeEventListener('pointerout', handlePointerOut)\n if (hoverTimeout) {\n clearTimeout(hoverTimeout)\n }\n }\n}\n\nfunction prefetchElementQrls(el: Element): void {\n // Prefetch event handler QRLs\n const eventAttrs = ['on:click', 'on:input', 'on:change', 'on:submit', 'on:keydown', 'on:keyup']\n for (const attr of eventAttrs) {\n const qrl = el.getAttribute(attr)\n if (qrl) {\n prefetchQrl(qrl)\n }\n }\n\n // Prefetch resume handler QRL\n const resumeQrl = el.getAttribute('data-fict-h')\n if (resumeQrl) {\n prefetchQrl(resumeQrl)\n }\n\n // Also check children for nested QRLs\n const children = el.querySelectorAll(\n '[on\\\\:click], [on\\\\:input], [on\\\\:change], [on\\\\:submit], [data-fict-h]',\n )\n children.forEach(child => {\n for (const attr of eventAttrs) {\n const qrl = child.getAttribute(attr)\n if (qrl) {\n prefetchQrl(qrl)\n }\n }\n const childResumeQrl = child.getAttribute('data-fict-h')\n if (childResumeQrl) {\n prefetchQrl(childResumeQrl)\n }\n })\n}\n\nfunction prefetchQrl(qrl: string): void {\n const { url } = parseQrl(qrl)\n if (!url || prefetchedUrls.has(url)) return\n\n prefetchedUrls.add(url)\n\n // Resolve through manifest for production builds\n const resolvedUrl = resolveModuleUrl(url)\n\n // Use modulepreload link for best browser support\n if (typeof document !== 'undefined') {\n const link = document.createElement('link')\n link.rel = 'modulepreload'\n link.href = resolvedUrl\n link.crossOrigin = 'anonymous'\n document.head.appendChild(link)\n }\n}\n\n// ============================================================================\n\n/**\n * Wrapper that tracks the async handler promise for testing.\n */\nfunction handleResumableEvent(event: Event): void {\n const promise = handleResumableEventAsync(event)\n pendingHandlers.add(promise)\n promise.finally(() => {\n pendingHandlers.delete(promise)\n })\n}\n\nasync function handleResumableEventAsync(event: Event): Promise<void> {\n const path =\n typeof event.composedPath === 'function' ? event.composedPath() : buildEventPath(event)\n\n for (const node of path) {\n if (!(node instanceof Element)) continue\n const qrl = node.getAttribute(`on:${event.type}`)\n if (!qrl) continue\n\n const host = node.closest('[data-fict-s]') as Element | null\n if (!host) continue\n const scopeId = host.getAttribute('data-fict-s')\n if (!scopeId) continue\n\n const snapshot = __fictGetSSRScope(scopeId)\n if (snapshot) {\n __fictEnsureScope(scopeId, host, snapshot)\n }\n\n const { url, exportName } = parseQrl(qrl)\n\n // Pre-emptively prevent default on navigations/forms while we await modules\n if (event.cancelable && (event.type === 'click' || event.type === 'submit')) {\n const tag = node.tagName.toLowerCase()\n if (tag === 'a' || tag === 'form') {\n event.preventDefault()\n }\n }\n\n // Resume FIRST to set up reactive bindings BEFORE the handler runs\n if (!hydratedScopes.has(scopeId)) {\n const resumeQrl = host.getAttribute('data-fict-h')\n if (resumeQrl) {\n const { url: resumeUrl, exportName: resumeExport } = parseQrl(resumeQrl)\n const resolvedResumeUrl = resolveModuleUrl(resumeUrl)\n // Load the module to ensure resume functions are registered\n await import(/* @vite-ignore */ resolvedResumeUrl)\n // Get resume function from registry (not module exports)\n const resumeFn = __fictGetResume(resumeExport)\n if (typeof resumeFn === 'function') {\n await (resumeFn as (scopeId: string, host: Element) => unknown)(scopeId, host)\n hydratedScopes.add(scopeId)\n }\n }\n }\n\n // THEN run the handler - now signal updates will trigger DOM updates\n const resolvedUrl = resolveModuleUrl(url)\n const mod = await import(/* @vite-ignore */ resolvedUrl)\n const handler = (mod as Record<string, unknown>)[exportName]\n if (typeof handler === 'function') {\n await (handler as (scopeId: string, ev: Event, el: Element) => unknown)(scopeId, event, node)\n }\n\n return\n }\n}\n\nfunction parseQrl(qrl: string): { url: string; exportName: string } {\n const [ref] = qrl.split('[')\n if (!ref) {\n return { url: '', exportName: 'default' }\n }\n const hashIndex = ref.lastIndexOf('#')\n if (hashIndex === -1) {\n return { url: ref, exportName: 'default' }\n }\n return { url: ref.slice(0, hashIndex), exportName: ref.slice(hashIndex + 1) }\n}\n\nfunction buildEventPath(event: Event): EventTarget[] {\n const path: EventTarget[] = []\n let node: EventTarget | null = event.target\n while (node) {\n path.push(node)\n node = (node as Node).parentNode\n }\n path.push(window)\n return path\n}\n\n// Re-export for handler authors (optional)\nexport { __fictUseLexicalScope } from './resume'\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../src/loader.ts"],"sourcesContent":["import { DelegatedEvents } from './constants'\nimport {\n __fictEnableResumable,\n __fictEnsureScope,\n __fictGetResume,\n __fictGetSSRScope,\n __fictMergeSSRState,\n __fictSetSSRState,\n __fictUseLexicalScope,\n} from './resume'\n\n// ============================================================================\n// Module Resolution\n// ============================================================================\n\n/**\n * Resolve a module URL through the manifest if available.\n * In production, virtual module URLs (virtual:fict-handler:...) are mapped\n * to their built chunk URLs through the manifest.\n */\nfunction resolveModuleUrl(url: string): string {\n const manifest = (globalThis as Record<string, unknown>).__FICT_MANIFEST__ as\n | Record<string, string>\n | undefined\n\n if (manifest) {\n // Check if the URL (without #fragment) is in the manifest\n const resolved = manifest[url]\n if (resolved) {\n return resolved\n }\n }\n\n return url\n}\n\n// ============================================================================\n// Types\n// ============================================================================\n\nexport interface PrefetchStrategy {\n /**\n * Enable visibility-based prefetch using IntersectionObserver.\n * Prefetches modules when interactive elements come into view.\n * @default true\n */\n visibility?: boolean\n /**\n * Root margin for IntersectionObserver (e.g., '200px' to prefetch earlier).\n * @default '200px'\n */\n visibilityMargin?: string\n /**\n * Enable hover-based prefetch using pointerover events.\n * Prefetches modules when user hovers over interactive elements.\n * @default true\n */\n hover?: boolean\n /**\n * Delay in ms before prefetching on hover (debounce rapid movements).\n * @default 50\n */\n hoverDelay?: number\n}\n\nexport interface ResumableLoaderOptions {\n document?: Document\n snapshotScriptId?: string\n events?: string[]\n /**\n * Prefetch strategy configuration.\n * Set to false to disable all prefetching.\n * @default { visibility: true, hover: true }\n */\n prefetch?: PrefetchStrategy | false\n}\n\n// ============================================================================\n// State\n// ============================================================================\n\nconst hydratedScopes = new Set<string>()\nconst prefetchedUrls = new Set<string>()\nlet prefetchCleanup: (() => void) | null = null\nlet eventListenerCleanup: (() => void) | null = null\nlet snapshotObserver: MutationObserver | null = null\nconst processedSnapshots = new Set<HTMLScriptElement>()\n\n/**\n * Reset the hydrated scopes set. Useful for testing.\n */\nexport function resetHydratedScopes(): void {\n hydratedScopes.clear()\n}\n\n/**\n * Reset the prefetched URLs set. Useful for testing.\n */\nexport function resetPrefetchedUrls(): void {\n prefetchedUrls.clear()\n}\n\n/**\n * Set of pending handler promises. Used for testing to wait for all handlers to complete.\n */\nconst pendingHandlers = new Set<Promise<void>>()\n\n/**\n * Wait for all pending event handlers to complete. Useful for testing.\n */\nexport async function waitForPendingHandlers(): Promise<void> {\n if (pendingHandlers.size === 0) return\n await Promise.allSettled([...pendingHandlers])\n}\n\n/**\n * Clean up all registered event listeners. Useful for testing.\n */\nexport function cleanupEventListeners(): void {\n if (eventListenerCleanup) {\n eventListenerCleanup()\n eventListenerCleanup = null\n }\n}\n\n// ============================================================================\n// Main Entry Point\n// ============================================================================\n\nexport function installResumableLoader(options: ResumableLoaderOptions = {}): void {\n const doc = options.document ?? window.document\n const scriptId = options.snapshotScriptId ?? '__FICT_SNAPSHOT__'\n\n // Reset hydrated scopes for fresh loader installation\n hydratedScopes.clear()\n prefetchedUrls.clear()\n processedSnapshots.clear()\n\n // Clean up previous event listeners\n if (eventListenerCleanup) {\n eventListenerCleanup()\n eventListenerCleanup = null\n }\n\n // Clean up previous prefetch handlers\n if (prefetchCleanup) {\n prefetchCleanup()\n prefetchCleanup = null\n }\n\n if (snapshotObserver) {\n snapshotObserver.disconnect()\n snapshotObserver = null\n }\n\n const snapshotEl = doc.getElementById(scriptId)\n if (snapshotEl?.textContent) {\n try {\n const state = JSON.parse(snapshotEl.textContent)\n __fictSetSSRState(state)\n } catch {\n // Ignore parse errors\n }\n }\n\n const snapshotScripts = doc.querySelectorAll(\n 'script[type=\"application/json\"][data-fict-snapshot]',\n )\n for (const script of Array.from(snapshotScripts)) {\n parseSnapshotScript(script as HTMLScriptElement)\n }\n\n if (typeof MutationObserver !== 'undefined') {\n snapshotObserver = new MutationObserver(mutations => {\n for (const mutation of mutations) {\n for (const node of Array.from(mutation.addedNodes)) {\n if (!(node instanceof Element)) continue\n if (node.tagName === 'SCRIPT') {\n const script = node as HTMLScriptElement\n if (isSnapshotScript(script)) {\n parseSnapshotScript(script)\n }\n }\n const nested = node.querySelectorAll?.(\n 'script[type=\"application/json\"][data-fict-snapshot]',\n )\n if (nested && nested.length) {\n for (const script of Array.from(nested)) {\n parseSnapshotScript(script as HTMLScriptElement)\n }\n }\n }\n }\n })\n snapshotObserver.observe(doc.documentElement ?? doc, { childList: true, subtree: true })\n }\n\n __fictEnableResumable()\n\n const events = options.events ?? Array.from(DelegatedEvents)\n for (const eventName of events) {\n doc.addEventListener(eventName, handleResumableEvent, true)\n }\n\n // Store cleanup function for event listeners\n eventListenerCleanup = () => {\n for (const eventName of events) {\n doc.removeEventListener(eventName, handleResumableEvent, true)\n }\n }\n\n // Setup prefetch if enabled\n if (options.prefetch !== false) {\n prefetchCleanup = setupPrefetch(doc, options.prefetch ?? {})\n }\n}\n\nfunction isSnapshotScript(script: HTMLScriptElement): boolean {\n return script.type === 'application/json' && script.hasAttribute('data-fict-snapshot')\n}\n\nfunction parseSnapshotScript(script: HTMLScriptElement): void {\n if (processedSnapshots.has(script)) return\n processedSnapshots.add(script)\n const text = script.textContent\n if (!text) return\n try {\n const state = JSON.parse(text)\n __fictMergeSSRState(state)\n } catch {\n // Ignore parse errors\n }\n}\n\n// ============================================================================\n// Prefetch Implementation\n// ============================================================================\n\nfunction setupPrefetch(doc: Document, strategy: PrefetchStrategy): () => void {\n const cleanupFns: (() => void)[] = []\n\n // Visibility-based prefetch\n if (strategy.visibility !== false) {\n const cleanup = setupVisibilityPrefetch(doc, strategy.visibilityMargin ?? '200px')\n cleanupFns.push(cleanup)\n }\n\n // Hover-based prefetch\n if (strategy.hover !== false) {\n const cleanup = setupHoverPrefetch(doc, strategy.hoverDelay ?? 50)\n cleanupFns.push(cleanup)\n }\n\n return () => {\n for (const cleanup of cleanupFns) {\n cleanup()\n }\n }\n}\n\nfunction setupVisibilityPrefetch(doc: Document, rootMargin: string): () => void {\n // Check if IntersectionObserver is available\n if (typeof IntersectionObserver === 'undefined') {\n return () => {}\n }\n\n const observer = new IntersectionObserver(\n entries => {\n for (const entry of entries) {\n if (entry.isIntersecting) {\n const el = entry.target as Element\n prefetchElementQrls(el)\n // Stop observing after prefetch\n observer.unobserve(el)\n }\n }\n },\n { rootMargin },\n )\n\n // Observe all elements with on:* attributes\n const interactiveElements = doc.querySelectorAll(\n '[on\\\\:click], [on\\\\:input], [on\\\\:change], [on\\\\:submit], [on\\\\:keydown], [on\\\\:keyup]',\n )\n interactiveElements.forEach(el => observer.observe(el))\n\n // Also observe elements with data-fict-h (resumable components)\n const resumableHosts = doc.querySelectorAll('[data-fict-h]')\n resumableHosts.forEach(el => observer.observe(el))\n\n return () => {\n observer.disconnect()\n }\n}\n\nfunction setupHoverPrefetch(doc: Document, delay: number): () => void {\n let hoverTimeout: ReturnType<typeof setTimeout> | null = null\n let lastHoveredElement: Element | null = null\n\n const handlePointerOver = (event: Event) => {\n const target = event.target\n if (!(target instanceof Element)) return\n\n // Find the closest element with interactive attributes\n const interactiveEl =\n target.closest('[on\\\\:click]') ||\n target.closest('[on\\\\:input]') ||\n target.closest('[on\\\\:change]') ||\n target.closest('[on\\\\:submit]') ||\n target.closest('[data-fict-h]')\n\n if (!interactiveEl || interactiveEl === lastHoveredElement) return\n\n lastHoveredElement = interactiveEl\n\n // Clear previous timeout\n if (hoverTimeout) {\n clearTimeout(hoverTimeout)\n }\n\n // Debounce prefetch\n hoverTimeout = setTimeout(() => {\n prefetchElementQrls(interactiveEl)\n }, delay)\n }\n\n const handlePointerOut = () => {\n if (hoverTimeout) {\n clearTimeout(hoverTimeout)\n hoverTimeout = null\n }\n lastHoveredElement = null\n }\n\n doc.addEventListener('pointerover', handlePointerOver, { passive: true })\n doc.addEventListener('pointerout', handlePointerOut, { passive: true })\n\n return () => {\n doc.removeEventListener('pointerover', handlePointerOver)\n doc.removeEventListener('pointerout', handlePointerOut)\n if (hoverTimeout) {\n clearTimeout(hoverTimeout)\n }\n }\n}\n\nfunction prefetchElementQrls(el: Element): void {\n // Prefetch event handler QRLs\n const eventAttrs = ['on:click', 'on:input', 'on:change', 'on:submit', 'on:keydown', 'on:keyup']\n for (const attr of eventAttrs) {\n const qrl = el.getAttribute(attr)\n if (qrl) {\n prefetchQrl(qrl)\n }\n }\n\n // Prefetch resume handler QRL\n const resumeQrl = el.getAttribute('data-fict-h')\n if (resumeQrl) {\n prefetchQrl(resumeQrl)\n }\n\n // Also check children for nested QRLs\n const children = el.querySelectorAll(\n '[on\\\\:click], [on\\\\:input], [on\\\\:change], [on\\\\:submit], [data-fict-h]',\n )\n children.forEach(child => {\n for (const attr of eventAttrs) {\n const qrl = child.getAttribute(attr)\n if (qrl) {\n prefetchQrl(qrl)\n }\n }\n const childResumeQrl = child.getAttribute('data-fict-h')\n if (childResumeQrl) {\n prefetchQrl(childResumeQrl)\n }\n })\n}\n\nfunction prefetchQrl(qrl: string): void {\n const { url } = parseQrl(qrl)\n if (!url || prefetchedUrls.has(url)) return\n\n prefetchedUrls.add(url)\n\n // Resolve through manifest for production builds\n const resolvedUrl = resolveModuleUrl(url)\n\n // Use modulepreload link for best browser support\n if (typeof document !== 'undefined') {\n const link = document.createElement('link')\n link.rel = 'modulepreload'\n link.href = resolvedUrl\n link.crossOrigin = 'anonymous'\n document.head.appendChild(link)\n }\n}\n\n// ============================================================================\n\n/**\n * Wrapper that tracks the async handler promise for testing.\n */\nfunction handleResumableEvent(event: Event): void {\n const promise = handleResumableEventAsync(event)\n pendingHandlers.add(promise)\n promise.finally(() => {\n pendingHandlers.delete(promise)\n })\n}\n\nasync function handleResumableEventAsync(event: Event): Promise<void> {\n const path =\n typeof event.composedPath === 'function' ? event.composedPath() : buildEventPath(event)\n\n for (const node of path) {\n if (!(node instanceof Element)) continue\n const qrl = node.getAttribute(`on:${event.type}`)\n if (!qrl) continue\n\n const host = node.closest('[data-fict-s]') as Element | null\n if (!host) continue\n const scopeId = host.getAttribute('data-fict-s')\n if (!scopeId) continue\n\n const snapshot = __fictGetSSRScope(scopeId)\n if (snapshot) {\n __fictEnsureScope(scopeId, host, snapshot)\n }\n\n const { url, exportName } = parseQrl(qrl)\n\n // Pre-emptively prevent default on navigations/forms while we await modules\n if (event.cancelable && (event.type === 'click' || event.type === 'submit')) {\n const tag = node.tagName.toLowerCase()\n if (tag === 'a' || tag === 'form') {\n event.preventDefault()\n }\n }\n\n // Resume FIRST to set up reactive bindings BEFORE the handler runs\n if (!hydratedScopes.has(scopeId)) {\n const resumeQrl = host.getAttribute('data-fict-h')\n if (resumeQrl) {\n const { url: resumeUrl, exportName: resumeExport } = parseQrl(resumeQrl)\n const resolvedResumeUrl = resolveModuleUrl(resumeUrl)\n // Load the module to ensure resume functions are registered\n await import(/* @vite-ignore */ resolvedResumeUrl)\n // Get resume function from registry (not module exports)\n const resumeFn = __fictGetResume(resumeExport)\n if (typeof resumeFn === 'function') {\n await (resumeFn as (scopeId: string, host: Element) => unknown)(scopeId, host)\n hydratedScopes.add(scopeId)\n }\n }\n }\n\n // THEN run the handler - now signal updates will trigger DOM updates\n const resolvedUrl = resolveModuleUrl(url)\n const mod = await import(/* @vite-ignore */ resolvedUrl)\n const handler = (mod as Record<string, unknown>)[exportName]\n if (typeof handler === 'function') {\n await (handler as (scopeId: string, ev: Event, el: Element) => unknown)(scopeId, event, node)\n }\n\n return\n }\n}\n\nfunction parseQrl(qrl: string): { url: string; exportName: string } {\n const [ref] = qrl.split('[')\n if (!ref) {\n return { url: '', exportName: 'default' }\n }\n const hashIndex = ref.lastIndexOf('#')\n if (hashIndex === -1) {\n return { url: ref, exportName: 'default' }\n }\n return { url: ref.slice(0, hashIndex), exportName: ref.slice(hashIndex + 1) }\n}\n\nfunction buildEventPath(event: Event): EventTarget[] {\n const path: EventTarget[] = []\n let node: EventTarget | null = event.target\n while (node) {\n path.push(node)\n node = (node as Node).parentNode\n }\n path.push(window)\n return path\n}\n\n// Re-export for handler authors (optional)\nexport { __fictUseLexicalScope } from './resume'\n"],"mappings":"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","names":[]}
@@ -35,6 +35,7 @@ interface ScopeRecord {
35
35
  id: string;
36
36
  ctx: HookContext;
37
37
  host: Element;
38
+ boundaryId?: string;
38
39
  type?: string;
39
40
  props?: Record<string, unknown>;
40
41
  }
@@ -49,8 +50,11 @@ declare function __fictExitHydration(): void;
49
50
  declare function __fictIsHydrating(): boolean;
50
51
  declare function __fictRegisterScope(ctx: HookContext, host: Element, type?: string, props?: Record<string, unknown>): string;
51
52
  declare function __fictGetScopeRegistry(): Map<string, ScopeRecord>;
53
+ declare function __fictGetScopesForBoundary(boundaryId: string): string[];
52
54
  declare function __fictSerializeSSRState(): SSRState;
55
+ declare function __fictSerializeSSRStateForScopes(scopeIds: Iterable<string>): SSRState;
53
56
  declare function __fictSetSSRState(state: SSRState | null): void;
57
+ declare function __fictMergeSSRState(state: SSRState | null): void;
54
58
  declare function __fictGetSSRScope(id: string): ScopeSnapshot | undefined;
55
59
  declare function __fictEnsureScope(scopeId: string, host: Element, snapshot?: ScopeSnapshot): HookContext;
56
60
  declare function __fictUseLexicalScope(scopeId: string, names: string[]): unknown[];
@@ -76,4 +80,4 @@ declare function serializeValue(value: unknown, seen?: Map<object, string>, path
76
80
  */
77
81
  declare function deserializeValue(value: unknown, refs?: Map<string, unknown>, path?: string): unknown;
78
82
 
79
- export { __fictRegisterResume as A, __fictGetResume as B, serializeValue as C, deserializeValue as D, __fictUseContext as _, __fictPushContext as a, __fictPopContext as b, __fictUseSignal as c, __fictUseMemo as d, __fictUseEffect as e, __fictRender as f, __fictResetContext as g, __fictPrepareContext as h, __fictEnableSSR as i, __fictDisableSSR as j, __fictIsSSR as k, __fictEnableResumable as l, __fictDisableResumable as m, __fictIsResumable as n, __fictEnterHydration as o, __fictExitHydration as p, __fictIsHydrating as q, __fictRegisterScope as r, __fictGetScopeRegistry as s, __fictSerializeSSRState as t, __fictSetSSRState as u, __fictGetSSRScope as v, __fictEnsureScope as w, __fictUseLexicalScope as x, __fictGetScopeProps as y, __fictQrl as z };
83
+ export { __fictUseLexicalScope as A, __fictGetScopeProps as B, __fictQrl as C, __fictRegisterResume as D, __fictGetResume as E, serializeValue as F, deserializeValue as G, __fictUseContext as _, __fictPushContext as a, __fictPopContext as b, __fictUseSignal as c, __fictUseMemo as d, __fictUseEffect as e, __fictRender as f, __fictResetContext as g, __fictPrepareContext as h, __fictEnableSSR as i, __fictDisableSSR as j, __fictIsSSR as k, __fictEnableResumable as l, __fictDisableResumable as m, __fictIsResumable as n, __fictEnterHydration as o, __fictExitHydration as p, __fictIsHydrating as q, __fictRegisterScope as r, __fictGetScopeRegistry as s, __fictGetScopesForBoundary as t, __fictSerializeSSRState as u, __fictSerializeSSRStateForScopes as v, __fictSetSSRState as w, __fictMergeSSRState as x, __fictGetSSRScope as y, __fictEnsureScope as z };
@@ -35,6 +35,7 @@ interface ScopeRecord {
35
35
  id: string;
36
36
  ctx: HookContext;
37
37
  host: Element;
38
+ boundaryId?: string;
38
39
  type?: string;
39
40
  props?: Record<string, unknown>;
40
41
  }
@@ -49,8 +50,11 @@ declare function __fictExitHydration(): void;
49
50
  declare function __fictIsHydrating(): boolean;
50
51
  declare function __fictRegisterScope(ctx: HookContext, host: Element, type?: string, props?: Record<string, unknown>): string;
51
52
  declare function __fictGetScopeRegistry(): Map<string, ScopeRecord>;
53
+ declare function __fictGetScopesForBoundary(boundaryId: string): string[];
52
54
  declare function __fictSerializeSSRState(): SSRState;
55
+ declare function __fictSerializeSSRStateForScopes(scopeIds: Iterable<string>): SSRState;
53
56
  declare function __fictSetSSRState(state: SSRState | null): void;
57
+ declare function __fictMergeSSRState(state: SSRState | null): void;
54
58
  declare function __fictGetSSRScope(id: string): ScopeSnapshot | undefined;
55
59
  declare function __fictEnsureScope(scopeId: string, host: Element, snapshot?: ScopeSnapshot): HookContext;
56
60
  declare function __fictUseLexicalScope(scopeId: string, names: string[]): unknown[];
@@ -76,4 +80,4 @@ declare function serializeValue(value: unknown, seen?: Map<object, string>, path
76
80
  */
77
81
  declare function deserializeValue(value: unknown, refs?: Map<string, unknown>, path?: string): unknown;
78
82
 
79
- export { __fictRegisterResume as A, __fictGetResume as B, serializeValue as C, deserializeValue as D, __fictUseContext as _, __fictPushContext as a, __fictPopContext as b, __fictUseSignal as c, __fictUseMemo as d, __fictUseEffect as e, __fictRender as f, __fictResetContext as g, __fictPrepareContext as h, __fictEnableSSR as i, __fictDisableSSR as j, __fictIsSSR as k, __fictEnableResumable as l, __fictDisableResumable as m, __fictIsResumable as n, __fictEnterHydration as o, __fictExitHydration as p, __fictIsHydrating as q, __fictRegisterScope as r, __fictGetScopeRegistry as s, __fictSerializeSSRState as t, __fictSetSSRState as u, __fictGetSSRScope as v, __fictEnsureScope as w, __fictUseLexicalScope as x, __fictGetScopeProps as y, __fictQrl as z };
83
+ export { __fictUseLexicalScope as A, __fictGetScopeProps as B, __fictQrl as C, __fictRegisterResume as D, __fictGetResume as E, serializeValue as F, deserializeValue as G, __fictUseContext as _, __fictPushContext as a, __fictPopContext as b, __fictUseSignal as c, __fictUseMemo as d, __fictUseEffect as e, __fictRender as f, __fictResetContext as g, __fictPrepareContext as h, __fictEnableSSR as i, __fictDisableSSR as j, __fictIsSSR as k, __fictEnableResumable as l, __fictDisableResumable as m, __fictIsResumable as n, __fictEnterHydration as o, __fictExitHydration as p, __fictIsHydrating as q, __fictRegisterScope as r, __fictGetScopeRegistry as s, __fictGetScopesForBoundary as t, __fictSerializeSSRState as u, __fictSerializeSSRStateForScopes as v, __fictSetSSRState as w, __fictMergeSSRState as x, __fictGetSSRScope as y, __fictEnsureScope as z };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fictjs/runtime",
3
- "version": "0.5.1",
3
+ "version": "0.6.0",
4
4
  "description": "Fict reactive runtime",
5
5
  "publishConfig": {
6
6
  "access": "public",
package/src/internal.ts CHANGED
@@ -50,8 +50,11 @@ export {
50
50
  __fictIsHydrating,
51
51
  __fictRegisterScope,
52
52
  __fictGetScopeRegistry,
53
+ __fictGetScopesForBoundary,
53
54
  __fictSerializeSSRState,
55
+ __fictSerializeSSRStateForScopes,
54
56
  __fictSetSSRState,
57
+ __fictMergeSSRState,
55
58
  __fictGetSSRScope,
56
59
  __fictEnsureScope,
57
60
  __fictUseLexicalScope,
@@ -62,6 +65,7 @@ export {
62
65
  serializeValue,
63
66
  deserializeValue,
64
67
  } from './resume'
68
+ export { __fictGetSSRStreamHooks, __fictSetSSRStreamHooks } from './ssr-stream'
65
69
 
66
70
  // ============================================================================
67
71
  // Props Helpers (Compiler-generated code)
package/src/loader.ts CHANGED
@@ -4,6 +4,7 @@ import {
4
4
  __fictEnsureScope,
5
5
  __fictGetResume,
6
6
  __fictGetSSRScope,
7
+ __fictMergeSSRState,
7
8
  __fictSetSSRState,
8
9
  __fictUseLexicalScope,
9
10
  } from './resume'
@@ -82,6 +83,8 @@ const hydratedScopes = new Set<string>()
82
83
  const prefetchedUrls = new Set<string>()
83
84
  let prefetchCleanup: (() => void) | null = null
84
85
  let eventListenerCleanup: (() => void) | null = null
86
+ let snapshotObserver: MutationObserver | null = null
87
+ const processedSnapshots = new Set<HTMLScriptElement>()
85
88
 
86
89
  /**
87
90
  * Reset the hydrated scopes set. Useful for testing.
@@ -131,6 +134,7 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
131
134
  // Reset hydrated scopes for fresh loader installation
132
135
  hydratedScopes.clear()
133
136
  prefetchedUrls.clear()
137
+ processedSnapshots.clear()
134
138
 
135
139
  // Clean up previous event listeners
136
140
  if (eventListenerCleanup) {
@@ -144,6 +148,11 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
144
148
  prefetchCleanup = null
145
149
  }
146
150
 
151
+ if (snapshotObserver) {
152
+ snapshotObserver.disconnect()
153
+ snapshotObserver = null
154
+ }
155
+
147
156
  const snapshotEl = doc.getElementById(scriptId)
148
157
  if (snapshotEl?.textContent) {
149
158
  try {
@@ -154,6 +163,38 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
154
163
  }
155
164
  }
156
165
 
166
+ const snapshotScripts = doc.querySelectorAll(
167
+ 'script[type="application/json"][data-fict-snapshot]',
168
+ )
169
+ for (const script of Array.from(snapshotScripts)) {
170
+ parseSnapshotScript(script as HTMLScriptElement)
171
+ }
172
+
173
+ if (typeof MutationObserver !== 'undefined') {
174
+ snapshotObserver = new MutationObserver(mutations => {
175
+ for (const mutation of mutations) {
176
+ for (const node of Array.from(mutation.addedNodes)) {
177
+ if (!(node instanceof Element)) continue
178
+ if (node.tagName === 'SCRIPT') {
179
+ const script = node as HTMLScriptElement
180
+ if (isSnapshotScript(script)) {
181
+ parseSnapshotScript(script)
182
+ }
183
+ }
184
+ const nested = node.querySelectorAll?.(
185
+ 'script[type="application/json"][data-fict-snapshot]',
186
+ )
187
+ if (nested && nested.length) {
188
+ for (const script of Array.from(nested)) {
189
+ parseSnapshotScript(script as HTMLScriptElement)
190
+ }
191
+ }
192
+ }
193
+ }
194
+ })
195
+ snapshotObserver.observe(doc.documentElement ?? doc, { childList: true, subtree: true })
196
+ }
197
+
157
198
  __fictEnableResumable()
158
199
 
159
200
  const events = options.events ?? Array.from(DelegatedEvents)
@@ -174,6 +215,23 @@ export function installResumableLoader(options: ResumableLoaderOptions = {}): vo
174
215
  }
175
216
  }
176
217
 
218
+ function isSnapshotScript(script: HTMLScriptElement): boolean {
219
+ return script.type === 'application/json' && script.hasAttribute('data-fict-snapshot')
220
+ }
221
+
222
+ function parseSnapshotScript(script: HTMLScriptElement): void {
223
+ if (processedSnapshots.has(script)) return
224
+ processedSnapshots.add(script)
225
+ const text = script.textContent
226
+ if (!text) return
227
+ try {
228
+ const state = JSON.parse(text)
229
+ __fictMergeSSRState(state)
230
+ } catch {
231
+ // Ignore parse errors
232
+ }
233
+ }
234
+
177
235
  // ============================================================================
178
236
  // Prefetch Implementation
179
237
  // ============================================================================
package/src/resume.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { HookContext } from './hooks'
2
2
  import { createSignal, isSignal } from './signal'
3
+ import { __fictGetCurrentSSRBoundary } from './ssr-stream'
3
4
  import { createStore, isStoreProxy, unwrapStore } from './store'
4
5
 
5
6
  // ============================================================================
@@ -43,6 +44,7 @@ export interface ScopeRecord {
43
44
  id: string
44
45
  ctx: HookContext
45
46
  host: Element
47
+ boundaryId?: string
46
48
  type?: string
47
49
  props?: Record<string, unknown>
48
50
  }
@@ -52,6 +54,7 @@ let resumableEnabled = false
52
54
  let hydrating = false
53
55
  let scopeCounter = 0
54
56
  let scopeRegistry = new Map<string, ScopeRecord>()
57
+ let boundaryScopes = new Map<string, Set<string>>()
55
58
  let snapshotState: SSRState | null = null
56
59
  const resumedScopes = new Map<
57
60
  string,
@@ -62,12 +65,14 @@ export function __fictEnableSSR(): void {
62
65
  ssrEnabled = true
63
66
  scopeCounter = 0
64
67
  scopeRegistry = new Map()
68
+ boundaryScopes = new Map()
65
69
  resumedScopes.clear()
66
70
  snapshotState = null
67
71
  }
68
72
 
69
73
  export function __fictDisableSSR(): void {
70
74
  ssrEnabled = false
75
+ boundaryScopes = new Map()
71
76
  }
72
77
 
73
78
  export function __fictEnableResumable(): void {
@@ -124,6 +129,16 @@ export function __fictRegisterScope(
124
129
  if (props !== undefined) {
125
130
  record.props = props
126
131
  }
132
+ const boundaryId = __fictGetCurrentSSRBoundary()
133
+ if (boundaryId) {
134
+ record.boundaryId = boundaryId
135
+ let scopes = boundaryScopes.get(boundaryId)
136
+ if (!scopes) {
137
+ scopes = new Set()
138
+ boundaryScopes.set(boundaryId, scopes)
139
+ }
140
+ scopes.add(id)
141
+ }
127
142
  scopeRegistry.set(id, record)
128
143
  return id
129
144
  }
@@ -132,6 +147,12 @@ export function __fictGetScopeRegistry(): Map<string, ScopeRecord> {
132
147
  return scopeRegistry
133
148
  }
134
149
 
150
+ export function __fictGetScopesForBoundary(boundaryId: string): string[] {
151
+ const scopes = boundaryScopes.get(boundaryId)
152
+ if (!scopes) return []
153
+ return Array.from(scopes)
154
+ }
155
+
135
156
  export function __fictSerializeSSRState(): SSRState {
136
157
  const scopes: Record<string, ScopeSnapshot> = {}
137
158
 
@@ -155,6 +176,31 @@ export function __fictSerializeSSRState(): SSRState {
155
176
  return { scopes }
156
177
  }
157
178
 
179
+ export function __fictSerializeSSRStateForScopes(scopeIds: Iterable<string>): SSRState {
180
+ const scopes: Record<string, ScopeSnapshot> = {}
181
+
182
+ for (const id of scopeIds) {
183
+ const record = scopeRegistry.get(id)
184
+ if (!record) continue
185
+ const snapshot: ScopeSnapshot = {
186
+ id,
187
+ slots: serializeSlots(record.ctx),
188
+ }
189
+ if (record.type !== undefined) {
190
+ snapshot.t = record.type
191
+ }
192
+ if (record.props !== undefined) {
193
+ snapshot.props = record.props
194
+ }
195
+ if (record.ctx.slotMap !== undefined) {
196
+ snapshot.vars = record.ctx.slotMap
197
+ }
198
+ scopes[id] = snapshot
199
+ }
200
+
201
+ return { scopes }
202
+ }
203
+
158
204
  export function __fictSetSSRState(state: SSRState | null): void {
159
205
  snapshotState = state
160
206
  if (!state) {
@@ -162,6 +208,15 @@ export function __fictSetSSRState(state: SSRState | null): void {
162
208
  }
163
209
  }
164
210
 
211
+ export function __fictMergeSSRState(state: SSRState | null): void {
212
+ if (!state) return
213
+ if (!snapshotState) {
214
+ snapshotState = { scopes: { ...state.scopes } }
215
+ return
216
+ }
217
+ Object.assign(snapshotState.scopes, state.scopes)
218
+ }
219
+
165
220
  export function __fictGetSSRScope(id: string): ScopeSnapshot | undefined {
166
221
  return snapshotState?.scopes[id]
167
222
  }
@@ -0,0 +1,38 @@
1
+ export interface SSRStreamHooks {
2
+ registerBoundary?: (start: Comment, end: Comment) => string | null
3
+ boundaryPending?: (id: string) => void
4
+ boundaryResolved?: (id: string) => void
5
+ onError?: (error: unknown, boundaryId?: string) => void
6
+ }
7
+
8
+ let ssrStreamHooks: SSRStreamHooks | null = null
9
+ const boundaryStack: string[] = []
10
+
11
+ export function __fictSetSSRStreamHooks(hooks: SSRStreamHooks | null): void {
12
+ ssrStreamHooks = hooks
13
+ if (!hooks) {
14
+ boundaryStack.length = 0
15
+ }
16
+ }
17
+
18
+ export function __fictGetSSRStreamHooks(): SSRStreamHooks | null {
19
+ return ssrStreamHooks
20
+ }
21
+
22
+ export function __fictPushSSRBoundary(id: string): void {
23
+ boundaryStack.push(id)
24
+ }
25
+
26
+ export function __fictPopSSRBoundary(expected?: string): void {
27
+ if (boundaryStack.length === 0) return
28
+ const top = boundaryStack[boundaryStack.length - 1]
29
+ if (expected && top !== expected) {
30
+ boundaryStack.pop()
31
+ return
32
+ }
33
+ boundaryStack.pop()
34
+ }
35
+
36
+ export function __fictGetCurrentSSRBoundary(): string | null {
37
+ return boundaryStack.length > 0 ? boundaryStack[boundaryStack.length - 1]! : null
38
+ }
package/src/suspense.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  } from './lifecycle'
13
13
  import { insertNodesBefore, removeNodes, toNodeArray } from './node-ops'
14
14
  import { createSignal } from './signal'
15
+ import { __fictGetSSRStreamHooks, __fictPopSSRBoundary, __fictPushSSRBoundary } from './ssr-stream'
15
16
  import type { BaseProps, FictNode, SuspenseToken } from './types'
16
17
 
17
18
  export interface SuspenseProps extends BaseProps {
@@ -49,6 +50,7 @@ const isThenable = (value: unknown): value is PromiseLike<unknown> =>
49
50
  typeof (value as PromiseLike<unknown>).then === 'function'
50
51
 
51
52
  export function Suspense(props: SuspenseProps): FictNode {
53
+ const streamHooks = __fictGetSSRStreamHooks()
52
54
  const pending = createSignal(0)
53
55
  let resolvedOnce = false
54
56
  let epoch = 0
@@ -76,7 +78,12 @@ export function Suspense(props: SuspenseProps): FictNode {
76
78
  const root = createRootContext(hostRoot)
77
79
  const prev = pushRoot(root)
78
80
  let nodes: Node[] = []
81
+ let boundaryPushed = false
79
82
  try {
83
+ if (streamBoundaryId) {
84
+ __fictPushSSRBoundary(streamBoundaryId)
85
+ boundaryPushed = true
86
+ }
80
87
  const output = createElement(view)
81
88
  nodes = toNodeArray(output)
82
89
  // Suspended view: child threw a suspense token and was handled upstream.
@@ -90,9 +97,9 @@ export function Suspense(props: SuspenseProps): FictNode {
90
97
  destroyRoot(root)
91
98
  return
92
99
  }
93
- const parentNode = marker.parentNode as (ParentNode & Node) | null
100
+ const parentNode = endMarker.parentNode as (ParentNode & Node) | null
94
101
  if (parentNode) {
95
- insertNodesBefore(parentNode, nodes, marker)
102
+ insertNodesBefore(parentNode, nodes, endMarker)
96
103
  }
97
104
  } catch (err) {
98
105
  popRoot(prev)
@@ -101,6 +108,10 @@ export function Suspense(props: SuspenseProps): FictNode {
101
108
  throw err
102
109
  }
103
110
  return
111
+ } finally {
112
+ if (boundaryPushed) {
113
+ __fictPopSSRBoundary(streamBoundaryId ?? undefined)
114
+ }
104
115
  }
105
116
  popRoot(prev)
106
117
  flushOnMount(root)
@@ -113,10 +124,22 @@ export function Suspense(props: SuspenseProps): FictNode {
113
124
  }
114
125
 
115
126
  const fragment = document.createDocumentFragment()
116
- const marker = document.createComment('fict:suspense')
117
- fragment.appendChild(marker)
127
+ const startMarker = document.createComment('fict:suspense-start')
128
+ const endMarker = document.createComment('fict:suspense-end')
129
+ fragment.appendChild(startMarker)
130
+ fragment.appendChild(endMarker)
118
131
  let cleanup: (() => void) | undefined
119
132
  let activeNodes: Node[] = []
133
+ let streamBoundaryId: string | null = null
134
+ let streamPending = false
135
+
136
+ if (streamHooks?.registerBoundary) {
137
+ streamBoundaryId = streamHooks.registerBoundary(startMarker, endMarker) ?? null
138
+ if (streamBoundaryId) {
139
+ startMarker.data = `fict:suspense-start:${streamBoundaryId}`
140
+ endMarker.data = `fict:suspense-end:${streamBoundaryId}`
141
+ }
142
+ }
120
143
 
121
144
  const onResolveMaybe = () => {
122
145
  if (!resolvedOnce) {
@@ -127,6 +150,10 @@ export function Suspense(props: SuspenseProps): FictNode {
127
150
 
128
151
  registerSuspenseHandler(token => {
129
152
  const tokenEpoch = epoch
153
+ if (!streamPending && streamBoundaryId && streamHooks?.boundaryPending) {
154
+ streamPending = true
155
+ streamHooks.boundaryPending(streamBoundaryId)
156
+ }
130
157
  pending(pending() + 1)
131
158
  // Directly render fallback instead of using switchView to avoid
132
159
  // triggering the effect which would cause duplicate renders
@@ -155,6 +182,10 @@ export function Suspense(props: SuspenseProps): FictNode {
155
182
  if (newPending === 0) {
156
183
  // Directly render children instead of using switchView
157
184
  renderView(props.children ?? null)
185
+ if (streamPending && streamBoundaryId && streamHooks?.boundaryResolved) {
186
+ streamPending = false
187
+ streamHooks.boundaryResolved(streamBoundaryId)
188
+ }
158
189
  onResolveMaybe()
159
190
  }
160
191
  },
@@ -165,9 +196,29 @@ export function Suspense(props: SuspenseProps): FictNode {
165
196
  }
166
197
  const newPending = Math.max(0, pending() - 1)
167
198
  pending(newPending)
168
- props.onReject?.(err)
169
- if (!handleError(err, { source: 'render' }, hostRoot)) {
170
- throw err
199
+ let rejectionError = err
200
+ try {
201
+ props.onReject?.(err)
202
+ } catch (callbackError) {
203
+ rejectionError = callbackError
204
+ }
205
+
206
+ const handled = handleError(rejectionError, { source: 'render' }, hostRoot)
207
+ if (!handled) {
208
+ if (streamHooks?.onError) {
209
+ streamHooks.onError(rejectionError, streamBoundaryId ?? undefined)
210
+ return
211
+ }
212
+ throw rejectionError
213
+ }
214
+ if (
215
+ newPending === 0 &&
216
+ streamPending &&
217
+ streamBoundaryId &&
218
+ streamHooks?.boundaryResolved
219
+ ) {
220
+ streamPending = false
221
+ streamHooks.boundaryResolved(streamBoundaryId)
171
222
  }
172
223
  },
173
224
  )
@@ -195,6 +246,10 @@ export function Suspense(props: SuspenseProps): FictNode {
195
246
  pending(0)
196
247
  // Directly render children instead of using switchView
197
248
  renderView(props.children ?? null)
249
+ if (streamPending && streamBoundaryId && streamHooks?.boundaryResolved) {
250
+ streamPending = false
251
+ streamHooks.boundaryResolved(streamBoundaryId)
252
+ }
198
253
  }
199
254
  })
200
255
  }