@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.
- package/dist/advanced.cjs +9 -9
- package/dist/advanced.js +4 -4
- package/dist/{chunk-AR6NSCZM.js → chunk-4NUHM77Z.js} +3 -3
- package/dist/{chunk-RY5CY4CI.js → chunk-D2IWOO4X.js} +2 -2
- package/dist/{chunk-LFMXNQZC.cjs → chunk-KNGHYGK4.cjs} +17 -17
- package/dist/{chunk-LFMXNQZC.cjs.map → chunk-KNGHYGK4.cjs.map} +1 -1
- package/dist/{chunk-7BO6P2KP.js → chunk-LRFMCJY3.js} +84 -1
- package/dist/chunk-LRFMCJY3.js.map +1 -0
- package/dist/{chunk-4ZPZM5IG.cjs → chunk-QB2UD62G.cjs} +8 -8
- package/dist/{chunk-4ZPZM5IG.cjs.map → chunk-QB2UD62G.cjs.map} +1 -1
- package/dist/{chunk-5OYBRKE4.js → chunk-SLFAEVKJ.js} +3 -3
- package/dist/{chunk-WJHXPF7M.cjs → chunk-Z6M3HKLG.cjs} +156 -156
- package/dist/{chunk-WJHXPF7M.cjs.map → chunk-Z6M3HKLG.cjs.map} +1 -1
- package/dist/{chunk-6RCEIWZL.cjs → chunk-ZR435MDC.cjs} +85 -2
- package/dist/chunk-ZR435MDC.cjs.map +1 -0
- package/dist/index.cjs +95 -45
- package/dist/index.cjs.map +1 -1
- package/dist/index.dev.js +87 -7
- package/dist/index.dev.js.map +1 -1
- package/dist/index.js +60 -10
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +51 -41
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +11 -2
- package/dist/internal.d.ts +11 -2
- package/dist/internal.js +13 -3
- package/dist/internal.js.map +1 -1
- package/dist/loader.cjs +60 -8
- package/dist/loader.cjs.map +1 -1
- package/dist/loader.d.cts +1 -1
- package/dist/loader.d.ts +1 -1
- package/dist/loader.js +53 -1
- package/dist/loader.js.map +1 -1
- package/dist/{resume-Dx8_l72o.d.ts → resume-CqeQ3v_q.d.ts} +5 -1
- package/dist/{resume-BrAkmSTY.d.cts → resume-i-A3EFox.d.cts} +5 -1
- package/package.json +1 -1
- package/src/internal.ts +4 -0
- package/src/loader.ts +58 -0
- package/src/resume.ts +55 -0
- package/src/ssr-stream.ts +38 -0
- package/src/suspense.ts +62 -7
- package/dist/chunk-6RCEIWZL.cjs.map +0 -1
- package/dist/chunk-7BO6P2KP.js.map +0 -1
- /package/dist/{chunk-AR6NSCZM.js.map → chunk-4NUHM77Z.js.map} +0 -0
- /package/dist/{chunk-RY5CY4CI.js.map → chunk-D2IWOO4X.js.map} +0 -0
- /package/dist/{chunk-5OYBRKE4.js.map → chunk-SLFAEVKJ.js.map} +0 -0
package/dist/loader.js.map
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
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 =
|
|
100
|
+
const parentNode = endMarker.parentNode as (ParentNode & Node) | null
|
|
94
101
|
if (parentNode) {
|
|
95
|
-
insertNodesBefore(parentNode, nodes,
|
|
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
|
|
117
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
}
|