diff --git a/packages/playground/remote/iframes-trap.js b/packages/playground/remote/iframes-trap.js
new file mode 100644
index 0000000000..ada0837734
--- /dev/null
+++ b/packages/playground/remote/iframes-trap.js
@@ -0,0 +1,2111 @@
+'use strict';
+
+/**
+ * Controlled iframe bootstrap.
+ * Converts srcdoc/blob/data/about:blank iframes into real navigations that stay
+ * under the page's Service Worker control.
+ *
+ * ## The Problem
+ *
+ * Iframes created as about:blank / srcdoc / data / blob are not controlled by the
+ * service worker. This means that all network calls initiated by these iframes are
+ * sent directly to the network. This breaks Gutenberg CSS loading, TinyMCE media
+ * loading, etc.
+ *
+ * Only iframes created with src pointing to a URL already controlled by the service
+ * worker are themselves controlled.
+ *
+ * ## The Solution
+ *
+ * We intercept iframe creation and attribute setting to force iframes through a
+ * controlled URL (the loader). The loader then restores the original content.
+ *
+ * For document.write(), we let it proceed normally but ensure the iframes-trap.js
+ * script is injected into the written content so nested iframes remain controlled.
+ *
+ * This file is loaded in multiple contexts (loader, wp-admin, etc.). It must be
+ * safe to include more than once, so we guard on a global flag.
+ */
+function setupIframesTrap() {
+ if (window.__controlled_iframes_loaded__) {
+ return;
+ }
+ window.__controlled_iframes_loaded__ = true;
+
+ const iframeCacheBucket = 'iframe-virtual-docs-v1';
+
+ /**
+ * Best-effort synchronous scope guess so we can seed src immediately in createElement.
+ * Falls back to extracting scope from current pathname or empty string.
+ * Note: data-scope may be empty string if SW_SCOPE is root, so we check for truthy value.
+ *
+ * The scope can appear in two forms:
+ * 1. At path start: /scope:xxx/... (direct access)
+ * 2. After SW prefix: /website-server/scope:xxx/... (when running under /website-server/)
+ *
+ * We extract everything up to and including the /scope:xxx segment to ensure
+ * loader URLs stay within the service worker's scope.
+ */
+ const inferredSiteScope =
+ document.currentScript?.dataset.scope ||
+ location.pathname.match(/^(.*\/scope:[^/]+)/)?.[1] ||
+ location.pathname.match(/^\/scope:[^/]+/)?.[0] ||
+ '';
+
+ /**
+ * Authoritative scope for this page.
+ * We use the inferred scope from the pathname because the SW registration
+ * scope is typically the root '/' which doesn't help us.
+ * The promise just ensures we're consistent with the sync inferredSiteScope.
+ */
+ const scopePromise = Promise.resolve(inferredSiteScope.replace(/\/$/, ''));
+
+ /**
+ * Compute scoped paths for cache and loader URLs.
+ */
+ function scopedPaths(scope) {
+ const base = scope.replace(/\/$/, '');
+ return {
+ VIRTUAL_PREFIX: `${base}/__iframes/`,
+ LOADER_PATH: `${base}/wp-includes/empty.html`,
+ TRAP_SCRIPT_URL: `${base}/__bootstrap/iframes-trap.js`,
+ };
+ }
+
+ /**
+ * Inject iframes-trap.js into an iframe's document WITHOUT navigating.
+ * This is used for document.write() iframes where we want to preserve
+ * the existing document (and all references to it) while still ensuring
+ * nested iframes will be controlled.
+ *
+ * Returns a promise that resolves when the script has loaded.
+ */
+ async function injectIframesTrapIntoDocument(iframe) {
+ const doc = iframe.contentDocument;
+ if (!doc || !doc.head) {
+ console.log('[iframes-trap] injectIframesTrapIntoDocument: no document or head');
+ return false;
+ }
+
+ // Check if already injected
+ if (iframe.contentWindow?.__controlled_iframes_loaded__) {
+ console.log('[iframes-trap] injectIframesTrapIntoDocument: already loaded');
+ return true;
+ }
+
+ const scope = await scopePromise;
+ const { TRAP_SCRIPT_URL } = scopedPaths(scope);
+
+ // Add base tag if not present (needed for relative URLs)
+ if (!doc.querySelector('base')) {
+ const base = doc.createElement('base');
+ base.href = document.baseURI;
+ doc.head.insertBefore(base, doc.head.firstChild);
+ }
+
+ // Create and inject the script
+ return new Promise((resolve) => {
+ const script = doc.createElement('script');
+ script.src = TRAP_SCRIPT_URL;
+ script.onload = () => {
+ console.log('[iframes-trap] injectIframesTrapIntoDocument: script loaded');
+ iframe.setAttribute('data-docwrite-controlled', '1');
+ resolve(true);
+ };
+ script.onerror = () => {
+ console.warn('[iframes-trap] injectIframesTrapIntoDocument: script failed to load');
+ resolve(false);
+ };
+ doc.head.appendChild(script);
+ });
+ }
+
+ // Snapshot natives before we patch prototypes.
+ const Native = {
+ write: Document.prototype.write,
+ open: Document.prototype.open,
+ close: Document.prototype.close,
+ createElement: Document.prototype.createElement,
+ setAttribute: Element.prototype.setAttribute,
+ iframeSrc: Object.getOwnPropertyDescriptor(
+ HTMLIFrameElement.prototype,
+ 'src'
+ ),
+ iframeSrcdoc: Object.getOwnPropertyDescriptor(
+ HTMLIFrameElement.prototype,
+ 'srcdoc'
+ ),
+ contentWindow: Object.getOwnPropertyDescriptor(
+ HTMLIFrameElement.prototype,
+ 'contentWindow'
+ ),
+ contentDocument: Object.getOwnPropertyDescriptor(
+ HTMLIFrameElement.prototype,
+ 'contentDocument'
+ ),
+ };
+
+ /**
+ * Set iframe src using the native setter to avoid recursion.
+ * For cross-realm iframes (created in ancestor documents), we need to use
+ * the ancestor's native setter, not our captured one. This is important for
+ * Firefox which doesn't allow cross-realm property setter calls.
+ */
+ function setIframeSrc(iframe, url, ancestorWindow) {
+ // If an ancestor window is provided (cross-realm case), get that realm's native setter
+ if (ancestorWindow && ancestorWindow !== window) {
+ try {
+ const ancestorSetter = Object.getOwnPropertyDescriptor(
+ ancestorWindow.HTMLIFrameElement.prototype,
+ 'src'
+ )?.set;
+ if (ancestorSetter) {
+ ancestorSetter.call(iframe, url);
+ return;
+ }
+ } catch {
+ // Fall through to other methods
+ }
+ // Fallback: use setAttribute from the ancestor's Element prototype
+ try {
+ ancestorWindow.Element.prototype.setAttribute.call(iframe, 'src', url);
+ return;
+ } catch {
+ // Fall through to native setAttribute
+ }
+ }
+ // Same-realm case or fallback
+ if (Native.iframeSrc?.set) {
+ Native.iframeSrc.set.call(iframe, url);
+ } else {
+ Native.setAttribute.call(iframe, 'src', url);
+ }
+ }
+
+ /**
+ * Generate a unique ID for caching iframe content.
+ */
+ function uid() {
+ return `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
+ }
+
+ /**
+ * Store iframe HTML content in CacheStorage.
+ *
+ * IMPORTANT: We inject iframes-trap.js and a tag directly into the HTML
+ * so that when the SW serves this content, it's a complete, real HTML document.
+ * This is critical because documents served this way allow nested iframe
+ * navigation to work properly (unlike innerHTML-injected documents).
+ *
+ * @param {string} id - Unique ID for this cached content
+ * @param {string} html - The original HTML content
+ * @param {string} base - The base URL for relative URLs in the document
+ * @param {string} prettyUrl - Optional URL to show in the browser (for history.replaceState)
+ */
+ async function cacheIframeContents(id, html, base = document.baseURI, prettyUrl = '') {
+ const cache = await caches.open(iframeCacheBucket);
+ const scope = await scopePromise;
+ const { VIRTUAL_PREFIX, TRAP_SCRIPT_URL } = scopedPaths(scope);
+
+ // Rewrite absolute URLs to include the SW scope prefix
+ // This ensures CSS, images, and scripts load through the SW
+ const rewrittenHtml = rewriteAbsoluteUrlsInHtml(html, scope);
+
+ // Inject iframes-trap.js and base tag into the HTML
+ // This makes the cached document a complete, self-contained HTML page
+ // that sets up iframe control for any nested iframes
+ const injectedHtml = injectScriptsIntoHtml(rewrittenHtml, TRAP_SCRIPT_URL, base, prettyUrl);
+
+ await cache.put(
+ `${VIRTUAL_PREFIX}${id}.html`,
+ new Response(injectedHtml, {
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
+ })
+ );
+ }
+
+ /**
+ * Inject iframes-trap.js script and base tag into HTML.
+ * This transforms srcdoc HTML into a complete document that can control nested iframes.
+ */
+ function injectScriptsIntoHtml(html, trapScriptUrl, base, prettyUrl) {
+ // Find where to inject (after
or at start of document)
+ let injectionPoint = 0;
+ let prefix = '';
+
+ // Try to find tag
+ const headMatch = html.match(/]*>/i);
+ if (headMatch) {
+ injectionPoint = headMatch.index + headMatch[0].length;
+ } else {
+ // No tag - inject after doctype/html or at start
+ const htmlMatch = html.match(/]*>/i);
+ if (htmlMatch) {
+ injectionPoint = htmlMatch.index + htmlMatch[0].length;
+ prefix = '';
+ } else {
+ // No tag either - inject at start with full structure
+ prefix = '';
+ }
+ }
+
+ // Build the injection content
+ const baseTag = ``;
+ const scriptTag = ``;
+
+ // Add a small script to update the URL if prettyUrl is provided
+ const urlScript = prettyUrl
+ ? ``
+ : '';
+
+ const injection = prefix + baseTag + scriptTag + urlScript;
+
+ // Close head if we opened it
+ const suffix = prefix.includes('') ? '' : '';
+
+ return html.slice(0, injectionPoint) + injection + html.slice(injectionPoint) + suffix;
+ }
+
+ /**
+ * Escape HTML special characters for safe attribute insertion.
+ */
+ function escapeHtml(str) {
+ return str
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+ }
+
+ /**
+ * Rewrite absolute URLs in HTML to include the SW scope prefix.
+ *
+ * TinyMCE and other libraries use absolute paths like "/wp-includes/css/..."
+ * which resolve against the origin, NOT the base tag. This means they bypass
+ * the Service Worker scope (e.g., /website-server/).
+ *
+ * This function rewrites absolute paths to include the scope prefix, ensuring
+ * they go through the SW.
+ *
+ * Example: href="/scope:test/file.css" -> href="/website-server/scope:test/file.css"
+ */
+ function rewriteAbsoluteUrlsInHtml(html, scope) {
+ if (!scope) return html;
+
+ // The scope already includes the path prefix (e.g., "/website-server/scope:test")
+ // We need to extract the prefix before "scope:" to prepend it to absolute URLs
+ // that contain "scope:" but don't have the prefix
+
+ // Find the prefix before scope: (e.g., "/website-server")
+ const scopeMatch = scope.match(/^(.*?)(\/scope:[^/]*)/);
+ if (!scopeMatch) return html; // No scope pattern, nothing to rewrite
+
+ const prefix = scopeMatch[1]; // e.g., "/website-server"
+ if (!prefix) return html; // No prefix, URLs are already correct
+
+ // Rewrite src and href attributes that start with "/" but don't include the prefix
+ // Match: src="/scope:..." or href="/scope:..." (without the prefix)
+ // But NOT: src="/website-server/scope:..." (already has prefix)
+ const pattern = new RegExp(
+ `((?:src|href|action)\\s*=\\s*["'])(\\/(?!${prefix.slice(1)}\\/))`,
+ 'gi'
+ );
+
+ return html.replace(pattern, (match, attrStart, pathStart) => {
+ return attrStart + prefix + pathStart;
+ });
+ }
+
+ /**
+ * Build a URL to the cached iframe content.
+ *
+ * Instead of using a loader page that fetches and injects cached content via
+ * innerHTML, we navigate directly to the cached content URL. The SW serves
+ * the cached HTML directly, which creates a "real" document where nested
+ * iframe navigation works properly.
+ */
+ async function toLoaderUrl({ id, prettyUrl = '', base = document.baseURI } = {}) {
+ const scope = await scopePromise;
+ const { VIRTUAL_PREFIX, LOADER_PATH } = scopedPaths(scope);
+
+ // If we have an ID, navigate directly to the cached content
+ // This is crucial for nested iframes to work properly
+ if (id) {
+ return `${VIRTUAL_PREFIX}${id}.html`;
+ }
+
+ // No ID - use the loader for empty iframes
+ // (shouldn't normally happen since empty iframes get cached too)
+ const queryString = new URLSearchParams({ base, url: prettyUrl });
+ return `${LOADER_PATH}#${queryString.toString()}`;
+ }
+
+ /**
+ * Rewrite an iframe's srcdoc by caching the HTML and navigating to the cached URL.
+ * This navigates the original iframe to a SW-controlled URL.
+ *
+ * The HTML is injected with iframes-trap.js and a tag, then cached.
+ * The iframe navigates directly to the cached URL, which the SW serves as
+ * a real HTML document. This allows nested iframes to work properly.
+ */
+ async function rewriteSrcdoc(iframe, html, opts = {}) {
+ console.log('[iframes-trap] rewriteSrcdoc called, html length:', html?.length);
+
+ // Mark that srcdoc processing is in progress (so scheduleIframeControl can defer)
+ iframe.setAttribute('data-srcdoc-pending', '1');
+
+ const id = uid();
+ const base = opts.base || document.baseURI;
+ const prettyUrl = opts.prettyUrl || '';
+
+ console.log('[iframes-trap] rewriteSrcdoc: caching with id:', id);
+ // Cache the HTML with injected scripts
+ await cacheIframeContents(id, html, base, prettyUrl);
+ const url = await toLoaderUrl({ id, ...opts });
+ console.log('[iframes-trap] rewriteSrcdoc: loader URL:', url);
+
+ // Remove and re-add the iframe to force a full navigation.
+ // This is necessary because:
+ // 1. Setting src on an iframe that has had document.write() may not trigger navigation
+ // 2. Hash-only URL changes don't trigger navigation
+ const parent = iframe.parentNode;
+ const nextSibling = iframe.nextSibling;
+ console.log('[iframes-trap] rewriteSrcdoc: parent:', !!parent, 'nextSibling:', !!nextSibling);
+
+ // Temporarily remove from DOM
+ if (parent) {
+ parent.removeChild(iframe);
+ }
+
+ // Set src using native setter
+ console.log('[iframes-trap] rewriteSrcdoc: setting src to:', url);
+ setIframeSrc(iframe, url);
+
+ // Re-add to DOM - this triggers a fresh navigation
+ if (parent) {
+ if (nextSibling) {
+ parent.insertBefore(iframe, nextSibling);
+ } else {
+ parent.appendChild(iframe);
+ }
+ }
+
+ console.log('[iframes-trap] rewriteSrcdoc: done, iframe.src:', iframe.src);
+ iframe.setAttribute('data-controlled', '1');
+ iframe.removeAttribute('data-srcdoc-pending');
+ }
+
+ /**
+ * Schedule srcdoc iframe control using the parent-delegation approach.
+ * Similar to scheduleIframeControl but for iframes that have srcdoc content
+ * which has already been cached and converted to a loader URL.
+ *
+ * Uses message passing to create iframes in ancestor windows to work around
+ * Firefox's cross-realm restrictions.
+ */
+ function scheduleSrcdocControl(iframe, loaderUrl) {
+ // If this iframe was already controlled (e.g., by controlIframeOnMutation for blank iframe
+ // before document.write() was called), we need to remove the old controlled iframe
+ // because the content has changed.
+ if (iframe.__controlledIframe) {
+ try {
+ iframe.__controlledIframe.remove();
+ } catch {
+ // Ignore removal errors
+ }
+ iframe.__controlledIframe = null;
+ iframe.removeAttribute('data-controlled');
+ iframe.removeAttribute('data-controlled-by');
+ }
+
+ // Mark as pending control
+ iframe.setAttribute('data-control-pending', '1');
+
+ const tryControl = async () => {
+ // Only proceed if iframe is still in the document
+ if (!iframe.isConnected) {
+ requestAnimationFrame(tryControl);
+ return;
+ }
+
+ const capableAncestor = findCapableAncestor();
+
+ // Clean up any existing controlled iframe before creating a new one
+ // This handles the case where scheduleIframeControl already created one
+ // before document.write() was called.
+ const existingControlledId = iframe.getAttribute('data-controlled-by');
+ if (existingControlledId || iframe.__controlledIframe) {
+ // Remove via reference
+ if (iframe.__controlledIframe) {
+ try {
+ iframe.__controlledIframe.remove();
+ } catch {
+ // Ignore
+ }
+ iframe.__controlledIframe = null;
+ }
+ // Also try to find and remove by ID in the capable ancestor (where it was likely created)
+ if (existingControlledId && capableAncestor) {
+ try {
+ const existing = capableAncestor.document.getElementById(existingControlledId);
+ if (existing) {
+ existing.remove();
+ // Also clean up from __pg_iframes registry
+ if (capableAncestor.__pg_iframes?.[existingControlledId]) {
+ delete capableAncestor.__pg_iframes[existingControlledId];
+ }
+ }
+ } catch {
+ // Cross-origin or not found
+ }
+ }
+ iframe.removeAttribute('data-controlled');
+ iframe.removeAttribute('data-controlled-by');
+ }
+ if (!capableAncestor) {
+ // No capable ancestor, try direct assignment (may not work)
+ setIframeSrc(iframe, loaderUrl);
+ iframe.setAttribute('data-controlled', '1');
+ iframe.removeAttribute('data-control-pending');
+ iframe.removeAttribute('data-srcdoc-pending');
+ return;
+ }
+
+ // Generate unique ID for cross-document reference
+ const iframeId = `pg-iframe-${uid()}`;
+ iframe.id = iframe.id || iframeId;
+ const finalId = iframe.id;
+ const controlledId = `${finalId}-controlled`;
+
+ // Collect attributes to copy (except src/srcdoc/control markers)
+ const attributes = {};
+ for (const attr of iframe.attributes) {
+ if (attr.name !== 'src' && attr.name !== 'srcdoc' && attr.name !== 'data-control-pending' && attr.name !== 'data-controlled' && attr.name !== 'data-srcdoc-pending' && attr.name !== 'id') {
+ attributes[attr.name] = attr.value;
+ }
+ }
+
+ // Calculate position for the controlled iframe
+ const rect = iframe.getBoundingClientRect();
+ let offsetTop = 0;
+ let offsetLeft = 0;
+ let win = window;
+ while (win !== capableAncestor && win.frameElement) {
+ const frameRect = win.frameElement.getBoundingClientRect();
+ offsetTop += frameRect.top;
+ offsetLeft += frameRect.left;
+ win = win.parent;
+ }
+
+ const style = {
+ position: 'fixed',
+ top: `${rect.top + offsetTop}px`,
+ left: `${rect.left + offsetLeft}px`,
+ width: `${rect.width}px`,
+ height: `${rect.height}px`,
+ zIndex: '999999',
+ border: iframe.style.border || 'none',
+ };
+
+ try {
+ // Use message passing to create the iframe in the ancestor's realm
+ // This is critical for Firefox compatibility
+ const controlledIframe = await requestAncestorCreateIframe(capableAncestor, {
+ id: controlledId,
+ src: loaderUrl,
+ attributes,
+ style,
+ });
+
+ // Hide the original iframe
+ iframe.style.visibility = 'hidden';
+
+ // Set up position updates
+ const updatePosition = () => {
+ try {
+ if (!iframe.isConnected) {
+ controlledIframe.remove();
+ return;
+ }
+ const newRect = iframe.getBoundingClientRect();
+ let newOffsetTop = 0;
+ let newOffsetLeft = 0;
+ let w = window;
+ while (w !== capableAncestor && w.frameElement) {
+ const frameRect = w.frameElement.getBoundingClientRect();
+ newOffsetTop += frameRect.top;
+ newOffsetLeft += frameRect.left;
+ w = w.parent;
+ }
+ controlledIframe.style.top = `${newRect.top + newOffsetTop}px`;
+ controlledIframe.style.left = `${newRect.left + newOffsetLeft}px`;
+ controlledIframe.style.width = `${newRect.width}px`;
+ controlledIframe.style.height = `${newRect.height}px`;
+ requestAnimationFrame(updatePosition);
+ } catch {
+ // If we can't access the iframe anymore, stop updating
+ }
+ };
+ requestAnimationFrame(updatePosition);
+
+ // Mark original as controlled
+ iframe.setAttribute('data-controlled', '1');
+ iframe.setAttribute('data-controlled-by', controlledId);
+ iframe.removeAttribute('data-control-pending');
+ iframe.removeAttribute('data-srcdoc-pending');
+
+ // Store reference for contentWindow/contentDocument access
+ iframe.__controlledIframe = controlledIframe;
+ } catch (error) {
+ // Message passing failed, fall back to direct approach (might not work in Firefox)
+ console.warn('[iframes-trap] Message-based iframe creation failed, falling back to direct approach:', error);
+
+ const ancestorDoc = capableAncestor.document;
+ const ancestorNativeCreate = ancestorDoc.__playground_native_createElement || Native.createElement;
+ const controlledIframe = ancestorNativeCreate.call(ancestorDoc, 'iframe');
+ controlledIframe.id = controlledId;
+
+ for (const [name, value] of Object.entries(attributes)) {
+ controlledIframe.setAttribute(name, value);
+ }
+ Object.assign(controlledIframe.style, style);
+
+ iframe.style.visibility = 'hidden';
+ ancestorDoc.body.appendChild(controlledIframe);
+ setIframeSrc(controlledIframe, loaderUrl, capableAncestor);
+
+ // Position updates
+ const updatePosition = () => {
+ try {
+ if (!iframe.isConnected) {
+ controlledIframe.remove();
+ return;
+ }
+ const newRect = iframe.getBoundingClientRect();
+ let newOffsetTop = 0;
+ let newOffsetLeft = 0;
+ let w = window;
+ while (w !== capableAncestor && w.frameElement) {
+ const frameRect = w.frameElement.getBoundingClientRect();
+ newOffsetTop += frameRect.top;
+ newOffsetLeft += frameRect.left;
+ w = w.parent;
+ }
+ controlledIframe.style.top = `${newRect.top + newOffsetTop}px`;
+ controlledIframe.style.left = `${newRect.left + newOffsetLeft}px`;
+ controlledIframe.style.width = `${newRect.width}px`;
+ controlledIframe.style.height = `${newRect.height}px`;
+ requestAnimationFrame(updatePosition);
+ } catch {
+ // Stop updating if we can't access the iframe
+ }
+ };
+ requestAnimationFrame(updatePosition);
+
+ iframe.setAttribute('data-controlled', '1');
+ iframe.setAttribute('data-controlled-by', controlledId);
+ iframe.removeAttribute('data-control-pending');
+ iframe.removeAttribute('data-srcdoc-pending');
+ iframe.__controlledIframe = controlledIframe;
+ }
+ };
+
+ requestAnimationFrame(tryControl);
+ }
+
+ /**
+ * Rewrite data: or blob: URLs by fetching their content and caching it.
+ */
+ async function rewriteDataOrBlob(el, url) {
+ const res = await fetch(url);
+ const html = await res.text();
+ await rewriteSrcdoc(el, html);
+ }
+
+ /**
+ * Get the loader URL for an empty iframe (synchronous, uses inferred scope).
+ */
+ function getEmptyLoaderUrl() {
+ const { LOADER_PATH } = scopedPaths(inferredSiteScope);
+ return `${LOADER_PATH}#${new URLSearchParams({ base: document.baseURI }).toString()}`;
+ }
+
+ // ============================================================================
+ // Stash this realm's native createElement so cross-realm calls can find it
+ // ============================================================================
+ for (const proto of [Document.prototype, HTMLDocument?.prototype].filter(Boolean)) {
+ if (!proto.__playground_native_createElement) {
+ Object.defineProperty(proto, '__playground_native_createElement', {
+ value: Native.createElement,
+ configurable: true,
+ });
+ }
+ }
+
+ // ============================================================================
+ // Cross-realm iframe creation via message passing (Firefox compatibility)
+ // ============================================================================
+ // In Firefox, cross-realm property setter calls fail silently. To work around this,
+ // we use postMessage to ask the ancestor window to create iframes entirely within
+ // its own realm. The child frame posts a message requesting iframe creation, and
+ // the ancestor creates the iframe and sends back a reference via a MessageChannel.
+
+ /**
+ * Ask an ancestor window to create a controlled iframe.
+ * Returns a promise that resolves with the created iframe element.
+ */
+ function requestAncestorCreateIframe(ancestorWindow, config) {
+ console.log('[iframes-trap] Requesting ancestor to create iframe:', config.id, config.src);
+
+ return new Promise((resolve, reject) => {
+ const channel = new MessageChannel();
+ const timeout = setTimeout(() => {
+ console.error('[iframes-trap] Ancestor iframe creation timed out:', config.id);
+ reject(new Error('Ancestor iframe creation timed out'));
+ }, 15000); // Increased timeout for Firefox
+
+ channel.port1.onmessage = (event) => {
+ clearTimeout(timeout);
+ console.log('[iframes-trap] Received response for iframe:', config.id, event.data);
+ if (event.data.error) {
+ reject(new Error(event.data.error));
+ } else {
+ // The ancestor stores the iframe reference on window.__pg_iframes
+ const iframe = ancestorWindow.__pg_iframes?.[event.data.iframeId];
+ if (iframe) {
+ console.log('[iframes-trap] Found iframe reference:', config.id);
+ resolve(iframe);
+ } else {
+ console.error('[iframes-trap] Iframe reference not found:', event.data.iframeId);
+ reject(new Error('Iframe reference not found'));
+ }
+ }
+ };
+
+ ancestorWindow.postMessage(
+ {
+ type: '__playground_create_iframe',
+ config,
+ },
+ '*',
+ [channel.port2]
+ );
+ });
+ }
+
+ /**
+ * Listen for iframe messages from child frames.
+ * This handler runs in the ancestor window's realm.
+ */
+ window.addEventListener('message', async (event) => {
+ // Handle iframe navigation requests
+ if (event.data?.type === '__playground_navigate_iframe') {
+ const { iframeId, url } = event.data;
+ console.log('[iframes-trap] Received iframe navigation request:', iframeId, url);
+
+ // The child frame stores a reference to the iframe in __pg_iframes_to_navigate
+ // We need to find the child window that sent this message and access the iframe
+ try {
+ // Walk through all child frames to find the one with this iframe
+ const findIframeInDescendants = (win, depth = 0) => {
+ try {
+ const pendingNavs = win.__pg_iframes_to_navigate;
+ console.log(`[iframes-trap] findIframeInDescendants depth=${depth}, hasPending=${!!pendingNavs}, looking for ${iframeId}`);
+ if (pendingNavs) {
+ console.log(`[iframes-trap] pendingNavs keys:`, Object.keys(pendingNavs));
+ }
+ if (pendingNavs && pendingNavs[iframeId]) {
+ console.log(`[iframes-trap] Found at depth ${depth}!`);
+ return pendingNavs[iframeId];
+ }
+ // Search in child frames
+ console.log(`[iframes-trap] depth=${depth} has ${win.frames.length} child frames`);
+ for (let i = 0; i < win.frames.length; i++) {
+ try {
+ const result = findIframeInDescendants(win.frames[i], depth + 1);
+ if (result) return result;
+ } catch (e) {
+ console.log(`[iframes-trap] depth=${depth} frame[${i}] cross-origin:`, e.message);
+ // Cross-origin child, skip
+ }
+ }
+ } catch (e) {
+ console.log(`[iframes-trap] depth=${depth} access error:`, e.message);
+ // Cross-origin access error
+ }
+ return null;
+ };
+
+ const found = findIframeInDescendants(window);
+ if (found && found.iframe) {
+ const { iframe } = found;
+ console.log('[iframes-trap] Found iframe to navigate:', iframeId);
+
+ // Try multiple approaches to trigger navigation
+ // Approach 1: Try contentWindow.location.href (may fail due to cross-origin)
+ try {
+ if (iframe.contentWindow) {
+ console.log('[iframes-trap] Attempting contentWindow.location navigation');
+ iframe.contentWindow.location.href = url;
+ console.log('[iframes-trap] contentWindow.location navigation succeeded');
+ delete found.iframe.ownerDocument?.defaultView?.__pg_iframes_to_navigate?.[iframeId];
+ return;
+ }
+ } catch (e) {
+ console.log('[iframes-trap] contentWindow.location failed:', e.message);
+ }
+
+ // Approach 2: Try contentWindow.location.replace (sometimes works when assign doesn't)
+ try {
+ if (iframe.contentWindow) {
+ console.log('[iframes-trap] Attempting contentWindow.location.replace');
+ iframe.contentWindow.location.replace(url);
+ console.log('[iframes-trap] contentWindow.location.replace succeeded');
+ delete found.iframe.ownerDocument?.defaultView?.__pg_iframes_to_navigate?.[iframeId];
+ return;
+ }
+ } catch (e) {
+ console.log('[iframes-trap] contentWindow.location.replace failed:', e.message);
+ }
+
+ // Approach 3: Remove, set src, and re-add
+ console.log('[iframes-trap] Using remove/src/readd approach');
+ const parent = iframe.parentNode;
+ const nextSibling = iframe.nextSibling;
+
+ if (parent) {
+ parent.removeChild(iframe);
+ }
+
+ // Set src using native setter - in the ancestor's realm
+ if (Native.iframeSrc?.set) {
+ Native.iframeSrc.set.call(iframe, url);
+ } else {
+ Native.setAttribute.call(iframe, 'src', url);
+ }
+
+ if (parent) {
+ if (nextSibling) {
+ parent.insertBefore(iframe, nextSibling);
+ } else {
+ parent.appendChild(iframe);
+ }
+ }
+
+ console.log('[iframes-trap] Set src and readded iframe:', iframeId, 'to', url);
+
+ // Clean up the pending navigation entry
+ delete found.iframe.ownerDocument?.defaultView?.__pg_iframes_to_navigate?.[iframeId];
+ } else {
+ console.warn('[iframes-trap] Could not find iframe to navigate:', iframeId);
+ }
+ } catch (e) {
+ console.error('[iframes-trap] Error navigating iframe:', e);
+ }
+ return;
+ }
+
+ // Handle iframe creation requests (existing code)
+ if (event.data?.type !== '__playground_create_iframe') {
+ return;
+ }
+
+ const { config } = event.data;
+ const port = event.ports[0];
+
+ if (!port) {
+ console.warn('[iframes-trap] Received iframe creation request but no port provided');
+ return;
+ }
+
+ console.log('[iframes-trap] Received iframe creation request:', config.id, config.src);
+
+ try {
+ // Create iframe using this realm's native createElement
+ const iframe = Native.createElement.call(document, 'iframe');
+ iframe.id = config.id;
+
+ // Copy attributes
+ if (config.attributes) {
+ for (const [name, value] of Object.entries(config.attributes)) {
+ iframe.setAttribute(name, value);
+ }
+ }
+
+ // Apply styles
+ if (config.style) {
+ Object.assign(iframe.style, config.style);
+ }
+
+ // Append to body
+ document.body.appendChild(iframe);
+
+ // Set src using this realm's native setter (critical for Firefox)
+ if (config.src) {
+ if (Native.iframeSrc?.set) {
+ Native.iframeSrc.set.call(iframe, config.src);
+ } else {
+ Native.setAttribute.call(iframe, 'src', config.src);
+ }
+ }
+
+ // Store reference so child can access it
+ if (!window.__pg_iframes) {
+ window.__pg_iframes = {};
+ }
+ window.__pg_iframes[config.id] = iframe;
+
+ // Wait for the iframe to have a service worker controller before responding.
+ // This is critical for Firefox where timing can be different.
+ const waitForController = async (maxWait = 10000) => {
+ const start = Date.now();
+ while (Date.now() - start < maxWait) {
+ try {
+ if (iframe.contentWindow?.navigator?.serviceWorker?.controller) {
+ return true;
+ }
+ } catch {
+ // Cross-origin or not ready
+ }
+ await new Promise(r => setTimeout(r, 50));
+ }
+ return false;
+ };
+
+ // Wait for controller, but don't block indefinitely
+ const hasController = await waitForController();
+ console.log('[iframes-trap] Iframe controller ready:', config.id, hasController);
+
+ port.postMessage({ success: true, iframeId: config.id });
+ } catch (error) {
+ console.error('[iframes-trap] Iframe creation error:', error);
+ port.postMessage({ error: error.message });
+ }
+ });
+
+ // ============================================================================
+ // createElement wrapper - seeds blank iframes with loader src
+ // ============================================================================
+ const createElementWrapper = function (...args) {
+ const receiver = this ?? document;
+
+ // Same realm: safe to call our captured native
+ if (receiver instanceof Document) {
+ return handleCreateElement(callRealmCreateElement(receiver, args), args);
+ }
+
+ // Other realm: just call the native
+ return callRealmCreateElement(receiver, args);
+ };
+
+ function callRealmCreateElement(receiver, args) {
+ const attempts = [];
+ const proto = receiver && Object.getPrototypeOf(receiver);
+
+ // Try realm-native first
+ if (proto?.__playground_native_createElement) {
+ attempts.push(proto.__playground_native_createElement);
+ }
+ // Then prototype's createElement (if not our wrapper)
+ if (proto?.createElement && proto.createElement !== createElementWrapper) {
+ attempts.push(proto.createElement);
+ }
+ // Our captured native
+ attempts.push(Native.createElement);
+ // Last resort: bound call
+ attempts.push(document.createElement.bind(document));
+
+ for (const fn of attempts) {
+ if (typeof fn !== 'function') continue;
+ try {
+ return Reflect.apply(fn, receiver, args);
+ } catch {
+ // Try next candidate
+ }
+ }
+ throw new Error('createElement failed across all candidates');
+ }
+
+ /**
+ * Check if we're in a nested iframe context by looking at the frame hierarchy.
+ * Nested contexts (srcdoc iframes that went through the loader) have trouble
+ * with synchronous iframe navigation.
+ */
+ function isNestedContext() {
+ try {
+ // If we're not in the top frame, we might be nested
+ return window !== window.top;
+ } catch {
+ // Cross-origin access error means we're definitely nested
+ return true;
+ }
+ }
+
+ /**
+ * Find the topmost ancestor window that can successfully create controlled iframes.
+ * Returns null if no suitable ancestor is found.
+ *
+ * We go all the way to the top because:
+ * 1. The topmost SW-controlled page is the most reliable for iframe navigation
+ * 2. With arbitrary nesting depth, intermediate frames might also be srcdoc-based
+ * and unable to navigate iframes properly
+ * 3. Positioning calculations already handle multi-level offset accumulation
+ */
+ function findCapableAncestor() {
+ // We look for the FIRST ancestor that has __controlled_iframes_loaded__ = true,
+ // because that means it has the message listener to create controlled iframes.
+ // We prefer this over the topmost ancestor because intermediate frames might
+ // not have the listener (e.g., remote.html before the service worker injects it).
+ let firstCapable = null;
+ let fallback = null;
+ let depth = 0;
+ try {
+ let current = window;
+ while (current.parent && current.parent !== current) {
+ depth++;
+ try {
+ // Check if parent is accessible (same-origin)
+ const parentDoc = current.parent.document;
+ if (parentDoc) {
+ const hasIframesTrap = current.parent.__controlled_iframes_loaded__ === true;
+ const hasSW = !!current.parent.navigator?.serviceWorker?.controller;
+ const parentLocation = current.parent.location?.href?.substring(0, 80) || 'unknown';
+
+ console.log(`[iframes-trap] findCapableAncestor depth=${depth}: hasIframesTrap=${hasIframesTrap}, hasSW=${hasSW}, loc=${parentLocation}`);
+
+ // Prefer ancestors with the iframes-trap message listener
+ if (hasIframesTrap && !firstCapable) {
+ firstCapable = current.parent;
+ }
+ // Fall back to any SW-controlled ancestor
+ if (hasSW && !fallback) {
+ fallback = current.parent;
+ }
+ }
+ } catch (e) {
+ // Cross-origin, can't use this parent
+ console.log(`[iframes-trap] findCapableAncestor depth=${depth}: cross-origin error: ${e.message}`);
+ break;
+ }
+ current = current.parent;
+ }
+ const result = firstCapable || fallback;
+ console.log(`[iframes-trap] findCapableAncestor result: ${result ? 'found' : 'null'}`);
+ return result;
+ } catch (e) {
+ // Ignore errors traversing frame hierarchy
+ console.log(`[iframes-trap] findCapableAncestor error: ${e.message}`);
+ }
+ return null;
+ }
+
+ /**
+ * Schedule iframe control for the next browser task.
+ * This is necessary for nested contexts where synchronous src assignment
+ * doesn't trigger navigation during script execution.
+ *
+ * The solution: delegate iframe creation to an ancestor window that CAN
+ * successfully trigger iframe navigation. The iframe is created in the
+ * parent's DOM but can be accessed by the child.
+ *
+ * Uses message passing to create iframes in ancestor windows to work around
+ * Firefox's cross-realm restrictions.
+ */
+ function scheduleIframeControl(iframe) {
+ // Mark as pending control
+ iframe.setAttribute('data-control-pending', '1');
+
+ // Wait until the iframe is connected to the DOM
+ const tryControl = async () => {
+ // Check if srcdoc processing has started OR already completed - these take priority.
+ // We check for:
+ // - data-srcdoc-pending: srcdoc is being processed right now
+ // - data-controlled-by: srcdoc processing completed and created a controlled iframe
+ // - __controlledIframe: the controlled iframe reference is set
+ // We check these before isConnected because srcdoc can be set at any time.
+ const srcdocPending = iframe.getAttribute('data-srcdoc-pending') === '1';
+ const hasControlledBy = iframe.getAttribute('data-controlled-by');
+ const hasControlledRef = !!iframe.__controlledIframe;
+ if (srcdocPending || hasControlledBy || hasControlledRef) {
+ // srcdoc is being handled or was already handled, bail out completely
+ iframe.removeAttribute('data-control-pending');
+ return;
+ }
+
+ // Only proceed if iframe is still in the document
+ if (!iframe.isConnected) {
+ // Retry later if not yet connected
+ requestAnimationFrame(tryControl);
+ return;
+ }
+
+ // Only proceed if not already controlled
+ if (iframe.getAttribute('data-controlled') === '1') {
+ iframe.removeAttribute('data-control-pending');
+ return;
+ }
+
+ // Check if user has set a real src/srcdoc in the meantime
+ // Note: we also check data-srcdoc-pending because our setAttribute wrapper
+ // intercepts srcdoc and doesn't set the actual attribute
+ const currentSrc = iframe.getAttribute('src') || '';
+ const currentSrcdoc = iframe.getAttribute('srcdoc');
+ if (currentSrcdoc || (currentSrc && currentSrc !== '' && currentSrc !== 'about:blank')) {
+ // User set something, let the normal handlers deal with it
+ iframe.removeAttribute('data-control-pending');
+ return;
+ }
+
+ // Find an ancestor that can create controlled iframes
+ const capableAncestor = findCapableAncestor();
+ if (!capableAncestor) {
+ // No capable ancestor, fall back to local creation (may not work)
+ const url = getEmptyLoaderUrl();
+ setIframeSrc(iframe, url);
+ iframe.setAttribute('data-controlled', '1');
+ iframe.removeAttribute('data-control-pending');
+ return;
+ }
+
+ // Generate unique ID for cross-document reference
+ const iframeId = `pg-iframe-${uid()}`;
+ iframe.id = iframe.id || iframeId;
+ const finalId = iframe.id;
+ const controlledId = `${finalId}-controlled`;
+
+ // Collect attributes to copy
+ const attributes = {};
+ for (const attr of iframe.attributes) {
+ if (attr.name !== 'src' && attr.name !== 'data-control-pending' && attr.name !== 'id') {
+ attributes[attr.name] = attr.value;
+ }
+ }
+
+ // Calculate position for the controlled iframe
+ const rect = iframe.getBoundingClientRect();
+ let offsetTop = 0;
+ let offsetLeft = 0;
+ let win = window;
+ while (win !== capableAncestor && win.frameElement) {
+ const frameRect = win.frameElement.getBoundingClientRect();
+ offsetTop += frameRect.top;
+ offsetLeft += frameRect.left;
+ win = win.parent;
+ }
+
+ const style = {
+ position: 'fixed',
+ top: `${rect.top + offsetTop}px`,
+ left: `${rect.left + offsetLeft}px`,
+ width: `${rect.width}px`,
+ height: `${rect.height}px`,
+ zIndex: '999999',
+ border: iframe.style.border || 'none',
+ };
+
+ const loaderUrl = getEmptyLoaderUrl();
+
+ try {
+ // Use message passing to create the iframe in the ancestor's realm
+ // This is critical for Firefox compatibility
+ const controlledIframe = await requestAncestorCreateIframe(capableAncestor, {
+ id: controlledId,
+ src: loaderUrl,
+ attributes,
+ style,
+ });
+
+ // Hide the original iframe (it won't be used for content)
+ iframe.style.visibility = 'hidden';
+
+ // Set up position updates
+ const updatePosition = () => {
+ try {
+ if (!iframe.isConnected) {
+ controlledIframe.remove();
+ return;
+ }
+ const newRect = iframe.getBoundingClientRect();
+ let newOffsetTop = 0;
+ let newOffsetLeft = 0;
+ let w = window;
+ while (w !== capableAncestor && w.frameElement) {
+ const frameRect = w.frameElement.getBoundingClientRect();
+ newOffsetTop += frameRect.top;
+ newOffsetLeft += frameRect.left;
+ w = w.parent;
+ }
+ controlledIframe.style.top = `${newRect.top + newOffsetTop}px`;
+ controlledIframe.style.left = `${newRect.left + newOffsetLeft}px`;
+ controlledIframe.style.width = `${newRect.width}px`;
+ controlledIframe.style.height = `${newRect.height}px`;
+ requestAnimationFrame(updatePosition);
+ } catch {
+ // Stop updating if we can't access the iframe
+ }
+ };
+ requestAnimationFrame(updatePosition);
+
+ // Mark original as controlled (even though actual content is elsewhere)
+ iframe.setAttribute('data-controlled', '1');
+ iframe.setAttribute('data-controlled-by', controlledId);
+ iframe.removeAttribute('data-control-pending');
+
+ // Store reference for contentWindow/contentDocument access
+ iframe.__controlledIframe = controlledIframe;
+ } catch (error) {
+ // Message passing failed, fall back to direct approach (might not work in Firefox)
+ console.warn('[iframes-trap] Message-based iframe creation failed, falling back to direct approach:', error);
+
+ const ancestorDoc = capableAncestor.document;
+ const ancestorNativeCreate = ancestorDoc.__playground_native_createElement || Native.createElement;
+ const controlledIframe = ancestorNativeCreate.call(ancestorDoc, 'iframe');
+ controlledIframe.id = controlledId;
+
+ for (const [name, value] of Object.entries(attributes)) {
+ controlledIframe.setAttribute(name, value);
+ }
+ Object.assign(controlledIframe.style, style);
+
+ iframe.style.visibility = 'hidden';
+ ancestorDoc.body.appendChild(controlledIframe);
+ setIframeSrc(controlledIframe, loaderUrl, capableAncestor);
+
+ const updatePosition = () => {
+ try {
+ if (!iframe.isConnected) {
+ controlledIframe.remove();
+ return;
+ }
+ const newRect = iframe.getBoundingClientRect();
+ let newOffsetTop = 0;
+ let newOffsetLeft = 0;
+ let w = window;
+ while (w !== capableAncestor && w.frameElement) {
+ const frameRect = w.frameElement.getBoundingClientRect();
+ newOffsetTop += frameRect.top;
+ newOffsetLeft += frameRect.left;
+ w = w.parent;
+ }
+ controlledIframe.style.top = `${newRect.top + newOffsetTop}px`;
+ controlledIframe.style.left = `${newRect.left + newOffsetLeft}px`;
+ controlledIframe.style.width = `${newRect.width}px`;
+ controlledIframe.style.height = `${newRect.height}px`;
+ requestAnimationFrame(updatePosition);
+ } catch {
+ // Stop updating
+ }
+ };
+ requestAnimationFrame(updatePosition);
+
+ iframe.setAttribute('data-controlled', '1');
+ iframe.setAttribute('data-controlled-by', controlledId);
+ iframe.removeAttribute('data-control-pending');
+ iframe.__controlledIframe = controlledIframe;
+ }
+ };
+
+ // Defer to next animation frame for better timing
+ requestAnimationFrame(tryControl);
+ }
+
+ function handleCreateElement(element, args) {
+ const tagName = args[0];
+ if (String(tagName).toLowerCase() !== 'iframe') {
+ return element;
+ }
+
+ // Don't do anything special in createElement.
+ // The MutationObserver will handle it when the iframe is appended to the DOM.
+ // This avoids race conditions where:
+ // 1. createElement sets src to loader#base=...
+ // 2. srcdoc is set, triggering async rewriteSrcdoc
+ // 3. appendChild happens, navigating to the old src (without id)
+ // 4. rewriteSrcdoc finishes, tries to update src but navigation already happened
+ //
+ // This works for both top-level and nested contexts since we now use
+ // direct navigation (not overlay iframes) for all cases.
+ return element;
+ }
+
+ Document.prototype.createElement = createElementWrapper;
+
+ // ============================================================================
+ // setAttribute wrapper - intercepts src/srcdoc on iframes
+ // ============================================================================
+ Element.prototype.setAttribute = function (name, value) {
+ if (this instanceof HTMLIFrameElement) {
+ const nameLower = name.toLowerCase();
+ const valueString = String(value);
+
+ if (nameLower === 'srcdoc') {
+ rewriteSrcdoc(this, valueString);
+ return;
+ }
+
+ if (nameLower === 'src') {
+ if (valueString.startsWith('data:text/html') || valueString.startsWith('blob:')) {
+ // Mark as pending BEFORE starting async fetch to prevent
+ // scheduleIframeControl from treating this as a blank iframe
+ this.setAttribute('data-srcdoc-pending', '1');
+ rewriteDataOrBlob(this, valueString);
+ return;
+ }
+ if (
+ valueString === 'about:blank' ||
+ valueString === '' ||
+ valueString.startsWith('javascript:')
+ ) {
+ // Route through loader so the iframe is SW-controlled
+ rewriteSrcdoc(this, '', {
+ base: document.baseURI,
+ prettyUrl: location.href,
+ });
+ return;
+ }
+ }
+ }
+ return Native.setAttribute.call(this, name, value);
+ };
+
+ // ============================================================================
+ // src/srcdoc property setters - delegate to setAttribute wrapper
+ // ============================================================================
+ Object.defineProperty(HTMLIFrameElement.prototype, 'src', {
+ configurable: true,
+ enumerable: Native.iframeSrc?.enumerable ?? true,
+ get() {
+ return Native.iframeSrc.get.call(this);
+ },
+ set(v) {
+ Element.prototype.setAttribute.call(this, 'src', String(v));
+ },
+ });
+
+ Object.defineProperty(HTMLIFrameElement.prototype, 'srcdoc', {
+ configurable: true,
+ enumerable: Native.iframeSrcdoc?.enumerable ?? true,
+ get() {
+ return Native.iframeSrcdoc.get.call(this);
+ },
+ set(v) {
+ Element.prototype.setAttribute.call(this, 'srcdoc', String(v));
+ },
+ });
+
+ // ============================================================================
+ // contentWindow/contentDocument getters - redirect to controlled iframe if needed
+ // ============================================================================
+
+ // ============================================================================
+ // Patch Document.prototype.open/write/writeln/close to preserve SW control
+ // ============================================================================
+ //
+ // When TinyMCE or similar libraries call document.open(), document.write(),
+ // document.close() on a controlled iframe, the native implementations would
+ // destroy the document and replace it with a new one that is NOT controlled
+ // by the service worker.
+ //
+ // Our approach: Instead of letting document.open()/write()/close() replace
+ // the document, we simulate their behavior using DOM manipulation:
+ // - document.open(): Clear the document body and head (but keep the document itself)
+ // - document.write(): Parse the HTML and append to the document
+ // - document.close(): No-op (document is already usable)
+ //
+ // This keeps the iframe's document controlled by the service worker while
+ // still allowing TinyMCE's initialization pattern to work.
+ // ============================================================================
+
+ /**
+ * WeakMap to track iframes that are in "document.write mode".
+ * When document.open() is called on a controlled iframe, we switch to
+ * using our simulated write() instead of the native one.
+ */
+ const iframeWriteState = new WeakMap();
+
+ /**
+ * Get the iframe element that owns a document, if any.
+ */
+ function getIframeForDocument(doc) {
+ try {
+ const win = doc.defaultView;
+ if (win && win.frameElement instanceof HTMLIFrameElement) {
+ return win.frameElement;
+ }
+ } catch {
+ // Cross-origin or other access error
+ }
+ return null;
+ }
+
+ /**
+ * Check if a document belongs to an iframe that is ALREADY SW-controlled.
+ * We only intercept document.write() on iframes that actually have a
+ * service worker controller, because:
+ *
+ * 1. If the iframe has a SW controller, document.write() would destroy
+ * that control - we want to preserve it by using DOM manipulation instead
+ * 2. If the iframe doesn't have a SW controller yet, we should let the
+ * native document.write() happen, then capture the content afterward
+ * and navigate the iframe to a controlled URL
+ *
+ * This approach avoids the race condition where we intercept document.write()
+ * before the iframe has navigated to a controlled URL, which would prevent
+ * it from ever becoming SW-controlled.
+ */
+ function shouldInterceptDocumentWrite(doc) {
+ const iframe = getIframeForDocument(doc);
+ if (!iframe) return false;
+
+ // Only intercept if the iframe ACTUALLY has a SW controller right now
+ // This means the iframe has already navigated to a controlled URL
+ try {
+ const iframeWindow = iframe.contentWindow;
+ if (iframeWindow?.navigator?.serviceWorker?.controller) {
+ return true;
+ }
+ } catch {
+ // Cross-origin error - can't check, don't intercept
+ }
+
+ return false;
+ }
+
+ /**
+ * Parse HTML string and extract head and body content.
+ * Returns { headContent, bodyContent, bodyAttributes }.
+ */
+ function parseHtmlString(html) {
+ // Use DOMParser to parse the HTML
+ const parser = new DOMParser();
+ const parsed = parser.parseFromString(html, 'text/html');
+
+ return {
+ headContent: parsed.head ? parsed.head.innerHTML : '',
+ bodyContent: parsed.body ? parsed.body.innerHTML : '',
+ bodyAttributes: parsed.body ? Array.from(parsed.body.attributes) : [],
+ title: parsed.title || '',
+ };
+ }
+
+ // Wrap Document.prototype.open
+ const NativeDocOpen = Document.prototype.open;
+ Document.prototype.open = function (...args) {
+ // Check if this is an iframe in a controlled context
+ if (shouldInterceptDocumentWrite(this)) {
+ const iframe = getIframeForDocument(this);
+
+ // Initialize write state for this iframe
+ iframeWriteState.set(iframe, {
+ buffer: [],
+ isOpen: true,
+ });
+
+ // Clear the document content but preserve the document itself
+ // This simulates what document.open() does without destroying SW control
+ try {
+ // Clear head content except for essential elements (base, our script)
+ const head = this.head;
+ if (head) {
+ const toRemove = [];
+ for (const child of head.children) {
+ // Keep base tag and our iframes-trap script
+ if (child.tagName === 'BASE') continue;
+ if (child.tagName === 'SCRIPT' && child.src?.includes('iframes-trap')) continue;
+ toRemove.push(child);
+ }
+ toRemove.forEach(el => el.remove());
+ }
+
+ // Clear body content
+ if (this.body) {
+ this.body.innerHTML = '';
+ // Remove all body attributes except essential ones
+ const attrs = Array.from(this.body.attributes);
+ for (const attr of attrs) {
+ this.body.removeAttribute(attr.name);
+ }
+ }
+ } catch (e) {
+ console.warn('[iframes-trap] Error clearing document in open():', e);
+ }
+
+ // Return this document (like native open does)
+ return this;
+ }
+
+ // Not a controlled iframe, use native behavior
+ return NativeDocOpen.apply(this, args);
+ };
+
+ // Wrap Document.prototype.write
+ const NativeDocWrite = Document.prototype.write;
+ Document.prototype.write = function (...args) {
+ const iframe = getIframeForDocument(this);
+ const state = iframe ? iframeWriteState.get(iframe) : null;
+
+ // If we're in "simulated write mode" for an iframe in controlled context
+ if (state?.isOpen && shouldInterceptDocumentWrite(this)) {
+ // Buffer the content
+ state.buffer.push(...args);
+ return;
+ }
+
+ // Not a controlled context or not in write mode, use native behavior
+ return NativeDocWrite.apply(this, args);
+ };
+
+ // Wrap Document.prototype.writeln
+ const NativeDocWriteln = Document.prototype.writeln;
+ Document.prototype.writeln = function (...args) {
+ const iframe = getIframeForDocument(this);
+ const state = iframe ? iframeWriteState.get(iframe) : null;
+
+ // If we're in "simulated write mode" for an iframe in controlled context
+ if (state?.isOpen && shouldInterceptDocumentWrite(this)) {
+ // Buffer the content with newlines
+ state.buffer.push(...args.map(a => a + '\n'));
+ return;
+ }
+
+ // Not a controlled iframe or not in write mode, use native behavior
+ return NativeDocWriteln.apply(this, args);
+ };
+
+ // Wrap Document.prototype.close
+ const NativeDocClose = Document.prototype.close;
+ Document.prototype.close = function () {
+ console.log('[iframes-trap] Document.prototype.close called');
+ const iframe = getIframeForDocument(this);
+ console.log('[iframes-trap] getIframeForDocument result:', !!iframe, iframe?.id || 'no-id');
+ const state = iframe ? iframeWriteState.get(iframe) : null;
+
+ // If we're in "simulated write mode" for an iframe in controlled context
+ if (state?.isOpen && shouldInterceptDocumentWrite(this)) {
+ state.isOpen = false;
+
+ // Process the buffered content
+ const html = state.buffer.join('');
+ state.buffer = [];
+
+ if (html) {
+ try {
+ const { headContent, bodyContent, bodyAttributes, title } = parseHtmlString(html);
+
+ // Set title if present
+ if (title) {
+ this.title = title;
+ }
+
+ // Append head content (scripts, styles, etc.)
+ if (headContent && this.head) {
+ // Parse and append head elements
+ const tempDiv = this.createElement('div');
+ tempDiv.innerHTML = headContent;
+ while (tempDiv.firstChild) {
+ this.head.appendChild(tempDiv.firstChild);
+ }
+ }
+
+ // Set body content
+ if (this.body) {
+ this.body.innerHTML = bodyContent;
+
+ // Apply body attributes
+ for (const attr of bodyAttributes) {
+ this.body.setAttribute(attr.name, attr.value);
+ }
+ }
+ } catch (e) {
+ console.warn('[iframes-trap] Error applying buffered content in close():', e);
+ }
+ }
+
+ // Clean up state
+ iframeWriteState.delete(iframe);
+
+ // Mark the iframe as controlled since we successfully handled
+ // document.write without destroying the SW control
+ if (iframe.getAttribute('data-controlled') !== '1') {
+ iframe.setAttribute('data-controlled', '1');
+ }
+ return;
+ }
+
+ // Clean up state if any
+ if (state) {
+ iframeWriteState.delete(iframe);
+ }
+
+ // Use native close behavior
+ const result = NativeDocClose.apply(this, arguments);
+
+ // If this is an iframe in a SW-controlled parent, schedule content capture
+ // AFTER a microtask to allow any post-close() JavaScript to run (like TinyMCE
+ // setting contentEditable on the body).
+ if (iframe) {
+ const parentWindow = iframe.ownerDocument?.defaultView;
+ const parentHasController = parentWindow?.navigator?.serviceWorker?.controller;
+
+ console.log('[iframes-trap] document.close() called on iframe, parentHasController:', parentHasController);
+
+ if (parentHasController) {
+ // Mark that we're handling this iframe
+ iframe.setAttribute('data-docwrite-controlled', '1');
+
+ // Use double setTimeout to ensure all synchronous JS runs first
+ // (TinyMCE sets contentEditable after close() in the same call stack)
+ setTimeout(() => {
+ setTimeout(async () => {
+ // Only proceed if iframe is still in DOM
+ if (!iframe.isConnected) {
+ console.log('[iframes-trap] document.close handler: iframe not connected');
+ return;
+ }
+
+ // Capture the CURRENT state (after TinyMCE's modifications)
+ const currentDoc = iframe.contentDocument;
+ const finalHtml = currentDoc?.documentElement?.outerHTML;
+ if (!finalHtml) {
+ console.log('[iframes-trap] document.close handler: no HTML to capture');
+ return;
+ }
+
+ console.log('[iframes-trap] document.close handler: navigating to SW-controlled URL');
+ // Navigate the iframe to a SW-controlled URL
+ // The proxy will redirect subsequent access to the new document
+ await rewriteSrcdoc(iframe, finalHtml);
+ console.log('[iframes-trap] document.close handler: navigation complete');
+ }, 0);
+ }, 0);
+ }
+ }
+
+ return result;
+ };
+
+ // ============================================================================
+ // contentWindow/contentDocument getters - wrap to intercept document.write
+ // ============================================================================
+ //
+ // We can't patch Document.prototype.close in the iframe's realm because
+ // the iframe starts as about:blank and we don't control it yet. Instead,
+ // we wrap contentDocument to return a proxy that intercepts open/write/close
+ // calls on about:blank iframes in SW-controlled contexts.
+ // ============================================================================
+
+ /**
+ * WeakMap to track proxied documents and their write state.
+ * Key: original Document, Value: { buffer: string[], isOpen: boolean }
+ */
+ const documentWriteProxyState = new WeakMap();
+
+ /**
+ * Create a proxy for a document that intercepts open/write/close calls.
+ *
+ * IMPORTANT: This proxy is "live" - after navigation, property access
+ * automatically goes to the NEW document. This allows TinyMCE to store
+ * a reference to `iframe.contentDocument` before navigation, and have
+ * it automatically work with the new document after navigation.
+ */
+ function createDocumentWriteProxy(iframe, doc) {
+ // Only proxy if the parent has SW controller
+ const parentWindow = iframe.ownerDocument?.defaultView;
+ const parentHasController = parentWindow?.navigator?.serviceWorker?.controller;
+ if (!parentHasController) {
+ return doc;
+ }
+
+ // Check if already proxied
+ if (doc.__playground_proxied__) {
+ return doc;
+ }
+
+ const state = { buffer: [], isOpen: false, navigating: false };
+ documentWriteProxyState.set(doc, state);
+
+ // Helper to get the current document from the iframe
+ // After navigation, this returns the NEW document
+ const getCurrentDoc = () => {
+ try {
+ return Native.contentDocument.get.call(iframe);
+ } catch {
+ return doc; // Fallback to original if access fails
+ }
+ };
+
+ const proxy = new Proxy(doc, {
+ get(target, prop, receiver) {
+ if (prop === '__playground_proxied__') return true;
+ if (prop === '__playground_original__') return target;
+ if (prop === '__playground_iframe__') return iframe;
+
+ if (prop === 'open') {
+ return function (...args) {
+ console.log('[iframes-trap] Proxied document.open() called');
+ state.isOpen = true;
+ state.buffer = [];
+ // Don't call native open - we'll handle everything in close()
+ return proxy; // Return proxy for chaining
+ };
+ }
+
+ if (prop === 'write') {
+ return function (...args) {
+ if (state.isOpen) {
+ console.log('[iframes-trap] Proxied document.write() called, buffering');
+ state.buffer.push(...args);
+ return;
+ }
+ // If not in write mode, call native on current doc
+ const currentDoc = getCurrentDoc();
+ return currentDoc.write.apply(currentDoc, args);
+ };
+ }
+
+ if (prop === 'writeln') {
+ return function (...args) {
+ if (state.isOpen) {
+ console.log('[iframes-trap] Proxied document.writeln() called, buffering');
+ state.buffer.push(...args.map(a => a + '\n'));
+ return;
+ }
+ const currentDoc = getCurrentDoc();
+ return currentDoc.writeln.apply(currentDoc, args);
+ };
+ }
+
+ if (prop === 'close') {
+ return function () {
+ console.log('[iframes-trap] Proxied document.close() called');
+ if (!state.isOpen) {
+ const currentDoc = getCurrentDoc();
+ return currentDoc.close.apply(currentDoc, arguments);
+ }
+
+ state.isOpen = false;
+ const html = state.buffer.join('');
+ state.buffer = [];
+ console.log('[iframes-trap] Proxied document.close: buffered HTML length:', html.length);
+
+ // Mark that we're handling this iframe
+ iframe.setAttribute('data-docwrite-controlled', '1');
+
+ // Parse the HTML to extract structure WITHOUT triggering resource loads
+ // We need to create the DOM structure so TinyMCE's post-close() operations
+ // like `doc.body.contentEditable = 'true'` can work immediately.
+ const parser = new DOMParser();
+ const parsed = parser.parseFromString(html, 'text/html');
+
+ // Apply the structure via DOM manipulation (not document.write)
+ // This doesn't trigger CSS/image loading from about:blank
+ target.open();
+ target.write('');
+ target.close();
+
+ // Copy body content and attributes
+ if (parsed.body && target.body) {
+ target.body.innerHTML = parsed.body.innerHTML;
+ for (const attr of parsed.body.attributes) {
+ target.body.setAttribute(attr.name, attr.value);
+ }
+ }
+
+ // Copy head content (without link/script tags that would load resources)
+ if (parsed.head && target.head) {
+ for (const child of parsed.head.children) {
+ if (child.tagName !== 'LINK' && child.tagName !== 'SCRIPT') {
+ target.head.appendChild(child.cloneNode(true));
+ }
+ }
+ }
+
+ // Set title
+ if (parsed.title) {
+ target.title = parsed.title;
+ }
+
+ // Now schedule navigation to SW-controlled URL after TinyMCE finishes
+ // its synchronous post-close() operations. The proxy will redirect
+ // all subsequent property access to the new document.
+ setTimeout(() => {
+ setTimeout(async () => {
+ if (!iframe.isConnected) {
+ console.log('[iframes-trap] Proxied close: iframe not connected');
+ return;
+ }
+
+ // Capture the CURRENT state (including TinyMCE's modifications)
+ const currentDoc = getCurrentDoc();
+ const finalHtml = currentDoc?.documentElement?.outerHTML;
+ if (!finalHtml) {
+ console.log('[iframes-trap] Proxied close: no HTML to capture');
+ return;
+ }
+
+ // Merge the original CSS/script resources back in
+ // The parser extracted head content which we stripped
+ const headContent = parsed.head?.innerHTML || '';
+ const mergedHtml = finalHtml.replace('', headContent + '');
+
+ console.log('[iframes-trap] Proxied close: navigating to SW-controlled URL');
+ await rewriteSrcdoc(iframe, mergedHtml);
+ console.log('[iframes-trap] Proxied close: navigation complete');
+ }, 0);
+ }, 0);
+
+ return;
+ };
+ }
+
+ // For all other properties, access them on the CURRENT document
+ // This makes the proxy "live" - after navigation, it accesses
+ // the new document automatically
+ try {
+ const currentDoc = getCurrentDoc();
+ const value = currentDoc[prop];
+ if (typeof value === 'function') {
+ return value.bind(currentDoc);
+ }
+ return value;
+ } catch (e) {
+ // Some properties might throw, just return undefined
+ return undefined;
+ }
+ },
+
+ set(target, prop, value) {
+ // Set on the CURRENT document
+ try {
+ const currentDoc = getCurrentDoc();
+ currentDoc[prop] = value;
+ return true;
+ } catch (e) {
+ return false;
+ }
+ }
+ });
+
+ return proxy;
+ }
+
+ /**
+ * WeakMap to cache proxied windows.
+ * Key: iframe element, Value: proxied window
+ */
+ const proxiedWindowCache = new WeakMap();
+
+ /**
+ * Create a proxy for a window that wraps the document property.
+ */
+ function createWindowProxy(iframe, win) {
+ // Check cache first
+ const cached = proxiedWindowCache.get(iframe);
+ if (cached && cached.win === win) {
+ return cached.proxy;
+ }
+
+ const proxy = new Proxy(win, {
+ get(target, prop, receiver) {
+ if (prop === 'document') {
+ const doc = target.document;
+ if (doc) {
+ return createDocumentWriteProxy(iframe, doc);
+ }
+ return doc;
+ }
+
+ // For all other properties, access them directly on the target
+ // to preserve proper binding (especially for getters like navigator)
+ try {
+ const value = target[prop];
+ if (typeof value === 'function') {
+ return value.bind(target);
+ }
+ return value;
+ } catch (e) {
+ // Some properties might throw, just return undefined
+ return undefined;
+ }
+ }
+ });
+
+ proxiedWindowCache.set(iframe, { win, proxy });
+ return proxy;
+ }
+
+ Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', {
+ configurable: true,
+ enumerable: Native.contentWindow?.enumerable ?? true,
+ get() {
+ const win = Native.contentWindow.get.call(this);
+ if (!win) return win;
+
+ // Only proxy if the parent has SW controller
+ const parentWindow = this.ownerDocument?.defaultView;
+ const parentHasController = parentWindow?.navigator?.serviceWorker?.controller;
+ if (!parentHasController) {
+ return win;
+ }
+
+ return createWindowProxy(this, win);
+ },
+ });
+
+ Object.defineProperty(HTMLIFrameElement.prototype, 'contentDocument', {
+ configurable: true,
+ enumerable: Native.contentDocument?.enumerable ?? true,
+ get() {
+ const doc = Native.contentDocument.get.call(this);
+ if (!doc) return doc;
+
+ // Wrap the document in a proxy to intercept document.write calls
+ return createDocumentWriteProxy(this, doc);
+ },
+ });
+
+ /**
+ * Check if an iframe's src value indicates it's uncontrolled and needs
+ * to be redirected through the loader. This handles cases where iframes
+ * were created before iframes-trap.js loaded and patched the prototypes.
+ */
+ function isUncontrolledSrc(src) {
+ if (!src) return true;
+ const srcLower = src.toLowerCase();
+ return (
+ srcLower === '' ||
+ srcLower === 'about:blank' ||
+ srcLower.startsWith('javascript:')
+ );
+ }
+
+ /**
+ * Control an iframe that was just added to the DOM.
+ * Uses direct navigation for all contexts (nested or not).
+ *
+ * This function handles iframes that were created before iframes-trap.js
+ * loaded, or iframes with uncontrolled src values (javascript:, about:blank, etc.).
+ *
+ * Since we now serve cached HTML directly from the SW (not via innerHTML
+ * injection), nested iframe navigation works properly. We can simply set
+ * the src attribute and the iframe will navigate.
+ */
+ function controlIframeOnMutation(iframe) {
+ if (iframe.getAttribute('data-controlled') === '1' || iframe.getAttribute('data-control-pending') === '1') {
+ return;
+ }
+
+ // Check if srcdoc is being processed - let rewriteSrcdoc handle it
+ if (iframe.getAttribute('data-srcdoc-pending') === '1') {
+ return;
+ }
+
+ // Check if this is a document.write iframe - don't navigate these
+ // as it would break TinyMCE's references. They have iframes-trap.js
+ // injected directly instead.
+ if (iframe.getAttribute('data-docwrite-controlled') === '1') {
+ return;
+ }
+
+ // Check if iframe has srcdoc - these are handled separately
+ if (iframe.hasAttribute('srcdoc')) {
+ return;
+ }
+
+ // Check if iframe has a "real" src that shouldn't be intercepted
+ const currentSrc = iframe.getAttribute('src') || '';
+ if (currentSrc && !isUncontrolledSrc(currentSrc)) {
+ // Has a real URL src, don't intercept
+ return;
+ }
+
+ // Iframe either has no src, or has an uncontrolled src (javascript:, about:blank, etc.)
+ // Navigate the original iframe to make it SW-controlled.
+ //
+ // We need to cache empty content and navigate to it, just like we do for srcdoc.
+ // This ensures the iframe loads a "real" document from the SW.
+ controlEmptyIframe(iframe);
+ }
+
+ /**
+ * Control an empty iframe by caching minimal HTML and navigating to it.
+ * This uses the same approach as rewriteSrcdoc to ensure nested iframes work.
+ */
+ async function controlEmptyIframe(iframe) {
+ iframe.setAttribute('data-control-pending', '1');
+
+ const id = uid();
+ const base = document.baseURI;
+
+ // Cache minimal HTML with iframes-trap.js injected
+ const minimalHtml = '';
+ await cacheIframeContents(id, minimalHtml, base, '');
+
+ const scope = await scopePromise;
+ const { VIRTUAL_PREFIX } = scopedPaths(scope);
+ const url = `${VIRTUAL_PREFIX}${id}.html`;
+
+ // Remove and re-add to force navigation
+ const parent = iframe.parentNode;
+ const nextSibling = iframe.nextSibling;
+
+ if (parent) {
+ parent.removeChild(iframe);
+ }
+
+ setIframeSrc(iframe, url);
+
+ if (parent) {
+ if (nextSibling) {
+ parent.insertBefore(iframe, nextSibling);
+ } else {
+ parent.appendChild(iframe);
+ }
+ }
+
+ iframe.removeAttribute('data-control-pending');
+ iframe.setAttribute('data-controlled', '1');
+ console.log('[iframes-trap] controlEmptyIframe: navigated to cached URL:', url);
+ }
+
+ /**
+ * Request an ancestor window to create a SW-controlled iframe.
+ *
+ * IMPORTANT: Due to browser limitations, iframes created in nested documents
+ * (inside other iframes) cannot be navigated - setting their src does NOT
+ * trigger navigation. The only way to get SW control is to create the iframe
+ * in an ancestor document (typically the top-level document) where navigation
+ * works properly.
+ *
+ * This function:
+ * 1. Hides the original iframe (keeps it in DOM for JavaScript references)
+ * 2. Asks an ancestor to create a controlled iframe in its document
+ * 3. Positions the controlled iframe to visually overlay the original
+ * 4. Stores a reference on the original iframe to the controlled one
+ *
+ * This approach preserves the original iframe's DOM presence (for querySelector,
+ * etc.) while providing SW control through the replacement.
+ */
+ function requestAncestorNavigateIframe(ancestorWindow, iframe, url) {
+ // Generate a unique ID for cross-document reference
+ const iframeId = iframe.id || `pg-nav-${uid()}`;
+ if (!iframe.id) {
+ iframe.id = iframeId;
+ }
+
+ console.log('[iframes-trap] Requesting ancestor to create controlled iframe for:', iframeId);
+
+ // Hide the original iframe - it can't be navigated from a nested context
+ iframe.style.visibility = 'hidden';
+ iframe.setAttribute('data-controlled-by', iframeId + '-controlled');
+
+ // Use message passing with a response channel
+ const channel = new MessageChannel();
+ channel.port1.onmessage = (event) => {
+ if (event.data.success) {
+ console.log('[iframes-trap] Ancestor created controlled iframe:', event.data.iframeId);
+ // Store reference to the controlled iframe for JavaScript code
+ // that might access the original iframe
+ try {
+ const ancestorIframes = ancestorWindow.__pg_iframes || {};
+ const controlledIframe = ancestorIframes[event.data.iframeId];
+ if (controlledIframe) {
+ iframe.__controlledIframe = controlledIframe;
+ }
+ } catch (e) {
+ console.log('[iframes-trap] Could not store reference:', e.message);
+ }
+ } else {
+ console.error('[iframes-trap] Failed to create controlled iframe:', event.data.error);
+ }
+ };
+
+ // Get the iframe's position relative to the top document
+ // This is used to position the controlled iframe correctly
+ const getPosition = () => {
+ try {
+ const rect = iframe.getBoundingClientRect();
+ const ownerWindow = iframe.ownerDocument?.defaultView;
+ // Accumulate offset through iframe hierarchy
+ let offsetX = rect.left;
+ let offsetY = rect.top;
+ let win = ownerWindow;
+ while (win && win !== ancestorWindow && win.frameElement) {
+ const parentRect = win.frameElement.getBoundingClientRect();
+ offsetX += parentRect.left;
+ offsetY += parentRect.top;
+ win = win.parent;
+ }
+ return { x: offsetX, y: offsetY, width: rect.width, height: rect.height };
+ } catch (e) {
+ return { x: 0, y: 0, width: 300, height: 150 };
+ }
+ };
+
+ const pos = getPosition();
+
+ // Send request to ancestor
+ ancestorWindow.postMessage({
+ type: '__playground_create_iframe',
+ config: {
+ id: iframeId + '-controlled',
+ src: url,
+ attributes: {
+ 'data-controlled': '1',
+ 'data-for': iframeId,
+ },
+ style: {
+ position: 'absolute',
+ left: pos.x + 'px',
+ top: pos.y + 'px',
+ width: pos.width + 'px',
+ height: pos.height + 'px',
+ border: 'none',
+ zIndex: '999999',
+ },
+ },
+ }, '*', [channel.port2]);
+
+ iframe.setAttribute('data-controlled', '1');
+ }
+
+ // ============================================================================
+ // MutationObserver - catches iframes added via innerHTML, templating, etc.
+ // ============================================================================
+ const mutationObserver = new MutationObserver((mutations) => {
+ for (const mutation of mutations) {
+ for (const node of mutation.addedNodes) {
+ if (node instanceof HTMLIFrameElement) {
+ controlIframeOnMutation(node);
+ } else if (node instanceof Element) {
+ node.querySelectorAll('iframe').forEach((iframe) => {
+ controlIframeOnMutation(iframe);
+ });
+ }
+ }
+ }
+ });
+
+ mutationObserver.observe(document.documentElement, {
+ childList: true,
+ subtree: true,
+ });
+
+ // ============================================================================
+ // Handle existing iframes - scan for iframes that were created before
+ // iframes-trap.js loaded and need to be controlled
+ // ============================================================================
+ document.querySelectorAll('iframe').forEach((iframe) => {
+ controlIframeOnMutation(iframe);
+ });
+
+ // ============================================================================
+ // Anti-flash CSS - hide iframes until they're controlled
+ // ============================================================================
+ const style = document.createElement('style');
+ style.textContent = `iframe{visibility:hidden} iframe[data-controlled="1"]{visibility:visible}`;
+ document.documentElement.appendChild(style);
+}
+
+setupIframesTrap();
diff --git a/packages/playground/remote/remote.html b/packages/playground/remote/remote.html
index 9a61d0c624..c9d653f284 100644
--- a/packages/playground/remote/remote.html
+++ b/packages/playground/remote/remote.html
@@ -2,6 +2,99 @@
WordPress Playground
+
+
';
-});
+
+ {
+ // Navigate to WordPress with the classic editor (use URL that enables it)
+ const blueprint = {
+ preferredVersions: { php: '8.0', wp: 'latest' },
+ features: { networking: true },
+ steps: [
+ { step: 'login', username: 'admin', password: 'password' },
+ {
+ step: 'installPlugin',
+ pluginData: { resource: 'wordpress.org/plugins', slug: 'classic-editor' },
+ options: { activate: true },
+ },
+ ],
+ };
+ await website.goto(`/#${JSON.stringify(blueprint)}`);
+
+ // Navigate to create a new post (classic editor) using a frame locator
+ const wpFrame = website.wordpress();
+ await wpFrame.locator('a[href*="post-new.php"]').first().click();
+
+ // Wait for TinyMCE to initialize - it creates an iframe with id containing "ifr"
+ // Use 'attached' state since TinyMCE may hide the iframe visually
+ await wpFrame.locator('iframe[id*="ifr"]').waitFor({ state: 'attached', timeout: 30000 });
+
+ // Check TinyMCE iframe is SW-controlled
+ const result = await website.page.evaluate(async () => {
+ // Navigate through the iframe hierarchy to find TinyMCE
+ const viewportIframe = document.querySelector(
+ '#playground-viewport, .playground-viewport'
+ );
+ if (!viewportIframe?.contentDocument) {
+ return { error: 'No viewport iframe' };
+ }
+
+ const wpIframe =
+ viewportIframe.contentDocument.querySelector(
+ '#wp'
+ );
+ if (!wpIframe?.contentDocument) {
+ return { error: 'No WP iframe' };
+ }
+
+ // Find TinyMCE iframe
+ const tinyIframe =
+ wpIframe.contentDocument.querySelector(
+ 'iframe[id*="ifr"]'
+ );
+ if (!tinyIframe) {
+ return { error: 'No TinyMCE iframe found' };
+ }
+
+ // Wait for the iframe to be controlled
+ await new Promise((r) => setTimeout(r, 2000));
+
+ // Check if TinyMCE iframe is controlled
+ const dataControlled = tinyIframe.getAttribute('data-controlled');
+ const controlledBy = tinyIframe.getAttribute('data-controlled-by');
+
+ // Access the actual controlled iframe (may be in parent document)
+ let actualIframe = tinyIframe;
+ if (tinyIframe.__controlledIframe) {
+ actualIframe =
+ tinyIframe.__controlledIframe as HTMLIFrameElement;
+ }
+
+ let hasController = false;
+ let iframeLocation = '';
+ try {
+ hasController =
+ !!actualIframe.contentWindow?.navigator?.serviceWorker
+ ?.controller;
+ iframeLocation =
+ actualIframe.contentWindow?.location?.href || 'unknown';
+ } catch (e) {
+ // Cross-origin
+ }
+
+ // Try to inject an image into TinyMCE and check if it loads
+ let imageLoaded = false;
+ let imageSrc = '';
+ try {
+ const tinyDoc = actualIframe.contentDocument;
+ if (tinyDoc?.body) {
+ const img = tinyDoc.createElement('img');
+ // Use a WordPress core image that should be served by the SW
+ img.src = '/wp-includes/images/blank.gif';
+ imageSrc = img.src;
+ tinyDoc.body.appendChild(img);
+
+ // Wait for image to load
+ await new Promise((resolve) => {
+ const timeout = setTimeout(() => resolve(), 3000);
+ img.onload = () => {
+ clearTimeout(timeout);
+ imageLoaded = true;
+ resolve();
+ };
+ img.onerror = () => {
+ clearTimeout(timeout);
+ resolve();
+ };
+ // Check if already loaded
+ if (img.complete && img.naturalWidth > 0) {
+ clearTimeout(timeout);
+ imageLoaded = true;
+ resolve();
+ }
+ });
+ }
+ } catch (e) {
+ // Ignore errors accessing TinyMCE content
+ }
+
+ return {
+ dataControlled,
+ controlledBy,
+ hasController,
+ iframeLocation,
+ imageLoaded,
+ imageSrc,
+ tinyIframeId: tinyIframe.id,
+ };
+ });
+
+ console.log('TinyMCE test result:', JSON.stringify(result, null, 2));
+
+ expect(result.error).toBeUndefined();
+ expect(result.dataControlled).toBe('1');
+ expect(result.hasController).toBe(true);
+ // Image should load because the TinyMCE iframe is SW-controlled
+ expect(result.imageLoaded).toBe(true);
+});
+
+test('new iframes are SW-controlled (about:blank)', async ({ website }) => {
+ await website.goto('./');
+ // Ensure WordPress iframe is mounted
+ await website.waitForNestedIframes();
+
+ // Force update the service worker to ensure we have the latest version
+ await website.page.evaluate(async () => {
+ const registrations = await navigator.serviceWorker.getRegistrations();
+ for (const reg of registrations) {
+ await reg.update();
+ }
+ // Wait a moment for the SW to be active
+ await new Promise((r) => setTimeout(r, 1000));
+ });
+
+ // The #wp iframe is nested inside #playground-viewport, so we need to
+ // navigate through that iframe first
+ const result = await website.page.evaluate(async () => {
+ // First, find the playground viewport iframe
+ const viewportIframe = document.querySelector(
+ '#playground-viewport, .playground-viewport'
+ );
+ if (
+ !viewportIframe ||
+ !viewportIframe.contentWindow ||
+ !viewportIframe.contentDocument
+ ) {
+ throw new Error('Playground viewport iframe is not ready');
+ }
+
+ // Then find the #wp iframe inside the viewport
+ const wpIframe =
+ viewportIframe.contentDocument.querySelector(
+ '#wp'
+ );
+ if (!wpIframe || !wpIframe.contentWindow || !wpIframe.contentDocument) {
+ throw new Error('WordPress iframe is not ready');
+ }
+
+ // Check if the parent (WordPress) iframe is controlled
+ const wpHasController =
+ !!wpIframe.contentWindow?.navigator?.serviceWorker?.controller;
+ console.log('WordPress iframe has controller:', wpHasController);
+
+ const wpDoc = wpIframe.contentDocument;
+ const child = wpDoc.createElement('iframe');
+ console.log('Created child iframe, src:', child.src);
+
+ wpDoc.body.appendChild(child);
+ console.log('Appended child iframe, src:', child.src);
+
+ // Wait for the iframe to get the data-controlled attribute (set by iframes-trap.js)
+ const start = performance.now();
+ while (performance.now() - start < 10000) {
+ const controlled = child.getAttribute('data-controlled');
+ if (controlled === '1') {
+ break;
+ }
+ await new Promise((resolve) => setTimeout(resolve, 100));
+ }
+
+ console.log('Child iframe src:', child.src);
+ console.log('Child iframe data-controlled:', child.getAttribute('data-controlled'));
+
+ // Wait for the iframe content to load and for the SW to claim it
+ // The navigation + SW claim can take a moment
+ let hasController = false;
+ let swError = null;
+ const waitStart = performance.now();
+ while (performance.now() - waitStart < 10000) {
+ try {
+ hasController =
+ !!child.contentWindow?.navigator?.serviceWorker?.controller;
+ if (hasController) {
+ console.log(
+ 'Child SW controller:',
+ child.contentWindow?.navigator?.serviceWorker?.controller
+ );
+ break;
+ }
+ } catch (e: unknown) {
+ swError = e instanceof Error ? e.message : String(e);
+ }
+ await new Promise((resolve) => setTimeout(resolve, 200));
+ }
+
+ // Always log the iframe content for debugging
+ const iframeContent =
+ child.contentDocument?.documentElement?.outerHTML?.substring(0, 500);
+ console.log('Child iframe content:', iframeContent);
+
+ if (!hasController) {
+ console.log('Child SW controller still null after waiting');
+ }
+
+ return {
+ controlled: child.getAttribute('data-controlled'),
+ hasController,
+ wpHasController,
+ src: child.src,
+ swError,
+ iframeContent,
+ };
+ });
+
+ console.log('Test result:', result);
+ expect(result.controlled).toBe('1');
+ expect(result.hasController).toBeTruthy();
+});
diff --git a/packages/playground/website/playwright/e2e/iframe-control-fast.spec.ts b/packages/playground/website/playwright/e2e/iframe-control-fast.spec.ts
new file mode 100644
index 0000000000..9ed13de306
--- /dev/null
+++ b/packages/playground/website/playwright/e2e/iframe-control-fast.spec.ts
@@ -0,0 +1,2006 @@
+import { test, expect, Page } from '@playwright/test';
+
+/**
+ * Fast tests for iframe SW control.
+ * Navigates directly to the empty.html loader (which has iframes-trap.js)
+ * and tests various iframe creation methods.
+ * Each test should complete in under 10 seconds.
+ */
+
+let page: Page;
+let baseUrl: string;
+
+// Helper to set up the page for each test
+// Uses the baseURL from Playwright config which is set by the webServer
+async function setupPage(testPage: Page, configBaseURL: string) {
+ page = testPage;
+ // Use the baseURL from Playwright config - this is provided by the webServer
+ baseUrl = configBaseURL;
+
+ // First, navigate to the main page to register the SW
+ await page.goto(baseUrl);
+
+ // Wait for SW to register (with timeout to avoid hanging in case of SW issues)
+ await page.evaluate(async () => {
+ const timeout = new Promise((_, reject) =>
+ setTimeout(() => reject(new Error('SW ready timeout')), 10000)
+ );
+ await Promise.race([navigator.serviceWorker?.ready, timeout]).catch(() => {
+ console.warn('Service worker ready timeout, proceeding anyway');
+ });
+ });
+
+ // In Firefox, we may need to reload for the SW to claim the page
+ // after initial registration
+ const needsReload = await page.evaluate(() => {
+ return !navigator.serviceWorker?.controller;
+ });
+ if (needsReload) {
+ await page.reload();
+ await page.evaluate(async () => {
+ const start = Date.now();
+ while (Date.now() - start < 5000) {
+ if (navigator.serviceWorker?.controller) break;
+ await new Promise(r => setTimeout(r, 50));
+ }
+ });
+ }
+
+ // Navigate to the loader page (served by SW, has iframes-trap.js)
+ // IMPORTANT: The loader URL must be UNDER the SW scope (/website-server/)
+ // for the SW to intercept and serve iframeLoaderHtml.
+ // URL format: /website-server/scope:test-fast/wp-includes/empty.html
+ const loaderUrl = new URL(baseUrl);
+ loaderUrl.pathname = loaderUrl.pathname.replace(/\/$/, '') + '/scope:test-fast/wp-includes/empty.html';
+ await page.goto(loaderUrl.toString());
+
+ // Wait for the SW to claim this page and for iframes-trap.js to load
+ // This is crucial for Firefox which may take longer to claim the page
+ await page.evaluate(async () => {
+ // Wait for SW controller
+ const waitForController = async (maxWait = 10000) => {
+ const start = Date.now();
+ while (Date.now() - start < maxWait) {
+ if (navigator.serviceWorker?.controller) {
+ return true;
+ }
+ await new Promise(r => setTimeout(r, 50));
+ }
+ console.warn('Timed out waiting for SW controller');
+ return false;
+ };
+ await waitForController();
+ });
+ await page.waitForTimeout(300);
+}
+
+async function testIframe(
+ createFn: () => Promise<{ iframe: HTMLIFrameElement; description: string }>
+) {
+ return page.evaluate(async (createFnStr) => {
+ // Wait for the iframe to be processed and loaded
+ const waitForController = async (
+ iframe: HTMLIFrameElement,
+ timeout = 3000
+ ) => {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ try {
+ if (iframe.contentWindow?.navigator?.serviceWorker?.controller) {
+ return true;
+ }
+ } catch {
+ // Cross-origin, keep waiting
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ return false;
+ };
+
+ // Wait for iframe content to be loaded (loader script execution completes)
+ const waitForContentLoad = async (
+ iframe: HTMLIFrameElement,
+ timeout = 5000
+ ) => {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ try {
+ // Check if body has real content (h1, div, etc.)
+ const bodyHTML = iframe.contentDocument?.body?.innerHTML || '';
+ // The loader inserts content after its inline script runs
+ // Look for actual HTML tags that indicate content was injected
+ // Also check that the loader script is no longer present (it gets replaced)
+ const hasContent = bodyHTML.includes('
') || bodyHTML.includes('
') || bodyHTML.includes('
');
+ const loaderFinished = !bodyHTML.includes('searchParams.get');
+ if (hasContent && loaderFinished) {
+ return;
+ }
+ } catch {
+ // Cross-origin, keep waiting
+ }
+ await new Promise((r) => setTimeout(r, 100));
+ }
+ };
+
+ const parentHasController = !!navigator.serviceWorker?.controller;
+
+ // Create the iframe using the provided function
+ const createFnEval = new Function('document', `return (${createFnStr})()`);
+ const { iframe, description } = await createFnEval(document);
+
+ // Wait for data-controlled attribute
+ await new Promise((r) => setTimeout(r, 500));
+ const dataControlled = iframe.getAttribute('data-controlled');
+ const iframeSrc = iframe.src;
+
+ // Wait for controller
+ const hasController = await waitForController(iframe);
+
+ // Wait for content to be loaded if iframe has an id in the hash (srcdoc/data/blob case)
+ if (iframeSrc.includes('id=')) {
+ await waitForContentLoad(iframe);
+ }
+
+ let iframeContent = '';
+ try {
+ iframeContent =
+ iframe.contentDocument?.documentElement?.outerHTML?.substring(
+ 0,
+ 500
+ ) || '';
+ } catch {
+ iframeContent = '[cross-origin]';
+ }
+
+ return {
+ description,
+ parentHasController,
+ dataControlled,
+ iframeSrc,
+ hasController,
+ iframeContent,
+ };
+ }, createFn.toString());
+}
+
+test('blank iframe via createElement', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(10000);
+
+ const result = await testIframe(async () => {
+ const iframe = document.createElement('iframe');
+ document.body.appendChild(iframe);
+ return { iframe, description: 'createElement + appendChild' };
+ });
+
+ console.log('Result:', JSON.stringify(result, null, 2));
+ expect(result.parentHasController).toBe(true);
+ expect(result.dataControlled).toBe('1');
+ // Iframes are now served directly from the cache via /__iframes/ URLs
+ expect(result.iframeSrc).toContain('/__iframes/');
+ expect(result.hasController).toBe(true);
+});
+
+test('iframe with srcdoc attribute', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(10000);
+
+ const result = await testIframe(async () => {
+ const iframe = document.createElement('iframe');
+ iframe.srcdoc = '
Hello from srcdoc
';
+ document.body.appendChild(iframe);
+ return { iframe, description: 'srcdoc attribute' };
+ });
+
+ console.log('Result:', JSON.stringify(result, null, 2));
+ expect(result.parentHasController).toBe(true);
+ expect(result.dataControlled).toBe('1');
+ // Iframes are now served directly from the cache via /__iframes/ URLs
+ expect(result.iframeSrc).toContain('/__iframes/');
+ expect(result.hasController).toBe(true);
+ // The content should contain our injected content
+ expect(result.iframeContent).toContain('Hello from srcdoc');
+});
+
+test('iframe with src=about:blank', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(10000);
+
+ const result = await testIframe(async () => {
+ const iframe = document.createElement('iframe');
+ iframe.src = 'about:blank';
+ document.body.appendChild(iframe);
+ return { iframe, description: 'src=about:blank' };
+ });
+
+ console.log('Result:', JSON.stringify(result, null, 2));
+ expect(result.parentHasController).toBe(true);
+ expect(result.dataControlled).toBe('1');
+ // Iframes are now served directly from the cache via /__iframes/ URLs
+ expect(result.iframeSrc).toContain('/__iframes/');
+ expect(result.hasController).toBe(true);
+});
+
+test('iframe added via innerHTML', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(10000);
+
+ const result = await testIframe(async () => {
+ const div = document.createElement('div');
+ div.innerHTML = '';
+ document.body.appendChild(div);
+ const iframe = div.querySelector('iframe') as HTMLIFrameElement;
+ return { iframe, description: 'innerHTML' };
+ });
+
+ console.log('Result:', JSON.stringify(result, null, 2));
+ expect(result.parentHasController).toBe(true);
+ expect(result.dataControlled).toBe('1');
+ // Iframes are now served directly from the cache via /__iframes/ URLs
+ expect(result.iframeSrc).toContain('/__iframes/');
+ expect(result.hasController).toBe(true);
+});
+
+test('iframe with data: URL', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(10000);
+
+ const result = await testIframe(async () => {
+ const iframe = document.createElement('iframe');
+ iframe.src =
+ 'data:text/html,
Hello from data URL
';
+ document.body.appendChild(iframe);
+ return { iframe, description: 'data: URL' };
+ });
+
+ console.log('Result:', JSON.stringify(result, null, 2));
+ expect(result.parentHasController).toBe(true);
+ expect(result.dataControlled).toBe('1');
+ // Iframes are now served directly from the cache via /__iframes/ URLs
+ expect(result.iframeSrc).toContain('/__iframes/');
+ expect(result.hasController).toBe(true);
+ expect(result.iframeContent).toContain('Hello from data URL');
+});
+
+/**
+ * Test nested iframe scenario similar to TinyMCE:
+ * Top page -> First iframe (srcdoc with HTML document) -> Nested iframe (editor)
+ *
+ * The nested iframe must be SW-controlled to load resources like images.
+ * We verify this by loading an image from a SW-only path.
+ *
+ * This test verifies that the "parent-hosted iframe" approach works:
+ * nested iframes are created in the nearest capable ancestor's document
+ * (where iframe navigation works) and positioned to overlay the placeholder
+ * in the nested document.
+ */
+test('nested iframe (TinyMCE-like) can load SW-served resources', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(15000);
+
+ const result = await page.evaluate(async () => {
+ // Helper to wait for an element in nested iframes
+ const waitFor = (fn: () => boolean, timeout = 5000) => {
+ return new Promise((resolve, reject) => {
+ const start = Date.now();
+ const check = () => {
+ if (fn()) {
+ resolve();
+ } else if (Date.now() - start > timeout) {
+ reject(new Error('Timeout waiting for condition'));
+ } else {
+ setTimeout(check, 100);
+ }
+ };
+ check();
+ });
+ };
+
+ // Create outer iframe with srcdoc (simulates wp-admin page with editor)
+ const outerIframe = document.createElement('iframe');
+ outerIframe.srcdoc = `
+
+
+ Outer Frame (like wp-admin)
+
+
+
+
+
+ `;
+ document.body.appendChild(outerIframe);
+
+ // Wait for outer iframe to be controlled and loaded
+ await new Promise(r => setTimeout(r, 1000));
+
+ let outerControlled = false;
+ let nestedIframeFound = false;
+ let nestedControlled = false;
+ let imageLoadAttempted = false;
+ let imageLoadResult = 'not-checked';
+
+ try {
+ // Check outer iframe
+ const outerDoc = outerIframe.contentDocument;
+ outerControlled = !!outerIframe.contentWindow?.navigator?.serviceWorker?.controller;
+
+ // Wait for nested iframe to appear
+ await waitFor(() => {
+ const nested = outerDoc?.getElementById('editor-iframe') as HTMLIFrameElement;
+ return !!nested;
+ }, 3000);
+
+ const nestedIframe = outerDoc?.getElementById('editor-iframe') as HTMLIFrameElement;
+ nestedIframeFound = !!nestedIframe;
+
+ if (nestedIframe) {
+ // Wait for nested iframe to be controlled
+ await waitFor(() => {
+ try {
+ return !!nestedIframe.contentWindow?.navigator?.serviceWorker?.controller;
+ } catch {
+ return false;
+ }
+ }, 3000).catch(() => {});
+
+ nestedControlled = !!nestedIframe.contentWindow?.navigator?.serviceWorker?.controller;
+
+ // Wait for content to load in nested iframe
+ await new Promise(r => setTimeout(r, 1000));
+
+ // Check if the image load was attempted (SW should intercept it)
+ const nestedDoc = nestedIframe.contentDocument;
+ const img = nestedDoc?.getElementById('test-image') as HTMLImageElement;
+ if (img) {
+ imageLoadAttempted = true;
+
+ // Wait for image to load or error
+ await new Promise((resolve) => {
+ if (img.complete) {
+ resolve();
+ return;
+ }
+ const timeout = setTimeout(resolve, 5000);
+ img.onload = () => { clearTimeout(timeout); resolve(); };
+ img.onerror = () => { clearTimeout(timeout); resolve(); };
+ });
+
+ // naturalWidth > 0 means it loaded, 0 means failed but was attempted
+ // If it wasn't controlled, the request would go directly to network
+ if (img.complete) {
+ imageLoadResult = img.naturalWidth > 0 ? 'loaded' : 'failed-404';
+ } else {
+ // Try to get more info about why it's still loading
+ imageLoadResult = `loading:src=${img.src}:currentSrc=${img.currentSrc}`;
+ }
+ }
+ }
+ } catch (e) {
+ // Ignore errors from cross-origin access
+ }
+
+ // Debug info
+ let nestedBodyHtml = '';
+ let nestedLocation = '';
+ let hasControlledRef = false;
+ let outerLocation = '';
+ let outerHasControlledRef = false;
+ let nestedDataControlled = '';
+ let nestedDataControlledBy = '';
+ let nestedSrc = '';
+ let topPageIframes: string[] = [];
+ let controlledIframeInTop: any = null;
+ try {
+ const outerDoc = outerIframe.contentDocument;
+ outerLocation = outerIframe.contentWindow?.location?.href || 'no-access';
+ outerHasControlledRef = !!(outerIframe as any)?.__controlledIframe;
+ const nestedIframeDebug = outerDoc?.getElementById('editor-iframe') as HTMLIFrameElement;
+ nestedBodyHtml = nestedIframeDebug?.contentDocument?.body?.innerHTML?.slice(0, 200) || 'no-access';
+ nestedLocation = nestedIframeDebug?.contentWindow?.location?.href || 'no-access';
+ hasControlledRef = !!(nestedIframeDebug as any)?.__controlledIframe;
+ nestedDataControlled = nestedIframeDebug?.getAttribute('data-controlled') || 'not-set';
+ nestedDataControlledBy = nestedIframeDebug?.getAttribute('data-controlled-by') || 'not-set';
+ nestedSrc = nestedIframeDebug?.getAttribute('src') || 'not-set';
+
+ // Check what iframes exist in the TOP page
+ const topIframes = document.querySelectorAll('iframe');
+ topPageIframes = Array.from(topIframes).map(f => `id=${f.id || 'none'}, src=${f.src}`);
+
+ // Look for the controlled iframe that should have been created in the top page
+ if (nestedDataControlledBy && nestedDataControlledBy !== 'not-set') {
+ const controlledInTop = document.getElementById(nestedDataControlledBy) as HTMLIFrameElement;
+ if (controlledInTop) {
+ controlledIframeInTop = {
+ found: true,
+ src: controlledInTop.src,
+ location: controlledInTop.contentWindow?.location?.href || 'no-access',
+ controlled: !!controlledInTop.contentWindow?.navigator?.serviceWorker?.controller,
+ bodyHtml: controlledInTop.contentDocument?.body?.innerHTML?.slice(0, 200) || 'no-access',
+ };
+ } else {
+ controlledIframeInTop = { found: false, id: nestedDataControlledBy };
+ }
+ }
+ } catch (e) {
+ nestedBodyHtml = 'error: ' + (e as Error).message;
+ }
+
+ return {
+ outerControlled,
+ nestedIframeFound,
+ nestedControlled,
+ imageLoadAttempted,
+ imageLoadResult,
+ nestedBodyHtml,
+ nestedLocation,
+ hasControlledRef,
+ outerLocation,
+ outerHasControlledRef,
+ nestedDataControlled,
+ nestedDataControlledBy,
+ nestedSrc,
+ topPageIframes,
+ controlledIframeInTop,
+ };
+ });
+
+ console.log('Nested iframe result:', JSON.stringify(result, null, 2));
+
+ expect(result.outerControlled).toBe(true);
+ expect(result.nestedIframeFound).toBe(true);
+ expect(result.nestedControlled).toBe(true);
+ expect(result.imageLoadAttempted).toBe(true);
+ // The nested iframe is SW-controlled, which is the key thing we're testing.
+ // The image src was rewritten to include the SW scope prefix, meaning URLs
+ // are correctly going through the service worker.
+ // The image may 404 because the test file doesn't exist, but the URL is correct.
+ expect(result.nestedBodyHtml).toContain('/website-server/scope:test-fast/');
+ // With direct navigation approach, iframes are controlled in-place via /__iframes/ URLs
+ // The nested iframe should have a proper location (not about:srcdoc)
+ expect(result.nestedLocation).toContain('/__iframes/');
+});
+
+/**
+ * Test that script execution works inside a srcdoc iframe.
+ * This is a simpler test to isolate whether scripts run at all.
+ */
+test('scripts execute inside srcdoc iframe', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(15000);
+
+ const result = await page.evaluate(async () => {
+ // Create iframe with a script that modifies the DOM
+ const iframe = document.createElement('iframe');
+ iframe.srcdoc = `
+
+
+
before
+
+
+ `;
+ document.body.appendChild(iframe);
+
+ // Wait for iframe to load and script to run
+ await new Promise(r => setTimeout(r, 2000));
+
+ return {
+ controlled: !!iframe.contentWindow?.navigator?.serviceWorker?.controller,
+ containerText: iframe.contentDocument?.getElementById('container')?.textContent || '',
+ scriptRan: (iframe.contentWindow as any)?.scriptRan || false,
+ bodyHtml: iframe.contentDocument?.body?.innerHTML?.substring(0, 300) || '',
+ };
+ });
+
+ console.log('Script execution result:', JSON.stringify(result, null, 2));
+
+ expect(result.controlled).toBe(true);
+ expect(result.scriptRan).toBe(true);
+ expect(result.containerText).toBe('after');
+});
+
+/**
+ * Test that creating a blank iframe directly on the top page works.
+ * This establishes that direct iframe creation is working.
+ */
+test('direct blank iframe on top page is controlled', async ({ page: testPage, baseURL }) => {
+ test.setTimeout(15000);
+ await setupPage(testPage, baseURL!);
+
+ const result = await page.evaluate(async () => {
+ // Create a blank iframe directly (not inside another iframe)
+ const iframe = document.createElement('iframe');
+ iframe.id = 'direct-blank';
+ document.body.appendChild(iframe);
+
+ // Wait for it to be controlled
+ await new Promise(r => setTimeout(r, 2000));
+
+ return {
+ parentControlled: !!navigator.serviceWorker?.controller,
+ found: !!document.getElementById('direct-blank'),
+ src: iframe.src,
+ controlled: !!iframe.contentWindow?.navigator?.serviceWorker?.controller,
+ hasSwReady: !!iframe.contentWindow?.navigator?.serviceWorker?.ready,
+ };
+ });
+
+ console.log('Direct blank iframe result:', JSON.stringify(result, null, 2));
+
+ expect(result.parentControlled).toBe(true);
+ expect(result.found).toBe(true);
+ expect(result.controlled).toBe(true);
+});
+
+/**
+ * Debug test: understand what's happening with nested iframe creation.
+ * This collects detailed diagnostics about the iframe creation flow.
+ */
+test('DEBUG: nested iframe diagnostics', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ const result = await page.evaluate(async () => {
+ // Create outer iframe with srcdoc that will create an inner iframe
+ const outer = document.createElement('iframe');
+ outer.srcdoc = `
+
+
+
+
+
+ `;
+ document.body.appendChild(outer);
+
+ // Wait for outer to load and nested timeouts to fire
+ await new Promise(r => setTimeout(r, 5000));
+
+ const outerWin = outer.contentWindow as any;
+ const outerDoc = outer.contentDocument;
+ const outerDiag = outerWin?.diagnostics || {};
+
+ // Get the inner iframe
+ const innerIframe = outerDoc?.getElementById('inner') as HTMLIFrameElement;
+
+ let innerDiag: any = {};
+ try {
+ const innerWin = innerIframe?.contentWindow as any;
+ innerDiag = {
+ location: innerWin?.location?.href,
+ controlled: !!innerWin?.navigator?.serviceWorker?.controller,
+ trapLoaded: !!innerWin?.__controlled_iframes_loaded__,
+ bodyHtml: innerIframe?.contentDocument?.body?.innerHTML?.substring(0, 200),
+ };
+ } catch (e) {
+ innerDiag.accessError = (e as Error).message;
+ }
+
+ return {
+ topPageLocation: location.href,
+ topPageControlled: !!navigator.serviceWorker?.controller,
+ topTrapLoaded: !!(window as any).__controlled_iframes_loaded__,
+ outerSrc: outer.src,
+ outerDataControlled: outer.getAttribute('data-controlled'),
+ outerDiag,
+ innerSrc: innerIframe?.src,
+ innerDataControlled: innerIframe?.getAttribute('data-controlled'),
+ innerDiag,
+ };
+ });
+
+ console.log('DEBUG diagnostics:', JSON.stringify(result, null, 2));
+
+ // This test is just for diagnostics, always pass
+ expect(true).toBe(true);
+});
+
+/**
+ * Debug test: try manually triggering navigation after append
+ */
+test('DEBUG: manual navigation trigger', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ const result = await page.evaluate(async () => {
+ // Create outer iframe with srcdoc that will create an inner iframe
+ const outer = document.createElement('iframe');
+ outer.srcdoc = `
+
+
+
+
+
+ `;
+ document.body.appendChild(outer);
+
+ // Wait for everything
+ await new Promise(r => setTimeout(r, 5000));
+
+ const outerWin = outer.contentWindow as any;
+ const outerDiag = outerWin?.diagnostics || {};
+
+ return {
+ outerControlled: !!outerWin?.navigator?.serviceWorker?.controller,
+ outerDiag,
+ };
+ });
+
+ console.log('Manual navigation result:', JSON.stringify(result, null, 2));
+ expect(true).toBe(true);
+});
+
+/**
+ * Debug test: create iframe directly on loader page (not via injected script)
+ */
+test('DEBUG: direct iframe on loader page', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ // Navigate to loader page first (this is the setup in beforeEach)
+ // Then create iframe directly in the browser context
+ const result = await page.evaluate(async () => {
+ // We're on the loader page (empty.html) which has iframes-trap.js loaded
+ // Create an iframe directly here
+ const inner = document.createElement('iframe');
+ inner.id = 'direct-inner';
+ document.body.appendChild(inner);
+
+ // Wait a bit
+ await new Promise(r => setTimeout(r, 2000));
+
+ return {
+ pageLocation: location.href,
+ pageControlled: !!navigator.serviceWorker?.controller,
+ trapLoaded: !!(window as any).__controlled_iframes_loaded__,
+ innerSrc: inner.src,
+ innerLocation: inner.contentWindow?.location?.href,
+ innerControlled: !!inner.contentWindow?.navigator?.serviceWorker?.controller,
+ };
+ });
+
+ console.log('Direct on loader result:', JSON.stringify(result, null, 2));
+
+ // This SHOULD work since we're creating directly on the controlled page
+ expect(result.innerControlled).toBe(true);
+});
+
+/**
+ * Debug test: create iframe via innerHTML directly on loader page
+ */
+test('DEBUG: innerHTML iframe on loader page', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ const result = await page.evaluate(async () => {
+ const wrapper = document.createElement('div');
+ const loaderUrl = '/scope:test-fast/wp-includes/empty.html#' + new URLSearchParams({ base: document.baseURI }).toString();
+ wrapper.innerHTML = '';
+ document.body.appendChild(wrapper);
+
+ const iframe = document.getElementById('innerHTML-test') as HTMLIFrameElement;
+
+ // Wait a bit
+ await new Promise(r => setTimeout(r, 2000));
+
+ return {
+ pageLocation: location.href,
+ pageControlled: !!navigator.serviceWorker?.controller,
+ iframeSrc: iframe.src,
+ iframeLocation: iframe.contentWindow?.location?.href,
+ iframeControlled: !!iframe.contentWindow?.navigator?.serviceWorker?.controller,
+ };
+ });
+
+ console.log('innerHTML on loader result:', JSON.stringify(result, null, 2));
+
+ expect(result.iframeControlled).toBe(true);
+});
+
+/**
+ * Debug test: can nested page use parent to host iframe?
+ * This test creates iframe in parent, keeps it there, and accesses via parent.document
+ */
+test('DEBUG: parent-hosted iframe solution', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ const result = await page.evaluate(async () => {
+ const outer = document.createElement('iframe');
+ outer.srcdoc = `
+
Placeholder for inner iframe
+ `;
+ document.body.appendChild(outer);
+
+ await new Promise(r => setTimeout(r, 3000));
+
+ const outerWin = outer.contentWindow as any;
+ return {
+ outerControlled: !!outerWin?.navigator?.serviceWorker?.controller,
+ testResults: outerWin?.testResults || {},
+ };
+ });
+
+ console.log('Parent-hosted iframe result:', JSON.stringify(result, null, 2));
+
+ // This solution should work - iframe is in parent but accessible from child
+ expect(result.testResults.innerControlled).toBe(true);
+ expect(result.testResults.canAccessFromChild).toBe(true);
+});
+
+/**
+ * Debug test: check if the loader page itself can navigate iframes
+ */
+test('DEBUG: loader vs srcdoc comparison', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ const result = await page.evaluate(async () => {
+ // Create two iframes: one srcdoc, one direct
+ const srcdocIframe = document.createElement('iframe');
+ srcdocIframe.id = 'srcdoc-outer';
+ srcdocIframe.srcdoc = '';
+ document.body.appendChild(srcdocIframe);
+
+ // Wait for srcdoc iframe to load
+ await new Promise(r => setTimeout(r, 2000));
+
+ // Get srcdoc iframe's window and call createNested
+ const srcdocWin = srcdocIframe.contentWindow as any;
+ const nestedFromSrcdoc = srcdocWin?.createNested?.() || { error: 'createNested not found' };
+
+ // Wait for nested to potentially load
+ await new Promise(r => setTimeout(r, 1000));
+
+ // Check nested iframe state
+ const nested = srcdocIframe.contentDocument?.getElementById('nested-from-srcdoc') as HTMLIFrameElement;
+ let nestedFinal: any = {};
+ if (nested) {
+ try {
+ nestedFinal = {
+ src: nested.src,
+ location: nested.contentWindow?.location?.href,
+ controlled: !!nested.contentWindow?.navigator?.serviceWorker?.controller,
+ };
+ } catch (e) {
+ nestedFinal.error = (e as Error).message;
+ }
+ }
+
+ return {
+ srcdocControlled: !!srcdocWin?.navigator?.serviceWorker?.controller,
+ srcdocTrapLoaded: !!srcdocWin?.__controlled_iframes_loaded__,
+ nestedFromSrcdoc,
+ nestedFinal,
+ };
+ });
+
+ console.log('Loader vs srcdoc result:', JSON.stringify(result, null, 2));
+ expect(true).toBe(true);
+});
+
+/**
+ * Debug test: check if using fresh native setter works inside srcdoc
+ */
+test('DEBUG: fresh native setter in srcdoc', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ const result = await page.evaluate(async () => {
+ // Create srcdoc iframe that will try various ways to set src
+ const srcdocIframe = document.createElement('iframe');
+ srcdocIframe.id = 'srcdoc-setter-test';
+ srcdocIframe.srcdoc = ``;
+ document.body.appendChild(srcdocIframe);
+
+ // Wait for everything
+ await new Promise(r => setTimeout(r, 3000));
+
+ const srcdocWin = srcdocIframe.contentWindow as any;
+ return {
+ srcdocControlled: !!srcdocWin?.navigator?.serviceWorker?.controller,
+ testResults: srcdocWin?.testResults || {},
+ };
+ });
+
+ console.log('Fresh native setter result:', JSON.stringify(result, null, 2));
+ expect(true).toBe(true);
+});
+
+/**
+ * Debug test: defer iframe creation to after the script completes
+ */
+test('DEBUG: deferred iframe creation', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ const result = await page.evaluate(async () => {
+ // Create srcdoc iframe that defers iframe creation
+ const srcdocIframe = document.createElement('iframe');
+ srcdocIframe.id = 'srcdoc-deferred';
+ srcdocIframe.srcdoc = ``;
+ document.body.appendChild(srcdocIframe);
+
+ // Wait for everything
+ await new Promise(r => setTimeout(r, 4000));
+
+ const srcdocWin = srcdocIframe.contentWindow as any;
+ return {
+ srcdocControlled: !!srcdocWin?.navigator?.serviceWorker?.controller,
+ testResults: srcdocWin?.testResults || {},
+ };
+ });
+
+ console.log('Deferred iframe result:', JSON.stringify(result, null, 2));
+ expect(true).toBe(true);
+});
+
+/**
+ * Debug test: check if creating iframe via innerHTML works
+ */
+test('DEBUG: nested iframe via innerHTML', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ const result = await page.evaluate(async () => {
+ // Create srcdoc iframe that creates inner iframe via innerHTML
+ const srcdocIframe = document.createElement('iframe');
+ srcdocIframe.id = 'srcdoc-innerHTML';
+ srcdocIframe.srcdoc = ``;
+ document.body.appendChild(srcdocIframe);
+
+ // Wait
+ await new Promise(r => setTimeout(r, 4000));
+
+ const srcdocWin = srcdocIframe.contentWindow as any;
+ return {
+ srcdocControlled: !!srcdocWin?.navigator?.serviceWorker?.controller,
+ testResults: srcdocWin?.testResults || {},
+ };
+ });
+
+ console.log('innerHTML iframe result:', JSON.stringify(result, null, 2));
+ expect(true).toBe(true);
+});
+
+/**
+ * Debug test: create srcdoc with an immediate inner iframe (no loader redirect)
+ * This tests whether the issue is the loader page specifically
+ */
+test('DEBUG: srcdoc with inner iframe on TOP page (no outer redirect)', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(30000);
+
+ // Navigate to website base (not the loader)
+ await page.goto(baseUrl);
+ await page.evaluate(async () => {
+ await navigator.serviceWorker?.ready;
+ });
+ await page.waitForTimeout(500);
+
+ const result = await page.evaluate(async () => {
+ // Create srcdoc iframe directly on this page
+ // This page has NO loader redirect - it's the actual website
+ const outer = document.createElement('iframe');
+ outer.srcdoc = ``;
+ document.body.appendChild(outer);
+
+ // Wait
+ await new Promise(r => setTimeout(r, 3000));
+
+ const outerWin = outer.contentWindow as any;
+ return {
+ topPageUrl: location.href,
+ topPageControlled: !!navigator.serviceWorker?.controller,
+ outerControlled: !!outerWin?.navigator?.serviceWorker?.controller,
+ innerResult: outerWin?.innerResult || {},
+ };
+ });
+
+ console.log('Top page srcdoc result:', JSON.stringify(result, null, 2));
+ expect(true).toBe(true);
+});
+
+/**
+ * Test that a script inside srcdoc iframe can create another iframe.
+ * This tests the nested iframe creation path using the parent-hosted approach.
+ */
+test('srcdoc iframe script can create child iframe', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(15000);
+
+ const result = await page.evaluate(async () => {
+ // Create outer iframe that will create an inner iframe via script
+ const outer = document.createElement('iframe');
+ outer.srcdoc = `
+
+
+
+
+
+ `;
+ document.body.appendChild(outer);
+
+ // Wait for everything to load
+ await new Promise(r => setTimeout(r, 2000));
+
+ const outerWin = outer.contentWindow as any;
+ let innerIframe = outer.contentDocument?.getElementById('inner') as HTMLIFrameElement;
+
+ // Wait for inner iframe to potentially become controlled
+ for (let i = 0; i < 20 && innerIframe && !innerIframe.contentWindow?.navigator?.serviceWorker?.controller; i++) {
+ await new Promise(r => setTimeout(r, 200));
+ }
+
+ // Re-get in case it changed
+ innerIframe = outer.contentDocument?.getElementById('inner') as HTMLIFrameElement;
+
+ let innerHtml = '';
+ let innerHasSwReady = false;
+ try {
+ innerHtml = innerIframe?.contentDocument?.body?.innerHTML?.substring(0, 200) || '';
+ innerHasSwReady = !!innerIframe?.contentWindow?.navigator?.serviceWorker?.ready;
+ } catch (e) {
+ innerHtml = '[cross-origin]';
+ }
+
+ return {
+ outerControlled: !!outerWin?.navigator?.serviceWorker?.controller,
+ scriptStarted: outerWin?.scriptStarted || false,
+ innerCreated: outerWin?.innerCreated || false,
+ createError: outerWin?.createError || null,
+ innerFound: !!innerIframe,
+ innerSrc: innerIframe?.src || '',
+ innerControlled: !!innerIframe?.contentWindow?.navigator?.serviceWorker?.controller,
+ innerHtml,
+ innerHasSwReady,
+ bodyHtml: outer.contentDocument?.body?.innerHTML?.substring(0, 500) || '',
+ };
+ });
+
+ console.log('Child iframe creation result:', JSON.stringify(result, null, 2));
+
+ expect(result.outerControlled).toBe(true);
+ expect(result.scriptStarted).toBe(true);
+ expect(result.innerCreated).toBe(true);
+ expect(result.innerFound).toBe(true);
+ expect(result.innerControlled).toBe(true);
+});
+
+/**
+ * Test deeply nested iframes (4 levels):
+ * Top page -> Level 1 (srcdoc) -> Level 2 (srcdoc) -> Level 3 (srcdoc) -> Editor iframe (srcdoc)
+ *
+ * This verifies that the iframe control mechanism works with arbitrary nesting depth.
+ * The key is that findCapableAncestor() must find the topmost SW-controlled ancestor,
+ * not just the immediate parent (which may itself be a srcdoc iframe that can't navigate).
+ */
+test('deeply nested iframes (4 levels) are SW-controlled', async ({ page: testPage, baseURL }) => {
+ await setupPage(testPage, baseURL!);
+ test.setTimeout(45000);
+
+ const result = await page.evaluate(async () => {
+ // Helper to get the actual controlled iframe (handles __controlledIframe reference)
+ const getControlledIframe = (iframe: HTMLIFrameElement): HTMLIFrameElement => {
+ return (iframe as any).__controlledIframe || iframe;
+ };
+
+ // Helper to wait for iframe to be controlled
+ // Must check the actual controlled iframe (which may be in an ancestor document)
+ const waitForControlled = async (iframe: HTMLIFrameElement, timeout = 15000) => {
+ const start = Date.now();
+ let lastState = '';
+ while (Date.now() - start < timeout) {
+ try {
+ const dataControlled = iframe.getAttribute('data-controlled');
+ const dataPending = iframe.getAttribute('data-control-pending');
+ const dataSrcdocPending = iframe.getAttribute('data-srcdoc-pending');
+ const hasControlledRef = !!(iframe as any).__controlledIframe;
+
+ const currentState = `dc=${dataControlled},cp=${dataPending},sp=${dataSrcdocPending},ref=${hasControlledRef}`;
+ if (currentState !== lastState) {
+ results.debug.push(`waitForControlled: ${currentState}`);
+ lastState = currentState;
+ }
+
+ // First check if data-controlled is set
+ if (dataControlled === '1') {
+ // Get the actual controlled iframe
+ const controlled = getControlledIframe(iframe);
+ const controller = controlled.contentWindow?.navigator?.serviceWorker?.controller;
+ if (controller) {
+ results.debug.push(`waitForControlled: found controller`);
+ return true;
+ }
+ }
+ } catch (e) {
+ results.debug.push(`waitForControlled error: ${(e as Error).message}`);
+ }
+ await new Promise(r => setTimeout(r, 100));
+ }
+ results.debug.push(`waitForControlled: timed out`);
+ return false;
+ };
+
+ // Helper to wait for iframe content to be ready
+ // Must check the actual controlled iframe (though with direct serving, it's the same iframe)
+ const waitForContent = async (iframe: HTMLIFrameElement, timeout = 15000) => {
+ const start = Date.now();
+ while (Date.now() - start < timeout) {
+ try {
+ const controlled = getControlledIframe(iframe);
+ const body = controlled.contentDocument?.body;
+ if (body) {
+ // Check for iframes-trap.js marker - this is set when the script executes
+ const hasIframesTrap = !!(controlled.contentWindow as any)?.__controlled_iframes_loaded__;
+ // With direct HTML serving, content is ready when iframes-trap.js has loaded
+ // (no separate loader script - HTML is served directly from cache)
+ if (hasIframesTrap) {
+ results.debug.push(`waitForContent: ready - trap loaded`);
+ return true;
+ }
+ }
+ } catch (e) {
+ results.debug.push(`waitForContent error: ${(e as Error).message}`);
+ }
+ await new Promise(r => setTimeout(r, 100));
+ }
+ results.debug.push(`waitForContent: timed out`);
+ return false;
+ };
+
+ // Helper to create and wait for a nested iframe
+ const createNestedIframe = async (parentDoc: Document, id: string, content: string) => {
+ const iframe = parentDoc.createElement('iframe');
+ iframe.id = id;
+ iframe.srcdoc = content;
+ parentDoc.body.appendChild(iframe);
+
+ // Wait for it to be controlled
+ await waitForControlled(iframe);
+ await waitForContent(iframe);
+
+ // Give it a bit more time to settle
+ await new Promise(r => setTimeout(r, 500));
+
+ return iframe;
+ };
+
+ // Check ancestor hierarchy for debugging
+ const checkAncestors = () => {
+ const ancestors: any[] = [];
+ try {
+ let current = window;
+ let depth = 0;
+ while (depth < 10) {
+ ancestors.push({
+ depth,
+ isSelf: current === window,
+ hasIframesTrap: !!(current as any).__controlled_iframes_loaded__,
+ hasSW: !!current.navigator?.serviceWorker?.controller,
+ location: current.location?.href?.substring(0, 100) || 'no-access',
+ });
+ if (!current.parent || current.parent === current) break;
+ current = current.parent;
+ depth++;
+ }
+ } catch (e) {
+ ancestors.push({ error: (e as Error).message });
+ }
+ return ancestors;
+ };
+
+ const results: any = {
+ topControlled: !!navigator.serviceWorker?.controller,
+ topHasIframesTrap: !!(window as any).__controlled_iframes_loaded__,
+ ancestors: checkAncestors(),
+ levels: [],
+ controlledIframesInTop: 0,
+ debug: [],
+ };
+
+ // Helper to add level data with extra debug info
+ const addLevelData = (level: number, iframe: HTMLIFrameElement, extraData: any = {}) => {
+ try {
+ // Get both the original and controlled iframe info
+ const controlledRef = getControlledIframe(iframe);
+ const isUsingControlled = controlledRef !== iframe;
+
+ // Get info from the controlled iframe (which is what we actually use)
+ let controlledSWController = false;
+ let controlledLocation = '';
+ let controlledHasIframesTrap = false;
+ try {
+ controlledSWController = !!controlledRef.contentWindow?.navigator?.serviceWorker?.controller;
+ controlledLocation = controlledRef.contentWindow?.location?.href || 'no-access';
+ controlledHasIframesTrap = !!(controlledRef.contentWindow as any)?.__controlled_iframes_loaded__;
+ } catch (e) {
+ controlledLocation = `error: ${(e as Error).message}`;
+ }
+
+ // Also try direct access to see if there's a difference
+ let directLocation = '';
+ try {
+ const nativeContentWindow = Object.getOwnPropertyDescriptor(
+ HTMLIFrameElement.prototype, 'contentWindow'
+ )?.get?.call(iframe);
+ directLocation = nativeContentWindow?.location?.href || 'no-native-access';
+ } catch (e) {
+ directLocation = `native-error: ${(e as Error).message}`;
+ }
+
+ const dataControlled = iframe.getAttribute('data-controlled');
+ const dataControlledBy = iframe.getAttribute('data-controlled-by');
+ const hasControlledIframe = !!(iframe as any).__controlledIframe;
+
+ results.levels.push({
+ level,
+ controlled: controlledSWController,
+ location: controlledLocation,
+ hasId: controlledLocation.includes('id='),
+ hasIframesTrap: controlledHasIframesTrap,
+ dataControlled,
+ dataControlledBy,
+ hasControlledIframe,
+ isUsingControlled,
+ directLocation,
+ ...extraData,
+ });
+ } catch (e) {
+ results.debug.push(`Level ${level} data collection error: ${(e as Error).message}`);
+ }
+ };
+
+ try {
+ // Level 1: Create in top document
+ results.debug.push('Creating level 1...');
+ const level1 = await createNestedIframe(
+ document,
+ 'level1',
+ 'Level 1