From 4abacf541d621bcd785dca7aef4e4d14fedb0300 Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Mon, 2 Nov 2026 07:32:26 -0500 Subject: [PATCH 1/2] refactor(mosaic): tighten types to catch misuse at compile time --- .changeset/mosaic-machine-state-names.md | 2 + packages/mosaic/docs/controllers.md | 2 + packages/mosaic/eslint/config.mjs | 11 ++- packages/mosaic/eslint/import-guards.test.mjs | 33 +++++++ packages/mosaic/eslint/rules.mjs | 17 ++++ .../src/components/action-bar/action-bar.tsx | 2 +- ...anization-profile-profile-section.model.ts | 7 +- .../reverification.controller.ts | 6 +- .../user-profile-add-email.controller.ts | 4 +- .../user-profile-passkeys-section.model.ts | 2 +- .../user-profile-add-phone.controller.ts | 2 +- packages/mosaic/src/hooks/use-spin-delay.ts | 1 + packages/mosaic/src/machine/ADOPTION.md | 51 ++++++----- packages/mosaic/src/machine/README.md | 19 ++-- packages/mosaic/src/machine/setup.ts | 24 +++-- packages/mosaic/src/machine/types.test-d.ts | 89 +++++++++++++++++++ packages/mosaic/src/machine/types.ts | 5 +- .../primitives/accordion/accordion-panel.tsx | 18 ++-- .../primitives/accordion/accordion-root.tsx | 6 +- .../accordion/accordion-trigger.tsx | 6 +- .../autocomplete/autocomplete-list.tsx | 4 +- .../autocomplete/autocomplete-positioner.tsx | 4 +- .../mosaic/src/primitives/button/button.tsx | 4 +- .../collapsible/collapsible-panel.tsx | 20 +++-- .../collapsible/collapsible-root.tsx | 6 +- .../collapsible/collapsible-trigger.tsx | 6 +- .../src/primitives/combobox/combobox-list.tsx | 4 +- .../combobox/combobox-positioner.tsx | 4 +- .../src/primitives/combobox/combobox-root.tsx | 1 + .../primitives/dialog/dialog-handle.test-d.ts | 22 +++++ .../src/primitives/dialog/dialog-handle.ts | 6 +- .../file-upload/file-upload-dropzone.tsx | 6 +- .../file-upload/file-upload-item-delete.tsx | 6 +- .../file-upload/file-upload-item-preview.tsx | 6 +- .../file-upload/file-upload-item.tsx | 4 +- .../file-upload/file-upload-root.tsx | 6 +- .../file-upload/file-upload-trigger.tsx | 6 +- .../mosaic/src/primitives/flow/flow-root.tsx | 2 +- .../src/primitives/menu/menu-positioner.tsx | 5 +- .../mosaic/src/primitives/otp/otp-input.tsx | 6 +- .../mosaic/src/primitives/otp/otp-root.tsx | 6 +- .../segmented-control-item.tsx | 6 +- .../segmented-control-root.tsx | 6 +- .../primitives/select/select-positioner.tsx | 5 +- .../src/primitives/table/table-cell.tsx | 4 +- .../src/primitives/table/table-column.tsx | 6 +- .../src/primitives/table/table-header.tsx | 6 +- .../src/primitives/table/table-root.tsx | 6 +- .../mosaic/src/primitives/table/table-row.tsx | 12 +-- .../mosaic/src/primitives/tabs/tabs-list.tsx | 6 +- .../mosaic/src/primitives/tabs/tabs-tab.tsx | 8 +- .../primitives/tag-input/tag-input-input.tsx | 6 +- .../primitives/tag-input/tag-input-root.tsx | 6 +- .../tag-input/tag-input-tag-remove.tsx | 6 +- .../primitives/tag-input/tag-input-tag.tsx | 6 +- .../primitives/tooltip/tooltip-positioner.tsx | 4 +- .../mosaic/src/primitives/utils/css-vars.ts | 2 +- .../src/primitives/utils/use-render.test-d.ts | 11 +++ .../src/primitives/utils/use-render.test.tsx | 2 +- .../src/primitives/utils/use-render.tsx | 2 +- packages/mosaic/tsconfig.json | 5 ++ 61 files changed, 386 insertions(+), 170 deletions(-) create mode 100644 .changeset/mosaic-machine-state-names.md create mode 100644 packages/mosaic/src/primitives/dialog/dialog-handle.test-d.ts diff --git a/.changeset/mosaic-machine-state-names.md b/.changeset/mosaic-machine-state-names.md new file mode 100644 index 00000000000..a845151cc84 --- /dev/null +++ b/.changeset/mosaic-machine-state-names.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/packages/mosaic/docs/controllers.md b/packages/mosaic/docs/controllers.md index 835e73df287..94f05e37503 100644 --- a/packages/mosaic/docs/controllers.md +++ b/packages/mosaic/docs/controllers.md @@ -313,6 +313,8 @@ every successful removal drops focus on the floor. - **No machine snapshot in the return value.** Return the plain props the view reads (`open`, `pendingKey`, `isDeleting`, `errorMessage`), never `snapshot` and `send`. +- A transition shared across states in a `const` needs `target: 'name' as const`. + Without it the target widens to `string` and the machine rejects it. - Dismissing must not abandon an in-flight effect. Model `open` as context, and give `OPEN`/`CLOSE` no target in the busy state so they don't leave it: diff --git a/packages/mosaic/eslint/config.mjs b/packages/mosaic/eslint/config.mjs index ab7e3b23c2d..79642a983d9 100644 --- a/packages/mosaic/eslint/config.mjs +++ b/packages/mosaic/eslint/config.mjs @@ -4,7 +4,7 @@ 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 { forwardRefParts, noDynamicMessageCatalogs, noModelReactState } from './rules.mjs'; +import { forwardRefParts, noDynamicMessageCatalogs, noModelReactState, noRawInvoke } from './rules.mjs'; const mosaicRoot = path.resolve(import.meta.dirname, '..'); @@ -30,6 +30,7 @@ export default [ 'no-model-react-state': noModelReactState, 'no-dynamic-message-catalogs': noDynamicMessageCatalogs, 'forward-ref-parts': forwardRefParts, + 'no-raw-invoke': noRawInvoke, }, }, }, @@ -63,6 +64,14 @@ export default [ 'mosaic/forward-ref-parts': 'error', }, }, + { + name: 'packages/mosaic - machines', + files: ['src/**/*.{ts,tsx}'], + ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.{ts,tsx}'], + rules: { + 'mosaic/no-raw-invoke': 'error', + }, + }, { name: 'packages/mosaic - effects', files: ['src/features/**/*.{ts,tsx}'], diff --git a/packages/mosaic/eslint/import-guards.test.mjs b/packages/mosaic/eslint/import-guards.test.mjs index d063d0d2b97..a06c132093e 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -349,6 +349,38 @@ describe('Mosaic layers', { timeout: 30_000 }, () => { }); }); +describe('Mosaic machines', { timeout: 30_000 }, () => { + async function rawInvokeMessages(file, source) { + const messages = await lintAt(file, source); + expect(messages.find(message => message.fatal)).toBeUndefined(); + return messages.filter(message => message.ruleId === 'mosaic/no-raw-invoke').map(message => message.message); + } + + test('rejects an invoke written without fromPromise', async () => { + expect( + await rawInvokeMessages( + `${featureDir}/sample.controller.ts`, + 'export const state = { invoke: { src: () => Promise.resolve(1), onDone: { target: "idle" } } };', + ), + ).toEqual(['Write invoke with fromPromise from setup(), so onDone sees the typed output instead of any.']); + }); + + test('allows an invoke from fromPromise', async () => { + expect( + await rawInvokeMessages( + `${featureDir}/sample.controller.ts`, + 'declare const fromPromise: (run: () => Promise) => object;\nexport const state = { invoke: fromPromise(() => Promise.resolve(1)) };', + ), + ).toEqual([]); + }); + + test('ignores test files', async () => { + expect( + await rawInvokeMessages(testFixture, 'export const state = { invoke: { src: () => Promise.resolve(1) } };'), + ).toEqual([]); + }); +}); + describe('Mosaic parts', { timeout: 30_000 }, () => { async function forwardRefMessages(file, source) { const messages = await lintAt( @@ -499,6 +531,7 @@ describe('Mosaic package configuration', { timeout: 30_000 }, () => { 'mosaic/no-model-react-state', 'mosaic/no-dynamic-message-catalogs', 'mosaic/forward-ref-parts', + 'mosaic/no-raw-invoke', '@typescript-eslint/no-import-type-side-effects', ...Object.keys(noUnneededEffects.configs.strict.rules), ]) { diff --git a/packages/mosaic/eslint/rules.mjs b/packages/mosaic/eslint/rules.mjs index 88b0b363139..26dd217cefd 100644 --- a/packages/mosaic/eslint/rules.mjs +++ b/packages/mosaic/eslint/rules.mjs @@ -268,3 +268,20 @@ export const forwardRefParts = { }; }, }; + +export const noRawInvoke = { + meta: { + type: 'problem', + schema: [], + messages: { + useFromPromise: 'Write invoke with fromPromise from setup(), so onDone sees the typed output instead of any.', + }, + }, + create(context) { + return { + 'Property[key.name="invoke"] > ObjectExpression:has(> Property[key.name="src"])'(node) { + context.report({ node, messageId: 'useFromPromise' }); + }, + }; + }, +}; diff --git a/packages/mosaic/src/components/action-bar/action-bar.tsx b/packages/mosaic/src/components/action-bar/action-bar.tsx index 2d97a731238..f4ed1e2dbb0 100644 --- a/packages/mosaic/src/components/action-bar/action-bar.tsx +++ b/packages/mosaic/src/components/action-bar/action-bar.tsx @@ -263,8 +263,8 @@ const Action = React.forwardRef(functio