Commit 9694222751d for woocommerce

commit 9694222751de5700abc48f715069278d2f2981dc
Author: Vladimir Reznichenko <kalessil@gmail.com>
Date:   Tue May 12 10:30:50 2026 +0200

    [dev] Monorepo: address circular dependencies surfaced by SWC TDZ. (#64797)

    Extract the circular dependency fixes surfaced in https://github.com/woocommerce/woocommerce/pull/64726 and update the code review AI skill accordingly.

diff --git a/.ai/skills/woocommerce-code-review/SKILL.md b/.ai/skills/woocommerce-code-review/SKILL.md
index 28d4fd41b5d..65e29851a84 100644
--- a/.ai/skills/woocommerce-code-review/SKILL.md
+++ b/.ai/skills/woocommerce-code-review/SKILL.md
@@ -40,6 +40,12 @@ Consult the `woocommerce-backend-dev` skill for detailed standards. Using these
 - ❌ **Missing `@testdox`** - Required in test method docblocks ([unit-tests.md](../woocommerce-backend-dev/unit-tests.md))
 - ❌ **Test file naming** - Must follow convention for `includes/` vs `src/` ([unit-tests.md](../woocommerce-backend-dev/unit-tests.md))

+### Frontend JS/TS Code
+
+**Architecture & Structure:**
+
+- ❌ **Barrel self-import (circular dependency)** — a JS/TS file anywhere in the monorepo importing from its own package barrel (`from '../'`, `from '../../'`, `from '../index'`, `from '../../index'`) when that barrel re-exports it. Relevant to SWC TDZ / esbuild tree-shaking / tsc incremental builds. Fix: use the direct module path instead.
+
 ### UI Text & Copy

 Consult the `woocommerce-copy-guidelines` skill. Flag:
diff --git a/packages/js/components/changelog/64797-dev-circular-deps-tdz b/packages/js/components/changelog/64797-dev-circular-deps-tdz
new file mode 100644
index 00000000000..8cdedfd5525
--- /dev/null
+++ b/packages/js/components/changelog/64797-dev-circular-deps-tdz
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Monorepo: address circular dependencies surfaced by SWC TDZ.
\ No newline at end of file
diff --git a/packages/js/components/src/dynamic-form/dynamic-form.tsx b/packages/js/components/src/dynamic-form/dynamic-form.tsx
index a415e3d16f8..959f59f997d 100644
--- a/packages/js/components/src/dynamic-form/dynamic-form.tsx
+++ b/packages/js/components/src/dynamic-form/dynamic-form.tsx
@@ -8,7 +8,7 @@ import { __ } from '@wordpress/i18n';
 /**
  * Internal dependencies
  */
-import { Form } from '../index';
+import { Form } from '../form';
 import {
 	TextField,
 	PasswordField,
diff --git a/packages/js/components/src/dynamic-form/field-types/field-select.tsx b/packages/js/components/src/dynamic-form/field-types/field-select.tsx
index edbf83d4827..077159c3363 100644
--- a/packages/js/components/src/dynamic-form/field-types/field-select.tsx
+++ b/packages/js/components/src/dynamic-form/field-types/field-select.tsx
@@ -6,7 +6,7 @@ import { createElement, useMemo } from '@wordpress/element';
 /**
  * Internal dependencies
  */
-import { SelectControl } from '../../index';
+import SelectControl from '../../select-control';
 import { ControlProps } from '../types';

 type SelectControlOption = {
diff --git a/packages/js/components/src/dynamic-form/field-types/field-text.tsx b/packages/js/components/src/dynamic-form/field-types/field-text.tsx
index 5f70ceea2db..0d1c8d07c2b 100644
--- a/packages/js/components/src/dynamic-form/field-types/field-text.tsx
+++ b/packages/js/components/src/dynamic-form/field-types/field-text.tsx
@@ -6,7 +6,7 @@ import { createElement } from '@wordpress/element';
 /**
  * Internal dependencies
  */
-import { TextControl } from '../../index';
+import TextControl from '../../text-control';
 import { ControlProps } from '../types';

 export const TextField = ( {
diff --git a/packages/js/customer-effort-score/changelog/64797-dev-circular-deps-tdz b/packages/js/customer-effort-score/changelog/64797-dev-circular-deps-tdz
new file mode 100644
index 00000000000..8cdedfd5525
--- /dev/null
+++ b/packages/js/customer-effort-score/changelog/64797-dev-circular-deps-tdz
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Monorepo: address circular dependencies surfaced by SWC TDZ.
\ No newline at end of file
diff --git a/packages/js/customer-effort-score/src/components/customer-effort-score-modal-container/index.tsx b/packages/js/customer-effort-score/src/components/customer-effort-score-modal-container/index.tsx
index 7cf9f48a07e..cb4df97ae3d 100644
--- a/packages/js/customer-effort-score/src/components/customer-effort-score-modal-container/index.tsx
+++ b/packages/js/customer-effort-score/src/components/customer-effort-score-modal-container/index.tsx
@@ -10,7 +10,7 @@ import { recordEvent } from '@woocommerce/tracks';
 /**
  * Internal dependencies
  */
-import { CustomerFeedbackModal } from '../';
+import { CustomerFeedbackModal } from '../customer-feedback-modal';
 import { getStoreAgeInWeeks } from '../../utils';
 import { ADMIN_INSTALL_TIMESTAMP_OPTION_NAME } from '../../constants';
 import store from '../../store';
diff --git a/packages/js/customer-effort-score/src/components/customer-effort-score-tracks/index.js b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks/index.js
index 980cf6bbcc2..0c25bc2a53d 100644
--- a/packages/js/customer-effort-score/src/components/customer-effort-score-tracks/index.js
+++ b/packages/js/customer-effort-score/src/components/customer-effort-score-tracks/index.js
@@ -12,7 +12,7 @@ import apiFetch from '@wordpress/api-fetch';
 /**
  * Internal dependencies
  */
-import { CustomerEffortScore } from '../';
+import { CustomerEffortScore } from '../customer-effort-score';
 import {
 	ADMIN_INSTALL_TIMESTAMP_OPTION_NAME,
 	ALLOW_TRACKING_OPTION_NAME,
diff --git a/packages/js/data/changelog/64797-dev-circular-deps-tdz b/packages/js/data/changelog/64797-dev-circular-deps-tdz
new file mode 100644
index 00000000000..8cdedfd5525
--- /dev/null
+++ b/packages/js/data/changelog/64797-dev-circular-deps-tdz
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Monorepo: address circular dependencies surfaced by SWC TDZ.
\ No newline at end of file
diff --git a/packages/js/data/src/onboarding/actions.ts b/packages/js/data/src/onboarding/actions.ts
index 054ac214a07..f6d9fc6df65 100644
--- a/packages/js/data/src/onboarding/actions.ts
+++ b/packages/js/data/src/onboarding/actions.ts
@@ -25,7 +25,7 @@ import {
 	CoreProfilerCompletedSteps,
 } from './types';
 import { Plugin, PluginNames } from '../plugins/types';
-import { optionsStore } from '..';
+import { store as optionsStore } from '../options';

 export function getFreeExtensionsError( error: unknown ) {
 	return {
diff --git a/packages/js/experimental/changelog/64797-dev-circular-deps-tdz b/packages/js/experimental/changelog/64797-dev-circular-deps-tdz
new file mode 100644
index 00000000000..8cdedfd5525
--- /dev/null
+++ b/packages/js/experimental/changelog/64797-dev-circular-deps-tdz
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Monorepo: address circular dependencies surfaced by SWC TDZ.
\ No newline at end of file
diff --git a/packages/js/experimental/src/experimental-list/task-item/index.tsx b/packages/js/experimental/src/experimental-list/task-item/index.tsx
index 860015457de..45621cee5a7 100644
--- a/packages/js/experimental/src/experimental-list/task-item/index.tsx
+++ b/packages/js/experimental/src/experimental-list/task-item/index.tsx
@@ -18,7 +18,8 @@ import { sanitizeHTML } from '@woocommerce/sanitize';
 /**
  * Internal dependencies
  */
-import { Text, ListItem } from '../../';
+import { Text } from '../../text';
+import { ExperimentalListItem as ListItem } from '../experimental-list-item';
 import { VerticalCSSTransition } from '../../vertical-css-transition';

 const ALLOWED_TAGS = [ 'a', 'b', 'em', 'i', 'strong', 'p', 'br' ];
diff --git a/packages/js/experimental/src/index.js b/packages/js/experimental/src/index.js
index 40ff28f7c80..82ee7f5b453 100644
--- a/packages/js/experimental/src/index.js
+++ b/packages/js/experimental/src/index.js
@@ -7,7 +7,6 @@ import {
 	__experimentalNavigationGroup,
 	__experimentalNavigationMenu,
 	__experimentalNavigationItem,
-	__experimentalText,
 	__experimentalUseSlot,
 	__experimentalUseSlotFills as useSlotFillsHook,
 	Navigation as NavigationComponent,
@@ -15,10 +14,13 @@ import {
 	NavigationGroup as NavigationGroupComponent,
 	NavigationMenu as NavigationMenuComponent,
 	NavigationItem as NavigationItemComponent,
-	Text as TextComponent,
 	useSlot as useSlotHook,
 } from '@wordpress/components';

+/**
+ * Internal dependencies
+ */
+
 /**
  * Prioritize exports of non-experimental components over experimental.
  */
@@ -31,7 +33,7 @@ export const NavigationMenu =
 	NavigationMenuComponent || __experimentalNavigationMenu;
 export const NavigationItem =
 	NavigationItemComponent || __experimentalNavigationItem;
-export const Text = TextComponent || __experimentalText;
+export { Text } from './text';

 // Add a fallback for useSlotFills hook to not break in older versions of wp.components.
 // This hook was introduced in wp.components@21.2.0.
diff --git a/packages/js/experimental/src/text.ts b/packages/js/experimental/src/text.ts
new file mode 100644
index 00000000000..d9e75f725b9
--- /dev/null
+++ b/packages/js/experimental/src/text.ts
@@ -0,0 +1,7 @@
+/**
+ * External dependencies
+ */
+import { __experimentalText } from '@wordpress/components'; // eslint-disable-line @wordpress/no-unsafe-wp-apis
+
+// Preserve permissive prop types of the original JS barrel shim.
+export const Text = __experimentalText as any; // eslint-disable-line @typescript-eslint/no-explicit-any
diff --git a/packages/js/navigation/changelog/64797-dev-circular-deps-tdz b/packages/js/navigation/changelog/64797-dev-circular-deps-tdz
new file mode 100644
index 00000000000..8cdedfd5525
--- /dev/null
+++ b/packages/js/navigation/changelog/64797-dev-circular-deps-tdz
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Monorepo: address circular dependencies surfaced by SWC TDZ.
\ No newline at end of file
diff --git a/packages/js/navigation/src/hooks/use-confirm-unsaved-changes.ts b/packages/js/navigation/src/hooks/use-confirm-unsaved-changes.ts
index 9f43bf3310c..ff1e8bbd45a 100644
--- a/packages/js/navigation/src/hooks/use-confirm-unsaved-changes.ts
+++ b/packages/js/navigation/src/hooks/use-confirm-unsaved-changes.ts
@@ -9,7 +9,7 @@ import { useEffect, useMemo } from '@wordpress/element';
  * Internal dependencies
  */
 import { getHistory } from '../history';
-import { parseAdminUrl } from '../';
+import { parseAdminUrl } from '../url';

 export const useConfirmUnsavedChanges = (
 	hasUnsavedChanges: boolean,
diff --git a/packages/js/navigation/src/index.js b/packages/js/navigation/src/index.js
index 713a2d9bad2..7d86f68269e 100644
--- a/packages/js/navigation/src/index.js
+++ b/packages/js/navigation/src/index.js
@@ -2,22 +2,21 @@
  * External dependencies
  */
 import { useState, useEffect, useLayoutEffect } from '@wordpress/element';
-import { addQueryArgs } from '@wordpress/url';
-import { parse } from 'qs';
 import { pick } from 'lodash';
 import { applyFilters } from '@wordpress/hooks';
-import { getAdminLink } from '@woocommerce/settings';

 /**
  * Internal dependencies
  */
 import { getHistory } from './history';
+import { getPath, getQuery, getNewPath, parseAdminUrl } from './url';

 // Expose history so all uses get the same history object.
 export { getHistory };

 // Export all filter utilities
 export * from './filters';
+export { getPath, getQuery, getNewPath, parseAdminUrl } from './url';

 // Export all hooks
 export { useConfirmUnsavedChanges } from './hooks/use-confirm-unsaved-changes';
@@ -26,48 +25,6 @@ const TIME_EXCLUDED_SCREENS_FILTER = 'woocommerce_admin_time_excluded_screens';
 const NAVIGATION_UPDATE_EXCLUDED_SCREENS_FILTER =
 	'woocommerce_admin_nav_update_excluded_screens';

-/**
- * Get the current path from history.
- *
- * @return {string}  Current path.
- */
-export const getPath = () => getHistory().location.pathname;
-
-/**
- * Get the current query string, parsed into an object, from history.
- *
- * @return {Object}  Current query object, defaults to empty object.
- */
-export function getQuery() {
-	const search = getHistory().location.search;
-	if ( search.length ) {
-		return parse( search.substring( 1 ) ) || {};
-	}
-	return {};
-}
-
-/**
- * Return a URL with set query parameters.
- *
- * @param {Object} query        object of params to be updated.
- * @param {string} path         Relative path (defaults to current path).
- * @param {Object} currentQuery object of current query params (defaults to current querystring).
- * @param {string} page         Page key (defaults to "wc-admin")
- * @return {string}  Updated URL merging query params into existing params.
- */
-export function getNewPath(
-	query,
-	path = getPath(),
-	currentQuery = getQuery(),
-	page = 'wc-admin'
-) {
-	const args = { page, ...currentQuery, ...query };
-	if ( path !== '/' ) {
-		args.path = path;
-	}
-	return addQueryArgs( 'admin.php', args );
-}
-
 /**
  * Gets query parameters that should persist between screens or updates
  * to reports, such as filtering.
@@ -313,22 +270,6 @@ export const isWCAdmin = ( url = window.location.href ) => {
 	return /admin.php\?page=wc-admin/.test( url );
 };

-/**
- * Returns a parsed object for an absolute or relative admin URL.
- *
- * @param {*} url - the url to test.
- * @return {URL} - the URL object of the given url.
- */
-export const parseAdminUrl = ( url ) => {
-	if ( url.startsWith( 'http' ) ) {
-		return new URL( url );
-	}
-
-	return /^\/?[a-z0-9]+.php/i.test( url )
-		? new URL( `${ window.wcSettings.adminUrl }${ url }` )
-		: new URL( getAdminLink( getNewPath( {}, url, {} ) ) );
-};
-
 /**
  * A utility function that navigates to a page, using a redirect
  * or the router as appropriate.
diff --git a/packages/js/navigation/src/url.js b/packages/js/navigation/src/url.js
new file mode 100644
index 00000000000..be8d37fb040
--- /dev/null
+++ b/packages/js/navigation/src/url.js
@@ -0,0 +1,69 @@
+/**
+ * External dependencies
+ */
+import { addQueryArgs } from '@wordpress/url';
+import { parse } from 'qs';
+import { getAdminLink } from '@woocommerce/settings';
+
+/**
+ * Internal dependencies
+ */
+import { getHistory } from './history';
+
+/**
+ * Get the current path from history.
+ *
+ * @return {string}  Current path.
+ */
+export const getPath = () => getHistory().location.pathname;
+
+/**
+ * Get the current query string, parsed into an object, from history.
+ *
+ * @return {Object}  Current query object, defaults to empty object.
+ */
+export function getQuery() {
+	const search = getHistory().location.search;
+	if ( search.length ) {
+		return parse( search.substring( 1 ) ) || {};
+	}
+	return {};
+}
+
+/**
+ * Return a URL with set query parameters.
+ *
+ * @param {Object} query        object of params to be updated.
+ * @param {string} path         Relative path (defaults to current path).
+ * @param {Object} currentQuery object of current query params (defaults to current querystring).
+ * @param {string} page         Page key (defaults to "wc-admin")
+ * @return {string}  Updated URL merging query params into existing params.
+ */
+export function getNewPath(
+	query,
+	path = getPath(),
+	currentQuery = getQuery(),
+	page = 'wc-admin'
+) {
+	const args = { page, ...currentQuery, ...query };
+	if ( path !== '/' ) {
+		args.path = path;
+	}
+	return addQueryArgs( 'admin.php', args );
+}
+
+/**
+ * Returns a parsed object for an absolute or relative admin URL.
+ *
+ * @param {*} url - the url to test.
+ * @return {URL} - the URL object of the given url.
+ */
+export const parseAdminUrl = ( url ) => {
+	if ( url.startsWith( 'http' ) ) {
+		return new URL( url );
+	}
+
+	return /^\/?[a-z0-9]+.php/i.test( url )
+		? new URL( `${ window.wcSettings.adminUrl }${ url }` )
+		: new URL( getAdminLink( getNewPath( {}, url, {} ) ) );
+};
diff --git a/packages/js/product-editor/changelog/64797-dev-circular-deps-tdz b/packages/js/product-editor/changelog/64797-dev-circular-deps-tdz
new file mode 100644
index 00000000000..8cdedfd5525
--- /dev/null
+++ b/packages/js/product-editor/changelog/64797-dev-circular-deps-tdz
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Monorepo: address circular dependencies surfaced by SWC TDZ.
\ No newline at end of file
diff --git a/packages/js/product-editor/src/hooks/use-product-helper.ts b/packages/js/product-editor/src/hooks/use-product-helper.ts
index 80de32ab608..e7577465283 100644
--- a/packages/js/product-editor/src/hooks/use-product-helper.ts
+++ b/packages/js/product-editor/src/hooks/use-product-helper.ts
@@ -20,7 +20,7 @@ import { CurrencyContext } from '@woocommerce/currency';
 /**
  * Internal dependencies
  */
-import { AUTO_DRAFT_NAME, getDerivedProductType } from '../index';
+import { AUTO_DRAFT_NAME, getDerivedProductType } from '../utils';
 import {
 	NUMBERS_AND_DECIMAL_SEPARATOR,
 	ONLY_ONE_DECIMAL_SEPARATOR,
diff --git a/plugins/woocommerce/changelog/64797-dev-circular-deps-tdz b/plugins/woocommerce/changelog/64797-dev-circular-deps-tdz
new file mode 100644
index 00000000000..8cdedfd5525
--- /dev/null
+++ b/plugins/woocommerce/changelog/64797-dev-circular-deps-tdz
@@ -0,0 +1,4 @@
+Significance: patch
+Type: dev
+
+Monorepo: address circular dependencies surfaced by SWC TDZ.
\ No newline at end of file
diff --git a/plugins/woocommerce/client/admin/client/layout/test/controller.test.js b/plugins/woocommerce/client/admin/client/layout/test/controller.test.js
index 983f44e68ca..945afb67e77 100644
--- a/plugins/woocommerce/client/admin/client/layout/test/controller.test.js
+++ b/plugins/woocommerce/client/admin/client/layout/test/controller.test.js
@@ -8,6 +8,14 @@ import * as navigation from '@woocommerce/navigation';
  */
 import { updateLinkHref } from '../controller';

+jest.mock( '@woocommerce/navigation', () => {
+	const actual = jest.requireActual( '@woocommerce/navigation' );
+	return {
+		...actual,
+		getHistory: jest.fn( actual.getHistory ),
+	};
+} );
+
 describe( 'updateLinkHref', () => {
 	const timeExcludedScreens = [ 'stock', 'settings', 'customers' ];

@@ -27,6 +35,7 @@ describe( 'updateLinkHref', () => {

 	beforeEach( () => {
 		jest.restoreAllMocks();
+		navigation.getHistory.mockClear();
 	} );

 	it( 'should update report urls', () => {
@@ -88,7 +97,6 @@ describe( 'updateLinkHref', () => {

 	it( 'should not prevent default when Command key is pressed', () => {
 		const item = { href: REPORT_URL };
-		const spyGetHistory = jest.spyOn( navigation, 'getHistory' );
 		const event = {
 			ctrlKey: false,
 			metaKey: true,
@@ -98,13 +106,12 @@ describe( 'updateLinkHref', () => {
 		updateLinkHref( item, nextQuery, timeExcludedScreens );

 		item.onclick( event );
-		expect( spyGetHistory ).not.toHaveBeenCalled();
+		expect( navigation.getHistory ).not.toHaveBeenCalled();
 		expect( event.preventDefault ).not.toHaveBeenCalled();
 	} );

 	it( 'should not prevent default when Control key is pressed', () => {
 		const item = { href: REPORT_URL };
-		const spyGetHistory = jest.spyOn( navigation, 'getHistory' );
 		const event = {
 			ctrlKey: true,
 			metaKey: false,
@@ -114,13 +121,12 @@ describe( 'updateLinkHref', () => {
 		updateLinkHref( item, nextQuery, timeExcludedScreens );

 		item.onclick( event );
-		expect( spyGetHistory ).not.toHaveBeenCalled();
+		expect( navigation.getHistory ).not.toHaveBeenCalled();
 		expect( event.preventDefault ).not.toHaveBeenCalled();
 	} );

 	it( 'should prevent default on normal clicks', () => {
 		const item = { href: REPORT_URL };
-		const spyGetHistory = jest.spyOn( navigation, 'getHistory' );
 		const event = {
 			ctrlKey: false,
 			metaKey: false,
@@ -130,7 +136,7 @@ describe( 'updateLinkHref', () => {
 		updateLinkHref( item, nextQuery, timeExcludedScreens );

 		item.onclick( event );
-		expect( spyGetHistory ).toHaveBeenCalledTimes( 1 );
+		expect( navigation.getHistory ).toHaveBeenCalledTimes( 1 );
 		expect( event.preventDefault ).toHaveBeenCalledTimes( 1 );
 	} );
 } );