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 17 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.
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