PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3-a.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3-a.3
16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 All 506 releases
jetpack / modules / subscriptions / email-design-editor / src / index.jsx

index.jsx in Jetpack – WP Security, Backup, Speed, & Growth 16.3-a.3, at modules/subscriptions/email-design-editor/src/index.jsx

639 lines 24.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 /**
2 * Client-side entry point for the newsletter email design screen.
3 *
4 * The editor's bootstrap bundle lives on the WordPress.com shadow blog and is
5 * fetched from the browser rather than inlined, so the screen paints without a
6 * blocking proxy request on Atomic and self-hosted. It also carries records the
7 * editor would otherwise fetch — see `buildPreloadMap()`.
8 *
9 * The page that renders the container and localises
10 * `window.JetpackEmailDesignEditor` lands separately; until then nothing enqueues
11 * this bundle and the mount below returns. See NL-848 and NL-851.
12 */
13 import { ExperimentalEmailEditor } from '@woocommerce/email-editor';
14 import apiFetch from '@wordpress/api-fetch';
15 import { useBlockProps } from '@wordpress/block-editor';
16 import { getBlockType, registerBlockType } from '@wordpress/blocks';
17 import { Disabled, Notice } from '@wordpress/components';
18 import { store as coreStore } from '@wordpress/core-data';
19 import { dispatch, select } from '@wordpress/data';
20 import { createRoot, RawHTML, StrictMode } from '@wordpress/element';
21 import { __ } from '@wordpress/i18n';
22 import { store as noticesStore } from '@wordpress/notices';
23 import { addQueryArgs } from '@wordpress/url';
24
25 // Declared by the Jetpack plugin on every platform, answered by WordPress.com, so
26 // the browser calls one local URL everywhere.
27 const BOOTSTRAP_PATH = '/wpcom/v2/email-editor-bootstrap';
28
29 // The design is a block template. Unlike the template's id, this is the same
30 // everywhere, so the page does not supply it.
31 const TEMPLATE_POST_TYPE = 'wp_template';
32
33 // The editor assigns these straight to `window.location.href`, so `javascript:`
34 // and `data:` would execute rather than navigate.
35 const NAVIGABLE_PROTOCOLS = [ 'http:', 'https:' ];
36
37 // Addressed by name rather than by importing the store descriptor: importing it pulls
38 // `@wordpress/block-editor`'s own store module, which resolves core's private APIs at load time.
39 const BLOCK_EDITOR_STORE = 'core/block-editor';
40
41 // A save arrives as any of these, depending on whether core-data creates or updates.
42 const WRITE_METHODS = [ 'POST', 'PUT', 'PATCH' ];
43
44 /**
45 * Check that a URL the editor will navigate to is one the browser can navigate to.
46 *
47 * Reads the parsed protocol rather than testing the string: relative resolution
48 * does not neutralise a scheme, and the parser strips leading whitespace that a
49 * `startsWith( 'javascript:' )` test would miss.
50 *
51 * @param {*} value - The configured URL.
52 * @param {string} key - Its key, named in the error so the page can be fixed.
53 * @throws {Error} If the value is not a URL the browser can navigate to.
54 * @return {void}
55 */
56 function assertNavigableUrl( value, key ) {
57 if ( typeof value !== 'string' ) {
58 throw new Error( `JetpackEmailDesignEditor.urls.${ key } must be a string.` );
59 }
60
61 let resolved;
62
63 try {
64 resolved = new URL( value, window.location.href );
65 } catch {
66 throw new Error( `JetpackEmailDesignEditor.urls.${ key } is not a valid URL.` );
67 }
68
69 if ( ! NAVIGABLE_PROTOCOLS.includes( resolved.protocol ) ) {
70 throw new Error( `JetpackEmailDesignEditor.urls.${ key } must be an http or https URL.` );
71 }
72 }
73
74 /**
75 * Translate the page's data and the fetched bundle into the editor's configuration.
76 *
77 * Not a pass-through: the bundle is snake_cased (`editor_settings`, `editor_theme`)
78 * while the package's store reads `editorSettings`, `theme`, `urls`, `userEmail`
79 * and `globalStylesPostId`. Passing it unmapped boots the editor with no settings
80 * and no theme, and reports nothing.
81 *
82 * `editorSettings` merges both halves — WordPress.com strips the two settings that
83 * describe the installation rather than the design, and the page supplies this
84 * site's own — with the page's half last so it wins.
85 *
86 * @param {object} bundle - The response from the bootstrap route.
87 * @param {object} data - The value of `window.JetpackEmailDesignEditor`.
88 * @throws {Error} If either half left out something the editor cannot start without.
89 * @return {object} The editor's `config` prop.
90 */
91 export function buildEditorConfig( bundle, data ) {
92 const { editorSettings, urls, userEmail, globalStylesPostId } = data;
93
94 // Nothing validates these on the `config` prop path, so an omission would
95 // otherwise surface as an unrelated failure deep in the editor.
96 if ( ! bundle?.editor_settings ) {
97 throw new Error( 'The email editor bundle is missing editor_settings.' );
98 }
99
100 if ( ! bundle?.editor_theme ) {
101 throw new Error( 'The email editor bundle is missing editor_theme.' );
102 }
103
104 if ( typeof urls?.back !== 'string' || typeof urls?.listings !== 'string' ) {
105 throw new Error( 'JetpackEmailDesignEditor.urls.back and .listings are required strings.' );
106 }
107
108 // These all end up assigned to `window.location.href` by the editor's header
109 // buttons.
110 Object.entries( urls ).forEach( ( [ key, value ] ) => assertNavigableUrl( value, key ) );
111
112 return {
113 // Forced last so neither half can turn it off. The package renders core's `FullscreenMode`
114 // on this, which hides the admin menu — without it a full-viewport editor sits beside a menu
115 // whose flyouts open over the canvas, and no z-index satisfies both. Forced rather than the
116 // `fullscreenMode` preference so it is not a per-user toggle; it also brings the back button.
117 editorSettings: withCanvasStyles(
118 {
119 ...bundle.editor_settings,
120 ...editorSettings,
121 isFullScreenForced: true,
122 },
123 bundle
124 ),
125 theme: bundle.editor_theme,
126 urls,
127 userEmail,
128
129 // Dereferenced, not merely a flag: null makes the package generate no canvas CSS at all,
130 // and any valid id has its record fetched — which the preload answers. The record's
131 // `styles` and `settings` are merged last over `editor_theme`, which is what paints the
132 // canvas while editing. Bundle first because it is a WordPress.com post id; the page stays
133 // a fallback. See NL-871.
134 globalStylesPostId: getGlobalStylesPostId( bundle ) ?? globalStylesPostId ?? null,
135 };
136 }
137
138 /**
139 * Add the canvas rules that depend on what the bundle reported, keeping the ones it sent.
140 *
141 * Appended to `styles` rather than replacing it: WordPress.com ships the email's own stylesheet
142 * through that same array, and the package reads the whole of it.
143 *
144 * The one rule so far hides the featured image. `core/post-featured-image` is a core block, so
145 * WordPress.com deliberately leaves it out of `blocks` — sending core definitions would replace
146 * this site's working implementations with placeholders — and no `preview_html` can reach it.
147 * Only WordPress.com knows whether a send carries the image, so the bundle reports that instead.
148 *
149 * @param {object} settings - The editor settings assembled so far.
150 * @param {object} bundle - The response from the bootstrap route.
151 * @return {object} The settings, with any extra canvas rules appended.
152 */
153 function withCanvasStyles( settings, bundle ) {
154 // Strictly false. A bundle from before WordPress.com reported this omits the key, and hiding
155 // the image on that reading would be a regression on every blog that does send one.
156 if ( false !== bundle?.shows_featured_image ) {
157 return settings;
158 }
159
160 return {
161 ...settings,
162 styles: [
163 ...( Array.isArray( settings.styles ) ? settings.styles : [] ),
164 { css: '.wp-block-post-featured-image { display: none; }' },
165 ],
166 };
167 }
168
169 /**
170 * Everything the editor would otherwise fetch, keyed by the path it asks for.
171 *
172 * The canvas templates and the global-styles record are registered only while
173 * WordPress.com builds the bootstrap bundle, so a request from the browser cannot
174 * reach them and the editor waits forever on a record it will never get.
175 *
176 * Registering them for every REST request would fix that, but would also list the
177 * email templates in the Site Editor — a visible regression on every enrolled blog.
178 * Preloading confines them to this page.
179 *
180 * The records come from the bundle rather than being assembled here: the editor
181 * reads fields a four-field summary cannot stand in for, including `post_types`
182 * with no optional chaining.
183 *
184 * @param {object} bundle - The response from the bootstrap route.
185 * @param {string} templateId - The id of the template the editor opens.
186 * @return {object|null} A map for `createPreloadingMiddleware`, or null when the bundle
187 * carries nothing to preload.
188 */
189 export function buildPreloadMap( bundle, templateId ) {
190 const map = {
191 ...templatePreloads( bundle, templateId ),
192 ...globalStylesPreloads( bundle ),
193 };
194
195 return Object.keys( map ).length > 0 ? map : null;
196 }
197
198 /**
199 * The id of the global-styles record the bundle points at, or null when it sent no usable one.
200 *
201 * Validated because it is interpolated into the preload's path keys, which are
202 * deliberately exact — an id carrying a slash or query string would widen what we
203 * answer for, and the site's own global-styles record has to keep reaching the
204 * network untouched.
205 *
206 * @param {object} bundle - The response from the bootstrap route.
207 * @return {number|null} The record's id, or null.
208 */
209 function getGlobalStylesPostId( bundle ) {
210 const id = bundle?.global_styles?.post_id;
211
212 return Number.isInteger( id ) && id > 0 ? id : null;
213 }
214
215 /**
216 * The global-styles record the editor reads its design from.
217 *
218 * The `GET` and the `OPTIONS` both matter, and both carry `Allow`: core-data derives the
219 * record's permissions from either response, last one winning.
220 *
221 * The body has to be the record WordPress.com sent, not a placeholder — the canvas
222 * takes its colours from these contents.
223 *
224 * `can_edit` decides whether the Styles panel exists at all. Without update permission the
225 * package's sidebar returns nothing; it does not render a read-only panel.
226 *
227 * Only this exact id, never a pattern — the editor loads the site's own global-styles
228 * record alongside ours, and that one must keep reaching the network.
229 *
230 * @param {object} bundle - The response from the bootstrap route.
231 * @return {object} Preload entries, empty when the bundle carries no global styles.
232 */
233 /**
234 * A theme.json half as an object, whatever shape it arrived in.
235 *
236 * Off Simple the bootstrap is proxied through `json_decode( …, true )`, so an empty `{}` comes
237 * back as `[]`. The editor writes edits onto whatever it finds, and a property set on an array is
238 * dropped by `JSON.stringify` — the swatch flashes and nothing persists. See NL-871.
239 *
240 * @param {*} value - `styles` or `settings` as it arrived.
241 * @return {object} The value when it is a usable object, an empty object otherwise.
242 */
243 function objectOrEmpty( value ) {
244 return value && 'object' === typeof value && ! Array.isArray( value ) ? value : {};
245 }
246
247 function globalStylesPreloads( bundle ) {
248 const globalStyles = bundle?.global_styles;
249 const id = getGlobalStylesPostId( bundle );
250
251 if ( ! id || ! globalStyles?.record ) {
252 return {};
253 }
254
255 // A preloaded GET carries permissions as well as data: core-data reads `Allow` off the record's
256 // own response too, and reads a missing header as "nothing is permitted" rather than as silence.
257 // The GETs resolve after the OPTIONS, so omitting it here overwrites the OPTIONS answer with a
258 // flat no and the Styles panel never renders.
259 const allow = globalStyles.can_edit ? 'GET, POST, PUT' : 'GET';
260 const record = {
261 body: {
262 ...globalStyles.record,
263 styles: objectOrEmpty( globalStyles.record.styles ),
264 settings: objectOrEmpty( globalStyles.record.settings ),
265 },
266 headers: { Allow: allow },
267 };
268
269 return {
270 [ `/wp/v2/global-styles/${ id }` ]: record,
271 [ `/wp/v2/global-styles/${ id }?context=view` ]: record,
272 [ `/wp/v2/global-styles/${ id }?context=edit` ]: record,
273
274 // OPTIONS responses live under their own top-level key in the preload format.
275 OPTIONS: {
276 [ `/wp/v2/global-styles/${ id }` ]: { body: {}, headers: { Allow: allow } },
277 },
278 };
279 }
280
281 /**
282 * The template records the editor resolves its canvas from.
283 *
284 * @param {object} bundle - The response from the bootstrap route.
285 * @param {string} templateId - The id of the template the editor opens.
286 * @return {object} Preload entries, empty when the bundle carries no template records.
287 */
288 function templatePreloads( bundle, templateId ) {
289 const templates = bundle?.templates;
290
291 if ( ! Array.isArray( templates ) || 0 === templates.length ) {
292 return {};
293 }
294
295 // `parse: false` callers build a Response from these and read `headers`
296 // unconditionally, so every entry carries one even when empty.
297 const collection = {
298 body: templates,
299 headers: {
300 'X-WP-Total': String( templates.length ),
301 'X-WP-TotalPages': '1',
302 },
303 };
304
305 // The context asked for varies by platform (`?context=edit` on WordPress 7.1, none
306 // on WordPress.com). An unmatched key costs nothing; a miss leaves the editor
307 // waiting forever.
308 const map = {
309 '/wp/v2/templates': collection,
310 '/wp/v2/templates?context=edit': collection,
311 '/wp/v2/templates?context=view': collection,
312 };
313
314 const item = templates.find( template => template?.id === templateId );
315
316 if ( item ) {
317 // Explicitly read-only, for the reason above: the header is an assertion, not decoration.
318 // Nothing on this screen edits the template, and granting writes here would hand the
319 // editor a template it believes it may save.
320 const record = { body: item, headers: { Allow: 'GET' } };
321
322 map[ `/wp/v2/templates/${ templateId }` ] = record;
323 map[ `/wp/v2/templates/${ templateId }?context=edit` ] = record;
324 }
325
326 return map;
327 }
328
329 /**
330 * The id of the template the editor opens.
331 *
332 * Read from the bundle, never derived: the package builds it from the stylesheet of
333 * whichever installation registered the template, so computing it locally is right on
334 * Simple and wrong on Atomic and self-hosted.
335 *
336 * @param {object} bundle - The response from the bootstrap route.
337 * @throws {Error} If the bundle carries no template.
338 * @return {string} The template's id.
339 */
340 export function getTemplateId( bundle ) {
341 const id = bundle?.template?.id;
342
343 if ( typeof id !== 'string' || '' === id ) {
344 throw new Error( 'The email editor bundle is missing its template id.' );
345 }
346
347 return id;
348 }
349
350 /**
351 * Register the email blocks the bootstrap describes and no client defines.
352 *
353 * They are registered in PHP only, on every host including Simple, so without this the editor
354 * reports each one as an unsupported block. WordPress.com allowlists the namespaces it owns, but
355 * a site is still free to have registered one itself, so anything already registered is left
356 * alone rather than replaced by a placeholder.
357 *
358 * Dynamic blocks with no client-side edit. Where WordPress.com sent the block's rendered structure
359 * the canvas shows that; otherwise it falls back to a labelled placeholder. What the subscriber
360 * receives is rendered server-side and is unaffected either way.
361 *
362 * @param {object} bundle - The response from the bootstrap route.
363 * @return {void}
364 */
365 export function registerEmailBlocks( bundle ) {
366 const blocks = Array.isArray( bundle?.blocks ) ? bundle.blocks : [];
367
368 blocks.forEach( block => {
369 if ( ! block?.name || getBlockType( block.name ) ) {
370 return;
371 }
372
373 // Falls back to the slug on anything that is not a usable string. A non-string title is
374 // not just unlabelled: it reaches the placeholder as a React child, and an object there
375 // throws rather than rendering.
376 const title = typeof block.title === 'string' && block.title ? block.title : block.name;
377
378 // Server-rendered markup, trusted on the same footing as the rest of the bundle: it reaches
379 // us from WordPress.com over the route that already supplies `editor_settings`.
380 const preview =
381 typeof block.preview_html === 'string' && block.preview_html ? block.preview_html : '';
382
383 // Named and capitalised so it reads as a component: `useBlockProps` is a hook, and an
384 // anonymous arrow here trips rules-of-hooks.
385 const EmailBlockEdit = () => (
386 <div { ...useBlockProps() }>
387 { preview ? (
388 // `inert`, so clicking the email's own links selects the block rather than
389 // navigating, and tabbing skips them. The outline stays on the wrapper above.
390 <Disabled>
391 <RawHTML>{ preview }</RawHTML>
392 </Disabled>
393 ) : (
394 title
395 ) }
396 </div>
397 );
398
399 registerBlockType( block.name, {
400 apiVersion: 3,
401 title,
402 description: block.description || '',
403 category: block.category || 'design',
404 attributes: block.attributes || {},
405
406 // Template furniture rather than blocks a creator adds by hand.
407 supports: { ...( block.supports || {} ), html: false, inserter: false },
408
409 edit: EmailBlockEdit,
410 save: () => null,
411 } );
412 } );
413 }
414
415 /**
416 * Take block editing away from the canvas, leaving the Styles panel as the only control.
417 *
418 * The template's core blocks arrive with their own inspector controls — a background on the
419 * "Email Content" group, say — and nothing on this screen saves template edits, so every one of
420 * them is offered and then silently does nothing. `templateLock` does not help: it stops blocks
421 * being moved or removed and leaves the settings panel exactly where it was.
422 *
423 * Set on the root, which the store's derived modes propagate down to every descendant, so this
424 * covers blocks the bundle never describes. Marked not-persistent because a mode is a view
425 * setting: without it the editor opens holding an undo step and believing it has changes to save.
426 *
427 * @return {void}
428 */
429 export function lockCanvasEditing() {
430 // `dispatch()` answers null for a store the registry does not hold. Controls that do nothing are
431 // a worse screen; no screen at all is worse still, so this declines rather than throwing into
432 // the mount's catch.
433 const blockEditor = dispatch( BLOCK_EDITOR_STORE );
434
435 if ( ! blockEditor ) {
436 return;
437 }
438
439 blockEditor.__unstableMarkNextChangeAsNotPersistent();
440 blockEditor.setBlockEditingMode( '', 'disabled' );
441 }
442
443 /**
444 * Catch the Styles panel's save and send it to WordPress.com instead.
445 *
446 * The editor writes a core-data `globalStyles` entity, but the design is stored in a WordPress.com
447 * blog option rather than a post, so the write has to be re-addressed to the bootstrap route.
448 *
449 * Matched on this one record's exact path and nothing else. The editor also holds the *site's* own
450 * global-styles record, at edit context, so anything broader would push the site's design through
451 * the email endpoint — and would look correct while doing it on Simple, where the site and the
452 * shadow blog are the same database.
453 *
454 * @param {number} id - The global-styles id the bundle named.
455 * @return {Function} An `apiFetch` middleware.
456 */
457 export function createDesignSaveMiddleware( id ) {
458 const target = `/wp/v2/global-styles/${ id }`;
459
460 return async ( options, next ) => {
461 const path = 'string' === typeof options.path ? options.path.split( '?' )[ 0 ] : '';
462 const method = ( options.method || 'GET' ).toUpperCase();
463
464 if ( target !== path || ! WRITE_METHODS.includes( method ) ) {
465 return next( options );
466 }
467
468 // Only the theme.json halves: core-data hands over its whole record, and its `id` is the
469 // sentinel that stands in for a post that does not exist. Sanitizing drops it either way,
470 // but sending it makes every save look like it lost a property to anything comparing what
471 // was sent against what was stored. `version` and `isGlobalStylesUserThemeJSON` are the
472 // store's to set, so they are not ours to send.
473 // core-data drops unchanged keys from its edits, so `options.data` routinely carries one half
474 // — a styles-only save is the ordinary case here, not an anomaly. The store replaces the
475 // whole document rather than merging, so sending that half alone would destroy the other.
476 // The editor's own view — persisted record plus pending edits — is what the creator means.
477 const edited = select( coreStore ).getEditedEntityRecord( 'root', 'globalStyles', id );
478
479 if ( ! edited ) {
480 throw new Error( 'Email design save found no global styles record to read.' );
481 }
482
483 const submitted = { styles: edited.styles ?? {}, settings: edited.settings ?? {} };
484
485 const saved = await apiFetch( {
486 path: BOOTSTRAP_PATH,
487 method: 'POST',
488 data: { design: submitted },
489 } );
490
491 // The route answers with an envelope — `{ blog_id, design, discarded }` — around a read-back
492 // of what was stored, since sanitizing drops anything outside the theme.json schema. Unwrap
493 // it: core-data takes what comes back as the record itself, and the canvas is drawn by
494 // merging that record's `styles` and `settings` over the theme, so handing back the envelope
495 // leaves both undefined and the canvas snaps to its pre-edit design.
496 const design = saved?.design ?? {};
497
498 // `discarded` means the save succeeded and kept none of it: sanitizing drops whatever falls
499 // outside the theme.json schema. Without saying so, the panel goes clean and the creator is
500 // told their edit was saved when the stored design no longer contains it.
501 if ( saved?.discarded ) {
502 dispatch( noticesStore ).createNotice(
503 'error',
504 __( 'Those changes could not be saved to your email design.', 'jetpack' ),
505 { type: 'snackbar', isDismissible: true }
506 );
507 }
508
509 return {
510 id,
511 settings: objectOrEmpty( design.settings ),
512 styles: objectOrEmpty( design.styles ),
513 };
514 };
515 }
516
517 /**
518 * Tell the creator when a design saved here would never reach anyone.
519 *
520 * `renders_through_email_editor` is the blog's state, not the reader's — independent of
521 * `can_edit`, which asks whether *this person* may edit. Only `false` warns: `null` means
522 * WordPress.com could not determine it during a deploy window, and warning a creator whose blog is
523 * fine is a false alarm they cannot act on. Pinned, not a snackbar — it is a standing condition.
524 * See NL-864.
525 *
526 * @param {object} bundle - The response from the bootstrap route.
527 * @return {void}
528 */
529 export function reportInactiveEmailDesign( bundle ) {
530 if ( false !== bundle?.renders_through_email_editor ) {
531 return;
532 }
533
534 dispatch( noticesStore ).createNotice(
535 'warning',
536 __(
537 'Email design is not active on this site, so changes saved here will not affect the emails your subscribers receive.',
538 'jetpack'
539 ),
540 // The editor's pinned notice list reads this context and this type; the default context
541 // only reaches its snackbars.
542 { context: 'email-editor', type: 'default', isDismissible: false }
543 );
544 }
545
546 /**
547 * What the screen shows when it could not load.
548 *
549 * The design lives on another site, so without this "nothing appeared" and "your
550 * design is empty" look identical to whoever opened the page.
551 *
552 * @return {import('react').ReactElement} The error notice.
553 */
554 function LoadError() {
555 return (
556 <Notice status="error" isDismissible={ false }>
557 { __(
558 'The email design editor could not be loaded. Please reload the page to try again.',
559 'jetpack'
560 ) }
561 </Notice>
562 );
563 }
564
565 /**
566 * Fetch the bootstrap bundle and mount the editor into the page's container.
567 *
568 * @return {Promise<void>} Resolves once the editor or an error has rendered.
569 */
570 export async function mountEmailDesignEditor() {
571 const data = window.JetpackEmailDesignEditor;
572
573 // Not our page — the bundle is only enqueued on the design screen.
574 if ( ! data || typeof data !== 'object' ) {
575 return;
576 }
577
578 const container = document.getElementById( data.elementId );
579
580 if ( ! container ) {
581 return;
582 }
583
584 const root = createRoot( container );
585
586 try {
587 const bundle = await apiFetch( {
588 path: data.templateSlug
589 ? addQueryArgs( BOOTSTRAP_PATH, { template_slug: data.templateSlug } )
590 : BOOTSTRAP_PATH,
591 } );
592
593 const config = buildEditorConfig( bundle, data );
594
595 // Before the render, not after: the template is parsed on first render and its blocks are
596 // resolved against the registry at that moment. Registering later leaves the same
597 // unsupported-block errors, which looks identical to this never running.
598 registerEmailBlocks( bundle );
599 lockCanvasEditing();
600 reportInactiveEmailDesign( bundle );
601
602 const postId = getTemplateId( bundle );
603 const preload = buildPreloadMap( bundle, postId );
604
605 if ( config.globalStylesPostId ) {
606 apiFetch.use( createDesignSaveMiddleware( config.globalStylesPostId ) );
607 }
608
609 if ( preload ) {
610 // Registered last so it runs first: api-fetch applies middlewares right to
611 // left, so this sees `options.path` before the rewriting middlewares. Must be
612 // installed before the editor mounts, which resolves the template on first
613 // render.
614 apiFetch.use( apiFetch.createPreloadingMiddleware( preload ) );
615 }
616
617 root.render(
618 <StrictMode>
619 <ExperimentalEmailEditor
620 postId={ postId }
621 postType={ TEMPLATE_POST_TYPE }
622 config={ config }
623 />
624 </StrictMode>
625 );
626 } catch ( error ) {
627 // The notice deliberately does not name which half failed; this does.
628 // eslint-disable-next-line no-console
629 console.error( 'Jetpack email design editor:', error );
630 root.render( <LoadError /> );
631 }
632 }
633
634 if ( document.readyState === 'loading' ) {
635 document.addEventListener( 'DOMContentLoaded', mountEmailDesignEditor, { once: true } );
636 } else {
637 mountEmailDesignEditor();
638 }
639