Commit 10d3101eff0 for woocommerce

commit 10d3101eff00b0aa70c7e4abcc47098052bcd9e3
Author: Rostislav Wolný <1082140+costasovo@users.noreply.github.com>
Date:   Fri Sep 11 09:10:34 2026 +0200

    Fix the Settings > Emails listing styles by loading the listing on demand (#68498)

    * Load the emails listing on demand and restore its DataViews styles

    PR #67865 removed the DataViews stylesheet import from settings-embed as a
    duplicate of the one in @woocommerce/settings-ui. That stylesheet only
    loads when the new Settings UI runtime resolves, so the Settings > Emails
    listing, which renders on a classic settings tab, lost all of its table
    styles.

    Rather than putting the stylesheet back into the shared bundle, the fill
    now renders its list view through lazy() behind a placeholder that matches
    the server-rendered one. Webpack splits the list view and the DataViews
    `/wp` runtime, which inlines its own copies of the WordPress packages, into
    chunks that load only where the fill renders: the Emails tab with the block
    email editor enabled. Every other settings tab stops shipping about 2 MB of
    code it never ran, and the Settings UI pages no longer receive the older
    DataViews CSS next to their own.

    The stylesheet is a separate chunk picked by text direction, because the
    chunk runtime bypasses the RTL swap WordPress applies to registered styles.
    The DataViews package ships both builds, so nothing is requested that may
    not exist. A failed chunk request shows a notice instead of unmounting the
    whole slot, and the data hook imports the DataViews View type as a type so
    the runtime stays out of the entry.

diff --git a/plugins/woocommerce/changelog/fix-email-listing-dataviews-styles b/plugins/woocommerce/changelog/fix-email-listing-dataviews-styles
new file mode 100644
index 00000000000..922737f8f1e
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-email-listing-dataviews-styles
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Restore the table styles on the Settings > Emails listing.
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill-load-error.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill-load-error.test.tsx
new file mode 100644
index 00000000000..32e8800b623
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill-load-error.test.tsx
@@ -0,0 +1,91 @@
+/**
+ * The list view loads as its own chunk. When that request fails the fill must
+ * keep the rest of the slot (description, "Edit template" button) and show a
+ * notice instead of unmounting everything.
+ */
+
+/**
+ * External dependencies
+ */
+import { render, screen } from '@testing-library/react';
+
+/**
+ * Internal dependencies
+ */
+import {
+	EmailListingFill,
+	type EmailType,
+} from '../settings-email-listing-slotfill';
+
+jest.mock( '@woocommerce/tracks', () => ( {
+	recordEvent: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/components', () => ( {
+	createSlotFill: () => ( {
+		Fill: ( { children }: { children: React.ReactNode } ) => (
+			<div>{ children }</div>
+		),
+	} ),
+	Button: ( { children }: { children: React.ReactNode } ) => (
+		<button>{ children }</button>
+	),
+	Notice: ( { children }: { children: React.ReactNode } ) => (
+		<div role="alert">{ children }</div>
+	),
+} ) );
+
+// Stands in for a chunk request that fails: the dynamic import rejects.
+jest.mock( '../settings-email-listing-listview', () => {
+	throw new Error( 'Loading chunk failed' );
+} );
+
+jest.mock( '../settings-email-listing-data', () => ( {
+	recreateEmailPostRequest: jest.fn(),
+} ) );
+
+jest.mock( '@wordpress/data', () => ( {
+	...jest.requireActual( '@wordpress/data' ),
+	dispatch: () => ( { createErrorNotice: jest.fn() } ),
+} ) );
+
+jest.mock( '@woocommerce/settings', () => ( {
+	getAdminLink: ( path: string ) => `https://example.com/wp-admin/${ path }`,
+} ) );
+
+const email: EmailType = {
+	id: 'new-order',
+	post_id: '123',
+	file_template_preview_url: null,
+	title: 'New order',
+	description: '',
+	enabled: true,
+	manual: false,
+	email_key: 'new_order',
+	email_class_name: 'WC_Email_New_Order',
+	recipients: { to: '', cc: '', bcc: '' },
+	status: 'enabled',
+	templateStatus: null,
+	templateVersion: null,
+	currentVersion: null,
+	wasBackfilled: false,
+};
+
+describe( 'EmailListingFill when the list view chunk fails to load', () => {
+	it( 'shows a notice and keeps the rest of the fill', async () => {
+		render(
+			<EmailListingFill
+				emailTypes={ [ email ] }
+				editTemplateUrl={ null }
+				emailTemplateId={ null }
+			/>
+		);
+
+		expect(
+			await screen.findByText( /email list could not be loaded/i )
+		).toBeInTheDocument();
+		expect(
+			screen.getByText( /Manage email notifications/ )
+		).toBeInTheDocument();
+	} );
+} );
diff --git a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx
index f17553335b4..d9ec660a397 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/__tests__/settings-email-listing-slotfill.test.tsx
@@ -110,7 +110,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 		window.sessionStorage.clear();
 	} );

