diff --git a/change/@fluentui-react-tag-picker-73122b43-f401-4b38-ba87-5f92358110f5.json b/change/@fluentui-react-tag-picker-73122b43-f401-4b38-ba87-5f92358110f5.json
new file mode 100644
index 00000000000000..a66fc07c09bb61
--- /dev/null
+++ b/change/@fluentui-react-tag-picker-73122b43-f401-4b38-ba87-5f92358110f5.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "fix: omit the default TagPicker expand icon from headless state",
+ "packageName": "@fluentui/react-tag-picker",
+ "email": "vgenaev@gmail.com",
+ "dependentChangeType": "patch"
+}
diff --git a/change/@fluentui-react-teaching-popover-c2dda2cd-f30a-4bea-a04b-400a23471e90.json b/change/@fluentui-react-teaching-popover-c2dda2cd-f30a-4bea-a04b-400a23471e90.json
new file mode 100644
index 00000000000000..17f3227c36460f
--- /dev/null
+++ b/change/@fluentui-react-teaching-popover-c2dda2cd-f30a-4bea-a04b-400a23471e90.json
@@ -0,0 +1,7 @@
+{
+ "type": "patch",
+ "comment": "fix: separate headless teaching popover title state from bundled icon defaults",
+ "packageName": "@fluentui/react-teaching-popover",
+ "email": "vgenaev@gmail.com",
+ "dependentChangeType": "patch"
+}
diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/TagPicker.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/TagPicker.fixture.js
new file mode 100644
index 00000000000000..afa18f9cb4f6f1
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/library/bundle-size/TagPicker.fixture.js
@@ -0,0 +1,7 @@
+import * as TagPicker from '@fluentui/react-headless-components-preview/tag-picker';
+
+console.log(TagPicker);
+
+export default {
+ name: '@fluentui/react-headless-components-preview/tag-picker',
+};
diff --git a/packages/react-components/react-headless-components-preview/library/bundle-size/TeachingPopover.fixture.js b/packages/react-components/react-headless-components-preview/library/bundle-size/TeachingPopover.fixture.js
new file mode 100644
index 00000000000000..8e23718683a360
--- /dev/null
+++ b/packages/react-components/react-headless-components-preview/library/bundle-size/TeachingPopover.fixture.js
@@ -0,0 +1,7 @@
+import * as TeachingPopover from '@fluentui/react-headless-components-preview/teaching-popover';
+
+console.log(TeachingPopover);
+
+export default {
+ name: '@fluentui/react-headless-components-preview/teaching-popover',
+};
diff --git a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx
index 8abad2ea4033be..37063801bec7c2 100644
--- a/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx
+++ b/packages/react-components/react-headless-components-preview/library/src/components/TeachingPopover/TeachingPopover.test.tsx
@@ -3,8 +3,10 @@ import { render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { isConformant } from '../../testing/isConformant';
import { TeachingPopover } from './TeachingPopover';
+import { TeachingPopoverHeader } from './TeachingPopoverHeader';
import { TeachingPopoverTrigger } from './TeachingPopoverTrigger';
import { TeachingPopoverSurface } from './TeachingPopoverSurface';
+import { TeachingPopoverTitle } from './TeachingPopoverTitle';
describe('TeachingPopover', () => {
isConformant({
@@ -43,6 +45,52 @@ describe('TeachingPopover', () => {
expect(getByText('Surface content')).toBeInTheDocument();
});
+ it('does not render default header or title icons', () => {
+ const { container, getByTestId } = render(
+
+
+
+
+
+ Tips
+ Title
+
+ ,
+ );
+ const header = getByTestId('header');
+ const title = getByTestId('title');
+
+ expect(container.querySelector('svg')).toBeNull();
+ expect(header.querySelector('[aria-hidden="true"]')).toBeEmptyDOMElement();
+ expect(header.querySelector('button[aria-label="dismiss"]')).toBeEmptyDOMElement();
+ expect(title.querySelector('button')).toBeNull();
+ });
+
+ it('renders explicitly provided header and title slots', () => {
+ const { getByTestId } = render(
+
+
+
+
+
+ Tip }}
+ dismissButton={{ children: Close header }}
+ >
+ Tips
+
+ Close title }}>
+ Title
+
+
+ ,
+ );
+
+ expect(getByTestId('header-icon')).toHaveTextContent('Tip');
+ expect(getByTestId('header-dismiss')).toHaveTextContent('Close header');
+ expect(getByTestId('title-dismiss')).toHaveTextContent('Close title');
+ });
+
it('renders an arrow by default (withArrow=true)', () => {
const { getByRole } = render(
diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.test.tsx b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.test.tsx
index e60d00f8869029..70f683794796cd 100644
--- a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.test.tsx
+++ b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.test.tsx
@@ -1,9 +1,10 @@
import { renderHook } from '@testing-library/react-hooks';
import * as React from 'react';
+import { ChevronDownRegular } from '@fluentui/react-icons';
import { TagPickerContextProvider } from '../../contexts/TagPickerContext';
import type { TagPickerContextValue } from '../../contexts/TagPickerContext';
-import { useTagPickerControl_unstable } from './useTagPickerControl';
+import { useTagPickerControlBase_unstable, useTagPickerControl_unstable } from './useTagPickerControl';
const makeContext = (overrides: Partial = {}): TagPickerContextValue => ({
triggerRef: React.createRef(),
@@ -35,23 +36,57 @@ const wrap = (overrides: Partial = {}): React.FC<{ childr
return Wrapper;
};
+describe('useTagPickerControlBase_unstable', () => {
+ it('does not create an expandIcon or aside by default', () => {
+ const ref = React.createRef();
+ const { result } = renderHook(() => useTagPickerControlBase_unstable({}, ref), { wrapper: wrap() });
+
+ expect(result.current.expandIcon).toBeUndefined();
+ expect(result.current.aside).toBeUndefined();
+ });
+
+ it('creates an expandIcon and aside when explicitly provided', () => {
+ const ref = React.createRef();
+ const { result } = renderHook(() => useTagPickerControlBase_unstable({ expandIcon: {} }, ref), {
+ wrapper: wrap({ open: true }),
+ });
+
+ expect(result.current.expandIcon).toBeDefined();
+ expect(result.current.expandIcon?.children).toBeUndefined();
+ expect(result.current.expandIcon?.['aria-expanded']).toBe(true);
+ expect(result.current.expandIcon?.role).toBe('button');
+ expect(result.current.aside).toBeDefined();
+ });
+});
+
describe('useTagPickerControl_unstable', () => {
- it('always renders the internal aside slot', () => {
+ it('renders the default expandIcon and aside when a popover exists', () => {
const ref = React.createRef();
const { result } = renderHook(() => useTagPickerControl_unstable({}, ref), { wrapper: wrap() });
+ const defaultIcon = result.current.expandIcon?.children;
+ expect(React.isValidElement(defaultIcon) && defaultIcon.type).toBe(ChevronDownRegular);
expect(result.current.aside).toBeDefined();
});
- it('renders an expandIcon by default and hides it when noPopover is set', () => {
+ it('does not render a default expandIcon or aside when noPopover is set', () => {
const ref = React.createRef();
- const withPopover = renderHook(() => useTagPickerControl_unstable({}, ref), { wrapper: wrap() });
const noPopover = renderHook(() => useTagPickerControl_unstable({}, ref), {
wrapper: wrap({ noPopover: true }),
});
- expect(withPopover.result.current.expandIcon).toBeDefined();
expect(noPopover.result.current.expandIcon).toBeUndefined();
+ expect(noPopover.result.current.aside).toBeUndefined();
+ });
+
+ it('preserves a consumer-provided expandIcon', () => {
+ const ref = React.createRef();
+ const customIcon = ;
+ const { result } = renderHook(() => useTagPickerControl_unstable({ expandIcon: customIcon }, ref), {
+ wrapper: wrap(),
+ });
+
+ expect(result.current.expandIcon?.children).toBe(customIcon);
});
it('binds aria-expanded on expandIcon to picker open state', () => {
diff --git a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx
index e5c3164185cbec..967d5061d4d812 100644
--- a/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx
+++ b/packages/react-components/react-tag-picker/library/src/components/TagPickerControl/useTagPickerControl.tsx
@@ -11,13 +11,14 @@ import {
useMergedRefs,
} from '@fluentui/react-utilities';
import { useFluent_unstable } from '@fluentui/react-shared-contexts';
-import type { TagPickerControlBaseState, TagPickerControlProps, TagPickerControlState } from './TagPickerControl.types';
-import { useTagPickerContext_unstable } from '../../contexts/TagPickerContext';
+import { useFieldContext_unstable } from '@fluentui/react-field';
import { ChevronDownRegular } from '@fluentui/react-icons';
-import { useResizeObserverRef } from '../../utils/useResizeObserverRef';
+
+import type { TagPickerControlBaseState, TagPickerControlProps, TagPickerControlState } from './TagPickerControl.types';
import { tagPickerControlAsideWidthToken } from './useTagPickerControlStyles.styles';
-import { useFieldContext_unstable } from '@fluentui/react-field';
+import { useTagPickerContext_unstable } from '../../contexts/TagPickerContext';
import { useExpandLabel } from '../../utils/useExpandLabel';
+import { useResizeObserverRef } from '../../utils/useResizeObserverRef';
/**
* Create the base state required to render TagPickerControl, without design-only state.
@@ -57,11 +58,9 @@ export const useTagPickerControlBase_unstable = (
}
const expandIcon = slot.optional(props.expandIcon, {
- renderByDefault: !noPopover,
defaultProps: {
'aria-expanded': open,
'aria-disabled': disabled ? 'true' : undefined,
- children: ,
role: 'button',
},
elementType: 'span',
@@ -164,7 +163,15 @@ export const useTagPickerControl_unstable = (
props: TagPickerControlProps,
ref: React.Ref,
): TagPickerControlState => {
- const baseState = useTagPickerControlBase_unstable(props, ref);
+ const noPopover = useTagPickerContext_unstable(ctx => ctx.noPopover ?? false);
+ const baseProps =
+ !noPopover && props.expandIcon === undefined
+ ? {
+ ...props,
+ expandIcon: ,
+ }
+ : props;
+ const baseState = useTagPickerControlBase_unstable(baseProps, ref);
const size = useTagPickerContext_unstable(ctx => ctx.size);
const appearance = useTagPickerContext_unstable(ctx => ctx.appearance);
diff --git a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/index.ts b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/index.ts
index 935babeb0b4975..7db7b13bb528fe 100644
--- a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/index.ts
+++ b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/index.ts
@@ -7,7 +7,8 @@ export type {
TeachingPopoverTitleState,
} from './TeachingPopoverTitle.types';
export { renderTeachingPopoverTitle_unstable } from './renderTeachingPopoverTitle';
-export { useTeachingPopoverTitle_unstable, useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitle';
+export { useTeachingPopoverTitle_unstable } from './useTeachingPopoverTitle';
+export { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase';
export {
teachingPopoverTitleClassNames,
useTeachingPopoverTitleStyles_unstable,
diff --git a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitle.tsx b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitle.tsx
index 5c19840a8dfa8c..5a747b70691225 100644
--- a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitle.tsx
+++ b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitle.tsx
@@ -1,67 +1,13 @@
'use client';
import * as React from 'react';
-import { getIntrinsicElementProps, useEventCallback, slot } from '@fluentui/react-utilities';
-import type {
- TeachingPopoverTitleBaseProps,
- TeachingPopoverTitleBaseState,
- TeachingPopoverTitleProps,
- TeachingPopoverTitleState,
-} from './TeachingPopoverTitle.types';
import { DismissFilled, DismissRegular, bundleIcon } from '@fluentui/react-icons';
import { usePopoverContext_unstable } from '@fluentui/react-popover';
+import type { TeachingPopoverTitleProps, TeachingPopoverTitleState } from './TeachingPopoverTitle.types';
+import { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase';
const DismissIcon = bundleIcon(DismissFilled, DismissRegular);
-/**
- * Base hook that builds TeachingPopoverTitle state for behavior and structure only.
- * Does not bundle/render the dismiss icon and does not read `appearance` from popover context.
- * @param props - TeachingPopoverTitle properties
- * @param ref - reference to root HTMLElement of TeachingPopoverTitle
- */
-export const useTeachingPopoverTitleBase_unstable = (
- props: TeachingPopoverTitleBaseProps,
- ref: React.Ref,
-): TeachingPopoverTitleBaseState => {
- const { dismissButton } = props;
-
- const setOpen = usePopoverContext_unstable(context => context.setOpen);
- const triggerRef = usePopoverContext_unstable(context => context.triggerRef);
-
- const onDismissButtonClick = useEventCallback((ev: React.MouseEvent) => {
- if (!ev.defaultPrevented) {
- setOpen(ev, false);
- }
-
- if (triggerRef.current) {
- triggerRef.current.focus();
- }
- });
-
- return {
- components: {
- root: 'h2',
- dismissButton: 'button',
- },
- root: slot.always(
- getIntrinsicElementProps('h2', {
- ref,
- ...props,
- }),
- { elementType: 'h2' },
- ),
- dismissButton: slot.optional(dismissButton, {
- renderByDefault: false,
- defaultProps: {
- onClick: onDismissButtonClick,
- 'aria-label': 'dismiss',
- 'aria-hidden': true,
- },
- elementType: 'button',
- }),
- };
-};
-
/**
* Returns the props and state required to render the component
* @param props - TeachingPopoverTitle properties
diff --git a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.test.tsx b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.test.tsx
new file mode 100644
index 00000000000000..2a08ab1d10c1e2
--- /dev/null
+++ b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.test.tsx
@@ -0,0 +1,53 @@
+import * as React from 'react';
+import { renderHook } from '@testing-library/react-hooks';
+import { PopoverProvider } from '@fluentui/react-popover';
+import type { PopoverContextValue } from '@fluentui/react-popover';
+import { useTeachingPopoverTitleBase_unstable } from './useTeachingPopoverTitleBase';
+
+const defaultPopoverContext: PopoverContextValue = {
+ open: true,
+ setOpen: () => null,
+ toggleOpen: () => null,
+ triggerRef: { current: null },
+ contentRef: { current: null },
+ arrowRef: { current: null },
+ openOnContext: false,
+ openOnHover: false,
+ size: 'medium',
+ inline: false,
+};
+
+function makeWrapper(contextValue: Partial = {}) {
+ const value = { ...defaultPopoverContext, ...contextValue };
+ return ({ children }: { children: React.ReactNode }) => React.createElement(PopoverProvider, { value }, children);
+}
+
+describe('useTeachingPopoverTitleBase_unstable', () => {
+ it('returns structural state without styled defaults', () => {
+ const ref = React.createRef();
+ const { result } = renderHook(() => useTeachingPopoverTitleBase_unstable({ dismissButton: {} }, ref), {
+ wrapper: makeWrapper(),
+ });
+
+ // eslint-disable-next-line @typescript-eslint/no-deprecated
+ expect(result.current.components).toEqual({ root: 'h2', dismissButton: 'button' });
+ expect(result.current).not.toHaveProperty('appearance');
+ expect(result.current.dismissButton?.children).toBeUndefined();
+ });
+
+ it('dismisses the popover and restores focus', () => {
+ const ref = React.createRef();
+ const setOpen = jest.fn();
+ const focus = jest.fn();
+ const triggerRef = { current: { focus } as unknown as HTMLElement };
+ const { result } = renderHook(() => useTeachingPopoverTitleBase_unstable({ dismissButton: {} }, ref), {
+ wrapper: makeWrapper({ setOpen, triggerRef }),
+ });
+ const event = { defaultPrevented: false } as React.MouseEvent;
+
+ result.current.dismissButton?.onClick?.(event);
+
+ expect(setOpen).toHaveBeenCalledWith(event, false);
+ expect(focus).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.tsx b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.tsx
new file mode 100644
index 00000000000000..d949ab831ee877
--- /dev/null
+++ b/packages/react-components/react-teaching-popover/library/src/components/TeachingPopoverTitle/useTeachingPopoverTitleBase.tsx
@@ -0,0 +1,55 @@
+'use client';
+
+import type * as React from 'react';
+import { usePopoverContext_unstable } from '@fluentui/react-popover';
+import { getIntrinsicElementProps, slot, useEventCallback } from '@fluentui/react-utilities';
+import type { TeachingPopoverTitleBaseProps, TeachingPopoverTitleBaseState } from './TeachingPopoverTitle.types';
+
+/**
+ * Base hook that builds TeachingPopoverTitle state for behavior and structure only.
+ * Does not bundle/render the dismiss icon and does not read `appearance` from popover context.
+ * @param props - TeachingPopoverTitle properties
+ * @param ref - reference to root HTMLElement of TeachingPopoverTitle
+ */
+export const useTeachingPopoverTitleBase_unstable = (
+ props: TeachingPopoverTitleBaseProps,
+ ref: React.Ref,
+): TeachingPopoverTitleBaseState => {
+ const { dismissButton } = props;
+
+ const setOpen = usePopoverContext_unstable(context => context.setOpen);
+ const triggerRef = usePopoverContext_unstable(context => context.triggerRef);
+
+ const onDismissButtonClick = useEventCallback((ev: React.MouseEvent) => {
+ if (!ev.defaultPrevented) {
+ setOpen(ev, false);
+ }
+
+ if (triggerRef.current) {
+ triggerRef.current.focus();
+ }
+ });
+
+ return {
+ components: {
+ root: 'h2',
+ dismissButton: 'button',
+ },
+ root: slot.always(
+ getIntrinsicElementProps('h2', {
+ ref,
+ ...props,
+ }),
+ { elementType: 'h2' },
+ ),
+ dismissButton: slot.optional(dismissButton, {
+ renderByDefault: false,
+ defaultProps: {
+ onClick: onDismissButtonClick,
+ 'aria-label': 'dismiss',
+ 'aria-hidden': true,
+ },
+ elementType: 'button',
+ }),
+ };
+};
diff --git a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.spec.ts b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.spec.ts
index 9ddbc96b6aa064..539ca0176e294b 100644
--- a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.spec.ts
+++ b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.spec.ts
@@ -495,6 +495,25 @@ describe('migrate-converged-pkg generator', () => {
"
`);
});
+
+ it(`emits the CommonJS jest config + setup as .cjs when the package opts into "type": "module"`, async () => {
+ const projectConfig = readProjectConfiguration(tree, options.name);
+ updateJson(tree, `${projectConfig.root}/package.json`, (json: PackageJson) => {
+ json.type = 'module';
+ return json;
+ });
+
+ await generator(tree, options);
+
+ // legacy `.js` variants are replaced by `.cjs` so Node parses them as CommonJS under type:module
+ expect(tree.exists(`${projectConfig.root}/jest.config.js`)).toBeFalsy();
+ expect(tree.exists(`${projectConfig.root}/jest.config.cjs`)).toBeTruthy();
+ expect(tree.exists(`${projectConfig.root}/config/tests.js`)).toBeFalsy();
+ expect(tree.exists(`${projectConfig.root}/config/tests.cjs`)).toBeTruthy();
+ expect(tree.read(`${projectConfig.root}/jest.config.cjs`, 'utf-8')).toContain(
+ `setupFilesAfterEnv: ['./config/tests.cjs']`,
+ );
+ });
});
describe(`storybook updates`, () => {
@@ -951,6 +970,58 @@ describe('migrate-converged-pkg generator', () => {
const pkgJson = getPackageJson();
expect((pkgJson.exports?.['.'] as { import?: string }).import).toBe(undefined);
});
+
+ it(`opts a package into the ESM-first shape when it declares "type": "module"`, async () => {
+ const { getPackageJson } = updatePackageJson(tree, {
+ projectName: options.name,
+ jsonUpdates: {
+ type: 'module',
+ main: './lib-commonjs/index.js',
+ module: './lib/index.js',
+ style: './css/index.css',
+ },
+ });
+
+ await generator(tree, options);
+
+ const pkgJson = getPackageJson();
+ expect(pkgJson.type).toBe('module');
+ // CommonJS entry switches to `.cjs` so Node parses it correctly under `type: module`
+ expect(pkgJson.main).toBe('./lib-commonjs/index.cjs');
+ // no `node` condition; `import`/`require` carry their own per-condition `types`
+ expect((pkgJson.exports?.['.'] as { node?: unknown }).node).toBeUndefined();
+ expect(pkgJson.exports).toMatchInlineSnapshot(`
+ Object {
+ ".": Object {
+ "import": Object {
+ "default": "./lib/index.js",
+ "types": "./dist/index.d.ts",
+ },
+ "require": Object {
+ "default": "./lib-commonjs/index.cjs",
+ "types": "./dist/index.d.cts",
+ },
+ "style": "./css/index.css",
+ },
+ "./package.json": "./package.json",
+ }
+ `);
+ });
+
+ it(`keeps CommonJS packages on the legacy shape (no opt-in)`, async () => {
+ const { getPackageJson } = updatePackageJson(tree, {
+ projectName: options.name,
+ jsonUpdates: { module: './lib/index.js' },
+ });
+
+ await generator(tree, options);
+
+ const pkgJson = getPackageJson();
+ expect(pkgJson.type).toBeUndefined();
+ // CommonJS entry is not rewritten to `.cjs`
+ expect(pkgJson.main).not.toMatch(/\.cjs$/);
+ expect((pkgJson.exports?.['.'] as { node?: unknown }).node).toBe('./lib-commonjs/index.js');
+ });
});
it(`should not add start scripts to node packages`, async () => {
diff --git a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts
index bc798ab216deb1..db008382632cac 100644
--- a/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts
+++ b/tools/workspace-plugin/src/generators/migrate-converged-pkg/index.ts
@@ -685,6 +685,33 @@ function updatePackageJson(tree: Tree, options: NormalizedSchemaWithTsConfigs) {
}
function setupExportMaps(json: PackageJson) {
+ // Opt-in: a package becomes ESM-first by declaring `"type": "module"` in its package.json. Such
+ // packages get the ESM/CJS conditional export shape (no `node` condition); every other package
+ // keeps the existing CommonJS-first shape below unchanged (this stays a no-op for them).
+ if (json.type === 'module') {
+ const esm = json.module ? normalizePackageEntryPointPaths(json.module) : null;
+ const commonjs = json.main ? normalizePackageEntryPointPaths(json.main) : null;
+ // bare Node `import` resolves to valid ESM (`lib/`), `require` resolves to CommonJS
+ // (`lib-commonjs/*.cjs`). Per-condition `types` point `require` at a `.d.cts` so `node16`/
+ // `nodenext` CJS consumers get a CommonJS-flavoured declaration (keeps `@arethetypeswrong/cli` green).
+ const commonjsCjs = commonjs ? commonjs.replace(/\.js$/, '.cjs') : null;
+ if (commonjsCjs) {
+ json.main = commonjsCjs;
+ }
+ const esmTypes = json.typings;
+ const cjsTypes = json.typings ? json.typings.replace(/\.d\.ts$/, '.d.cts') : undefined;
+ json.exports = {
+ '.': {
+ ...(json.style ? { style: normalizePackageEntryPointPaths(json.style) } : null),
+ ...(esm && esmTypes ? { import: { types: esmTypes, default: esm } } : null),
+ ...(commonjsCjs && cjsTypes ? { require: { types: cjsTypes, default: commonjsCjs } } : null),
+ },
+ './package.json': './package.json',
+ };
+
+ return json;
+ }
+
json.exports = {
'.': {
types: json.typings,
@@ -719,6 +746,8 @@ function updatePackageJson(tree: Tree, options: NormalizedSchemaWithTsConfigs) {
const typesPath = getTypes(json);
const storybookPath = getStorybookPath(json);
const amdPath = options.projectConfig.tags?.includes('ships-amd') ? 'lib-amd' : null;
+ // `type: module` packages also ship `.d.cts` declarations for the `require` types condition
+ const cjsTypesPath = json.type === 'module' && typesPath ? typesPath.replace(/\.d\.ts$/, '.d.cts') : null;
const filesDefinition = [
rootMarkdownPath,
mainPath,
@@ -726,6 +755,7 @@ function updatePackageJson(tree: Tree, options: NormalizedSchemaWithTsConfigs) {
...(binPath ?? []),
stylesPath,
typesPath,
+ cjsTypesPath,
storybookPath,
amdPath,
]
@@ -970,20 +1000,39 @@ function updateLocalJestConfig(tree: Tree, options: NormalizedSchema) {
const packageJson = readJson(tree, options.paths.packageJson);
packageJson.dependencies = packageJson.dependencies ?? {};
+ // ESM-first packages opt in via `"type": "module"`, so their CommonJS jest config + setup must use
+ // the `.cjs` extension. CommonJS (default) packages keep `.js` unchanged.
+ const isEsmFirst = packageJson.type === 'module';
+ const testSetupExtension = isEsmFirst ? 'cjs' : 'js';
+ const jestConfigPath = isEsmFirst ? options.paths.jestConfig.replace(/\.js$/, '.cjs') : options.paths.jestConfig;
+ const resolvedJestSetupFilePath = isEsmFirst ? jestSetupFilePath.replace(/\.js$/, '.cjs') : jestSetupFilePath;
+
const config = {
pkgName: options.normalizedPkgName,
addSnapshotSerializers:
packageType === 'web' &&
Object.keys(packageJson.dependencies).some(pkgDepName => packagesThatTriggerAddingSnapshots.includes(pkgDepName)),
- testSetupFilePath: `./${path.basename(options.paths.configRoot)}/tests.js`,
+ testSetupFilePath: `./${path.basename(options.paths.configRoot)}/tests.${testSetupExtension}`,
platform: packageType,
projectConfig: options.projectConfig,
} as const;
- tree.write(options.paths.jestConfig, templates.jest(config));
+ // drop the legacy `.js` variants when emitting `.cjs` (type:module packages)
+ if (isEsmFirst) {
+ if (tree.exists(options.paths.jestConfig)) {
+ tree.delete(options.paths.jestConfig);
+ }
+ if (tree.exists(jestSetupFilePath) && !tree.exists(resolvedJestSetupFilePath)) {
+ const existingSetup = tree.read(jestSetupFilePath, 'utf-8') as string;
+ tree.write(resolvedJestSetupFilePath, existingSetup);
+ tree.delete(jestSetupFilePath);
+ }
+ }
+
+ tree.write(jestConfigPath, templates.jest(config));
- if (!tree.exists(jestSetupFilePath)) {
- tree.write(jestSetupFilePath, templates.jestSetup);
+ if (!tree.exists(resolvedJestSetupFilePath)) {
+ tree.write(resolvedJestSetupFilePath, templates.jestSetup);
}
return tree;
@@ -1075,8 +1124,12 @@ function createTsSolutionConfig(tree: Tree, options: NormalizedSchema) {
function updateTsGlobalTypes(tree: Tree, options: NormalizedSchema) {
// update test TS config
updateJson(tree, options.paths.tsconfig.test, (json: TsConfig) => {
- if (tree.exists(options.paths.jestSetupFile)) {
- const jestSetupFile = tree.read(options.paths.jestSetupFile, 'utf8')!;
+ // ESM-first (type:module) packages emit the jest setup as `.cjs`; fall back to the legacy `.js` path
+ const jestSetupFilePath = [options.paths.jestSetupFile.replace(/\.js$/, '.cjs'), options.paths.jestSetupFile].find(
+ candidate => tree.exists(candidate),
+ );
+ if (jestSetupFilePath) {
+ const jestSetupFile = tree.read(jestSetupFilePath, 'utf8')!;
if (jestSetupFile.includes(`require('@testing-library/jest-dom')`)) {
json.compilerOptions.types = json.compilerOptions.types ?? [];