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..491a33bc007 100644 --- a/packages/mosaic/eslint/import-guards.test.mjs +++ b/packages/mosaic/eslint/import-guards.test.mjs @@ -349,6 +349,47 @@ 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.each([ + ['a quoted src key', 'export const state = { invoke: { "src": () => Promise.resolve(1) } };'], + ['a quoted invoke key', 'export const state = { "invoke": { src: () => Promise.resolve(1) } };'], + ['an as cast', 'export const state = { invoke: { src: () => Promise.resolve(1) } as object };'], + ['satisfies', 'export const state = { invoke: { src: () => Promise.resolve(1) } satisfies object };'], + ])('rejects a raw invoke written with %s', async (_name, source) => { + expect(await rawInvokeMessages(`${featureDir}/sample.controller.ts`, source)).toHaveLength(1); + }); + + 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 +540,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..4b8ec13fb60 100644 --- a/packages/mosaic/eslint/rules.mjs +++ b/packages/mosaic/eslint/rules.mjs @@ -268,3 +268,31 @@ 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(node) { + if (propertyName(node.key, node.computed) !== 'invoke') { + return; + } + const invoke = unwrapExpression(node.value); + if ( + invoke.type === 'ObjectExpression' && + invoke.properties.some( + property => property.type === 'Property' && propertyName(property.key, property.computed) === 'src', + ) + ) { + context.report({ node: invoke, 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