Last active
October 14, 2025 15:06
-
-
Save mekanics/ceebb8f66afe9c49ecae446d9eda655a to your computer and use it in GitHub Desktop.
React WindowPortal
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { ReactNode, useEffect, useRef, useState } from 'react' | |
| import { createRoot, Root } from 'react-dom/client' | |
| import { ResourceResolver, InjectionStrategy, HybridInjectionStrategy } from './ResourceResolver' | |
| export type IndependentWindowConfig = { | |
| width?: number | |
| height?: number | |
| left?: number | |
| top?: number | |
| resizable?: boolean | |
| scrollbars?: boolean | |
| menubar?: boolean | |
| toolbar?: boolean | |
| location?: boolean | |
| status?: boolean | |
| } | |
| export type WindowMessage<T = unknown> = { | |
| type: string | |
| payload?: T | |
| timestamp?: number | |
| } | |
| export type IndependentWindowPortalProps = { | |
| title: string | |
| children: ReactNode | |
| onClose?: () => void | |
| config?: IndependentWindowConfig | |
| injectionStrategy?: InjectionStrategy | |
| onError?: (error: Error) => void | |
| onReady?: () => void | |
| onMessage?: (message: WindowMessage) => void | |
| allowedOrigins?: string[] // For postMessage security | |
| } | |
| const DEFAULT_CONFIG: IndependentWindowConfig = { | |
| width: 800, | |
| height: 600, | |
| resizable: true, | |
| scrollbars: true, | |
| menubar: false, | |
| toolbar: false, | |
| location: false, | |
| status: false, | |
| } | |
| /** | |
| * IndependentWindowPortal - Renders a completely separate React application | |
| * in a new window with its own React root. | |
| * | |
| * Key Differences from WindowPortalV2: | |
| * - Creates a separate React root (not a portal) | |
| * - Independent React context (no shared providers) | |
| * - Own router, state management, etc. | |
| * - Communication via postMessage API | |
| * - Can be configured to survive parent window closing | |
| * | |
| * Use Cases: | |
| * - Help documentation windows | |
| * - Independent tools/utilities | |
| * - Multi-window applications with isolated state | |
| * - Pop-out widgets that don't need parent context | |
| */ | |
| export function IndependentWindowPortal({ | |
| title, | |
| children, | |
| onClose, | |
| config = {}, | |
| injectionStrategy, | |
| onError, | |
| onReady, | |
| onMessage, | |
| allowedOrigins = ['*'], | |
| }: IndependentWindowPortalProps) { | |
| const windowRef = useRef<Window | null>(null) | |
| const reactRootRef = useRef<Root | null>(null) | |
| const [error, setError] = useState<Error | null>(null) | |
| const onCloseRef = useRef(onClose) | |
| const onMessageRef = useRef(onMessage) | |
| const titleRef = useRef(title) | |
| // Update refs without triggering effect | |
| useEffect(() => { | |
| onCloseRef.current = onClose | |
| }, [onClose]) | |
| useEffect(() => { | |
| onMessageRef.current = onMessage | |
| }, [onMessage]) | |
| useEffect(() => { | |
| titleRef.current = title | |
| }, [title]) | |
| // Update title without recreating window | |
| useEffect(() => { | |
| if (windowRef.current && !windowRef.current.closed) { | |
| windowRef.current.document.title = title | |
| } | |
| }, [title]) | |
| // Update content when children change | |
| useEffect(() => { | |
| if (reactRootRef.current && windowRef.current && !windowRef.current.closed) { | |
| reactRootRef.current.render(children) | |
| } | |
| }, [children]) | |
| // Main initialization effect - only runs once | |
| useEffect(() => { | |
| const initializeWindow = async () => { | |
| try { | |
| const mergedConfig = { ...DEFAULT_CONFIG, ...config } | |
| const features = buildWindowFeatures(mergedConfig) | |
| // Open window | |
| const newWindow = window.open('', '', features) | |
| if (!newWindow) { | |
| throw new Error('Failed to open window. Please check popup blocker settings.') | |
| } | |
| windowRef.current = newWindow | |
| // Set title | |
| newWindow.document.title = titleRef.current | |
| // Inject styles with resource resolution | |
| const resolver = new ResourceResolver() | |
| const strategy = injectionStrategy || new HybridInjectionStrategy() | |
| await strategy.inject(newWindow, resolver) | |
| // Create root container | |
| const rootContainer = newWindow.document.createElement('div') | |
| rootContainer.id = 'independent-app-root' | |
| rootContainer.style.cssText = 'width: 100%; height: 100%; margin: 0; padding: 0;' | |
| newWindow.document.body.appendChild(rootContainer) | |
| // Reset body styles for better default layout | |
| newWindow.document.body.style.cssText = 'margin: 0; padding: 0; overflow: auto;' | |
| // Create independent React root | |
| const root = createRoot(rootContainer) | |
| reactRootRef.current = root | |
| // Render the independent app | |
| root.render(children) | |
| // Set up message handler for parent → child communication | |
| const messageHandler = (event: MessageEvent) => { | |
| // Security check | |
| if (allowedOrigins.length > 0 && !allowedOrigins.includes('*')) { | |
| if (!allowedOrigins.includes(event.origin)) { | |
| console.warn('Message from unauthorized origin:', event.origin) | |
| return | |
| } | |
| } | |
| // Call onMessage callback | |
| onMessageRef.current?.(event.data) | |
| } | |
| newWindow.addEventListener('message', messageHandler) | |
| // Set up close handler | |
| const unloadHandler = () => { | |
| onCloseRef.current?.() | |
| } | |
| newWindow.addEventListener('beforeunload', unloadHandler) | |
| // Mark as ready | |
| onReady?.() | |
| // Cleanup | |
| return () => { | |
| newWindow.removeEventListener('message', messageHandler) | |
| newWindow.removeEventListener('beforeunload', unloadHandler) | |
| if (reactRootRef.current) { | |
| reactRootRef.current.unmount() | |
| reactRootRef.current = null | |
| } | |
| if (!newWindow.closed) { | |
| newWindow.close() | |
| } | |
| } | |
| } catch (err) { | |
| const error = err instanceof Error ? err : new Error('Unknown error occurred') | |
| setError(error) | |
| onError?.(error) | |
| } | |
| } | |
| const cleanup = initializeWindow() | |
| return () => { | |
| cleanup.then(cleanupFn => cleanupFn?.()) | |
| } | |
| // Only run once on mount | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []) | |
| // Error state - render nothing | |
| if (error) { | |
| return null | |
| } | |
| // This component renders nothing in the parent window | |
| // All rendering happens in the child window via the independent React root | |
| return null | |
| } | |
| /** | |
| * Hook to communicate from child window back to parent | |
| * Use this inside components rendered in IndependentWindowPortal | |
| */ | |
| export function useWindowMessaging() { | |
| const sendToParent = <T,>(type: string, payload?: T) => { | |
| if (!window.opener) { | |
| console.warn('No parent window available') | |
| return | |
| } | |
| const message: WindowMessage<T> = { | |
| type, | |
| payload, | |
| timestamp: Date.now(), | |
| } | |
| window.opener.postMessage(message, '*') | |
| } | |
| const sendToChild = <T,>(childWindow: Window, type: string, payload?: T) => { | |
| if (!childWindow || childWindow.closed) { | |
| console.warn('Child window not available') | |
| return | |
| } | |
| const message: WindowMessage<T> = { | |
| type, | |
| payload, | |
| timestamp: Date.now(), | |
| } | |
| childWindow.postMessage(message, '*') | |
| } | |
| return { | |
| sendToParent, | |
| sendToChild, | |
| } | |
| } | |
| /** | |
| * Builds window.open() features string from config | |
| */ | |
| function buildWindowFeatures(config: IndependentWindowConfig): string { | |
| const features: string[] = [] | |
| if (config.width) features.push(`width=${config.width}`) | |
| if (config.height) features.push(`height=${config.height}`) | |
| if (config.left !== undefined) features.push(`left=${config.left}`) | |
| if (config.top !== undefined) features.push(`top=${config.top}`) | |
| features.push(`resizable=${config.resizable ? 'yes' : 'no'}`) | |
| features.push(`scrollbars=${config.scrollbars ? 'yes' : 'no'}`) | |
| features.push(`menubar=${config.menubar ? 'yes' : 'no'}`) | |
| features.push(`toolbar=${config.toolbar ? 'yes' : 'no'}`) | |
| features.push(`location=${config.location ? 'yes' : 'no'}`) | |
| features.push(`status=${config.status ? 'yes' : 'no'}`) | |
| return features.join(',') | |
| } | |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Resource Resolution Service | |
| * Handles conversion of relative URLs to absolute URLs in CSS and other resources | |
| * for use in window portals where the base URL context differs from the parent window | |
| */ | |
| export class ResourceResolver { | |
| private baseUrl: string | |
| constructor(baseUrl: string = window.location.href) { | |
| this.baseUrl = baseUrl | |
| } | |
| /** | |
| * Resolves a relative URL to an absolute URL based on the base URL | |
| */ | |
| private resolveUrl(url: string, contextUrl: string = this.baseUrl): string { | |
| try { | |
| return new URL(url, contextUrl).href | |
| } catch { | |
| // If URL parsing fails, return original | |
| return url | |
| } | |
| } | |
| /** | |
| * Resolves all url() references in CSS content to absolute URLs | |
| */ | |
| resolveCssUrls(cssContent: string, stylesheetUrl?: string): string { | |
| const baseForResolution = stylesheetUrl || this.baseUrl | |
| // Match url() with various quote styles: url("..."), url('...'), url(...) | |
| const urlPattern = /url\s*\(\s*(['"]?)([^'")]+)\1\s*\)/gi | |
| return cssContent.replace(urlPattern, (match, quote, url) => { | |
| // Skip data URLs and absolute URLs | |
| if (url.startsWith('data:') || url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) { | |
| return match | |
| } | |
| const resolvedUrl = this.resolveUrl(url.trim(), baseForResolution) | |
| return `url(${quote}${resolvedUrl}${quote})` | |
| }) | |
| } | |
| /** | |
| * Resolves URLs in @import statements | |
| */ | |
| private resolveCssImports(cssContent: string, stylesheetUrl?: string): string { | |
| const baseForResolution = stylesheetUrl || this.baseUrl | |
| // Match @import with various formats | |
| const importPattern = /@import\s+(['"])([^'"]+)\1/gi | |
| return cssContent.replace(importPattern, (match, quote, url) => { | |
| // Skip absolute URLs | |
| if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('//')) { | |
| return match | |
| } | |
| const resolvedUrl = this.resolveUrl(url.trim(), baseForResolution) | |
| return `@import ${quote}${resolvedUrl}${quote}` | |
| }) | |
| } | |
| /** | |
| * Resolves all CSS resources (urls and imports) | |
| */ | |
| resolveAllCssResources(cssContent: string, stylesheetUrl?: string): string { | |
| let resolved = this.resolveCssUrls(cssContent, stylesheetUrl) | |
| resolved = this.resolveCssImports(resolved, stylesheetUrl) | |
| return resolved | |
| } | |
| /** | |
| * Fetches and resolves a stylesheet from a URL | |
| */ | |
| async fetchAndResolveStylesheet(stylesheetHref: string): Promise<string> { | |
| try { | |
| const response = await fetch(stylesheetHref) | |
| if (!response.ok) { | |
| throw new Error(`Failed to fetch stylesheet: ${response.statusText}`) | |
| } | |
| const cssContent = await response.text() | |
| return this.resolveAllCssResources(cssContent, stylesheetHref) | |
| } catch (error) { | |
| console.error(`Error fetching stylesheet ${stylesheetHref}:`, error) | |
| throw error | |
| } | |
| } | |
| /** | |
| * Processes inline style elements and returns resolved CSS | |
| */ | |
| processInlineStyle(styleElement: HTMLStyleElement): string { | |
| const cssContent = styleElement.textContent || '' | |
| return this.resolveAllCssResources(cssContent) | |
| } | |
| /** | |
| * Checks if a URL is same-origin (can be fetched) | |
| */ | |
| private isSameOrigin(url: string): boolean { | |
| try { | |
| const urlObj = new URL(url, this.baseUrl) | |
| return urlObj.origin === window.location.origin | |
| } catch { | |
| return false | |
| } | |
| } | |
| /** | |
| * Gets all stylesheets from the document with metadata | |
| */ | |
| getStylesheetInfo(): Array<{ | |
| type: 'link' | 'inline' | |
| href?: string | |
| content?: string | |
| canFetch: boolean | |
| element: HTMLLinkElement | HTMLStyleElement | |
| }> { | |
| const styles = document.querySelectorAll('link[rel="stylesheet"], style') | |
| const info: Array<{ | |
| type: 'link' | 'inline' | |
| href?: string | |
| content?: string | |
| canFetch: boolean | |
| element: HTMLLinkElement | HTMLStyleElement | |
| }> = [] | |
| styles.forEach(style => { | |
| if (style.tagName === 'LINK') { | |
| const link = style as HTMLLinkElement | |
| info.push({ | |
| type: 'link', | |
| href: link.href, | |
| canFetch: this.isSameOrigin(link.href), | |
| element: link, | |
| }) | |
| } else if (style.tagName === 'STYLE') { | |
| const styleEl = style as HTMLStyleElement | |
| info.push({ | |
| type: 'inline', | |
| content: styleEl.textContent || '', | |
| canFetch: true, // Always can process inline | |
| element: styleEl, | |
| }) | |
| } | |
| }) | |
| return info | |
| } | |
| } | |
| /** | |
| * Strategy interface for different injection approaches | |
| */ | |
| export interface InjectionStrategy { | |
| inject(win: Window, resolver: ResourceResolver): Promise<void> | |
| } | |
| /** | |
| * Inline strategy - converts all stylesheets to inline <style> with resolved URLs | |
| * Pros: No additional HTTP requests, works cross-origin | |
| * Cons: Can be slow, duplicates CSS | |
| */ | |
| export class InlineInjectionStrategy implements InjectionStrategy { | |
| async inject(win: Window, resolver: ResourceResolver): Promise<void> { | |
| const stylesheets = resolver.getStylesheetInfo() | |
| for (const stylesheet of stylesheets) { | |
| const style = win.document.createElement('style') | |
| if (stylesheet.type === 'inline') { | |
| // Process inline styles | |
| style.textContent = resolver.processInlineStyle(stylesheet.element as HTMLStyleElement) | |
| } else if (stylesheet.type === 'link' && stylesheet.canFetch && stylesheet.href) { | |
| // Fetch and inline external stylesheets | |
| try { | |
| style.textContent = await resolver.fetchAndResolveStylesheet(stylesheet.href) | |
| } catch (error) { | |
| console.warn(`Could not fetch stylesheet ${stylesheet.href}, using link fallback`) | |
| // Fallback to link | |
| const link = win.document.createElement('link') | |
| link.rel = 'stylesheet' | |
| link.href = stylesheet.href | |
| win.document.head.appendChild(link) | |
| continue | |
| } | |
| } else if (stylesheet.type === 'link' && stylesheet.href) { | |
| // Cross-origin or can't fetch - use link as-is | |
| const link = win.document.createElement('link') | |
| link.rel = 'stylesheet' | |
| link.href = stylesheet.href | |
| win.document.head.appendChild(link) | |
| continue | |
| } | |
| win.document.head.appendChild(style) | |
| } | |
| } | |
| } | |
| /** | |
| * Link strategy - uses <link> tags but sets base URL | |
| * Pros: Fast, no duplication | |
| * Cons: Requires base URL, may not work with all configurations | |
| */ | |
| export class LinkWithBaseStrategy implements InjectionStrategy { | |
| async inject(win: Window, resolver: ResourceResolver): Promise<void> { | |
| // Set base URL first | |
| const base = win.document.createElement('base') | |
| base.href = window.location.href | |
| win.document.head.appendChild(base) | |
| const stylesheets = resolver.getStylesheetInfo() | |
| for (const stylesheet of stylesheets) { | |
| if (stylesheet.type === 'link' && stylesheet.href) { | |
| const link = win.document.createElement('link') | |
| link.rel = 'stylesheet' | |
| link.href = stylesheet.href | |
| win.document.head.appendChild(link) | |
| } else if (stylesheet.type === 'inline') { | |
| // Still need to resolve URLs in inline styles | |
| const style = win.document.createElement('style') | |
| style.textContent = resolver.processInlineStyle(stylesheet.element as HTMLStyleElement) | |
| win.document.head.appendChild(style) | |
| } | |
| } | |
| } | |
| } | |
| /** | |
| * Hybrid strategy - uses links for external, inline for embedded styles | |
| * Pros: Balanced approach | |
| * Cons: More complex | |
| */ | |
| export class HybridInjectionStrategy implements InjectionStrategy { | |
| async inject(win: Window, resolver: ResourceResolver): Promise<void> { | |
| const stylesheets = resolver.getStylesheetInfo() | |
| for (const stylesheet of stylesheets) { | |
| if (stylesheet.type === 'link' && stylesheet.href) { | |
| // Use link for external stylesheets (browser handles caching) | |
| const link = win.document.createElement('link') | |
| link.rel = 'stylesheet' | |
| link.href = stylesheet.href // Already absolute | |
| win.document.head.appendChild(link) | |
| } else if (stylesheet.type === 'inline') { | |
| // Resolve and inline for embedded styles | |
| const style = win.document.createElement('style') | |
| style.textContent = resolver.processInlineStyle(stylesheet.element as HTMLStyleElement) | |
| win.document.head.appendChild(style) | |
| } | |
| } | |
| } | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Usage examples for WindowPortal | |
| * This file demonstrates various usage patterns | |
| */ | |
| import { useState } from 'react' | |
| import { WindowPortalV2, InlineInjectionStrategy, LinkWithBaseStrategy, HybridInjectionStrategy } from './index' | |
| /** | |
| * Example 1: Basic usage with default settings | |
| */ | |
| export function BasicExample() { | |
| const [isOpen, setIsOpen] = useState(false) | |
| return ( | |
| <div> | |
| <button onClick={() => setIsOpen(true)}>Open Portal</button> | |
| {isOpen && ( | |
| <WindowPortalV2 title="My Portal" onClose={() => setIsOpen(false)}> | |
| <div className="p-4"> | |
| <h1 className="text-2xl font-bold">Hello from Portal!</h1> | |
| <p>This content renders in a new window.</p> | |
| <p>All fonts and styles are properly resolved.</p> | |
| </div> | |
| </WindowPortalV2> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * Example 2: Custom window configuration | |
| */ | |
| export function CustomConfigExample() { | |
| const [isOpen, setIsOpen] = useState(false) | |
| return ( | |
| <div> | |
| <button onClick={() => setIsOpen(true)}>Open Large Portal</button> | |
| {isOpen && ( | |
| <WindowPortalV2 | |
| title="Large Portal" | |
| onClose={() => setIsOpen(false)} | |
| config={{ | |
| width: 1200, | |
| height: 800, | |
| left: 100, | |
| top: 100, | |
| resizable: true, | |
| scrollbars: true, | |
| }}> | |
| <div className="min-h-screen bg-gray-100 p-8"> | |
| <h1 className="text-4xl">Large Window</h1> | |
| </div> | |
| </WindowPortalV2> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * Example 3: Using inline injection strategy for maximum compatibility | |
| */ | |
| export function InlineStrategyExample() { | |
| const [isOpen, setIsOpen] = useState(false) | |
| return ( | |
| <div> | |
| <button onClick={() => setIsOpen(true)}>Open with Inline Strategy</button> | |
| {isOpen && ( | |
| <WindowPortalV2 | |
| title="Inline Strategy Portal" | |
| onClose={() => setIsOpen(false)} | |
| injectionStrategy={new InlineInjectionStrategy()}> | |
| <div className="p-4"> | |
| <p>Using inline strategy - all CSS is inlined with resolved URLs</p> | |
| <p className="font-custom">Custom fonts will work!</p> | |
| </div> | |
| </WindowPortalV2> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * Example 4: Using link with base strategy for better performance | |
| */ | |
| export function LinkStrategyExample() { | |
| const [isOpen, setIsOpen] = useState(false) | |
| return ( | |
| <div> | |
| <button onClick={() => setIsOpen(true)}>Open with Link Strategy</button> | |
| {isOpen && ( | |
| <WindowPortalV2 | |
| title="Link Strategy Portal" | |
| onClose={() => setIsOpen(false)} | |
| injectionStrategy={new LinkWithBaseStrategy()}> | |
| <div className="p-4"> | |
| <p>Using link strategy with base URL - faster loading</p> | |
| </div> | |
| </WindowPortalV2> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * Example 5: With error handling and ready callback | |
| */ | |
| export function ErrorHandlingExample() { | |
| const [isOpen, setIsOpen] = useState(false) | |
| const [error, setError] = useState<Error | null>(null) | |
| const [isReady, setIsReady] = useState(false) | |
| const handleOpen = () => { | |
| setError(null) | |
| setIsReady(false) | |
| setIsOpen(true) | |
| } | |
| return ( | |
| <div> | |
| <button onClick={handleOpen}>Open Portal</button> | |
| {error && <div className="mt-2 p-4 bg-red-100 text-red-700 rounded">Error: {error.message}</div>} | |
| {isReady && <div className="mt-2 p-4 bg-green-100 text-green-700 rounded">Portal is ready!</div>} | |
| {isOpen && ( | |
| <WindowPortalV2 | |
| title="Error Handling Portal" | |
| onClose={() => setIsOpen(false)} | |
| onError={err => { | |
| setError(err) | |
| setIsOpen(false) | |
| }} | |
| onReady={() => setIsReady(true)}> | |
| <div className="p-4"> | |
| <h1>Portal Content</h1> | |
| </div> | |
| </WindowPortalV2> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * Example 6: With script copying (use carefully!) | |
| */ | |
| export function WithScriptsExample() { | |
| const [isOpen, setIsOpen] = useState(false) | |
| return ( | |
| <div> | |
| <button onClick={() => setIsOpen(true)}>Open with Scripts</button> | |
| {isOpen && ( | |
| <WindowPortalV2 | |
| title="Portal with Scripts" | |
| onClose={() => setIsOpen(false)} | |
| copyScripts={true} // Enables script copying | |
| > | |
| <div className="p-4"> | |
| <p>Scripts are copied (analytics/tracking excluded)</p> | |
| </div> | |
| </WindowPortalV2> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * Example 7: Dynamic title updates | |
| */ | |
| export function DynamicTitleExample() { | |
| const [isOpen, setIsOpen] = useState(false) | |
| const [count, setCount] = useState(0) | |
| return ( | |
| <div> | |
| <button onClick={() => setIsOpen(true)}>Open Portal</button> | |
| {isOpen && ( | |
| <WindowPortalV2 | |
| title={`Portal - Count: ${count}`} // Title updates without recreating window | |
| onClose={() => setIsOpen(false)}> | |
| <div className="p-4"> | |
| <h1 className="text-2xl mb-4">Dynamic Title</h1> | |
| <p className="mb-4">Count: {count}</p> | |
| <button onClick={() => setCount(c => c + 1)} className="px-4 py-2 bg-blue-500 text-white rounded"> | |
| Increment (watch title) | |
| </button> | |
| </div> | |
| </WindowPortalV2> | |
| )} | |
| </div> | |
| ) | |
| } | |
| /** | |
| * Example 8: Hybrid strategy (recommended default) | |
| */ | |
| export function HybridStrategyExample() { | |
| const [isOpen, setIsOpen] = useState(false) | |
| return ( | |
| <div> | |
| <button onClick={() => setIsOpen(true)}>Open with Hybrid Strategy</button> | |
| {isOpen && ( | |
| <WindowPortalV2 | |
| title="Hybrid Strategy Portal" | |
| onClose={() => setIsOpen(false)} | |
| injectionStrategy={new HybridInjectionStrategy()} // Default | |
| config={{ | |
| width: 800, | |
| height: 600, | |
| }}> | |
| <div className="p-8 bg-gradient-to-br from-blue-50 to-purple-50"> | |
| <h1 className="text-3xl font-bold mb-4 text-gray-800">Hybrid Strategy</h1> | |
| <p className="text-gray-600 mb-4"> | |
| This uses link tags for external stylesheets (fast, cached) and inlines embedded styles with resolved | |
| URLs. | |
| </p> | |
| <ul className="list-disc list-inside space-y-2 text-gray-700"> | |
| <li>External stylesheets: link tags (cached)</li> | |
| <li>Inline styles: resolved URLs</li> | |
| <li>Fonts: properly loaded ✓</li> | |
| <li>Background images: properly loaded ✓</li> | |
| </ul> | |
| </div> | |
| </WindowPortalV2> | |
| )} | |
| </div> | |
| ) | |
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import { FC, ReactNode, useState, useEffect, useRef } from 'react' | |
| import { createPortal } from 'react-dom' | |
| import { ResourceResolver, InjectionStrategy, HybridInjectionStrategy } from './ResourceResolver' | |
| export type WindowPortalConfig = { | |
| width?: number | |
| height?: number | |
| left?: number | |
| top?: number | |
| resizable?: boolean | |
| scrollbars?: boolean | |
| menubar?: boolean | |
| toolbar?: boolean | |
| location?: boolean | |
| status?: boolean | |
| } | |
| export type WindowPortalProps = { | |
| title: string | |
| children: ReactNode | |
| onClose?: () => void | |
| config?: WindowPortalConfig | |
| injectionStrategy?: InjectionStrategy | |
| copyScripts?: boolean // Default false for safety | |
| onError?: (error: Error) => void | |
| onReady?: () => void | |
| } | |
| const DEFAULT_CONFIG: WindowPortalConfig = { | |
| width: 600, | |
| height: 400, | |
| resizable: true, | |
| scrollbars: true, | |
| menubar: false, | |
| toolbar: false, | |
| location: false, | |
| status: false, | |
| } | |
| /** | |
| * WindowPortal V2 - Enhanced version with proper resource resolution | |
| * | |
| * Features: | |
| * - Resolves relative URLs in CSS (fonts, images, etc.) | |
| * - Configurable injection strategies | |
| * - Better error handling | |
| * - Stable lifecycle management | |
| * - Optional script copying (disabled by default) | |
| */ | |
| export const WindowPortalV2: FC<WindowPortalProps> = ({ | |
| title, | |
| children, | |
| onClose, | |
| config = {}, | |
| injectionStrategy, | |
| copyScripts = false, | |
| onError, | |
| onReady, | |
| }) => { | |
| const [container, setContainer] = useState<HTMLDivElement | null>(null) | |
| const [isReady, setIsReady] = useState(false) | |
| const [error, setError] = useState<Error | null>(null) | |
| const windowRef = useRef<Window | null>(null) | |
| const onCloseRef = useRef(onClose) | |
| const titleRef = useRef(title) | |
| // Update refs without triggering effect | |
| useEffect(() => { | |
| onCloseRef.current = onClose | |
| }, [onClose]) | |
| useEffect(() => { | |
| titleRef.current = title | |
| }, [title]) | |
| // Update title without recreating window | |
| useEffect(() => { | |
| if (windowRef.current && !windowRef.current.closed) { | |
| windowRef.current.document.title = title | |
| } | |
| }, [title]) | |
| // Main initialization effect - only runs once | |
| useEffect(() => { | |
| const initializeWindow = async () => { | |
| try { | |
| const mergedConfig = { ...DEFAULT_CONFIG, ...config } | |
| const features = buildWindowFeatures(mergedConfig) | |
| // Open window | |
| const newWindow = window.open('', '', features) | |
| if (!newWindow) { | |
| throw new Error('Failed to open window. Please check popup blocker settings.') | |
| } | |
| windowRef.current = newWindow | |
| // Set title | |
| newWindow.document.title = titleRef.current | |
| // Inject styles with resource resolution | |
| const resolver = new ResourceResolver() | |
| const strategy = injectionStrategy || new HybridInjectionStrategy() | |
| await strategy.inject(newWindow, resolver) | |
| // Optionally copy scripts (careful with this!) | |
| if (copyScripts) { | |
| injectScripts(newWindow) | |
| } | |
| // Create container for portal | |
| const containerDiv = newWindow.document.createElement('div') | |
| containerDiv.id = 'portal-root' | |
| newWindow.document.body.appendChild(containerDiv) | |
| setContainer(containerDiv) | |
| // Set up close handler | |
| const unloadHandler = () => { | |
| onCloseRef.current?.() | |
| } | |
| newWindow.addEventListener('beforeunload', unloadHandler) | |
| // Mark as ready | |
| setIsReady(true) | |
| onReady?.() | |
| // Cleanup | |
| return () => { | |
| newWindow.removeEventListener('beforeunload', unloadHandler) | |
| if (!newWindow.closed) { | |
| newWindow.close() | |
| } | |
| } | |
| } catch (err) { | |
| const error = err instanceof Error ? err : new Error('Unknown error occurred') | |
| setError(error) | |
| onError?.(error) | |
| } | |
| } | |
| const cleanup = initializeWindow() | |
| return () => { | |
| cleanup.then(cleanupFn => cleanupFn?.()) | |
| } | |
| // Only run once on mount | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, []) | |
| // Error state | |
| if (error) { | |
| return null // Or render error UI if needed | |
| } | |
| // Loading state | |
| if (!container || !isReady) { | |
| return null // Or render loading indicator if needed | |
| } | |
| return createPortal(children, container) | |
| } | |
| /** | |
| * Builds window.open() features string from config | |
| */ | |
| function buildWindowFeatures(config: WindowPortalConfig): string { | |
| const features: string[] = [] | |
| if (config.width) features.push(`width=${config.width}`) | |
| if (config.height) features.push(`height=${config.height}`) | |
| if (config.left !== undefined) features.push(`left=${config.left}`) | |
| if (config.top !== undefined) features.push(`top=${config.top}`) | |
| features.push(`resizable=${config.resizable ? 'yes' : 'no'}`) | |
| features.push(`scrollbars=${config.scrollbars ? 'yes' : 'no'}`) | |
| features.push(`menubar=${config.menubar ? 'yes' : 'no'}`) | |
| features.push(`toolbar=${config.toolbar ? 'yes' : 'no'}`) | |
| features.push(`location=${config.location ? 'yes' : 'no'}`) | |
| features.push(`status=${config.status ? 'yes' : 'no'}`) | |
| return features.join(',') | |
| } | |
| /** | |
| * Injects scripts from parent window (use with caution) | |
| */ | |
| function injectScripts(win: Window): void { | |
| const mainWindowScripts = document.querySelectorAll('script') | |
| // Filter out scripts that should not be copied | |
| const scriptsToIgnore = [ | |
| /analytics/i, | |
| /gtag/i, | |
| /google-analytics/i, | |
| /hotjar/i, | |
| /segment/i, | |
| /mixpanel/i, | |
| // Add more patterns as needed | |
| ] | |
| mainWindowScripts.forEach(script => { | |
| // Check if script should be ignored | |
| const src = script.src || script.textContent || '' | |
| if (scriptsToIgnore.some(pattern => pattern.test(src))) { | |
| return | |
| } | |
| const newScript = win.document.createElement('script') | |
| if (script.src) { | |
| newScript.src = script.src | |
| newScript.async = (script as HTMLScriptElement).async | |
| newScript.defer = (script as HTMLScriptElement).defer | |
| } else { | |
| newScript.textContent = script.textContent | |
| } | |
| win.document.body.appendChild(newScript) | |
| }) | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment