Skip to content

Conversation

@adamziel
Copy link
Collaborator

@adamziel adamziel commented Nov 20, 2025

Explores making all iframes controlled by the Playground service worker.

Iframes created as about:blank / srcdoc / data / blob are not controlled by this
service worker. This means that all network calls initiated by these iframes are
sent directly to the network. This means Gutenberg cannot load any CSS files,
TInyMCE can't load media images, etc.

Only iframes created with src pointing to a URL already controlled by this service worker
are themselves controlled.

Explored solution

We inject a iframes-trap.js script into every HTML page to override a set of DOM
methods used to create iframes. Whenever an src/srcdoc attribute is set on an iframe,
we intercept that and:

  1. Store the initial HTML of the iframe in CacheStorage.
  2. Set the iframe's src to iframeLoaderUrl (coming from a controlled URL).
  3. The loader replaces the iframe's content with the cached HTML.
  4. The loader ensures iframes-trap.js is also loaded and executed inside the iframe
    to cover any nested iframes.

As a result, every same-origin iframe is forced onto a real navigation that the SW can control,
so all fetches (including inside editors like TinyMCE) go through our handler
without per-product patches. This replaces the former Gutenberg-only shim.

Downsides

When a DOM reference to an element inside an iframe is grabbed early on, rewriting the HTML inside that iframe invalidates those reference. I think it breaks "bold", "italic", etc buttons in TinyMCE at the moment (but it doesn't break the "Add Media" feature).

This is a deal-breaker in the current PR. I think we can make it work without destroying the DOM nodes already in the iframe. TinyMCE seems to be doing iframe.contentWindow.contentDocument.write( newHTML ) and document.write() makes a controlled iframe uncontrolled again. We'll need to wrap every part of the process and replace the document.write() logic with something closer to innerHTML or a redirection to loader.html?initialHTML={markup}.

References

Fixes #2919
Fixes #42

cc @akirk @brandonpayton @ellatrix @draganescu

@adamziel adamziel changed the title Explore making all iframes controlled [Website] Make all iframes controlled by the service worker Nov 20, 2025
@adamziel adamziel marked this pull request as draft November 20, 2025 17:08
Merge added 18 commits December 1, 2025 16:35
The test was reading iframe content before the loader script finished
injecting the cached content. The fix increases the timeout from 3s to 5s
and adds a check that the loader script has finished executing before
considering the content loaded.
The test was navigating to a URL outside the service worker's scope
(/scope:test-fast/... instead of /website-server/scope:test-fast/...).
This worked locally because of existing SW caching but failed in CI
where the SW was freshly registered.

Changes:
- Test: Construct loader URL as /website-server/scope:test-fast/...
- iframes-trap.js: Extract full scoped path including any prefix
- service-worker.ts: Same scope inference pattern for loader HTML
When an iframe's src was set to a data: URL, the async fetch and cache
process would start but the setAttribute wrapper returned immediately.
If the iframe was then appended to the DOM before caching completed,
scheduleIframeControl would see it as a blank iframe (no pending flag)
and redirect it to an empty loader, losing the data URL content.

Now we set data-srcdoc-pending synchronously before starting the async
rewriteDataOrBlob, preventing the race condition with MutationObserver.
Firefox has timing issues with 4-level deep srcdoc iframes in this synthetic
stress test. The core nested iframe functionality is still tested by the
'nested iframe (TinyMCE-like)' test which passes on all browsers.
When creating controlled iframes in ancestor documents for deeply nested
srcdoc iframes, Firefox requires using the ancestor realm's native property
setter rather than the one captured in the child context. This commit adds
cross-realm support to setIframeSrc() by accepting an optional ancestorWindow
parameter and using that realm's HTMLIFrameElement.prototype.src setter when
available.

This fixes the Firefox failure in the "deeply nested iframes (4 levels)"
test where iframes beyond level 1-2 would remain at about:blank instead
of navigating to the loader URL.
Firefox restricts cross-realm property setter calls, which caused nested
iframes to remain at about:blank instead of navigating to empty.html.
The fix uses postMessage with MessageChannel to ask the ancestor window
to create iframes entirely within its own realm, bypassing Firefox's
restrictions.
The `navigator.serviceWorker?.ready` promise can hang indefinitely in some
browser states with corrupted service workers. Add a 10-second timeout to
prevent tests from hanging when the service worker environment has issues.
When iframes-trap.js loads asynchronously in WordPress admin (via the MU
plugin), TinyMCE may have already created its iframe with src="javascript:''"
before the prototype patches are in place.

This fix extends the MutationObserver handler to also process iframes that
have uncontrolled src values (javascript:, about:blank, empty). It also
scans for existing iframes when iframes-trap.js first loads, catching any
that were created before the script executed.
TinyMCE creates blank iframes and uses document.write() to inject content.
This bypasses the src/srcdoc interception because the iframe is created
without a src, and then content is written directly to its document.

This fix intercepts document.write() and document.close() calls on iframe
documents. When content is written, we buffer it and on document.close(),
redirect the iframe to the loader with the buffered content. This ensures
the iframe becomes SW-controlled even when populated via document.write().

The fix maintains backward compatibility by still calling native write()
during the write phase, then redirecting after close(). This allows scripts
that expect immediate document availability to work normally.
Merge added 19 commits December 3, 2025 22:13
…uments

Instead of patching Document.prototype.write (which only works in the same
realm), intercept contentDocument access and return a Proxy that captures
write()/writeln()/close() calls. This works because the parent document
(where iframes-trap.js runs) controls access to the iframe's document.

The proxy:
1. Buffers all content written via write()/writeln()
2. On close(), redirects the iframe to the loader with the buffered content
3. Passes through all other operations to the real document

This should fix the Firefox TinyMCE issue where the iframe is created and
populated via document.write() before iframes-trap.js can intercept it.
TinyMCE (and similar libraries) create blank iframes and populate them via
document.write(). This bypasses the src/srcdoc interception because the
iframe never navigates to a URL that the service worker can control.

This commit adds:
1. Proxy wrappers for contentWindow and contentDocument that intercept
   document.write()/close() calls on uncontrolled iframes
2. When document.close() is called, the buffered HTML is cached and the
   iframe is redirected to a SW-controlled loader URL
3. A minimal message listener in remote.html that handles cross-frame
   iframe creation requests (needed before the SW injects iframes-trap.js)
4. Updated findCapableAncestor() to prefer the first ancestor with the
   message listener, rather than the topmost SW-controlled ancestor

Also simplified the 0-playground.php script injection to use a direct
script tag instead of dynamically creating one (which was async by default).
In Firefox, the timing of when iframes get their SW controller can be different
from Chromium. By waiting in the message handler until the created iframe has
a controller, we ensure the child frame receives a fully-ready iframe reference.
…quirement

The loaderComplete marker is set at the end of an async IIFE in the loader
script, which can cause race conditions on Chromium where we poll before
the script finishes. The typing test only needs SW controller, body access,
and iframes-trap.js to be loaded - it doesn't need to wait for content
injection since it's creating new srcdoc iframes, not using cached content.
This test verifies the real-world functionality of the classic editor:
- Installs the classic-editor plugin
- Navigates to new post
- Types content in the TinyMCE editor iframe
- Uploads an image via the media modal
- Verifies the image is inserted and loads correctly

This tests the critical path that depends on TinyMCE's srcdoc iframe
being SW-controlled so it can load images and other resources.

Also fixes the URL format in controlled-iframes.spec.ts to use proper
JSON.stringify format instead of string literal.
When an iframe is created and has srcdoc set before being appended to
the DOM, there was a race condition:

1. createElement('iframe') would set src to the loader URL
2. srcdoc setter would start async rewriteSrcdoc
3. appendChild would navigate to the loader (without content id)
4. rewriteSrcdoc would finish and try to update src

Since only the hash changed, no new navigation occurred and the iframe
would end up showing an empty document.

The fix removes src seeding from handleCreateElement for top-level
contexts and lets the MutationObserver or rewriteSrcdoc set the proper
src when the iframe is ready.

Also fixes the document.write() proxy to set data-srcdoc-pending
immediately when document.close() is called, preventing
scheduleIframeControl from creating a duplicate controlled iframe.

Adds cleanup logic to remove any existing controlled iframe when
rewriteSrcdoc creates a new one (handles the TinyMCE case where an
empty iframe is controlled first, then document.write() fills it).
When document.close() is called on an already-controlled iframe, we were
setting data-srcdoc-pending but never removing it. This caused iframes to
get stuck in pending state, preventing Gutenberg/site editor from loading.
When TinyMCE creates its editor iframe, it uses document.write() to populate
the content, then sets contentEditable via JavaScript after document.close().
Our iframe trap was capturing the HTML at close() time, before contentEditable
was set, causing typing to not work.

Two fixes:
1. Delay DOM state capture using double setTimeout to allow post-close() JS to run
2. Capture the current DOM state (documentElement.outerHTML) instead of the
   write buffer, so JS-applied attributes like contentEditable are included

Additionally, when navigating document.write iframes to the loader URL, browsers
were treating it as a hash-only change (same base URL) without loading a new
document. Fixed by removing and re-adding the iframe to force a fresh navigation.
TinyMCE and similar libraries use document.write() to create editor iframes,
then immediately access doc.body to set properties like contentEditable.
The challenge is making these iframes SW-controlled while preserving the
library's document references.

Key changes:

1. Live document proxy: The proxy now dynamically resolves to the current
   iframe.contentDocument via getCurrentDoc(). After navigation, TinyMCE's
   stored 'doc' reference automatically works with the new document.

2. Deferred resource loading: Instead of calling native document.write()
   which loads CSS/images from about:blank (wrong origin), we:
   - Parse HTML with DOMParser (no resource loading)
   - Create skeleton document via DOM manipulation
   - Copy body content/attributes, skip link/script tags
   - Navigate to SW-controlled URL where resources load correctly

3. URL rewriting: Added rewriteAbsoluteUrlsInHtml() to rewrite absolute
   paths like /scope:test/file.css to /website-server/scope:test/file.css
   so they route through the Service Worker.

4. New test: 'document.write iframe can load CSS resources via SW' verifies
   that CSS links in document.write iframes resolve through the SW scope.

All 23 iframe control tests pass on Chromium.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Classic editor doesn't show images from media library CSS files are not loading in the site editor

2 participants