From 969e26fd6160e3edd97de17b907ae41cf9fadda2 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 26 Oct 2026 10:11:59 -0400 Subject: [PATCH 1/5] docs(mosaic): point stale reuse table rows at the current files --- packages/mosaic/AGENTS.md | 54 +++++++++++++++++++-------------------- 1 file changed, 27 insertions(+), 27 deletions(-) diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 127bb615991..b234b298c86 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -31,33 +31,33 @@ A flow lives in `src/features//`. The **model** (`*.model.ts(x)`) is th Most problems a feature hits already have a shared answer. Find the row for your problem and use it. Do not write a local version, even a small one. -| Problem | Use | -| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------- | -| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) | -| A form: values, validation, submit, field errors | `useForm` (`src/components/form/`), with `validateAsync` for a server-side check | -| A `
` element, without the browser's validation popup | `Form` (`src/primitives/form/`) | -| A model save whose Clerk errors should land on form fields | `save` in the model (`src/utils/errors.ts`), or reject with `FormSubmitError` | -| A confirm step before an action | `useConfirmController` (`src/blocks/confirm/`) with `Confirmation` (`src/blocks/confirmation/`) | -| A delete that needs the user to type a phrase | `useConfirmController` (`src/blocks/confirm/`) with `Destructive` (`src/blocks/destructive/`) | -| An action that may need step-up verification | `useReverificationFlow` (`src/features/reverification/`) in the model, passed to `useConfirmController` | -| A direct action that needs step-up verification | `useReverificationFlow` in the model, with `ReverificationDialog` (`src/blocks/reverification/`) in the view | -| Showing any error to the user | One of the owners above. They call `toLocalizableError` and resolve copy with `useErrorText` | -| Moving focus after a list row is removed | `useListRemovalFocus` (`src/hooks/`) | -| A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) | -| A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) | -| A placeholder for content that is still loading | `Skeleton` (`src/components/skeleton/`), see [Skeletons](docs/skeletons.md) | -| A date shown to the user | `formatDate` (`src/utils/format-date.ts`) | -| A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) | -| Current time for display labels | `useNow` + `MosaicProvider` ([#10098](https://github.com/clerk/javascript/pull/10098), once available) | -| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | -| An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `styles/feedback.styles.ts` | -| A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) | -| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`use-user-sessions-query.ts`) | -| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` | -| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | -| An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) | -| An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) | -| Styles several components share | `src/styles/*.styles.ts` | +| Problem | Use | +| ------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------- | +| An async action from a button or row: pending, an error, one lock | `usePendingAction` (`src/hooks/`) | +| A form: values, validation, submit, field errors | `useForm` (`src/components/form/`), with `validateAsync` for a server-side check | +| A `` element, without the browser's validation popup | `Form` (`src/primitives/form/`) | +| A model save whose Clerk errors should land on form fields | `save` (`src/utils/errors.ts`) in the model, or reject with `FormSubmitError` | +| A confirm step before an action | `useConfirmController` (`src/blocks/confirm/`) with `Confirmation` (`src/blocks/confirmation/`) | +| A delete that needs the user to type a phrase | `useConfirmController` (`src/blocks/confirm/`) with `Destructive` (`src/blocks/destructive/`) | +| An action that may need step-up verification | `useReverificationFlow` (`src/features/reverification/`) in the model, passed to `useConfirmController` | +| A direct action that needs step-up verification | `useReverificationFlow` in the model, with `ReverificationDialog` (`src/blocks/reverification/`) in the view | +| Showing any error to the user | One of the owners above. They call `toLocalizableError` and resolve copy with `useErrorText` | +| Moving focus after a list row is removed | `useListRemovalFocus` (`src/hooks/`) | +| A debounced, abortable async check as the user types | `useDebouncedAsync` (`src/hooks/`) | +| A spinner that should not flash or flicker | `useSpinDelay` (`src/hooks/`) | +| A placeholder for content that is still loading | `Skeleton` (`src/components/skeleton/`), see [Skeletons](docs/skeletons.md) | +| A date shown to the user | `formatDate` (`src/utils/format-date.ts`) | +| A value turned into kebab case, e.g. for a `data-*` attribute | `kebabCase` (`src/utils/kebab-case.ts`) | +| Current time for display labels | `useNow` (`src/hooks/`) | +| Whether enterprise SSO permits adding another identification | `allowsIdentificationCreation` (`src/utils/allows-identification-creation.ts`) | +| An inline message row that animates open and closed | `useHeldMessage`, `useMessageHeight`, `FeedbackBody` (`src/utils/feedback.tsx`) + `src/styles/feedback.styles.ts` | +| A table whose sorting, paging and search are done by the server | `useServerDataTable` (`src/hooks/`) | +| Data read from a Clerk resource that should be cached across mounts | A query hook in `src/queries/`, called from the model (`src/queries/use-user-sessions-query.ts`) | +| An item changed in place in an infinite list, kept until the next reload | `replaceInPages` (`src/utils/infinite-pages.ts`), passed to the list's `setData` | +| Siblings that slide when an item is added or removed | `useLayoutAnimation` (`src/primitives/hooks/`) | +| An input that reformats as the user types without moving the caret | `useFormattedInput` (`src/primitives/hooks/`) | +| An interaction with an async lifecycle or two values that move together | A machine in the controller file, via `setup()` (`src/machine/`) | +| Styles several components share | `src/styles/*.styles.ts` | ## Signs you are about to invent a pattern From 438add49358836837d5c14633e4a79dd028cdd5b Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 26 Oct 2026 10:12:33 -0400 Subject: [PATCH 2/5] chore(mosaic): enforce layer boundaries with eslint-plugin-boundaries --- .changeset/mosaic-anti-slop-lint.md | 2 + packages/mosaic/AGENTS.md | 2 +- packages/mosaic/eslint-suppressions.json | 2 +- packages/mosaic/eslint/config.mjs | 175 ++++++++++---- packages/mosaic/eslint/import-guards.test.mjs | 219 ++++++++++++++---- packages/mosaic/package.json | 1 + .../features/switcher/switcher.controller.tsx | 2 +- .../src/features/switcher/switcher.types.ts | 13 ++ .../src/features/switcher/switcher.view.tsx | 16 +- pnpm-lock.yaml | 108 +++++++++ 10 files changed, 436 insertions(+), 104 deletions(-) create mode 100644 .changeset/mosaic-anti-slop-lint.md diff --git a/.changeset/mosaic-anti-slop-lint.md b/.changeset/mosaic-anti-slop-lint.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-anti-slop-lint.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index b234b298c86..47e7c994a52 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -96,7 +96,7 @@ A PR that adds, renames or changes the signature of a shared helper in `src/hook ## Lint -Mosaic runs stricter lint rules than the rest of the repo: the `packages/mosaic` block in the root `eslint.config.mjs`, plus the layer import guards in `packages/mosaic/eslint/config.mjs`, which only load through `packages/mosaic/eslint.config.mjs`. Lint with `pnpm lint` from `packages/mosaic`. Running `eslint` from the repo root skips the import guards. +Mosaic runs stricter lint rules than the rest of the repo: the `packages/mosaic` block in the root `eslint.config.mjs`, plus the layer boundaries in `packages/mosaic/eslint/config.mjs`, which only load through `packages/mosaic/eslint.config.mjs`. Lint with `pnpm lint` from `packages/mosaic`. Running `eslint` from the repo root skips them. Every disable directive in `src` needs a `-- reason` (`@eslint-community/eslint-comments/require-description`). The reason is not a code comment under the root no-comments rule. Write why the rule does not apply here, not what the code does; it is what a reviewer judges. diff --git a/packages/mosaic/eslint-suppressions.json b/packages/mosaic/eslint-suppressions.json index b9054df5907..e2619be5e70 100644 --- a/packages/mosaic/eslint-suppressions.json +++ b/packages/mosaic/eslint-suppressions.json @@ -30,7 +30,7 @@ } }, "src/features/user-profile/user-profile-web3-wallets-section/user-profile-solana-wallet.model.ts": { - "@typescript-eslint/no-restricted-imports": { + "boundaries/dependencies": { "count": 1 } }, diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs index fa55f068a51..6cf99a2d6bc 100644 --- a/packages/mosaic/eslint/config.mjs +++ b/packages/mosaic/eslint/config.mjs @@ -1,5 +1,23 @@ +import path from 'node:path'; + +import boundaries from 'eslint-plugin-boundaries'; + import { noDynamicMessageCatalogs, noModelReactState } from './rules.mjs'; +const mosaicRoot = path.resolve(import.meta.dirname, '..'); + +const SHARED = ['primitives', 'icons', 'machine', 'utils', 'hooks', 'styles', 'components', 'localization', 'root']; +const FLOW_UI = { file: { categories: { anyOf: ['view', 'controller'] } } }; +const CLERK_DATA_HOOKS = [ + 'useClerk', + 'useUser', + 'useSession', + 'useSessionList', + 'useOrganization', + 'useOrganizationList', + 'useAuth', +]; + export default [ { name: 'packages/mosaic - import guard plugin', @@ -13,79 +31,148 @@ export default [ }, }, }, + { + name: 'packages/mosaic - type-only imports', + files: ['src/**/*.{ts,tsx}'], + rules: { + '@typescript-eslint/no-import-type-side-effects': 'error', + }, + }, { name: 'packages/mosaic - views and controllers', files: ['src/**/*.{view,controller}.{ts,tsx}'], rules: { 'mosaic/no-dynamic-message-catalogs': 'error', - '@typescript-eslint/no-restricted-imports': [ + }, + }, + { + name: 'packages/mosaic - models', + files: ['src/**/*.model.{ts,tsx}'], + rules: { + 'mosaic/no-model-react-state': 'error', + }, + }, + { + name: 'packages/mosaic - boundaries', + files: ['src/**/*'], + ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.ts'], + plugins: { boundaries }, + settings: { + 'boundaries/root-path': mosaicRoot, + 'boundaries/flag-as-external': { outsideRootPath: true }, + 'boundaries/elements': [ + ...['primitives', 'icons', 'machine', 'utils', 'hooks', 'styles', 'components', 'localization'].map(type => ({ + type, + pattern: `src/${type}`, + partialMatch: false, + })), + { type: 'queries', pattern: 'src/queries', partialMatch: false }, + { type: 'modals', pattern: 'src/modals', partialMatch: false }, + { type: 'blocks', pattern: 'src/blocks', partialMatch: false }, + { type: 'features', pattern: 'src/features/*', partialMatch: false, capture: ['feature'] }, + { type: 'root', pattern: 'src', partialMatch: false }, + ], + 'boundaries/files': [ + { category: 'view', pattern: '**/*.view.{ts,tsx}' }, + { category: 'controller', pattern: '**/*.controller.{ts,tsx}' }, + { category: 'model', pattern: '**/*.model.{ts,tsx}' }, + { category: 'messages', pattern: '**/*.messages.ts' }, + { category: 'css-entry', pattern: 'src/styles/index.ts' }, + ], + }, + rules: { + 'boundaries/dependencies': [ 'error', { - paths: [ + default: 'disallow', + checkAllOrigins: true, + checkUnknownLocals: true, + checkInternals: true, + message: + 'Mosaic layers import down: features, then blocks, modals and queries, then components, hooks, utils and styles, then primitives. Move the shared piece down a layer, or pass it in as a prop.', + policies: [ + { allow: { to: { module: { origin: ['external', 'core'] } } } }, + { allow: { to: { element: { type: '{{ from.element.type }}' } } } }, + { from: { element: { type: 'root' } }, allow: { to: { element: { type: '*' } } } }, + { from: { file: { categories: 'css-entry' } }, allow: { to: { element: { type: '*' } } } }, { - name: '@clerk/shared/react', - importNames: [ - 'useClerk', - 'useUser', - 'useSession', - 'useSessionList', - 'useOrganization', - 'useOrganizationList', - 'useAuth', + from: { element: { type: ['utils', 'hooks', 'styles', 'components', 'localization', 'modals'] } }, + allow: { to: { element: { type: SHARED } } }, + }, + { + from: { element: { type: 'localization' } }, + allow: { to: { file: { categories: 'messages' } } }, + }, + { + from: { element: { type: 'blocks' } }, + allow: [ + { to: { element: { type: SHARED } } }, + { to: { element: { type: 'features', captured: { feature: 'reverification' } } } }, ], + }, + { + from: { element: { type: 'features' } }, + allow: { to: { element: { type: [...SHARED, 'queries', 'modals', 'blocks'] } } }, + }, + { + from: FLOW_UI, + disallow: { + dependency: { source: '@clerk/shared/react', kind: 'value', specifiers: CLERK_DATA_HOOKS }, + }, message: 'Clerk data hooks can only be imported in models. Pass plain data and callbacks to controllers and views.', - allowTypeImports: true, }, - ], - patterns: [ { - regex: '[.]messages(?:[.]ts)?$', + from: FLOW_UI, + disallow: { + to: { file: { categories: 'messages' } }, + dependency: { kind: 'value', nodeKind: ['import', 'export'] }, + }, message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', - allowTypeImports: true, }, { - regex: '(^|/)queries/', + from: FLOW_UI, + disallow: { to: { element: { type: 'queries' } }, dependency: { kind: 'value' } }, message: 'Read Clerk queries in the model and pass plain data to the controller or view.', - allowTypeImports: true, }, { - regex: '(^|/)use-mosaic-environment(?:[.]ts)?$', - importNames: ['useMosaicEnvironment'], + from: FLOW_UI, + disallow: { + to: { element: { type: 'hooks' } }, + dependency: { kind: 'value', specifiers: 'useMosaicEnvironment' }, + }, message: 'Read the Mosaic environment in the model and pass plain data to the controller or view.', - allowTypeImports: true, }, { - regex: '(^|/)use-mosaic-router(?:[.]ts)?$', - importNames: ['useMosaicRouter'], + from: FLOW_UI, + disallow: { + to: { element: { type: 'hooks' } }, + dependency: { kind: 'value', specifiers: 'useMosaicRouter' }, + }, message: 'Read the Mosaic router in the model and pass plain callbacks to the controller or view.', - allowTypeImports: true, }, { - regex: '(^|/)use-mosaic-support-email(?:[.]ts)?$', - importNames: ['useMosaicSupportEmail'], + from: FLOW_UI, + disallow: { + to: { element: { type: 'hooks' } }, + dependency: { kind: 'value', specifiers: 'useMosaicSupportEmail' }, + }, message: 'Read the support email in the model and pass plain data to the controller or view.', - allowTypeImports: true, }, - ], - }, - ], - }, - }, - { - name: 'packages/mosaic - models', - files: ['src/**/*.model.{ts,tsx}'], - rules: { - 'mosaic/no-model-react-state': 'error', - '@typescript-eslint/no-restricted-imports': [ - 'error', - { - paths: [ { - name: 'react', - importNames: ['useState', 'useReducer'], + from: { file: { categories: 'model' } }, + disallow: { dependency: { source: 'react', kind: 'value', specifiers: ['useState', 'useReducer'] } }, message: 'Put interaction state in the controller and pass its results to the view.', - allowTypeImports: true, + }, + { + from: { file: { categories: 'model' } }, + disallow: { to: { file: { categories: ['view', 'controller'] } }, dependency: { kind: 'value' } }, + message: 'A model answers with plain data and callbacks. Import only types from the controller or view.', + }, + { + from: { file: { categories: 'controller' } }, + disallow: { to: { file: { categories: 'view' } }, dependency: { kind: 'value' } }, + message: 'A controller hands props to the view. Import only types from the view.', }, ], }, diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index 72c3bec88e0..73e4c59a6e8 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -5,6 +5,26 @@ import { describe, expect, test } from 'vitest'; const root = path.resolve(import.meta.dirname, '../../..'); const mosaicRoot = path.resolve(import.meta.dirname, '..'); +const featureDir = 'src/features/reverification'; +const layerFixtures = [ + 'src/primitives/sample.ts', + 'src/components/sample.tsx', + 'src/hooks/sample.ts', + 'src/utils/sample.ts', + 'src/localization/sample.ts', + 'src/modals/sample.ts', + 'src/blocks/sample.tsx', + 'src/queries/sample.ts', +]; +const flowFixtures = [ + 'sample.view.ts', + 'sample.view.tsx', + 'sample.controller.ts', + 'sample.controller.tsx', + 'sample.model.ts', + 'sample.model.tsx', + 'sample.messages.ts', +].map(file => `${featureDir}/${file}`); const rootEslint = new ESLint({ cwd: root }); const eslint = new ESLint({ cwd: mosaicRoot, @@ -12,32 +32,31 @@ const eslint = new ESLint({ languageOptions: { parserOptions: { projectService: { - allowDefaultProject: [ - 'packages/mosaic/src/features/sample.view.ts', - 'packages/mosaic/src/features/sample.view.tsx', - 'packages/mosaic/src/features/sample.controller.ts', - 'packages/mosaic/src/features/sample.controller.tsx', - 'packages/mosaic/src/features/sample.model.ts', - 'packages/mosaic/src/features/sample.model.tsx', - 'packages/mosaic/src/features/sample.messages.ts', - ], + allowDefaultProject: [...flowFixtures, ...layerFixtures].map(file => `packages/mosaic/${file}`), + maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32, }, }, }, }, }); -async function lint(file, source) { - const [result] = await eslint.lintText(source, { filePath: path.join(mosaicRoot, 'src/features', file) }); +async function lintAt(file, source) { + const [result] = await eslint.lintText(source, { filePath: path.join(mosaicRoot, file) }); return result.messages; } -async function restrictedImports(file, source) { - const messages = await lint(file, source); +async function lint(file, source) { + return lintAt(`${featureDir}/${file}`, source); +} + +async function boundaryMessagesAt(file, source) { + const messages = await lintAt(file, source); expect(messages.find(message => message.fatal)).toBeUndefined(); - return messages - .filter(message => message.ruleId === '@typescript-eslint/no-restricted-imports') - .map(message => message.message); + return messages.filter(message => message.ruleId === 'boundaries/dependencies').map(message => message.message); +} + +async function restrictedImports(file, source) { + return boundaryMessagesAt(`${featureDir}/${file}`, source); } describe('Mosaic import boundaries', { timeout: 30_000 }, () => { @@ -47,9 +66,9 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { const messages = await restrictedImports( file, [ - "import { messages } from './sample.messages';", + "import { reverificationMessages } from './reverification.messages';", "import { useUser as user } from '@clerk/shared/react';", - "import { useMosaicRouter as router } from '@/hooks/use-mosaic-router';", + "import { useMosaicRouter as router } from '../../hooks/use-mosaic-router';", ].join('\n'), ); @@ -64,10 +83,9 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { const messages = await restrictedImports( 'sample.view.tsx', [ - "import type { MessageKey } from '@/features/sample.messages.ts';", - "import { type OtherMessageKey } from './sample.messages';", - "import { messages } from '@/features/sample.messages.ts';", - "import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment.ts';", + "import type { ReverificationMessageKey } from './reverification.messages';", + "import { reverificationMessages } from './reverification.messages';", + "import { useMosaicEnvironment } from '../../hooks/use-mosaic-environment';", "import { useMosaicSupportEmail } from '../../hooks/use-mosaic-support-email';", ].join('\n'), ); @@ -76,6 +94,16 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { expect(messages[0]).toContain('useMessages()'); }); + test('writes inline type-only imports as import type, so they stay type-only', async () => { + const messages = await lint( + 'sample.view.tsx', + "import { type ReverificationMessages } from './reverification.messages';", + ); + expect( + messages.filter(message => message.ruleId === '@typescript-eslint/no-import-type-side-effects'), + ).toHaveLength(1); + }); + test.each([ 'useClerk', 'useUser', @@ -93,12 +121,12 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { expect(messages[0]).toContain('model'); }); - test.each([ - "import { useUserSessionsQuery } from '../queries/use-user-sessions-query';", - "import { useUserSessionsQuery } from '@/queries/use-user-sessions-query.ts';", - ])('rejects Clerk queries in views and controllers: %s', async source => { + test('rejects Clerk queries in views and controllers', async () => { for (const file of ['sample.view.tsx', 'sample.controller.ts']) { - const messages = await restrictedImports(file, source); + const messages = await restrictedImports( + file, + "import { useUserSessionsQuery } from '../../queries/use-user-sessions-query';", + ); expect(messages).toHaveLength(1); expect(messages[0]).toContain('model'); } @@ -109,11 +137,49 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { async file => { const messages = await restrictedImports(file, "import { useState as state, useReducer } from 'react';"); - expect(messages).toHaveLength(2); - expect(messages.every(message => message.includes('controller'))).toBe(true); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('controller'); }, ); + test('a model imports only types from its controller and view', async () => { + expect( + await restrictedImports( + 'sample.model.ts', + [ + "import type { ReverificationController } from './reverification.controller';", + "import type { ReverificationViewProps } from './reverification.view';", + ].join('\n'), + ), + ).toEqual([]); + + const messages = await restrictedImports( + 'sample.model.ts', + [ + "import { useReverificationController } from './reverification.controller';", + "import { ReverificationView } from './reverification.view';", + ].join('\n'), + ); + expect(messages).toHaveLength(2); + expect(messages.every(message => message.includes('plain data'))).toBe(true); + }); + + test('a controller imports only types from its view', async () => { + expect( + await restrictedImports( + 'sample.controller.ts', + "import type { ReverificationViewProps } from './reverification.view';", + ), + ).toEqual([]); + + const messages = await restrictedImports( + 'sample.controller.ts', + "import { ReverificationView } from './reverification.view';", + ); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('props'); + }); + test.each([ "import React from 'react'; React.useState(0);", "import R from 'react'; R.useReducer(value => value, 0);", @@ -148,11 +214,12 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { }); test.each(['sample.view.ts', 'sample.view.tsx', 'sample.controller.ts', 'sample.controller.tsx'])( - '%s rejects dynamic catalog imports', + '%s rejects dynamic catalog imports once', async file => { - for (const source of ["import('./sample.messages');", 'import(`@/features/sample.messages.ts`);']) { + for (const source of ["import('./reverification.messages');", 'import(`./reverification.messages.ts`);']) { const messages = await lint(file, source); expect(messages.find(message => message.fatal)).toBeUndefined(); + expect(messages.filter(message => message.ruleId === 'boundaries/dependencies')).toEqual([]); expect(messages.filter(message => message.ruleId === 'mosaic/no-dynamic-message-catalogs')).toEqual([ expect.objectContaining({ message: 'Use useMessages() for localized and overridden copy instead of importing catalog values.', @@ -164,36 +231,60 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { ); test.each([ - ['sample.view.ts', "import('./sample.controller');"], - ['sample.controller.ts', 'import(`./sample.model`);'], - ['sample.view.ts', "export type Messages = typeof import('./sample.messages');"], - ['sample.model.ts', "import('./sample.messages');"], - ['sample.messages.ts', "import('./other.messages');"], + ['sample.view.ts', "import('./reverification.controller');"], + ['sample.controller.ts', 'import(`./reverification.model`);'], + ['sample.view.ts', "export type Messages = typeof import('./reverification.messages');"], + ['sample.model.ts', "import('./reverification.messages');"], + ['sample.messages.ts', "import('./reverification.messages');"], ['sample.controller.ts', "import React from 'react'; React.useState(0);"], ])('preserves allowed imports in %s: %s', async (file, source) => { const messages = await lint(file, source); expect(messages.find(message => message.fatal)).toBeUndefined(); expect( messages.filter(message => - ['mosaic/no-model-react-state', 'mosaic/no-dynamic-message-catalogs'].includes(message.ruleId), + ['mosaic/no-model-react-state', 'mosaic/no-dynamic-message-catalogs', 'boundaries/dependencies'].includes( + message.ruleId, + ), ), ).toEqual([]); }); test('allows model Clerk hooks, controller state, catalog registration and shared utilities', async () => { expect(await restrictedImports('sample.model.ts', "import { useUser } from '@clerk/shared/react';")).toEqual([]); - expect(await restrictedImports('sample.model.ts', "import { messages } from './sample.messages';")).toEqual([]); + expect( + await restrictedImports('sample.model.ts', "import { reverificationMessages } from './reverification.messages';"), + ).toEqual([]); + expect( + await restrictedImports( + 'sample.model.ts', + "import { useUserSessionsQuery } from '../../queries/use-user-sessions-query';", + ), + ).toEqual([]); expect(await restrictedImports('sample.controller.ts', "import { useState } from 'react';")).toEqual([]); expect( await restrictedImports('sample.controller.ts', "import type { useUser } from '@clerk/shared/react';"), ).toEqual([]); - expect(await restrictedImports('sample.messages.ts', "import { messages } from './other.messages';")).toEqual([]); + expect( + await restrictedImports('sample.controller.ts', "import { useReverification } from '@clerk/shared/react';"), + ).toEqual([]); + expect( + await restrictedImports( + 'sample.messages.ts', + "import { reverificationMessages } from './reverification.messages';", + ), + ).toEqual([]); expect( await restrictedImports('sample.view.tsx', "import { isClerkAPIResponseError } from '@clerk/shared/error';"), ).toEqual([]); - expect(await restrictedImports('sample.view.tsx', "import { useController } from './sample.controller';")).toEqual( - [], - ); + expect( + await restrictedImports( + 'sample.view.tsx', + "import { useReverificationController } from './reverification.controller';", + ), + ).toEqual([]); + expect( + await restrictedImports('sample.view.tsx', "import { usePendingAction } from '../../hooks/use-pending-action';"), + ).toEqual([]); expect( await restrictedImports('sample.view.tsx', "import type { CountryIso } from '@clerk/shared/phone';"), ).toEqual([]); @@ -211,6 +302,47 @@ describe('Mosaic import boundaries', { timeout: 30_000 }, () => { }); }); +describe('Mosaic layers', { timeout: 30_000 }, () => { + test.each([ + ['src/primitives/sample.ts', "import { Button } from '../components/button';"], + ['src/primitives/sample.ts', "import { kebabCase } from '../utils/kebab-case';"], + ['src/components/sample.tsx', "import { Reverification } from '../features/reverification';"], + ['src/components/sample.tsx', "import { Confirmation } from '../blocks/confirmation';"], + ['src/hooks/sample.ts', "import { useUserSessionsQuery } from '../queries/use-user-sessions-query';"], + ['src/utils/sample.ts', "import { useModal } from '../modals/use-modal';"], + ['src/localization/sample.ts', "import { Reverification } from '../features/reverification';"], + ['src/modals/sample.ts', "import { Confirmation } from '../blocks/confirmation';"], + ['src/blocks/sample.tsx', "import { UserButton } from '../features/user-button/user-button';"], + ['src/queries/sample.ts', "import { Button } from '../components/button';"], + ])('%s cannot import up: %s', async (file, source) => { + const messages = await boundaryMessagesAt(file, source); + expect(messages).toHaveLength(1); + expect(messages[0]).toContain('down'); + }); + + test.each([ + ['src/primitives/sample.ts', "import { Form } from './form';"], + ['src/components/sample.tsx', "import { Form } from '../primitives/form';"], + ['src/components/sample.tsx', "import { tokens } from '../tokens.stylex';"], + ['src/hooks/sample.ts', "import type { TableHeaderCellProps } from '../components/table';"], + ['src/utils/sample.ts', "import { feedbackStyles } from '../styles/feedback.styles';"], + ['src/localization/sample.ts', "import { formMessages } from '../components/form/form.messages';"], + [ + 'src/localization/sample.ts', + "import { apiKeysTableMessages } from '../features/api-keys/api-keys-table.messages';", + ], + ['src/modals/sample.ts', "import { setup } from '../machine/setup';"], + ['src/blocks/sample.tsx', "import { Reverification } from '../features/reverification';"], + ['src/blocks/sample.tsx', "import { autoUpdate } from '@floating-ui/react';"], + [`${featureDir}/sample.view.tsx`, "import { Confirmation } from '../../blocks/confirmation';"], + [`${featureDir}/sample.view.tsx`, "import { useModal } from '../../modals/use-modal';"], + [`${featureDir}/sample.view.tsx`, "import { UserButton } from '../user-button/user-button';"], + ['src/styles/index.ts', "export { UserButton } from '../features/user-button/user-button';"], + ])('%s may import down or across: %s', async (file, source) => { + expect(await boundaryMessagesAt(file, source)).toEqual([]); + }); +}); + describe('Mosaic package configuration', { timeout: 30_000 }, () => { test.each([ 'src/features/sample.view.tsx', @@ -225,9 +357,10 @@ describe('Mosaic package configuration', { timeout: 30_000 }, () => { const inheritedRules = { ...mosaicConfig.rules }; for (const rule of [ - '@typescript-eslint/no-restricted-imports', + 'boundaries/dependencies', 'mosaic/no-model-react-state', 'mosaic/no-dynamic-message-catalogs', + '@typescript-eslint/no-import-type-side-effects', ]) { expect(rootConfig.rules[rule]).toBeUndefined(); delete inheritedRules[rule]; diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index c3ae73d54a8..4d19ab84439 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -82,6 +82,7 @@ "@types/react-dom": "catalog:react", "axe-core": "^4.11.3", "bundlewatch": "^0.4.2", + "eslint-plugin-boundaries": "7.3.0", "happy-dom": "^20.8.9", "msw": "2.15.0", "playwright": "1.62.1", diff --git a/packages/mosaic/src/features/switcher/switcher.controller.tsx b/packages/mosaic/src/features/switcher/switcher.controller.tsx index d278b1ab895..9c4dd517f31 100644 --- a/packages/mosaic/src/features/switcher/switcher.controller.tsx +++ b/packages/mosaic/src/features/switcher/switcher.controller.tsx @@ -3,8 +3,8 @@ import { setup } from '../../machine/setup'; import { useMachine } from '../../machine/use-machine'; import type { SwitcherModel } from './switcher.model'; import type { SwitcherMenuProps, SwitcherMode } from './switcher.types'; +import { switcherBusyKeys } from './switcher.types'; import type { SwitcherTriggerProps, SwitcherViewProps } from './switcher.view'; -import { switcherBusyKeys } from './switcher.view'; /** The model once Clerk has answered, which is the only shape an action can start from. */ export type SwitcherReadyModel = Extract; diff --git a/packages/mosaic/src/features/switcher/switcher.types.ts b/packages/mosaic/src/features/switcher/switcher.types.ts index 1e26c9ef9f4..3affed65882 100644 --- a/packages/mosaic/src/features/switcher/switcher.types.ts +++ b/packages/mosaic/src/features/switcher/switcher.types.ts @@ -125,6 +125,19 @@ export interface SwitcherBusyState { pendingKey?: string | null; } +/** + * Stable keys naming which affordance owns the single in-flight action. Shared by the connected + * container (which sets `pendingKey`) and the view (which matches against it). + */ +export const switcherBusyKeys = { + selectOrganization: (organizationId: string | null) => `select-org:${organizationId ?? 'personal'}`, + switchSession: (sessionId: string) => `switch:${sessionId}`, + signOutSession: (sessionId: string, from: SwitcherSlot) => `sign-out:${from}:${sessionId}`, + signOutAll: () => 'sign-out-all', + acceptSuggestion: (suggestionId: string) => `accept-suggestion:${suggestionId}`, + acceptInvitation: (invitationId: string) => `accept-invitation:${invitationId}`, +} as const; + // ─── Menu items ───────────────────────────────────────────────────────────── /** diff --git a/packages/mosaic/src/features/switcher/switcher.view.tsx b/packages/mosaic/src/features/switcher/switcher.view.tsx index 83ab4c30c96..8d6667357b7 100644 --- a/packages/mosaic/src/features/switcher/switcher.view.tsx +++ b/packages/mosaic/src/features/switcher/switcher.view.tsx @@ -31,7 +31,7 @@ import { focusOutline } from '../../styles/focus-outline.styles'; import { rtl } from '../../styles/rtl.styles'; import { truncationStyles } from '../../styles/typography.styles'; import { applyOrder } from '../../utils/apply-order'; -import type { SwitcherLayout, SwitcherSlot } from './switcher.layout'; +import type { SwitcherLayout } from './switcher.layout'; import { resolveSwitcherLayout } from './switcher.layout'; import { styles } from './switcher.styles'; import type { @@ -46,6 +46,7 @@ import type { SwitcherSession, SwitcherTriggerBadge, } from './switcher.types'; +import { switcherBusyKeys } from './switcher.types'; import { BadgedAvatar, RowAvatar } from './switcher-avatar.view'; import { SwitcherHeader } from './switcher-header.view'; import { @@ -66,19 +67,6 @@ import { useSwitcherTriggerController } from './switcher-trigger.controller'; // what the model and the view agree on, so neither file owns them. export type * from './switcher.types'; -/** - * Stable keys naming which affordance owns the single in-flight action. Shared by the connected - * container (which sets `pendingKey`) and the view (which matches against it). - */ -export const switcherBusyKeys = { - selectOrganization: (organizationId: string | null) => `select-org:${organizationId ?? 'personal'}`, - switchSession: (sessionId: string) => `switch:${sessionId}`, - signOutSession: (sessionId: string, from: SwitcherSlot) => `sign-out:${from}:${sessionId}`, - signOutAll: () => 'sign-out-all', - acceptSuggestion: (suggestionId: string) => `accept-suggestion:${suggestionId}`, - acceptInvitation: (invitationId: string) => `accept-invitation:${invitationId}`, -} as const; - type SwitcherContextValue = SwitcherData & SwitcherCallbacks & SwitcherBusyState & diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d773008c6ae..ea905c3029f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -875,6 +875,9 @@ importers: bundlewatch: specifier: ^0.4.2 version: 0.4.2 + eslint-plugin-boundaries: + specifier: 7.3.0 + version: 7.3.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)) happy-dom: specifier: ^20.8.9 version: 20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) @@ -2298,6 +2301,10 @@ packages: commander: optional: true + '@boundaries/elements@3.2.0': + resolution: {integrity: sha512-c6wtVWptH+IXouw4/Kq8+/Fovp6XVNK3lBHS0YCQqLRFIEUstb2KFv61KSqpQavyw22ivz7oE45gj0i3Mbq4Fg==} + engines: {node: '>=18.18'} + '@braidai/lang@1.1.2': resolution: {integrity: sha512-qBcknbBufNHlui137Hft8xauQMTZDKdophmLFv05r2eNmdIv/MlPuP4TdUknHG68UdWLgVZwgxVe735HzJNIwA==} @@ -8874,6 +8881,33 @@ packages: eslint-import-resolver-webpack: optional: true + eslint-module-utils@2.14.0: + resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + + eslint-plugin-boundaries@7.3.0: + resolution: {integrity: sha512-NzzcW1FN7LEuHEdEPuPGzpqADfO0r6VL3z57gqDfasiU5pEUqKCF7PeUpbyuLW7qHfLR4QQcXSBRjJf/nYl5Bw==} + engines: {node: '>=18.18'} + peerDependencies: + eslint: '>=6.0.0' + eslint-plugin-import@2.32.0: resolution: {integrity: sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==} engines: {node: '>=4'} @@ -9774,6 +9808,11 @@ packages: crossws: optional: true + handlebars@4.7.10: + resolution: {integrity: sha512-P5VJMVM7qgBn6vjXMw8WG9uVI+ncf2pi72j4de4yz5ZULLj2RGqLYaKOYGsgyrViQ0tePOVlN1tDCCXXtFqXKg==} + engines: {node: '>=0.4.7'} + hasBin: true + happy-dom@20.10.2: resolution: {integrity: sha512-5p9Sxis3eowDJKqx90QCsgbNA02XXqJ59NOHvD4V6cxp+rP4d/xOyVx7uY3hS8hiUbY1VeiFH8lbJ81AyuDVLQ==} engines: {node: '>=20.0.0'} @@ -9817,6 +9856,10 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hasown@2.0.4: + resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} + engines: {node: '>= 0.4'} + hast-util-from-parse5@8.0.3: resolution: {integrity: sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==} @@ -10180,6 +10223,10 @@ packages: resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} engines: {node: '>= 0.4'} + is-core-module@2.17.0: + resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} + engines: {node: '>= 0.4'} + is-data-view@1.0.2: resolution: {integrity: sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==} engines: {node: '>= 0.4'} @@ -14394,6 +14441,11 @@ packages: ufo@1.6.4: resolution: {integrity: sha512-JFNbkD1Svwe0KvGi8GOeLcP4kAWQ609twvCdcHxq1oSL8svv39ZuSvajcD8B+5D0eL4+s1Is2D/O6KN3qcTeRA==} + uglify-js@3.19.3: + resolution: {integrity: sha512-v3Xu+yuwBXisp6QYTcH4UbH+xYJXqnq2m/LtQVWKWzYc1iehYnLixoQDN9FH6/j9/oybfd6W9Ghwkl8+UMKTKQ==} + engines: {node: '>=0.8.0'} + hasBin: true + ultrahtml@1.7.0: resolution: {integrity: sha512-2xRd0VHoAQE4M+vF/DvFFB7pUV0ZxTW1TLi7lHQWnF/Sb5TPeEUV/l+hxcNnGO00ZXGnR0voCMmYRKQf+rvJ2g==} @@ -15187,6 +15239,9 @@ packages: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} + wordwrap@1.0.0: + resolution: {integrity: sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q==} + wrap-ansi@6.2.0: resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} engines: {node: '>=8'} @@ -16526,6 +16581,12 @@ snapshots: citty: 0.2.2 commander: 15.0.0 + '@boundaries/elements@3.2.0': + dependencies: + handlebars: 4.7.10 + is-core-module: 2.17.0 + picomatch: 2.3.2 + '@braidai/lang@1.1.2': {} '@bruits/satteri-darwin-arm64@0.10.5': @@ -24664,6 +24725,31 @@ snapshots: transitivePeerDependencies: - supports-color + eslint-module-utils@2.14.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)): + dependencies: + debug: 3.2.7(supports-color@8.1.1) + optionalDependencies: + '@typescript-eslint/parser': 8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.31.0(jiti@2.7.0) + eslint-import-resolver-node: 0.3.9 + eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.7.0)) + transitivePeerDependencies: + - supports-color + + eslint-plugin-boundaries@7.3.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)): + dependencies: + '@boundaries/elements': 3.2.0 + chalk: 4.1.2 + eslint: 9.31.0(jiti@2.7.0) + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)) + handlebars: 4.7.10 + transitivePeerDependencies: + - '@typescript-eslint/parser' + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)): dependencies: '@rtsao/scc': 1.1.0 @@ -25917,6 +26003,15 @@ snapshots: rou3: 0.8.1 srvx: 0.11.22 + handlebars@4.7.10: + dependencies: + minimist: 1.2.8 + neo-async: 2.6.2 + source-map: 0.6.1 + wordwrap: 1.0.0 + optionalDependencies: + uglify-js: 3.19.3 + happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10): dependencies: '@types/node': 22.20.1 @@ -25961,6 +26056,10 @@ snapshots: dependencies: function-bind: 1.1.2 + hasown@2.0.4: + dependencies: + function-bind: 1.1.2 + hast-util-from-parse5@8.0.3: dependencies: '@types/hast': 3.0.5 @@ -26415,6 +26514,10 @@ snapshots: dependencies: hasown: 2.0.2 + is-core-module@2.17.0: + dependencies: + hasown: 2.0.4 + is-data-view@1.0.2: dependencies: call-bound: 1.0.4 @@ -31632,6 +31735,9 @@ snapshots: ufo@1.6.4: {} + uglify-js@3.19.3: + optional: true + ultrahtml@1.7.0: {} unbox-primitive@1.1.0: @@ -32537,6 +32643,8 @@ snapshots: word-wrap@1.2.5: {} + wordwrap@1.0.0: {} + wrap-ansi@6.2.0: dependencies: ansi-styles: 4.3.0 From 02d1350e9a3f5e56c8fdbed15dc9609bbb512774 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 26 Oct 2026 10:13:01 -0400 Subject: [PATCH 3/5] chore(mosaic): lint tests for focused runs, missing assertions and duplicate titles --- packages/mosaic/AGENTS.md | 2 +- packages/mosaic/eslint/config.mjs | 12 ++ packages/mosaic/eslint/import-guards.test.mjs | 29 ++++- packages/mosaic/package.json | 1 + .../fake-fapi-isolation.feature.test.tsx | 2 + .../__tests__/user-button.feature.test.tsx | 2 +- .../user-profile-phone-actions.test.tsx | 2 +- .../mosaic/src/primitives/menu/menu.test.tsx | 2 +- .../mosaic/src/primitives/otp/otp.test.tsx | 2 +- .../select/__tests__/select.test.tsx | 4 +- pnpm-lock.yaml | 119 ++++++++++++++++++ 11 files changed, 169 insertions(+), 8 deletions(-) diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 47e7c994a52..2a0fd082e73 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -96,7 +96,7 @@ A PR that adds, renames or changes the signature of a shared helper in `src/hook ## Lint -Mosaic runs stricter lint rules than the rest of the repo: the `packages/mosaic` block in the root `eslint.config.mjs`, plus the layer boundaries in `packages/mosaic/eslint/config.mjs`, which only load through `packages/mosaic/eslint.config.mjs`. Lint with `pnpm lint` from `packages/mosaic`. Running `eslint` from the repo root skips them. +Mosaic runs stricter lint rules than the rest of the repo: the `packages/mosaic` block in the root `eslint.config.mjs`, plus the layer boundaries and test rules in `packages/mosaic/eslint/config.mjs`, which only load through `packages/mosaic/eslint.config.mjs`. Lint with `pnpm lint` from `packages/mosaic`. Running `eslint` from the repo root skips them. Every disable directive in `src` needs a `-- reason` (`@eslint-community/eslint-comments/require-description`). The reason is not a code comment under the root no-comments rule. Write why the rule does not apply here, not what the code does; it is what a reviewer judges. diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs index 6cf99a2d6bc..fe1f2c3fd56 100644 --- a/packages/mosaic/eslint/config.mjs +++ b/packages/mosaic/eslint/config.mjs @@ -1,5 +1,6 @@ import path from 'node:path'; +import vitest from '@vitest/eslint-plugin'; import boundaries from 'eslint-plugin-boundaries'; import { noDynamicMessageCatalogs, noModelReactState } from './rules.mjs'; @@ -52,6 +53,17 @@ export default [ 'mosaic/no-model-react-state': 'error', }, }, + { + name: 'packages/mosaic - tests', + files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], + ignores: ['src/**/*.test-d.{ts,tsx}'], + plugins: { vitest }, + rules: { + 'vitest/expect-expect': ['error', { assertFunctionNames: ['expect', 'expect*', 'assert*'] }], + 'vitest/no-focused-tests': 'error', + 'vitest/no-identical-title': 'error', + }, + }, { name: 'packages/mosaic - boundaries', files: ['src/**/*'], diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index 73e4c59a6e8..5b00c9c62ab 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -25,6 +25,7 @@ const flowFixtures = [ 'sample.model.tsx', 'sample.messages.ts', ].map(file => `${featureDir}/${file}`); +const testFixture = 'src/hooks/__tests__/sample.test.ts'; const rootEslint = new ESLint({ cwd: root }); const eslint = new ESLint({ cwd: mosaicRoot, @@ -32,7 +33,7 @@ const eslint = new ESLint({ languageOptions: { parserOptions: { projectService: { - allowDefaultProject: [...flowFixtures, ...layerFixtures].map(file => `packages/mosaic/${file}`), + allowDefaultProject: [...flowFixtures, ...layerFixtures, testFixture].map(file => `packages/mosaic/${file}`), maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 32, }, }, @@ -343,6 +344,32 @@ describe('Mosaic layers', { timeout: 30_000 }, () => { }); }); +describe('Mosaic tests', { timeout: 30_000 }, () => { + async function vitestRules(source) { + const messages = await lintAt(testFixture, ["import { describe, expect, test } from 'vitest';", source].join('\n')); + expect(messages.find(message => message.fatal)).toBeUndefined(); + return messages.map(message => message.ruleId).filter(rule => rule?.startsWith('vitest/')); + } + + test.each([ + "test('adds', () => { expect(1 + 1).toBe(2); });", + "function expectSum(sum) { expect(sum).toBe(2); }\ntest('adds', () => { expectSum(1 + 1); });", + ])('accept a test that asserts: %s', async source => { + expect(await vitestRules(source)).toEqual([]); + }); + + test.each([ + ['vitest/no-focused-tests', "test.only('adds', () => { expect(1 + 1).toBe(2); });"], + ['vitest/expect-expect', "test('adds', () => { void (1 + 1); });"], + [ + 'vitest/no-identical-title', + "describe('math', () => { test('adds', () => { expect(1).toBe(1); }); test('adds', () => { expect(2).toBe(2); }); });", + ], + ])('report %s', async (rule, source) => { + expect(await vitestRules(source)).toContain(rule); + }); +}); + describe('Mosaic package configuration', { timeout: 30_000 }, () => { test.each([ 'src/features/sample.view.tsx', diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 4d19ab84439..4469d65e0ca 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -80,6 +80,7 @@ "@testing-library/user-event": "^14.6.5", "@types/react": "catalog:react", "@types/react-dom": "catalog:react", + "@vitest/eslint-plugin": "1.6.28", "axe-core": "^4.11.3", "bundlewatch": "^0.4.2", "eslint-plugin-boundaries": "7.3.0", diff --git a/packages/mosaic/src/__tests__/feature/fake-fapi-isolation.feature.test.tsx b/packages/mosaic/src/__tests__/feature/fake-fapi-isolation.feature.test.tsx index bc3a28aab87..700da58ae8a 100644 --- a/packages/mosaic/src/__tests__/feature/fake-fapi-isolation.feature.test.tsx +++ b/packages/mosaic/src/__tests__/feature/fake-fapi-isolation.feature.test.tsx @@ -5,6 +5,7 @@ import { fapiClient, fapiSession, fapiUser } from './fapi'; import { renderWithClerk } from './render'; describe('fake FAPI isolation between tests', () => { + // eslint-disable-next-line vitest/expect-expect -- Leaves work running for the next test, which asserts it is isolated. it('leaves a request in flight when the test ends', async () => { const user = fapiUser({ id: 'user_1' }); serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); @@ -20,6 +21,7 @@ describe('fake FAPI isolation between tests', () => { memberships.release(); }); + // eslint-disable-next-line vitest/expect-expect -- Leaves work running for the next test, which asserts it is isolated. it('starts a request after the test ends', async () => { const user = fapiUser({ id: 'user_1' }); serveFapi({ client: fapiClient([fapiSession({ id: 'sess_1', user })]) }); diff --git a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx index 73eac2992bd..b1d53a4a5e6 100644 --- a/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx +++ b/packages/mosaic/src/features/user-button/__tests__/user-button.feature.test.tsx @@ -1103,7 +1103,7 @@ describe('UserButton', () => { }); }); - describe('while an action is in flight', () => { + describe('while an action is in flight, beside the busy row', () => { it.each(['Other', 'Personal account', 'Sign out'])( 'holds "%s" in place, aria-disabled and still focusable', async label => { diff --git a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-actions.test.tsx b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-actions.test.tsx index b8799c3cceb..b5fcdaa1d09 100644 --- a/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-actions.test.tsx +++ b/packages/mosaic/src/features/user-profile/user-profile-phone-section/__tests__/user-profile-phone-actions.test.tsx @@ -146,7 +146,7 @@ describe('phone actions', () => { expect(onRemovePhone).not.toHaveBeenCalled(); }); - it('keeps the phone when removal is canceled with Escape', async () => { + it('keeps the phone when removal opened from the keyboard is canceled with Escape', async () => { const user = userEvent.setup(); const onRemovePhone = vi.fn(); renderPhone({ diff --git a/packages/mosaic/src/primitives/menu/menu.test.tsx b/packages/mosaic/src/primitives/menu/menu.test.tsx index 08b9a70783b..95337edf729 100644 --- a/packages/mosaic/src/primitives/menu/menu.test.tsx +++ b/packages/mosaic/src/primitives/menu/menu.test.tsx @@ -500,7 +500,7 @@ describe('Menu', () => { }); }); - describe('ARIA attributes', () => { + describe('ARIA roles and states', () => { it('trigger has aria-expanded', async () => { const user = userEvent.setup(); render( diff --git a/packages/mosaic/src/primitives/otp/otp.test.tsx b/packages/mosaic/src/primitives/otp/otp.test.tsx index 30511f8abeb..1dc5a33f37b 100644 --- a/packages/mosaic/src/primitives/otp/otp.test.tsx +++ b/packages/mosaic/src/primitives/otp/otp.test.tsx @@ -517,7 +517,7 @@ describe('Otp', () => { }); }); - describe('form integration', () => { + describe('form validation', () => { it('marks every slot required so a partial code fails validation', () => { render( { }); }); - describe('ARIA attributes', () => { + describe('ARIA roles and states', () => { it('options have role=option', () => { renderSelect({ defaultOpen: true }); @@ -766,7 +766,7 @@ describe('Select', () => { }); }); - describe('alignItemWithTrigger', () => { + describe('alignItemWithTrigger positioning', () => { it('marks the positioner with data-side="none" and blocks page scrolling while open', async () => { const user = userEvent.setup(); renderSelect({ alignItemWithTrigger: true, defaultValue: 'banana' }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ea905c3029f..916f4495201 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -869,6 +869,9 @@ importers: '@types/react-dom': specifier: catalog:react version: 18.3.7(@types/react@18.3.28) + '@vitest/eslint-plugin': + specifier: 1.6.28 + version: 1.6.28(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3)(vitest@5.0.0) axe-core: specifier: ^4.11.3 version: 4.11.4 @@ -6370,16 +6373,32 @@ packages: peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/project-service@8.72.0': + resolution: {integrity: sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/scope-manager@8.58.0': resolution: {integrity: sha512-W1Lur1oF50FxSnNdGp3Vs6P+yBRSmZiw4IIjEeYxd8UQJwhUF0gDgDD/W/Tgmh73mxgEU3qX0Bzdl/NGuSPEpQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/scope-manager@8.72.0': + resolution: {integrity: sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/tsconfig-utils@8.58.0': resolution: {integrity: sha512-doNSZEVJsWEu4htiVC+PR6NpM+pa+a4ClH9INRWOWCUzMst/VA9c4gXq92F8GUD1rwhNvRLkgjfYtFXegXQF7A==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/tsconfig-utils@8.72.0': + resolution: {integrity: sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/type-utils@8.58.0': resolution: {integrity: sha512-aGsCQImkDIqMyx1u4PrVlbi/krmDsQUs4zAcCV6M7yPcPev+RqVlndsJy9kJ8TLihW9TZ0kbDAzctpLn5o+lOg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6391,12 +6410,22 @@ packages: resolution: {integrity: sha512-O9CjxypDT89fbHxRfETNoAnHj/i6IpRK0CvbVN3qibxlLdo5p5hcLmUuCCrHMpxiWSwKyI8mCP7qRNYuOJ0Uww==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/types@8.72.0': + resolution: {integrity: sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/typescript-estree@8.58.0': resolution: {integrity: sha512-7vv5UWbHqew/dvs+D3e1RvLv1v2eeZ9txRHPnEEBUgSNLx5ghdzjHa0sgLWYVKssH+lYmV0JaWdoubo0ncGYLA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/typescript-estree@8.72.0': + resolution: {integrity: sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.58.0': resolution: {integrity: sha512-RfeSqcFeHMHlAWzt4TBjWOAtoW9lnsAGiP3GbaX9uVgTYYrMbVnGONEfUCiSss+xMHFl+eHZiipmA8WkQ7FuNA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -6404,10 +6433,21 @@ packages: eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/utils@8.72.0': + resolution: {integrity: sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: '>=4.8.4 <6.1.0' + '@typescript-eslint/visitor-keys@8.58.0': resolution: {integrity: sha512-XJ9UD9+bbDo4a4epraTwG3TsNPeiB9aShrUneAVXy8q4LuwowN+qu89/6ByLMINqvIMeI9H9hOHQtg/ijrYXzQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@typescript-eslint/visitor-keys@8.72.0': + resolution: {integrity: sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} deprecated: Potential CWE-502 - Update to 1.3.1 or higher @@ -6610,6 +6650,22 @@ packages: '@vitest/browser': optional: true + '@vitest/eslint-plugin@1.6.28': + resolution: {integrity: sha512-2QorUqhTIR7gaDkANEBTjuG2SE5MJgFaWElINyq9bIaOEJXycZUliu5xYSzG6QRLunWopYshgcRs2vW2CTRqGA==} + engines: {node: '>=22'} + peerDependencies: + '@typescript-eslint/eslint-plugin': '*' + eslint: '>=8.57.0' + typescript: '>=6.0.0' + vitest: '*' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + typescript: + optional: true + vitest: + optional: true + '@vitest/istanbul-lib-coverage@1.0.1': resolution: {integrity: sha512-k3DJZ8LhMBK9NS4SclF1ASD3OgXEWDorbIcPTRDK0/Zae6fRvu+fJRxtFdLfHsa9Y24beCdPnoNZ4LviTNstfA==} engines: {node: '>=22'} @@ -21677,15 +21733,33 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/project-service@8.72.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@6.0.3) + '@typescript-eslint/types': 8.72.0 + debug: 4.4.3(supports-color@8.1.1) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/scope-manager@8.58.0': dependencies: '@typescript-eslint/types': 8.58.0 '@typescript-eslint/visitor-keys': 8.58.0 + '@typescript-eslint/scope-manager@8.72.0': + dependencies: + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/visitor-keys': 8.72.0 + '@typescript-eslint/tsconfig-utils@8.58.0(typescript@6.0.3)': dependencies: typescript: 6.0.3 + '@typescript-eslint/tsconfig-utils@8.72.0(typescript@6.0.3)': + dependencies: + typescript: 6.0.3 + '@typescript-eslint/type-utils@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@typescript-eslint/types': 8.58.0 @@ -21700,6 +21774,8 @@ snapshots: '@typescript-eslint/types@8.58.0': {} + '@typescript-eslint/types@8.72.0': {} + '@typescript-eslint/typescript-estree@8.58.0(typescript@6.0.3)': dependencies: '@typescript-eslint/project-service': 8.58.0(typescript@6.0.3) @@ -21715,6 +21791,21 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/typescript-estree@8.72.0(typescript@6.0.3)': + dependencies: + '@typescript-eslint/project-service': 8.72.0(typescript@6.0.3) + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@6.0.3) + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/visitor-keys': 8.72.0 + debug: 4.4.3(supports-color@8.1.1) + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@6.0.3) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/utils@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3)': dependencies: '@eslint-community/eslint-utils': 4.9.1(eslint@9.31.0(jiti@2.7.0)) @@ -21726,11 +21817,27 @@ snapshots: transitivePeerDependencies: - supports-color + '@typescript-eslint/utils@8.72.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3)': + dependencies: + '@eslint-community/eslint-utils': 4.9.1(eslint@9.31.0(jiti@2.7.0)) + '@typescript-eslint/scope-manager': 8.72.0 + '@typescript-eslint/types': 8.72.0 + '@typescript-eslint/typescript-estree': 8.72.0(typescript@6.0.3) + eslint: 9.31.0(jiti@2.7.0) + typescript: 6.0.3 + transitivePeerDependencies: + - supports-color + '@typescript-eslint/visitor-keys@8.58.0': dependencies: '@typescript-eslint/types': 8.58.0 eslint-visitor-keys: 5.0.1 + '@typescript-eslint/visitor-keys@8.72.0': + dependencies: + '@typescript-eslint/types': 8.72.0 + eslint-visitor-keys: 5.0.1 + '@ungap/structured-clone@1.3.0': {} '@unhead/bundler@3.4.0(@oxc-project/types@0.134.0)(@rspack/core@2.0.6)(esbuild@0.28.1)(lightningcss@1.33.0)(rolldown@1.1.0)(rollup@4.61.0)(unhead@3.4.0(@rspack/core@2.0.6)(esbuild@0.28.1)(rolldown@1.1.0)(rollup@4.61.0)(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(webpack@5.102.1(esbuild@0.28.1)))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(webpack@5.102.1(esbuild@0.28.1))': @@ -22014,6 +22121,18 @@ snapshots: optionalDependencies: '@vitest/browser': 5.0.0(bufferutil@4.1.0)(msw@2.15.0(@types/node@22.20.1)(typescript@6.0.3))(utf-8-validate@5.0.10)(vite@8.2.2(@types/node@22.20.1)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0))(vitest@5.0.0) + '@vitest/eslint-plugin@1.6.28(@typescript-eslint/eslint-plugin@8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3)(vitest@5.0.0)': + dependencies: + '@typescript-eslint/scope-manager': 8.72.0 + '@typescript-eslint/utils': 8.72.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3) + eslint: 9.31.0(jiti@2.7.0) + optionalDependencies: + '@typescript-eslint/eslint-plugin': 8.58.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3) + typescript: 6.0.3 + vitest: 5.0.0(@edge-runtime/vm@5.0.0)(@opentelemetry/api@1.9.0)(@types/node@25.6.0)(@vitest/browser-playwright@5.0.0)(@vitest/coverage-v8@5.0.0)(@vitest/ui@5.0.0)(happy-dom@20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10))(jsdom@27.0.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))(msw@2.15.0(@types/node@25.6.0)(typescript@6.0.3))(vite@8.2.2(@types/node@25.6.0)(esbuild@0.28.1)(jiti@2.7.0)(terser@5.46.1)(tsx@4.23.14)(yaml@2.9.0)) + transitivePeerDependencies: + - supports-color + '@vitest/istanbul-lib-coverage@1.0.1': {} '@vitest/istanbul-lib-report@1.0.1': From 0026a9a0c503f32abfe14015add1c61aa8ca3a09 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 26 Oct 2026 10:14:09 -0400 Subject: [PATCH 4/5] chore(mosaic): lint feature effects that should be derived state or event handlers --- packages/mosaic/AGENTS.md | 2 +- packages/mosaic/eslint/config.mjs | 8 +++ packages/mosaic/eslint/import-guards.test.mjs | 64 +++++++++++++++++++ packages/mosaic/package.json | 1 + .../reverification.controller.ts | 2 + .../use-reverification-with-state.ts | 1 + pnpm-lock.yaml | 20 ++++++ 7 files changed, 97 insertions(+), 1 deletion(-) diff --git a/packages/mosaic/AGENTS.md b/packages/mosaic/AGENTS.md index 2a0fd082e73..c789a45bd55 100644 --- a/packages/mosaic/AGENTS.md +++ b/packages/mosaic/AGENTS.md @@ -96,7 +96,7 @@ A PR that adds, renames or changes the signature of a shared helper in `src/hook ## Lint -Mosaic runs stricter lint rules than the rest of the repo: the `packages/mosaic` block in the root `eslint.config.mjs`, plus the layer boundaries and test rules in `packages/mosaic/eslint/config.mjs`, which only load through `packages/mosaic/eslint.config.mjs`. Lint with `pnpm lint` from `packages/mosaic`. Running `eslint` from the repo root skips them. +Mosaic runs stricter lint rules than the rest of the repo: the `packages/mosaic` block in the root `eslint.config.mjs`, plus the layer boundaries, test rules and effect rules in `packages/mosaic/eslint/config.mjs`, which only load through `packages/mosaic/eslint.config.mjs`. Lint with `pnpm lint` from `packages/mosaic`. Running `eslint` from the repo root skips them. Every disable directive in `src` needs a `-- reason` (`@eslint-community/eslint-comments/require-description`). The reason is not a code comment under the root no-comments rule. Write why the rule does not apply here, not what the code does; it is what a reviewer judges. diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs index fe1f2c3fd56..0e2f95ca67f 100644 --- a/packages/mosaic/eslint/config.mjs +++ b/packages/mosaic/eslint/config.mjs @@ -2,6 +2,7 @@ import path from 'node:path'; import vitest from '@vitest/eslint-plugin'; import boundaries from 'eslint-plugin-boundaries'; +import noUnneededEffects from 'eslint-plugin-react-you-might-not-need-an-effect'; import { noDynamicMessageCatalogs, noModelReactState } from './rules.mjs'; @@ -53,6 +54,13 @@ export default [ 'mosaic/no-model-react-state': 'error', }, }, + { + name: 'packages/mosaic - effects', + files: ['src/features/**/*.{ts,tsx}'], + ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.{ts,tsx}'], + plugins: noUnneededEffects.configs.strict.plugins, + rules: noUnneededEffects.configs.strict.rules, + }, { name: 'packages/mosaic - tests', files: ['src/**/__tests__/**/*.{ts,tsx}', 'src/**/*.test.{ts,tsx}'], diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index 5b00c9c62ab..29b7e4de800 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -1,6 +1,7 @@ import path from 'node:path'; import { ESLint } from 'eslint'; +import noUnneededEffects from 'eslint-plugin-react-you-might-not-need-an-effect'; import { describe, expect, test } from 'vitest'; const root = path.resolve(import.meta.dirname, '../../..'); @@ -370,6 +371,68 @@ describe('Mosaic tests', { timeout: 30_000 }, () => { }); }); +describe('Mosaic effects', { timeout: 30_000 }, () => { + async function effectRules(body) { + const messages = await lintAt( + 'src/features/reverification/sample.view.tsx', + [ + "import { useEffect, useState } from 'react';", + 'export function FullName({ first, last }: { first: string; last: string }) {', + body, + ' return {fullName};', + '}', + ].join('\n'), + ); + expect(messages.find(message => message.fatal)).toBeUndefined(); + return messages + .map(message => message.ruleId) + .filter(rule => rule?.startsWith('react-you-might-not-need-an-effect/')); + } + + test('reject state derived in an effect', async () => { + expect( + await effectRules( + [ + " const [fullName, setFullName] = useState('');", + ' useEffect(() => {', + ' setFullName(`${first} ${last}`);', + ' }, [first, last]);', + ].join('\n'), + ), + ).toContain('react-you-might-not-need-an-effect/no-derived-state'); + }); + + test.each([ + ['src/features/reverification/sample.controller.ts', ['react-you-might-not-need-an-effect/no-event-handler']], + ['src/hooks/sample.ts', []], + ['src/primitives/sample.ts', []], + ])('%s reports an effect used as an event handler: %j', async (file, expected) => { + const messages = await lintAt( + file, + [ + "import { useEffect } from 'react';", + 'export function useOnOpen(open: boolean, onOpen: () => void) {', + ' useEffect(() => {', + ' if (open) {', + ' onOpen();', + ' }', + ' }, [open, onOpen]);', + '}', + ].join('\n'), + ); + expect(messages.find(message => message.fatal)).toBeUndefined(); + expect( + messages + .map(message => message.ruleId) + .filter(rule => rule === 'react-you-might-not-need-an-effect/no-event-handler'), + ).toEqual(expected); + }); + + test('accept state derived during render', async () => { + expect(await effectRules(' const fullName = `${first} ${last}`;\n void useState; void useEffect;')).toEqual([]); + }); +}); + describe('Mosaic package configuration', { timeout: 30_000 }, () => { test.each([ 'src/features/sample.view.tsx', @@ -388,6 +451,7 @@ describe('Mosaic package configuration', { timeout: 30_000 }, () => { 'mosaic/no-model-react-state', 'mosaic/no-dynamic-message-catalogs', '@typescript-eslint/no-import-type-side-effects', + ...Object.keys(noUnneededEffects.configs.strict.rules), ]) { expect(rootConfig.rules[rule]).toBeUndefined(); delete inheritedRules[rule]; diff --git a/packages/mosaic/package.json b/packages/mosaic/package.json index 4469d65e0ca..23b722168ec 100644 --- a/packages/mosaic/package.json +++ b/packages/mosaic/package.json @@ -84,6 +84,7 @@ "axe-core": "^4.11.3", "bundlewatch": "^0.4.2", "eslint-plugin-boundaries": "7.3.0", + "eslint-plugin-react-you-might-not-need-an-effect": "1.0.2", "happy-dom": "^20.8.9", "msw": "2.15.0", "playwright": "1.62.1", diff --git a/packages/mosaic/src/features/reverification/reverification.controller.ts b/packages/mosaic/src/features/reverification/reverification.controller.ts index b810370cff7..1fb1d62677c 100644 --- a/packages/mosaic/src/features/reverification/reverification.controller.ts +++ b/packages/mosaic/src/features/reverification/reverification.controller.ts @@ -449,6 +449,7 @@ function useReverificationState(model: ReverificationModel): ReverificationContr const onCancel = cancelModel; const needsStart = active !== null && snapshot.value === 'inactive'; const needsReset = model.status === 'inactive' && snapshot.value !== 'inactive'; + /* eslint-disable react-you-might-not-need-an-effect/no-event-handler -- Syncs the machine with the model status, which changes outside this component. */ useEffect(() => { if (needsStart) { send({ type: 'START' }); @@ -456,6 +457,7 @@ function useReverificationState(model: ReverificationModel): ReverificationContr send({ type: 'RESET' }); } }, [needsStart, needsReset, send]); + /* eslint-enable react-you-might-not-need-an-effect/no-event-handler */ const inactive = model.status === 'inactive'; diff --git a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts index a9948f81f6b..156c8602a96 100644 --- a/packages/mosaic/src/features/reverification/use-reverification-with-state.ts +++ b/packages/mosaic/src/features/reverification/use-reverification-with-state.ts @@ -146,6 +146,7 @@ export function useReverificationWithState( const phase = reverificationState.phase; useEffect(() => { + // eslint-disable-next-line react-you-might-not-need-an-effect/no-event-handler -- Reacts to the Clerk session changing, which happens outside any event handler here. if (phase !== 'active') { return; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 916f4495201..363578b0050 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -881,6 +881,9 @@ importers: eslint-plugin-boundaries: specifier: 7.3.0 version: 7.3.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)) + eslint-plugin-react-you-might-not-need-an-effect: + specifier: 1.0.2 + version: 1.0.2(eslint@9.31.0(jiti@2.7.0)) happy-dom: specifier: ^20.8.9 version: 20.10.2(bufferutil@4.1.0)(utf-8-validate@5.0.10) @@ -8998,6 +9001,12 @@ packages: peerDependencies: eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 + eslint-plugin-react-you-might-not-need-an-effect@1.0.2: + resolution: {integrity: sha512-HgYol2zhH3KbnW9Q4FY/FcIINbsYVL6rwQESChqBC2s9SLWtw1wg05+J7SCrr3BfQpNY2ReYhf8xjpd2JhKJOQ==} + engines: {node: '>=14.0.0'} + peerDependencies: + eslint: '>=8.40.0' + eslint-plugin-react@7.37.5: resolution: {integrity: sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==} engines: {node: '>=4'} @@ -9804,6 +9813,10 @@ packages: resolution: {integrity: sha512-7ACyT3wmyp3I61S4fG682L0VA2RGD9otkqGJIwNUMF1SWUombIIk+af1unuDYgMm082aHYwD+mzJvv9Iu8dsgg==} engines: {node: '>=18'} + globals@16.5.0: + resolution: {integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==} + engines: {node: '>=18'} + globals@17.13.0: resolution: {integrity: sha512-RwMTC61u7hrG3ZJMkZQOK4JLJrKS8QtoTii63EmWvFXHqycY9FObBJQCbsLxSO8kjKhWE5kV1GC+Vb1g3RI0Zg==} engines: {node: '>=18'} @@ -24942,6 +24955,11 @@ snapshots: dependencies: eslint: 9.31.0(jiti@2.7.0) + eslint-plugin-react-you-might-not-need-an-effect@1.0.2(eslint@9.31.0(jiti@2.7.0)): + dependencies: + eslint: 9.31.0(jiti@2.7.0) + globals: 16.5.0 + eslint-plugin-react@7.37.5(eslint@9.31.0(jiti@2.7.0)): dependencies: array-includes: 3.1.9 @@ -26037,6 +26055,8 @@ snapshots: globals@15.15.0: {} + globals@16.5.0: {} + globals@17.13.0: {} globalthis@1.0.4: From 6010b7712ed1dfc04fc8999d93ec10a1319fdb1c Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 26 Oct 2026 15:49:16 -0400 Subject: [PATCH 5/5] chore(mosaic): import switcherBusyKeys from switcher types in Swingset and dedupe the lockfile --- .../src/stories/fixtures/switcher.tsx | 2 +- pnpm-lock.yaml | 91 +++++-------------- 2 files changed, 22 insertions(+), 71 deletions(-) diff --git a/packages/swingset/src/stories/fixtures/switcher.tsx b/packages/swingset/src/stories/fixtures/switcher.tsx index 2c39ab4d272..891577e7659 100644 --- a/packages/swingset/src/stories/fixtures/switcher.tsx +++ b/packages/swingset/src/stories/fixtures/switcher.tsx @@ -1,5 +1,5 @@ +import { switcherBusyKeys } from '@clerk/mosaic/features/switcher/switcher.types'; import { - switcherBusyKeys, type SwitcherInvitation, type SwitcherMembership, type SwitcherSession, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 363578b0050..a94c2c18873 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8919,27 +8919,6 @@ packages: eslint-plugin-import-x: optional: true - eslint-module-utils@2.12.1: - resolution: {integrity: sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==} - engines: {node: '>=4'} - peerDependencies: - '@typescript-eslint/parser': '*' - eslint: '*' - eslint-import-resolver-node: '*' - eslint-import-resolver-typescript: '*' - eslint-import-resolver-webpack: '*' - peerDependenciesMeta: - '@typescript-eslint/parser': - optional: true - eslint: - optional: true - eslint-import-resolver-node: - optional: true - eslint-import-resolver-typescript: - optional: true - eslint-import-resolver-webpack: - optional: true - eslint-module-utils@2.14.0: resolution: {integrity: sha512-W2WCRZ9Dqntd+2u8jJcVMV2PKulc6RdLgUUoh/yQr3uB6lo/ZOeGx11sv60/8S4QFFKNslAlWhr9u0Ef7ZW6Ig==} engines: {node: '>=4'} @@ -9067,7 +9046,6 @@ packages: eslint@9.31.0: resolution: {integrity: sha512-QldCVh/ztyKJJZLr4jXNUByx3gR+TDYZCRXEktiZoUR3PGy4qCmSbkxcIle8GEwGpb5JBZazlaJ/CxLidXdEbQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true peerDependencies: jiti: '*' @@ -9921,10 +9899,6 @@ packages: resolution: {integrity: sha512-Hrp5vIK/xr5SkeN2onO32H0MgNZ0f17HRNH39WfL0SYUNOTZ5Lz1TJ8Pajo/87dYGEFlLMm7mIc/k/s6Bvz9HQ==} engines: {node: '>=8'} - hasown@2.0.2: - resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} - engines: {node: '>= 0.4'} - hasown@2.0.4: resolution: {integrity: sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==} engines: {node: '>= 0.4'} @@ -10288,10 +10262,6 @@ packages: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.16.1: - resolution: {integrity: sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==} - engines: {node: '>= 0.4'} - is-core-module@2.17.0: resolution: {integrity: sha512-J/vG0zBCbIKOQFfufSwyXdMrsohyJIUNkrnmo6WZGzoM7tr/lsbfW5b2BvisL6zsyMzK9UxV9L6c7AoFbyXHOA==} engines: {node: '>= 0.4'} @@ -17272,7 +17242,7 @@ snapshots: '@es-joy/jsdoccomment@0.50.2': dependencies: '@types/estree': 1.0.9 - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/types': 8.72.0 comment-parser: 1.4.1 esquery: 1.6.0 jsdoc-type-pratt-parser: 4.1.0 @@ -21739,8 +21709,8 @@ snapshots: '@typescript-eslint/project-service@8.58.0(typescript@6.0.3)': dependencies: - '@typescript-eslint/tsconfig-utils': 8.58.0(typescript@6.0.3) - '@typescript-eslint/types': 8.58.0 + '@typescript-eslint/tsconfig-utils': 8.72.0(typescript@6.0.3) + '@typescript-eslint/types': 8.72.0 debug: 4.4.3(supports-color@8.1.1) typescript: 6.0.3 transitivePeerDependencies: @@ -24619,7 +24589,7 @@ snapshots: has-property-descriptors: 1.0.2 has-proto: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 internal-slot: 1.1.0 is-array-buffer: 3.0.5 is-callable: 1.2.7 @@ -24701,11 +24671,11 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.3.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 es-shim-unscopables@1.1.0: dependencies: - hasown: 2.0.2 + hasown: 2.0.4 es-to-primitive@1.3.0: dependencies: @@ -24826,7 +24796,7 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7(supports-color@8.1.1) - is-core-module: 2.16.1 + is-core-module: 2.17.0 resolve: 1.22.11 transitivePeerDependencies: - supports-color @@ -24846,17 +24816,6 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-module-utils@2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)): - dependencies: - debug: 3.2.7(supports-color@8.1.1) - optionalDependencies: - '@typescript-eslint/parser': 8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3) - eslint: 9.31.0(jiti@2.7.0) - eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0)(eslint@9.31.0(jiti@2.7.0)) - transitivePeerDependencies: - - supports-color - eslint-module-utils@2.14.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)): dependencies: debug: 3.2.7(supports-color@8.1.1) @@ -24893,9 +24852,9 @@ snapshots: doctrine: 2.1.0 eslint: 9.31.0(jiti@2.7.0) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.12.1(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)) - hasown: 2.0.2 - is-core-module: 2.16.1 + eslint-module-utils: 2.14.0(@typescript-eslint/parser@8.58.0(eslint@9.31.0(jiti@2.7.0))(typescript@6.0.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.10.1)(eslint@9.31.0(jiti@2.7.0)) + hasown: 2.0.4 + is-core-module: 2.17.0 is-glob: 4.0.3 minimatch: 3.1.5 object.fromentries: 2.0.8 @@ -24938,7 +24897,7 @@ snapshots: damerau-levenshtein: 1.0.8 emoji-regex: 9.2.2 eslint: 9.31.0(jiti@2.7.0) - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 language-tags: 1.0.9 minimatch: 3.1.5 @@ -24970,7 +24929,7 @@ snapshots: es-iterator-helpers: 1.2.1 eslint: 9.31.0(jiti@2.7.0) estraverse: 5.3.0 - hasown: 2.0.2 + hasown: 2.0.4 jsx-ast-utils: 3.3.5 minimatch: 3.1.5 object.entries: 1.1.9 @@ -25817,7 +25776,7 @@ snapshots: asynckit: 0.4.0 combined-stream: 1.0.8 es-set-tostringtag: 2.1.0 - hasown: 2.0.2 + hasown: 2.0.4 mime-types: 2.1.35 formdata-polyfill@4.0.10: @@ -25891,7 +25850,7 @@ snapshots: call-bound: 1.0.4 define-properties: 1.2.1 functions-have-names: 1.2.3 - hasown: 2.0.2 + hasown: 2.0.4 is-callable: 1.2.7 functional-red-black-tree@1.0.1: {} @@ -25926,7 +25885,7 @@ snapshots: get-proto: 1.0.1 gopd: 1.2.0 has-symbols: 1.1.0 - hasown: 2.0.2 + hasown: 2.0.4 math-intrinsics: 1.1.0 get-own-enumerable-keys@1.0.0: {} @@ -26191,10 +26150,6 @@ snapshots: is-stream: 2.0.1 type-fest: 0.8.1 - hasown@2.0.2: - dependencies: - function-bind: 1.1.2 - hasown@2.0.4: dependencies: function-bind: 1.1.2 @@ -26579,7 +26534,7 @@ snapshots: internal-slot@1.1.0: dependencies: es-errors: 1.3.0 - hasown: 2.0.2 + hasown: 2.0.4 side-channel: 1.1.0 invariant@2.2.4: @@ -26649,10 +26604,6 @@ snapshots: is-callable@1.2.7: {} - is-core-module@2.16.1: - dependencies: - hasown: 2.0.2 - is-core-module@2.17.0: dependencies: hasown: 2.0.4 @@ -26776,7 +26727,7 @@ snapshots: call-bound: 1.0.4 gopd: 1.2.0 has-tostringtag: 1.0.2 - hasown: 2.0.2 + hasown: 2.0.4 is-regexp@3.1.0: {} @@ -28841,14 +28792,14 @@ snapshots: normalize-package-data@3.0.3: dependencies: hosted-git-info: 4.1.0 - is-core-module: 2.16.1 + is-core-module: 2.17.0 semver: 7.7.4 validate-npm-package-license: 3.0.4 normalize-package-data@4.0.1: dependencies: hosted-git-info: 5.2.1 - is-core-module: 2.16.1 + is-core-module: 2.17.0 semver: 7.7.4 validate-npm-package-license: 3.0.4 @@ -30380,7 +30331,7 @@ snapshots: resolve@1.22.11: dependencies: - is-core-module: 2.16.1 + is-core-module: 2.17.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 @@ -30390,7 +30341,7 @@ snapshots: resolve@2.0.0-next.5: dependencies: - is-core-module: 2.16.1 + is-core-module: 2.17.0 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0