@asciisd/vue-progressive-iframe
Version:
A Vue 3 component for progressive iframe loading with advanced content detection
425 lines (363 loc) • 14.3 kB
text/typescript
import { computed, onMounted, onUnmounted, ref, type Ref } from 'vue'
import type { ProgressiveIframeEvents, ProgressiveIframeOptions } from '../types'
import { detectIframeContent, isOriginAllowed } from '../utils/content-detection'
import { createFriendlyIframe } from '../utils/friendly-iframe'
import {
createAfterOnloadIframe,
createDynamicAsyncIframe,
createSetTimeoutIframe
} from '../utils/loading-strategies'
export interface UseProgressiveIframeReturn {
// Refs
iframeRef: Ref<HTMLIFrameElement | undefined>
isLoading: Ref<boolean>
isContentLoading: Ref<boolean>
hasError: Ref<boolean>
errorMessage: Ref<string>
contentCheckCount: Ref<number>
loadStartTime: Ref<number>
consecutiveErrorCount: Ref<number>
// Computed
loadTime: Ref<number>
isTimeout: Ref<boolean>
// Methods
forceLoad: () => void
refresh: () => void
startContentDetection: () => void
handleMessage: (_event: MessageEvent) => void
createIframeWithStrategy: (_container: HTMLElement) => HTMLIFrameElement | null
}
type EmitFunction = <K extends keyof ProgressiveIframeEvents>(
event: K,
payload: ProgressiveIframeEvents[K]
) => void
export function useProgressiveIframe(
options: ProgressiveIframeOptions,
emit?: EmitFunction
): UseProgressiveIframeReturn {
// Reactive state
const iframeRef = ref<HTMLIFrameElement>()
const isLoading = ref(true)
const isContentLoading = ref(true)
const hasError = ref(false)
const errorMessage = ref('')
const contentCheckCount = ref(0)
const loadStartTime = ref(Date.now())
const consecutiveErrorCount = ref(0)
// Configuration with defaults
const config = {
maxContentChecks: options.maxContentChecks || 30,
contentDetectionTimeout: options.contentDetectionTimeout || 30000,
crossOriginWaitTime: options.crossOriginWaitTime || 10000,
allowedOrigins: options.allowedOrigins || [],
loadingStrategy: options.loadingStrategy || 'progressive',
preventOnloadBlocking: options.preventOnloadBlocking ?? true,
minimizeBusyIndicators: options.minimizeBusyIndicators ?? true,
detectionInterval: 1000 // 1 second
}
// Internal state
const contentDetectionInterval = ref<number>()
// Computed properties
const loadTime = computed(() => Date.now() - loadStartTime.value)
const isTimeout = computed(() => loadTime.value > config.contentDetectionTimeout)
/**
* Handle messages from iframe (for postMessage-based detection)
*/
const handleMessage = (event: MessageEvent) => {
// Skip same-origin messages (usually browser extensions)
if (event.origin === window.location.origin) {
return
}
// Check if origin is allowed
if (!isOriginAllowed(event.origin, config.allowedOrigins)) {
if (options.showDebugInfo) {
console.warn('[ProgressiveIframe] Message from disallowed origin:', event.origin)
}
return
}
if (options.showDebugInfo) {
console.log('[ProgressiveIframe] Message received:', event.data)
}
// Handle standard iframe ready messages
if (event.data?.type === 'iframe_ready' || event.data?.type === 'content_loaded') {
handleContentLoaded('postmessage')
} else if (event.data?.type === 'iframe_error') {
handleLoadError(new Error(event.data.message || 'Iframe reported an error'))
}
}
/**
* Start content detection polling
*/
const startContentDetection = () => {
contentCheckCount.value = 0
if (contentDetectionInterval.value) {
clearInterval(contentDetectionInterval.value)
}
contentDetectionInterval.value = window.setInterval(() => {
contentCheckCount.value++
if (options.showDebugInfo) {
console.log(`[ProgressiveIframe] Content check ${contentCheckCount.value}/${config.maxContentChecks}`)
}
// Emit progress event
emit?.('detection-progress', {
checkCount: contentCheckCount.value,
maxChecks: config.maxContentChecks,
loadTime: loadTime.value
})
const detectionResult = detectIframeContent(
iframeRef.value,
contentCheckCount.value,
config.crossOriginWaitTime
)
if (detectionResult.hasContent) {
if (options.showDebugInfo) {
console.log('[ProgressiveIframe] Content detected!', detectionResult)
}
handleContentLoaded(detectionResult.method)
return
}
// Stop checking after max attempts
if (contentCheckCount.value >= config.maxContentChecks) {
if (options.showDebugInfo) {
console.warn(`[ProgressiveIframe] Content detection timeout after ${config.maxContentChecks} checks`)
}
handleDetectionTimeout()
}
}, config.detectionInterval)
}
/**
* Handle successful content loading
*/
const handleContentLoaded = (method: string = 'unknown') => {
// Clear detection interval
if (contentDetectionInterval.value) {
clearInterval(contentDetectionInterval.value)
contentDetectionInterval.value = undefined
}
// Update state
isLoading.value = false
isContentLoading.value = false
hasError.value = false
consecutiveErrorCount.value = 0
const currentLoadTime = loadTime.value
if (options.showDebugInfo) {
console.log(`[ProgressiveIframe] Content loaded successfully in ${currentLoadTime}ms using ${method}`)
}
// Emit success event
emit?.('content-loaded', {
loadTime: currentLoadTime,
checkCount: contentCheckCount.value,
method
})
}
/**
* Handle loading error
*/
const handleLoadError = (error: Error) => {
isLoading.value = false
isContentLoading.value = false
hasError.value = true
consecutiveErrorCount.value++
errorMessage.value = consecutiveErrorCount.value > 2
? 'Service is experiencing issues. Please contact support if this continues.'
: error.message || 'Failed to load iframe content. Please try refreshing.'
if (options.showDebugInfo) {
console.error('[ProgressiveIframe] Load error:', error)
}
// Emit error event
emit?.('load-error', {
error,
errorCount: consecutiveErrorCount.value
})
}
/**
* Handle detection timeout
*/
const handleDetectionTimeout = () => {
if (contentDetectionInterval.value) {
clearInterval(contentDetectionInterval.value)
contentDetectionInterval.value = undefined
}
// Don't set as error immediately - content might still be loading
// Just stop active detection and let user decide
if (options.showDebugInfo) {
console.log('[ProgressiveIframe] Detection timeout - content may still be loading')
}
}
/**
* Force load - manually clear loading states
*/
const forceLoad = () => {
if (options.showDebugInfo) {
console.log('[ProgressiveIframe] Force loading content...')
}
// Clear any running detection
if (contentDetectionInterval.value) {
clearInterval(contentDetectionInterval.value)
contentDetectionInterval.value = undefined
}
// Reset states
isLoading.value = false
isContentLoading.value = false
hasError.value = false
const currentLoadTime = loadTime.value
// Emit force load event
emit?.('force-loaded', {
loadTime: currentLoadTime
})
if (options.showDebugInfo) {
console.log(`[ProgressiveIframe] Force loaded after ${currentLoadTime}ms`)
}
}
/**
* Refresh iframe
*/
const refresh = () => {
if (!iframeRef.value) return
if (options.showDebugInfo) {
console.log('[ProgressiveIframe] Refreshing iframe...')
}
// Reset state
loadStartTime.value = Date.now()
isLoading.value = false // Show iframe immediately
isContentLoading.value = true // But content is loading
hasError.value = false
// Clear existing detection
if (contentDetectionInterval.value) {
clearInterval(contentDetectionInterval.value)
contentDetectionInterval.value = undefined
}
// Force reload iframe
const currentSrc = iframeRef.value.src
iframeRef.value.src = ''
setTimeout(() => {
if (iframeRef.value) {
iframeRef.value.src = currentSrc
// Start detection after iframe reloads
setTimeout(() => {
startContentDetection()
}, 2000)
}
}, 100)
}
/**
* Create iframe using specified loading strategy
*/
const createIframeWithStrategy = (container: HTMLElement) => {
const strategy = config.loadingStrategy
if (options.showDebugInfo) {
console.log(`[ProgressiveIframe] Using ${strategy} loading strategy`)
}
switch (strategy) {
case 'dynamic-async':
return createDynamicAsyncIframe(options.src, container, {
width: '100%',
height: '100%',
onReady: () => handleContentLoaded('dynamic-async'),
onError: (error) => handleLoadError(error)
})
case 'after-onload':
createAfterOnloadIframe(options.src, container, {
width: '100%',
height: '100%',
onReady: () => handleContentLoaded('after-onload')
})
return null // Iframe created asynchronously
case 'settimeout':
return createSetTimeoutIframe(options.src, container, {
width: '100%',
height: '100%',
delay: 5,
onReady: () => handleContentLoaded('settimeout')
})
case 'friendly':
// Friendly iframe for same-domain content
if (options.htmlContent) {
return createFriendlyIframe(options.htmlContent, container, {
width: '100%',
height: '100%',
styles: options.styles,
onReady: () => handleContentLoaded('friendly')
})
} else {
// Fallback to traditional for external URLs
console.warn('[ProgressiveIframe] Friendly iframe requires htmlContent, falling back to traditional')
const iframe = document.createElement('iframe')
iframe.src = options.src
iframe.style.cssText = 'width: 100%; height: 100%; border: none;'
iframe.onload = () => handleContentLoaded('friendly-fallback')
container.appendChild(iframe)
return iframe
}
case 'traditional':
// Standard iframe - will block onload
const iframe = document.createElement('iframe')
iframe.src = options.src
iframe.style.cssText = 'width: 100%; height: 100%; border: none;'
iframe.onload = () => handleContentLoaded('traditional')
container.appendChild(iframe)
return iframe
case 'progressive':
default:
// Our enhanced progressive approach
const progressiveIframe = document.createElement('iframe')
progressiveIframe.src = options.src
progressiveIframe.style.cssText = 'width: 100%; height: 100%; border: none;'
container.appendChild(progressiveIframe)
// Start progressive loading
isLoading.value = false
isContentLoading.value = true
setTimeout(() => {
startContentDetection()
}, 2000)
return progressiveIframe
}
}
// Lifecycle
onMounted(() => {
// Add message listener
window.addEventListener('message', handleMessage)
if (options.showDebugInfo) {
console.log('[ProgressiveIframe] Starting loading with strategy:', config.loadingStrategy)
console.log('[ProgressiveIframe] Prevent onload blocking:', config.preventOnloadBlocking)
console.log('[ProgressiveIframe] Minimize busy indicators:', config.minimizeBusyIndicators)
}
// Set fallback timeout
const fallbackTimeout = setTimeout(() => {
if (isContentLoading.value) {
if (options.showDebugInfo) {
console.warn('[ProgressiveIframe] Fallback timeout reached')
}
handleDetectionTimeout()
}
}, config.contentDetectionTimeout)
// Cleanup on unmount
onUnmounted(() => {
clearTimeout(fallbackTimeout)
if (contentDetectionInterval.value) {
clearInterval(contentDetectionInterval.value)
}
window.removeEventListener('message', handleMessage)
})
})
return {
// Refs
iframeRef,
isLoading,
isContentLoading,
hasError,
errorMessage,
contentCheckCount,
loadStartTime,
consecutiveErrorCount,
// Computed
loadTime,
isTimeout,
// Methods
forceLoad,
refresh,
startContentDetection,
handleMessage,
createIframeWithStrategy
}
}