-	it( 'fires one block_email_list_viewed on mount with eligible_count and total_count', () => {
+	it( 'fires one block_email_list_viewed on mount with eligible_count and total_count', async () => {
 		render(
 			<EmailListingFill
 				emailTypes={ [ baseEmail, eligibleEmail ] }
@@ -118,6 +118,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 				emailTemplateId={ null }
 			/>
 		);
+		await screen.findByTestId( 'listview' );

 		expect( recordEventMock ).toHaveBeenCalledTimes( 1 );
 		expect( recordEventMock ).toHaveBeenCalledWith(
@@ -130,7 +131,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 		);
 	} );

-	it( 'dedups within a session: a second mount in the same tab does not refire', () => {
+	it( 'dedups within a session: a second mount in the same tab does not refire', async () => {
 		const { unmount } = render(
 			<EmailListingFill
 				emailTypes={ [ eligibleEmail ] }
@@ -138,6 +139,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 				emailTemplateId={ null }
 			/>
 		);
+		await screen.findByTestId( 'listview' );
 		unmount();
 		render(
 			<EmailListingFill
@@ -146,11 +148,12 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 				emailTemplateId={ null }
 			/>
 		);
+		await screen.findByTestId( 'listview' );

 		expect( recordEventMock ).toHaveBeenCalledTimes( 1 );
 	} );

-	it( 'still fires when sessionStorage is unavailable (privacy-mode fallback)', () => {
+	it( 'still fires when sessionStorage is unavailable (privacy-mode fallback)', async () => {
 		const setItemSpy = jest
 			.spyOn( window.sessionStorage.__proto__, 'setItem' )
 			.mockImplementation( () => {
@@ -165,6 +168,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 					emailTemplateId={ null }
 				/>
 			);
+			await screen.findByTestId( 'listview' );

 			expect( recordEventMock ).toHaveBeenCalledTimes( 1 );
 		} finally {
@@ -172,7 +176,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 		}
 	} );

-	it( 'reports eligible_count=0 when no rows are eligible', () => {
+	it( 'reports eligible_count=0 when no rows are eligible', async () => {
 		render(
 			<EmailListingFill
 				emailTypes={ [ baseEmail, baseEmail ] }
@@ -180,6 +184,7 @@ describe( 'EmailListingFill — list-page Tracks instrumentation', () => {
 				emailTemplateId={ null }
 			/>
 		);
+		await screen.findByTestId( 'listview' );

 		expect( recordEventMock ).toHaveBeenCalledWith(
 			'block_email_list_viewed',
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
index a6af737692a..7979d2e3774 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-data.ts
@@ -7,7 +7,7 @@ import { settingsStore } from '@woocommerce/data';
 import { useState, useCallback, useMemo } from '@wordpress/element';
 import apiFetch from '@wordpress/api-fetch';
 // @ts-expect-error - We need to use this /wp see https://developer.wordpress.org/block-editor/reference-guides/packages/packages-dataviews/#dataviews
-import { View } from '@wordpress/dataviews/wp';
+import type { View } from '@wordpress/dataviews/wp';

 /**
  * Internal dependencies
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-rtl.scss b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-rtl.scss
new file mode 100644
index 00000000000..ace4bb68e20
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-rtl.scss
@@ -0,0 +1,4 @@
+// Right-to-left twin of settings-email-listing.scss. The fill loads one of the
+// two by text direction, because chunk stylesheets bypass the RTL swap
+// WordPress applies to registered styles.
+@import "@wordpress/dataviews/build-style/style-rtl.css";
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
index bf970e6ebdb..76e5766633d 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing-slotfill.tsx
@@ -1,11 +1,11 @@
 /**
  * External dependencies
  */
-import { createSlotFill, Button } from '@wordpress/components';
+import { createSlotFill, Button, Notice } from '@wordpress/components';
 import { registerPlugin } from '@wordpress/plugins';
-import { useEffect, useState } from '@wordpress/element';
+import { lazy, Suspense, useEffect, useState } from '@wordpress/element';
 import { dispatch } from '@wordpress/data';
-import { __ } from '@wordpress/i18n';
+import { __, isRTL } from '@wordpress/i18n';
 import { getAdminLink } from '@woocommerce/settings';
 import { recordEvent } from '@woocommerce/tracks';

@@ -13,11 +13,48 @@ import { recordEvent } from '@woocommerce/tracks';
  * Internal dependencies
  */
 import { SETTINGS_SLOT_FILL_CONSTANT } from '~/settings/settings-slots';
-import { ListView } from './settings-email-listing-listview';
+import type { ListView as ListViewComponent } from './settings-email-listing-listview';
 import { recreateEmailPostRequest } from './settings-email-listing-data';
 import { shouldShowReviewUpdate } from './settings-email-listing-update-state';
 import { VIEWED_FROM_EMAIL_LIST } from '../wp-admin-scripts/email-editor-integration/tracks/build-shared-payload';

+// Shown in place of the list when its chunk fails to load. Without it the
+// failure would unmount the whole slot, description and button included.
+const ListViewLoadError: typeof ListViewComponent = () => (
+	<Notice status="error" isDismissible={ false }>
+		{ __(
+			'The email list could not be loaded. Reload the page to try again.',
+			'woocommerce'
+		) }
+	</Notice>
+);
+
+// The list view carries the DataViews runtime, so it loads as its own chunk.
+// Only this fill renders it, and only on the Emails tab with the block email
+// editor enabled. Its stylesheet is a separate chunk picked by text direction,
+// because the chunk runtime bypasses the RTL swap WordPress applies to
+// registered styles. Both are awaited, so the list never renders unstyled.
+const ListView = lazy( () =>
+	Promise.all( [
+		import(
+			/* webpackChunkName: "settings-email-listing" */ './settings-email-listing-listview'
+		),
+		isRTL()
+			? import(
+					/* webpackChunkName: "settings-email-listing-styles-rtl" */ './settings-email-listing-rtl.scss'
+			  )
+			: import(
+					/* webpackChunkName: "settings-email-listing-styles" */ './settings-email-listing.scss'
+			  ),
+	] )
+		.then( ( [ module ] ) => ( { default: module.ListView } ) )
+		.catch( ( error ) => {
+			// eslint-disable-next-line no-console
+			console.error( error );
+			return { default: ListViewLoadError };
+		} )
+);
+
 export type Recipients = {
 	to: string;
 	cc: string;
@@ -162,6 +199,14 @@ const EditTemplateButton = ( {
 	);
 };

+// Same look as the server-rendered placeholder the slot shows before this
+// fill mounts, so the switch to React is not visible.
+const ListViewPlaceholder = () => (
+	<div className="woocommerce-email-listing-placeholder" role="status">
+		<p>{ __( 'Loading…', 'woocommerce' ) }</p>
+	</div>
+);
+
 export const EmailListingFill: React.FC< {
 	emailTypes: EmailType[];
 	editTemplateUrl: string | null;
@@ -220,7 +265,9 @@ export const EmailListingFill: React.FC< {
 					templateSessionEmailTypeId={ emailTypes[ 0 ]?.id ?? null }
 				/>
 			</div>
-			<ListView emailTypes={ emailTypes } />
+			<Suspense fallback={ <ListViewPlaceholder /> }>
+				<ListView emailTypes={ emailTypes } />
+			</Suspense>
 		</Fill>
 	);
 };
diff --git a/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing.scss b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing.scss
new file mode 100644
index 00000000000..9d0a1babe13
--- /dev/null
+++ b/plugins/woocommerce/client/admin/client/settings-email/settings-email-listing.scss
@@ -0,0 +1,5 @@
+// Styles for the DataViews runtime bundled into this chunk (the `/wp` build
+// pinned by the admin catalog). @woocommerce/settings-ui ships a different
+// DataViews major with its own stylesheet, so the two are not interchangeable
+// and this import must not be dropped as a duplicate of that one.
+@import "@wordpress/dataviews/build-style/style.css";
diff --git a/plugins/woocommerce/client/admin/client/settings-email/style.scss b/plugins/woocommerce/client/admin/client/settings-email/style.scss
index 6cf0bdea479..0498fc72c10 100644
--- a/plugins/woocommerce/client/admin/client/settings-email/style.scss
+++ b/plugins/woocommerce/client/admin/client/settings-email/style.scss
@@ -7,6 +7,21 @@
 	.dataviews-wrapper {
 		background-color: $white;
 	}
+	.woocommerce-email-listing-placeholder {
+		align-items: center;
+		display: flex;
+		height: 40px;
+		justify-content: center;
+		padding: 12px;
+
+		// Heading-sized text: the default paragraph size reads too small for a
+		// page-level loading message.
+		p {
+			font-size: 1.3em;
+			font-weight: 600;
+			margin: 0;
+		}
+	}
 	.woocommerce-email-listing-description {
 		display: inline-block;
 		margin-top: 4px;
diff --git a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
index 84c62aff538..36836fe3b5a 100644
--- a/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
+++ b/plugins/woocommerce/includes/admin/settings/class-wc-settings-emails.php
@@ -668,15 +668,8 @@ class WC_Settings_Emails extends WC_Settings_Page {
 			data-edit-template-url="<?php echo esc_attr( $edit_template_url ); ?>"
 			data-email-template-id="<?php echo esc_attr( $email_template_id ); ?>"
 		>
-			<div style="
-			display: flex;
-			align-items: center;
-			justify-content: center;
-			padding: 12px;
-			height: 40px;
-			width: 100%;
-			">
-				<h3> <?php esc_html_e( 'Loading&hellip;', 'woocommerce' ); ?>  </h3>
+			<div class="woocommerce-email-listing-placeholder" role="status">
+				<p><?php esc_html_e( 'Loading&hellip;', 'woocommerce' ); ?></p>
 			</div>
 		</div>
 		<div>
diff --git a/plugins/woocommerce/tests/e2e/tests/email/settings-email-listing.spec.ts b/plugins/woocommerce/tests/e2e/tests/email/settings-email-listing.spec.ts
index 385ba49af17..be0c6bb6732 100644
--- a/plugins/woocommerce/tests/e2e/tests/email/settings-email-listing.spec.ts
+++ b/plugins/woocommerce/tests/e2e/tests/email/settings-email-listing.spec.ts
@@ -45,6 +45,13 @@ test.describe( 'WooCommerce Email Settings List View', () => {

 		await expect( listViewLocator ).toBeVisible();

+		// The listing's DataViews styles ship in its lazy chunk. Without them
+		// the table falls back to the browser default (border-collapse:
+		// separate) and renders as plain, unstyled rows.
+		await expect(
+			listViewLocator.locator( '.dataviews-view-table' )
+		).toHaveCSS( 'border-collapse', 'collapse' );
+
 		// Check that "New order" email type exists within the list view
 		await expect( listViewLocator.getByText( /New order/ ) ).toBeVisible();

@@ -146,4 +153,34 @@ test.describe( 'WooCommerce Email Settings List View', () => {
 			'All Rights Reserved'
 		);
 	} );
+
+	test( 'Email listing assets load only where the listing renders', async ( {
+		page,
+		baseURL,
+	} ) => {
+		// The chunk's stylesheet link is its durable footprint: webpack removes
+		// the chunk script element once it has run. On these pages the listing
+		// fill never registers, because its slot element is not rendered, so no
+		// code path can request the chunk at all.
+		const listingAssets = () =>
+			page.locator( 'link[href*="settings-email-listing"]' );
+
+		// Other settings tabs run the same settings-embed script but never
+		// fetch the listing chunk.
+		await setBlockEmailEditorFeatureFlag( baseURL, 'yes' );
+		await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=general' );
+		await expect(
+			page.locator( 'script[src*="settings-embed"]' )
+		).toHaveCount( 1 );
+		await expect( listingAssets() ).toHaveCount( 0 );
+
+		// With the block email editor off there is no listing slot, so the
+		// Emails tab does not fetch the chunk either.
+		await setBlockEmailEditorFeatureFlag( baseURL, 'no' );
+		await page.goto( 'wp-admin/admin.php?page=wc-settings&tab=email' );
+		await expect(
+			page.locator( '#wc_settings_email_listing_slotfill' )
+		).toHaveCount( 0 );
+		await expect( listingAssets() ).toHaveCount( 0 );
+	} );
 } );