Skip to content

Commit

Permalink
[Patch WordPress] Ensure block editor iframe is controlled by a servi…
Browse files Browse the repository at this point in the history
…ce worker (#668)

## What is this PR doing?

Patches the block editor to use a special ControlledIframe component
instead of a regular HTML "iframe" element. The goal is to make the
iframe use a plain HTTP URL instead of srcDoc, blob URL and other
variations.

Normally, the patch applied here would be a huge maintenance burden over
time. However, @ellatrix explores fixing the issue upstream [in the
Gutenberg
repo](#646).
Once her PR is merged, the patch here will only be needed for a known
and limited set of WordPress and Gutenberg versions and will not require
ongoing reconciliation with new WP/GB releases.

Fixes #646

## What problem is it solving?

In Playground, the editor iframe needs to be controlled by Playground's
service worker so it can serve CSS and other static assets. Otherwise
all the requests originating in that iframe will yield 404s.

However, different WordPress versions use a variety of iframe techniques
that result in a non-controlled iframe:

* 6.3 uses a binary blob URL and the frame isn't controlled by a service
worker
* <= 6.2 uses srcdoc had a null origin and the frame isn't controlled by
a service worker
* Other dynamic techniques, such as using a data URL, also fail to
produce a controlled iframe

HTTP URL src like src="/doc.html" seems to be the only way to create a
controlled iframe.

And so, this commit ensures that the block editor iframe uses a plain
HTTP URL regardless of the WordPress version.

Once WordPress/gutenberg#55152 lands, this will
just work in WordPress 6.4 and new Gutenberg releases.

## Testing Instructions

Run `npm run dev`

Then, confirm the inserter is nicely styled and there are no CSS-related
404s in the network tools.

Test the following editors:

* Post editor
http://localhost:5400/website-server/?url=/wp-admin/post-new.php
* Site editor
http://localhost:5400/website-server/?url=/wp-admin/site-editor.php
* For all supported WordPress versions
* With and without the Gutenberg plugin (`&plugin=gutenberg`)


## Related

* https://bugs.chromium.org/p/chromium/issues/detail?id=880768
* https://bugzilla.mozilla.org/show_bug.cgi?id=1293277
* w3c/ServiceWorker#765
* #42
* b7ca737
  • Loading branch information
adamziel committed Oct 10, 2023
1 parent 1e1a7cb commit 6df1cef
Show file tree
Hide file tree
Showing 28 changed files with 736 additions and 135 deletions.
99 changes: 61 additions & 38 deletions packages/playground/blueprints/src/lib/steps/install-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ export const installPlugin: StepHandler<InstallPluginStep<File>> = async (
);
}

await maybeApplyGutenbergPatch(playground);
await applyGutenbergPatchOnce(playground);
} catch (error) {
console.error(
`Proceeding without the ${zipNiceName} plugin. Could not install it in wp-admin. ` +
Expand All @@ -93,64 +93,87 @@ export const installPlugin: StepHandler<InstallPluginStep<File>> = async (
}
};

async function maybeApplyGutenbergPatch(playground: UniversalPHP) {
async function applyGutenbergPatchOnce(playground: UniversalPHP) {
/**
* The patch below is no longer necessary as it's been fixed upstream:
* Ensures the block editor iframe is controlled by the playground
* service worker. Tl;dr it must use a HTTP URL as its src, not a
* data URL, blob URL, or a srcDoc like it does by default.
*
* https://github.com/WordPress/gutenberg/pull/50875
* @see https://github.com/WordPress/wordpress-playground/pull/668
*
* It can be removed in the next few WordPress releases.
*
* ---
*
* Pair the site editor's nested iframe to the Service Worker.
*
* Without the patch below, the site editor initiates network requests that
* aren't routed through the service worker. That's a known browser issue:
*
* * https://bugs.chromium.org/p/chromium/issues/detail?id=880768
* * https://bugzilla.mozilla.org/show_bug.cgi?id=1293277
* * https://github.com/w3c/ServiceWorker/issues/765
*
* The problem with iframes using srcDoc and src="about:blank" as they
* fail to inherit the root site's service worker.
*
* Gutenberg loads the site editor using <iframe srcDoc="<!doctype html">
* to force the standards mode and not the quirks mode:
*
* https://github.com/WordPress/gutenberg/pull/38855
*
* This commit patches the site editor to achieve the same result via
* <iframe src="/doctype.html"> and a doctype.html file containing just
* `<!doctype html>`. This allows the iframe to inherit the service worker
* and correctly load all the css, js, fonts, images, and other assets.
*
* Ideally this issue would be fixed directly in Gutenberg and the patch
* below would be removed.
*
* See https://github.com/WordPress/wordpress-playground/issues/42 for more details
* The code below repeated in the WordPress bundler in
* compile-wordpress/build-assets/controlled-iframe.js.
*/

if (
(await playground.isDir('/wordpress/wp-content/plugins/gutenberg')) &&
!(await playground.fileExists('/wordpress/.gutenberg-patched'))
) {
const controlledIframe = `
/**
* A synchronous function to read a blob URL as text.
*
* @param {string} url
* @returns {string}
*/
const __playground_readBlobAsText = function (url) {
try {
let xhr = new XMLHttpRequest();
xhr.open('GET', url, false);
xhr.overrideMimeType('text/plain;charset=utf-8');
xhr.send();
return xhr.responseText;
} catch(e) {
return '';
} finally {
URL.revokeObjectURL(url);
}
}
window.__playground_ControlledIframe = window.wp.element.forwardRef(function (props, ref) {
const source = window.wp.element.useMemo(function () {
if (props.srcDoc) {
// WordPress <= 6.2 uses a srcDoc that only contains a doctype.
return '/wp-includes/empty.html';
} else if (props.src && props.src.startsWith('blob:')) {
// WordPress 6.3 uses a blob URL with doctype and a list of static assets.
// Let's pass the document content to empty.html and render it there.
return '/wp-includes/empty.html#' + encodeURIComponent(__playground_readBlobAsText(props.src));
} else {
// WordPress >= 6.4 uses a plain HTTPS URL that needs no correction.
return props.src;
}
}, [props.src]);
return (
window.wp.element.createElement('iframe', {
...props,
ref: ref,
src: source,
// Make sure there's no srcDoc, as it would interfere with the src.
srcDoc: undefined
})
)
});`;

await playground.writeFile('/wordpress/.gutenberg-patched', '1');
await updateFile(
playground,
`/wordpress/wp-content/plugins/gutenberg/build/block-editor/index.js`,
(contents) =>
controlledIframe +
contents.replace(
/srcDoc:("[^"]+"|[^,]+)/g,
'src:"/wp-includes/empty.html"'
/\(\s*"iframe",/g,
'(window.__playground_ControlledIframe,'
)
);
await updateFile(
playground,
`/wordpress/wp-content/plugins/gutenberg/build/block-editor/index.min.js`,
(contents) =>
controlledIframe +
contents.replace(
/srcDoc:("[^"]+"|[^,]+)/g,
'src:"/wp-includes/empty.html"'
/\(\s*"iframe",/g,
'(window.__playground_ControlledIframe,'
)
);
}
Expand Down
14 changes: 10 additions & 4 deletions packages/playground/compile-wordpress/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,16 @@ RUN cp -r wordpress wordpress-static && \
# below would be removed.
#
# See https://github.com/WordPress/wordpress-playground/issues/42 for more details

RUN echo '<!doctype html>' > wordpress-static/wp-includes/empty.html && \
sed -E 's#srcDoc:\s*("[^"]+"|[^,\n}]+)#src:"/wp-includes/empty.html"#g' -i wordpress-static/wp-includes/js/dist/block-editor.min.js && \
sed -E 's#srcDoc:\s*("[^"]+"|[^,\n}]+)#src:"/wp-includes/empty.html"#g' -i wordpress-static/wp-includes/js/dist/block-editor.js
COPY ./build-assets/controlled-iframe.js /root/

RUN echo '<!doctype html><script>const hash = window.location.hash.substring(1); if ( hash ) document.write(decodeURIComponent(hash))</script>' > wordpress-static/wp-includes/empty.html
# Replace a vanilla HTML iframe with Playground's ControlledIframe component in the block editor.
RUN sed -E 's#\(\s*"iframe",#(__playground_ControlledIframe,#g' -i wordpress-static/wp-includes/js/dist/block-editor.js && \
cat /root/controlled-iframe.js wordpress-static/wp-includes/js/dist/block-editor.js >> /root/block-editor.js && \
mv /root/block-editor.js wordpress-static/wp-includes/js/dist/ && \
sed -E 's#\(\s*"iframe",#(__playground_ControlledIframe,#g' -i wordpress-static/wp-includes/js/dist/block-editor.min.js && \
cat /root/controlled-iframe.js wordpress-static/wp-includes/js/dist/block-editor.min.js >> /root/block-editor.min.js && \
mv /root/block-editor.min.js wordpress-static/wp-includes/js/dist/;

# Move the static files to the final output directory
RUN mkdir /root/output/$OUT_FILENAME
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/**
* Ensures the block editor iframe is controlled by the playground
* service worker. The block-editor.js is patched to use `__playground_ControlledIframe`
* instead of the plain HTML iframe element.
*
* @see https://github.com/WordPress/wordpress-playground/pull/668
* @see https://github.com/WordPress/wordpress-playground/issues/42
*
* This code is repeated in the Gutenberg plugin patcher in
* playground/blueprints/src/lib/steps/install-plugin.ts
*/

/**
* A synchronous function to read a blob URL as text.
*
* @param {string} url
* @returns {string}
*/
const __playground_readBlobAsText = function (url) {
try {
let xhr = new XMLHttpRequest();
xhr.open('GET', url, false);
xhr.overrideMimeType('text/plain;charset=utf-8');
xhr.send();
return xhr.responseText;
} catch(e) {
return '';
} finally {
URL.revokeObjectURL(url);
}
}

window.__playground_ControlledIframe = window.wp.element.forwardRef(function (props, ref) {
const source = window.wp.element.useMemo(function () {
if (props.srcDoc) {
// WordPress <= 6.2 uses a srcDoc that only contains a doctype.
return '/wp-includes/empty.html';
} else if (props.src && props.src.startsWith('blob:')) {
// WordPress 6.3 uses a blob URL with doctype and a list of static assets.
// Let's pass the document content to empty.html and render it there.
return '/wp-includes/empty.html#' + encodeURIComponent(__playground_readBlobAsText(props.src));
} else {
// WordPress >= 6.4 uses a plain HTTPS URL that needs no correction.
return props.src;
}
}, [props.src]);
return (
window.wp.element.createElement('iframe', {
...props,
ref: ref,
src: source,
// Make sure there's no srcDoc, as it would interfere with the src.
srcDoc: undefined
})
)
});
Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!doctype html>
<!doctype html><script>const hash = window.location.hash.substring(1); if ( hash ) document.write(decodeURIComponent(hash))</script>
Original file line number Diff line number Diff line change
@@ -1,4 +1,47 @@
this["wp"] = this["wp"] || {}; this["wp"]["blockEditor"] =
/**
* A synchronous function to read a blob URL as text.
*
* @param {string} url
* @returns {string}
*/
const __playground_readBlobAsText = function (url) {
try {
let xhr = new XMLHttpRequest();
xhr.open('GET', url, false);
xhr.overrideMimeType('text/plain;charset=utf-8');
xhr.send();
return xhr.responseText;
} catch(e) {
return '';
} finally {
URL.revokeObjectURL(url);
}
}

window.__playground_ControlledIframe = window.wp.element.forwardRef(function (props, ref) {
const source = window.wp.element.useMemo(function () {
if (props.srcDoc) {
// WordPress <= 6.2 uses a srcDoc that only contains a doctype.
return '/wp-includes/empty.html';
} else if (props.src && props.src.startsWith('blob:')) {
// WordPress 6.3 uses a blob URL with doctype and a list of static assets.
// Let's pass the document content to empty.html and render it there.
return '/wp-includes/empty.html#' + encodeURIComponent(__playground_readBlobAsText(props.src));
} else {
// WordPress >= 6.4 uses a plain HTTPS URL that needs no correction.
return props.src;
}
}, [props.src]);
return (
window.wp.element.createElement('iframe', {
...props,
ref: ref,
src: source,
// Make sure there's no srcDoc, as it would interfere with the src.
srcDoc: undefined
})
)
});this["wp"] = this["wp"] || {}; this["wp"]["blockEditor"] =
/******/ (function(modules) { // webpackBootstrap
/******/ // The module cache
/******/ var installedModules = {};
Expand Down Expand Up @@ -32271,7 +32314,7 @@ function Iframe(_ref3, ref) {
key: id
});
}), head);
return Object(_wordpress_element__WEBPACK_IMPORTED_MODULE_1__["createElement"])(_wordpress_element__WEBPACK_IMPORTED_MODULE_1__["Fragment"], null, tabIndex >= 0 && before, Object(_wordpress_element__WEBPACK_IMPORTED_MODULE_1__["createElement"])("iframe", Object(_babel_runtime_helpers_esm_extends__WEBPACK_IMPORTED_MODULE_0__[/* default */ "a"])({}, props, {
return Object(_wordpress_element__WEBPACK_IMPORTED_MODULE_1__["createElement"])(_wordpress_element__WEBPACK_IMPORTED_MODULE_1__["Fragment"], null, tabIndex >= 0 && before, Object(_wordpress_element__WEBPACK_IMPORTED_MODULE_1__["createElement"])(__playground_ControlledIframe, Object(_babel_runtime_helpers_esm_extends__WEBPACK_IMPORTED_MODULE_0__[/* default */ "a"])({}, props, {
ref: Object(_wordpress_compose__WEBPACK_IMPORTED_MODULE_4__["useMergeRefs"])([ref, setRef]),
tabIndex: tabIndex,
title: Object(_wordpress_i18n__WEBPACK_IMPORTED_MODULE_3__["__"])('Editor canvas')
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!doctype html>
<!doctype html><script>const hash = window.location.hash.substring(1); if ( hash ) document.write(decodeURIComponent(hash))</script>
Original file line number Diff line number Diff line change
@@ -1,4 +1,47 @@
/******/ (function() { // webpackBootstrap
/**
* A synchronous function to read a blob URL as text.
*
* @param {string} url
* @returns {string}
*/
const __playground_readBlobAsText = function (url) {
try {
let xhr = new XMLHttpRequest();
xhr.open('GET', url, false);
xhr.overrideMimeType('text/plain;charset=utf-8');
xhr.send();
return xhr.responseText;
} catch(e) {
return '';
} finally {
URL.revokeObjectURL(url);
}
}

window.__playground_ControlledIframe = window.wp.element.forwardRef(function (props, ref) {
const source = window.wp.element.useMemo(function () {
if (props.srcDoc) {
// WordPress <= 6.2 uses a srcDoc that only contains a doctype.
return '/wp-includes/empty.html';
} else if (props.src && props.src.startsWith('blob:')) {
// WordPress 6.3 uses a blob URL with doctype and a list of static assets.
// Let's pass the document content to empty.html and render it there.
return '/wp-includes/empty.html#' + encodeURIComponent(__playground_readBlobAsText(props.src));
} else {
// WordPress >= 6.4 uses a plain HTTPS URL that needs no correction.
return props.src;
}
}, [props.src]);
return (
window.wp.element.createElement('iframe', {
...props,
ref: ref,
src: source,
// Make sure there's no srcDoc, as it would interfere with the src.
srcDoc: undefined
})
)
});/******/ (function() { // webpackBootstrap
/******/ var __webpack_modules__ = ({

/***/ 6411:
Expand Down Expand Up @@ -20940,11 +20983,11 @@ function Iframe(_ref3, ref) {
key: id
});
}), head);
return (0,external_wp_element_namespaceObject.createElement)(external_wp_element_namespaceObject.Fragment, null, tabIndex >= 0 && before, (0,external_wp_element_namespaceObject.createElement)("iframe", _extends({}, props, {
return (0,external_wp_element_namespaceObject.createElement)(external_wp_element_namespaceObject.Fragment, null, tabIndex >= 0 && before, (0,external_wp_element_namespaceObject.createElement)(__playground_ControlledIframe, _extends({}, props, {
ref: (0,external_wp_compose_namespaceObject.useMergeRefs)([ref, setRef]),
tabIndex: tabIndex // Correct doctype is required to enable rendering in standards mode
,
src:"/wp-includes/empty.html",
srcDoc: "<!doctype html>",
title: (0,external_wp_i18n_namespaceObject.__)('Editor canvas')
}), iframeDocument && (0,external_wp_element_namespaceObject.createPortal)((0,external_wp_element_namespaceObject.createElement)(external_wp_element_namespaceObject.Fragment, null, (0,external_wp_element_namespaceObject.createElement)("head", {
ref: headRef
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -1 +1 @@
<!doctype html>
<!doctype html><script>const hash = window.location.hash.substring(1); if ( hash ) document.write(decodeURIComponent(hash))</script>
Loading

0 comments on commit 6df1cef

Please sign in to comment.