Skip to content

Commit aade9a4

Browse files
Editor: Fix stale preview tab under Document-Isolation-Policy and re-enable client-side media e2e on Chromium 148+ (#79495)
Co-authored-by: adamsilverstein <adamsilverstein@git.wordpress.org> Co-authored-by: jsnajdr <jsnajdr@git.wordpress.org>
1 parent b5bba24 commit aade9a4

7 files changed

Lines changed: 229 additions & 48 deletions

File tree

‎packages/editor/src/components/post-preview-button/index.js‎

Lines changed: 68 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import { VisuallyHidden } from '@wordpress/ui';
1414
*/
1515
import { store as editorStore } from '../../store';
1616

17-
function writeInterstitialMessage( targetDocument ) {
17+
function buildInterstitialMarkup() {
1818
let markup = renderToString(
1919
<div className="editor-post-preview-button__interstitial-message">
2020
<SVG xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96 96">
@@ -95,11 +95,77 @@ function writeInterstitialMessage( targetDocument ) {
9595
*/
9696
markup = applyFilters( 'editor.PostPreview.interstitialMarkup', markup );
9797

98+
return markup;
99+
}
100+
101+
function writeInterstitialMessage( targetDocument, markup ) {
98102
targetDocument.write( markup );
99103
targetDocument.title = __( 'Generating preview…' );
100104
targetDocument.close();
101105
}
102106

107+
/**
108+
* Resolves the preview window's `document`, working around
109+
* `Document-Isolation-Policy` (DIP) isolation.
110+
*
111+
* The editor screen is served with `Document-Isolation-Policy:
112+
* isolate-and-credentialless` to enable cross-origin isolation. This places the
113+
* editor tab and an already-open preview tab in separate agent clusters, so
114+
* synchronous access to a reused preview tab's `document` throws a
115+
* `SecurityError`. Navigating the reused tab back to `about:blank` returns it to
116+
* the opener's agent cluster and restores access. That navigation is
117+
* asynchronous and we can't attach a cross-isolation `load` listener, so poll
118+
* the `document` access (the operation that throws) until it succeeds, up to a
119+
* short timeout.
120+
*
121+
* @param {Window} previewWindow The preview window/tab.
122+
*
123+
* @return {?Document} The reachable preview document, or `null` if it never
124+
* becomes reachable within the timeout.
125+
*/
126+
async function getPreviewDocument( previewWindow ) {
127+
// A freshly opened tab is already on `about:blank` and accessible, so this
128+
// succeeds on the first preview without any reset.
129+
try {
130+
return previewWindow.document;
131+
} catch {
132+
// The reused preview tab is isolated from the editor; reset it below.
133+
}
134+
135+
previewWindow.location = 'about:blank';
136+
137+
const timeoutMs = 1000;
138+
const intervalMs = 50;
139+
const deadline = Date.now() + timeoutMs;
140+
do {
141+
await new Promise( ( resolve ) => setTimeout( resolve, intervalMs ) );
142+
try {
143+
return previewWindow.document;
144+
} catch {
145+
// Navigation to `about:blank` hasn't completed yet; keep polling.
146+
}
147+
} while ( Date.now() < deadline );
148+
149+
return null;
150+
}
151+
152+
/**
153+
* Writes the preview interstitial into the preview window, working around
154+
* `Document-Isolation-Policy` (DIP) isolation.
155+
*
156+
* The interstitial is a progressive enhancement: if the document never becomes
157+
* reachable we simply skip it, and the caller still navigates the preview to the
158+
* real content.
159+
*
160+
* @param {Window} previewWindow The preview window/tab.
161+
*/
162+
async function writeInterstitialIntoPreviewWindow( previewWindow ) {
163+
const previewDocument = await getPreviewDocument( previewWindow );
164+
if ( previewDocument ) {
165+
writeInterstitialMessage( previewDocument, buildInterstitialMarkup() );
166+
}
167+
}
168+
103169
/**
104170
* Renders a button that opens a new window or tab for the preview,
105171
* writes the interstitial message to this window, and then navigates
@@ -168,7 +234,7 @@ export default function PostPreviewButton( {
168234
// https://html.spec.whatwg.org/multipage/interaction.html#dom-window-focus
169235
previewWindow.focus();
170236

171-
writeInterstitialMessage( previewWindow.document );
237+
await writeInterstitialIntoPreviewWindow( previewWindow );
172238

173239
const link = await __unstableSaveForPreview( { forceIsAutosaveable } );
174240

‎test/e2e/specs/editor/blocks/image.spec.js‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -648,7 +648,14 @@ test.describe( 'Image', () => {
648648
await expect( urlInput ).toHaveValue( 'https://example.com' );
649649
} );
650650

651-
test( 'should upload external image to media library', async ( {
651+
// TODO: Re-enable once client-side external-image upload lands. With CSM
652+
// active on Chromium 148+, "Upload to Media Library" routes the external
653+
// URL through the client-side pipeline, which does not yet finalize to a
654+
// /wp-content/uploads/ URL. Fixed by
655+
// https://github.com/WordPress/gutenberg/issues/79407; re-introduce the
656+
// CSM-aware coverage there.
657+
// eslint-disable-next-line playwright/no-skipped-test
658+
test.skip( 'should upload external image to media library', async ( {
652659
editor,
653660
} ) => {
654661
await editor.insertBlock( {

‎test/e2e/specs/editor/various/client-side-media-processing.spec.js‎

Lines changed: 82 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,24 @@
44
const path = require( 'path' );
55
const fs = require( 'fs/promises' );
66
const os = require( 'os' );
7+
const { createRequire } = require( 'node:module' );
8+
const { pathToFileURL } = require( 'node:url' );
79
const { v4: uuid } = require( 'uuid' );
810

11+
/**
12+
* Resolves the `wasm-vips` entry point from the `@wordpress/vips` package,
13+
* which declares it as a direct dependency. This works whether or not
14+
* `wasm-vips` hoists to the repository root `node_modules` (it does not in a
15+
* clean CI install), so the dynamic import below resolves reliably.
16+
*
17+
* @type {string}
18+
*/
19+
const wasmVipsEntry = pathToFileURL(
20+
createRequire( require.resolve( '@wordpress/vips/package.json' ) ).resolve(
21+
'wasm-vips'
22+
)
23+
).href;
24+
925
/**
1026
* WordPress dependencies
1127
*/
@@ -21,7 +37,7 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
2137
* @return {Promise<{ width: number, height: number, hasGainmap: boolean }>} Probe result.
2238
*/
2339
async function probeUltraHdrUrl( url ) {
24-
const { default: Vips } = await import( 'wasm-vips' );
40+
const { default: Vips } = await import( wasmVipsEntry );
2541
const vips = await Vips( {} );
2642
const response = await fetch( url );
2743
if ( ! response.ok ) {
@@ -126,6 +142,7 @@ class MediaProcessingUtils {
126142
typeof Worker !== 'undefined'
127143
);
128144
} );
145+
129146
testInstance.skip(
130147
! isActive,
131148
'Client-side media processing is not active in this environment'
@@ -448,7 +465,7 @@ test.describe( 'Client-side media processing', () => {
448465
await expect( snackbar ).toBeVisible( { timeout: 10_000 } );
449466
} );
450467

451-
test( 'converts an opaque PNG to JPEG when image_editor_output_format is filtered', async ( {
468+
test( 'converts opaque PNG sub-sizes to JPEG when image_editor_output_format is filtered', async ( {
452469
page,
453470
editor,
454471
mediaProcessingUtils,
@@ -470,9 +487,18 @@ test.describe( 'Client-side media processing', () => {
470487
'200x150_e2e_test_image_opaque.png'
471488
);
472489

473-
// With the filter active and no transparency, CSM transcodes
474-
// sub-sizes to JPEG and the server transcodes the main file.
475-
expect( media.mime_type ).toBe( 'image/jpeg' );
490+
// CSM uploads the original full-size file unchanged: the
491+
// image_editor_output_format filter only governs the generated
492+
// sub-sizes, matching core, which keeps the full-size attachment's
493+
// original MIME type. With no alpha channel, the sub-sizes are
494+
// transcoded to JPEG.
495+
expect( media.mime_type ).toBe( 'image/png' );
496+
expect( media.media_details.sizes.thumbnail.mime_type ).toBe(
497+
'image/jpeg'
498+
);
499+
expect( media.media_details.sizes.thumbnail.source_url ).toMatch(
500+
/\.jpe?g$/
501+
);
476502
} finally {
477503
await requestUtils.deactivatePlugin(
478504
'gutenberg-test-plugin-image-format-conversion-png-to-jpeg'
@@ -510,7 +536,7 @@ test.describe( 'Client-side media processing', () => {
510536
}
511537
} );
512538

513-
test( 'converts a JPEG to WebP when image_editor_output_format is filtered', async ( {
539+
test( 'converts JPEG sub-sizes to WebP when image_editor_output_format is filtered', async ( {
514540
page,
515541
editor,
516542
mediaProcessingUtils,
@@ -530,7 +556,16 @@ test.describe( 'Client-side media processing', () => {
530556
'1024x768_e2e_test_image_size.jpeg'
531557
);
532558

533-
expect( media.mime_type ).toBe( 'image/webp' );
559+
// As with PNG-to-JPEG, the filter governs only the generated
560+
// sub-sizes; the full-size attachment keeps its original JPEG
561+
// MIME type. The sub-sizes are transcoded to WebP.
562+
expect( media.mime_type ).toBe( 'image/jpeg' );
563+
expect( media.media_details.sizes.medium.mime_type ).toBe(
564+
'image/webp'
565+
);
566+
expect( media.media_details.sizes.medium.source_url ).toMatch(
567+
/\.webp$/
568+
);
534569
} finally {
535570
await requestUtils.deactivatePlugin(
536571
'gutenberg-test-plugin-image-format-conversion-jpeg-to-webp'
@@ -578,15 +613,26 @@ test.describe( 'Client-side media processing', () => {
578613
page.getByRole( 'button', { name: 'Publish', exact: true } )
579614
).toBeEnabled( { timeout: 30_000 } );
580615

581-
// Confirm the stored block URL was updated to the scaled file after
582-
// finalize. Without the fix, the block would keep the unscaled
583-
// original's URL and the assertion would fail.
616+
// Confirm the stored block URL was updated to a real uploaded file
617+
// after finalize. Without finalize, the block would keep the transient
618+
// blob URL (or the unscaled original) and srcset matching would fail.
619+
// The editor's default image size is `large`, so the block settles on
620+
// the large sub-size — a registered size that wp_calculate_image_srcset()
621+
// can match — rather than the -scaled full file; either satisfies the
622+
// srcset contract verified on the front end below.
584623
const blockUrl = await page.evaluate( () => {
585624
return window.wp.data
586625
.select( 'core/block-editor' )
587626
.getSelectedBlock()?.attributes?.url;
588627
} );
589-
expect( blockUrl ).toMatch( /-scaled\.jpe?g$/ );
628+
expect( blockUrl ).not.toMatch( /^blob:/ );
629+
expect( blockUrl ).toMatch( /\/wp-content\/uploads\/.+\.jpe?g$/ );
630+
631+
// Capture the attachment ID while the editor (and its data store) is
632+
// still loaded — it is read again after navigating to the front end,
633+
// where window.wp.data does not exist.
634+
const imageId = await mediaProcessingUtils.getSelectedBlockImageId();
635+
expect( imageId ).toBeDefined();
590636

591637
const postId = await editor.publishPost();
592638
await page.goto( `/?p=${ postId }` );
@@ -605,8 +651,6 @@ test.describe( 'Client-side media processing', () => {
605651
// candidates qualify.
606652
await expect( imageDom ).toHaveAttribute( 'srcset', /\d+w.*\d+w/s );
607653

608-
const imageId = await mediaProcessingUtils.getSelectedBlockImageId();
609-
expect( imageId ).toBeDefined();
610654
const media = await mediaProcessingUtils.getMediaDetails(
611655
requestUtils,
612656
imageId
@@ -616,23 +660,32 @@ test.describe( 'Client-side media processing', () => {
616660
expect( media.media_details.sizes.large ).toBeDefined();
617661
} );
618662

619-
test( 'auto-rotates images based on EXIF orientation', async ( {
620-
editor,
621-
mediaProcessingUtils,
622-
requestUtils,
623-
} ) => {
624-
// EXIF orientation=6 means a 90° clockwise rotation. The asset is
625-
// stored 1024x768 in pixels but should land 768x1024 after CSM
626-
// applies the EXIF-driven rotation.
627-
const media = await mediaProcessingUtils.uploadImageAndGetMedia(
628-
editor,
629-
requestUtils,
630-
'1024x768_e2e_test_image_rotated.jpeg'
631-
);
663+
// Known gap: the client-side pipeline does not bake EXIF orientation into
664+
// the full-size attachment. create_item disables `wp_image_maybe_exif_rotate`
665+
// "so the client can handle it", but the client only sideloads a rotated
666+
// copy as `original_image` — it never rotates the stored full-size file, so
667+
// `media_details.width/height` keep the pre-rotation pixel dimensions
668+
// (1024x768) instead of the expected 768x1024. (Server-reported
669+
// `exif_orientation` is also 1 here, so the client never even attempts the
670+
// rotation.) Whether CSM should bake-in rotation like core, or intentionally
671+
// preserve the EXIF tag, is a product decision for the feature owners.
672+
// Marked fixme so it runs again once that behavior is settled.
673+
test.fixme(
674+
'auto-rotates images based on EXIF orientation',
675+
async ( { editor, mediaProcessingUtils, requestUtils } ) => {
676+
// EXIF orientation=6 means a 90° clockwise rotation. The asset is
677+
// stored 1024x768 in pixels but should land 768x1024 after CSM
678+
// applies the EXIF-driven rotation.
679+
const media = await mediaProcessingUtils.uploadImageAndGetMedia(
680+
editor,
681+
requestUtils,
682+
'1024x768_e2e_test_image_rotated.jpeg'
683+
);
632684

633-
expect( media.media_details.width ).toBe( 768 );
634-
expect( media.media_details.height ).toBe( 1024 );
635-
} );
685+
expect( media.media_details.width ).toBe( 768 );
686+
expect( media.media_details.height ).toBe( 1024 );
687+
}
688+
);
636689

637690
test( 'recovers from a transient upload failure via automatic retry', async ( {
638691
page,

‎test/e2e/specs/preload/post-editor.spec.js‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,10 @@ const { test, expect } = require( '@wordpress/e2e-test-utils-playwright' );
66
/**
77
* Internal dependencies
88
*/
9-
const { recordRequests } = require( './record-requests' );
9+
const {
10+
recordRequests,
11+
waitForRequestsToSettle,
12+
} = require( './record-requests' );
1013

1114
test.describe( 'Preload', () => {
1215
let postId;
@@ -56,11 +59,10 @@ test.describe( 'Preload', () => {
5659
.filter( { hasText: 'Hello' } )
5760
.waitFor();
5861
// This spec is explicitly testing network behaviour, so waiting for
59-
// the network to settle (rather than a UI marker) is the right
62+
// the REST traffic to settle (rather than a UI marker) is the right
6063
// signal here: it ensures trailing startup fetches and the racy
6164
// resolver duplicates have all been observed before we assert.
62-
// eslint-disable-next-line playwright/no-networkidle
63-
await page.waitForLoadState( 'networkidle' );
65+
await waitForRequestsToSettle( requests );
6466
stop();
6567

6668
// Only collab side effects (CRDT persist + first wp-sync poll)

‎test/e2e/specs/preload/record-requests.js‎

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,49 @@ function recordRequests( page ) {
3939
};
4040
}
4141

42-
module.exports = { recordRequests };
42+
/**
43+
* Resolves once the recorded REST requests have gone quiet — no new entry has
44+
* been pushed to `requests` for `quietMs` — or after `maxMs` as a safety cap.
45+
*
46+
* These specs previously waited on `page.waitForLoadState( 'networkidle' )`,
47+
* but `networkidle` never settles once client-side media processing is active
48+
* (cross-origin isolation via `Document-Isolation-Policy`, which on Chromium
49+
* 148+ is established for the editor). CSM eagerly spins up the `@wordpress/vips`
50+
* Web Worker — a module worker loaded from a `blob:` URL — and Chromium keeps
51+
* that worker's `script` request in-flight for the worker's lifetime, so the
52+
* page is never network-idle. That worker is not a `fetch` request, so it never
53+
* enters `requests`; the REST traffic these specs assert on does settle. Wait
54+
* for that fetch traffic to go quiet instead, which is the signal the
55+
* assertions actually depend on and works in every isolation mode.
56+
*
57+
* The quiet window comfortably exceeds the sub-second startup request burst yet
58+
* stays under the multi-second collaboration polling interval, so it lands in
59+
* the gap after startup without waiting for (or being reset by) a poll.
60+
*
61+
* @param {string[]} requests Live array from `recordRequests`.
62+
* @param {Object} [options]
63+
* @param {number} [options.quietMs] Required idle window, in milliseconds.
64+
* @param {number} [options.maxMs] Overall cap, in milliseconds.
65+
* @return {Promise<void>} Resolves when the requests have settled.
66+
*/
67+
async function waitForRequestsToSettle(
68+
requests,
69+
{ quietMs = 1000, maxMs = 15000 } = {}
70+
) {
71+
const deadline = Date.now() + maxMs;
72+
let lastCount = requests.length;
73+
let quietSince = Date.now();
74+
75+
while ( Date.now() < deadline ) {
76+
await new Promise( ( resolve ) => setTimeout( resolve, 100 ) );
77+
78+
if ( requests.length !== lastCount ) {
79+
lastCount = requests.length;
80+
quietSince = Date.now();
81+
} else if ( Date.now() - quietSince >= quietMs ) {
82+
return;
83+
}
84+
}
85+
}
86+
87+
module.exports = { recordRequests, waitForRequestsToSettle };

0 commit comments

Comments
 (0)