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 );
} );
} );