Skip to content

Commit a944bb6

Browse files
refactor(mosaic): tighten types to catch misuse at compile time (#10572)
1 parent 064bab8 commit a944bb6

61 files changed

Lines changed: 464 additions & 173 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
---
2+
---

‎packages/mosaic/docs/controllers.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,8 @@ every successful removal drops focus on the floor.
313313
- **No machine snapshot in the return value.** Return the plain props the view
314314
reads (`open`, `pendingKey`, `isDeleting`, `errorMessage`), never `snapshot`
315315
and `send`.
316+
- A transition shared across states in a `const` needs `target: 'name' as const`.
317+
Without it the target widens to `string` and the machine rejects it.
316318
- Dismissing must not abandon an in-flight effect. Model `open` as context, and
317319
give `OPEN`/`CLOSE` no target in the busy state so they don't leave it:
318320

‎packages/mosaic/eslint/config.mjs‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import vitest from '@vitest/eslint-plugin';
44
import boundaries from 'eslint-plugin-boundaries';
55
import noUnneededEffects from 'eslint-plugin-react-you-might-not-need-an-effect';
66

7-
import { forwardRefParts, noDynamicMessageCatalogs, noModelReactState } from './rules.mjs';
7+
import { forwardRefParts, noDynamicMessageCatalogs, noModelReactState, noRawInvoke } from './rules.mjs';
88

99
const mosaicRoot = path.resolve(import.meta.dirname, '..');
1010

@@ -30,6 +30,7 @@ export default [
3030
'no-model-react-state': noModelReactState,
3131
'no-dynamic-message-catalogs': noDynamicMessageCatalogs,
3232
'forward-ref-parts': forwardRefParts,
33+
'no-raw-invoke': noRawInvoke,
3334
},
3435
},
3536
},
@@ -63,6 +64,14 @@ export default [
6364
'mosaic/forward-ref-parts': 'error',
6465
},
6566
},
67+
{
68+
name: 'packages/mosaic - machines',
69+
files: ['src/**/*.{ts,tsx}'],
70+
ignores: ['src/**/__tests__/**', 'src/**/*.test.{ts,tsx}', 'src/**/*.test-d.{ts,tsx}'],
71+
rules: {
72+
'mosaic/no-raw-invoke': 'error',
73+
},
74+
},
6675
{
6776
name: 'packages/mosaic - effects',
6877
files: ['src/features/**/*.{ts,tsx}'],

‎packages/mosaic/eslint/import-guards.test.mjs‎

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -349,6 +349,47 @@ describe('Mosaic layers', { timeout: 30_000 }, () => {
349349
});
350350
});
351351

352+
describe('Mosaic machines', { timeout: 30_000 }, () => {
353+
async function rawInvokeMessages(file, source) {
354+
const messages = await lintAt(file, source);
355+
expect(messages.find(message => message.fatal)).toBeUndefined();
356+
return messages.filter(message => message.ruleId === 'mosaic/no-raw-invoke').map(message => message.message);
357+
}
358+
359+
test('rejects an invoke written without fromPromise', async () => {
360+
expect(
361+
await rawInvokeMessages(
362+
`${featureDir}/sample.controller.ts`,
363+
'export const state = { invoke: { src: () => Promise.resolve(1), onDone: { target: "idle" } } };',
364+
),
365+
).toEqual(['Write invoke with fromPromise from setup(), so onDone sees the typed output instead of any.']);
366+
});
367+
368+
test.each([
369+
['a quoted src key', 'export const state = { invoke: { "src": () => Promise.resolve(1) } };'],
370+
['a quoted invoke key', 'export const state = { "invoke": { src: () => Promise.resolve(1) } };'],
371+
['an as cast', 'export const state = { invoke: { src: () => Promise.resolve(1) } as object };'],
372+
['satisfies', 'export const state = { invoke: { src: () => Promise.resolve(1) } satisfies object };'],
373+
])('rejects a raw invoke written with %s', async (_name, source) => {
374+
expect(await rawInvokeMessages(`${featureDir}/sample.controller.ts`, source)).toHaveLength(1);
375+
});
376+
377+
test('allows an invoke from fromPromise', async () => {
378+
expect(
379+
await rawInvokeMessages(
380+
`${featureDir}/sample.controller.ts`,
381+
'declare const fromPromise: (run: () => Promise<number>) => object;\nexport const state = { invoke: fromPromise(() => Promise.resolve(1)) };',
382+
),
383+
).toEqual([]);
384+
});
385+
386+
test('ignores test files', async () => {
387+
expect(
388+
await rawInvokeMessages(testFixture, 'export const state = { invoke: { src: () => Promise.resolve(1) } };'),
389+
).toEqual([]);
390+
});
391+
});
392+
352393
describe('Mosaic parts', { timeout: 30_000 }, () => {
353394
async function forwardRefMessages(file, source) {
354395
const messages = await lintAt(
@@ -499,6 +540,7 @@ describe('Mosaic package configuration', { timeout: 30_000 }, () => {
499540
'mosaic/no-model-react-state',
500541
'mosaic/no-dynamic-message-catalogs',
501542
'mosaic/forward-ref-parts',
543+
'mosaic/no-raw-invoke',
502544
'@typescript-eslint/no-import-type-side-effects',
503545
...Object.keys(noUnneededEffects.configs.strict.rules),
504546
]) {

‎packages/mosaic/eslint/rules.mjs‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -268,3 +268,31 @@ export const forwardRefParts = {
268268
};
269269
},
270270
};
271+
272+
export const noRawInvoke = {
273+
meta: {
274+
type: 'problem',
275+
schema: [],
276+
messages: {
277+
useFromPromise: 'Write invoke with fromPromise from setup(), so onDone sees the typed output instead of any.',
278+
},
279+
},
280+
create(context) {
281+
return {
282+
Property(node) {
283+
if (propertyName(node.key, node.computed) !== 'invoke') {
284+
return;
285+
}
286+
const invoke = unwrapExpression(node.value);
287+
if (
288+
invoke.type === 'ObjectExpression' &&
289+
invoke.properties.some(
290+
property => property.type === 'Property' && propertyName(property.key, property.computed) === 'src',
291+
)
292+
) {
293+
context.report({ node: invoke, messageId: 'useFromPromise' });
294+
}
295+
},
296+
};
297+
},
298+
};

‎packages/mosaic/src/components/action-bar/action-bar.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,8 +263,8 @@ const Action = React.forwardRef<HTMLButtonElement, ActionBarActionProps>(functio
263263
<Button
264264
variant='ghost'
265265
size='md'
266-
color={color}
267266
{...merged}
267+
color={color}
268268
// eslint-disable-next-line react-hooks/rules-of-hooks -- floating-ui's CompositeItem calls this render callback synchronously during its own render, so the hook keeps a stable position.
269269
ref={useMergeRefs<unknown>([ref, isRef(itemRef) ? itemRef : undefined])}
270270
xstyle={[color === 'negative' && styles.destructive, xstyle]}

‎packages/mosaic/src/features/organization-profile/organization-profile-profile-section/organization-profile-profile-section.model.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { OrganizationResource } from '@clerk/shared/types';
44
import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
55
import { save, SaveError, UNEXPECTED_ERROR } from '../../../utils/errors';
66
import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions';
7+
import type { OrganizationProfileEditNameValue } from './organization-profile-edit-name.dialog';
8+
import type { OrganizationProfileEditSlugValue } from './organization-profile-edit-slug.dialog';
79
import type { OrganizationProfileProfileSectionViewProps } from './organization-profile-profile-section.view';
810

911
type OrganizationProfileProfileSectionModel =
@@ -52,10 +54,11 @@ export function useOrganizationProfileProfileSectionModel(): OrganizationProfile
5254
...profile,
5355
onLogoChange: file => save(() => currentOrganization().setLogo({ file })),
5456
onRemoveLogo: organization.hasImage ? () => save(() => currentOrganization().setLogo({ file: null })) : undefined,
55-
onSubmitName: name => save(() => currentOrganization().update({ name }), ['name']),
57+
onSubmitName: name =>
58+
save<keyof OrganizationProfileEditNameValue>(() => currentOrganization().update({ name }), ['name']),
5659
onSubmitSlug: !environment.organizationSettings.slug.disabled
5760
? slug =>
58-
save(() => {
61+
save<keyof OrganizationProfileEditSlugValue>(() => {
5962
const current = currentOrganization();
6063
return current.update({ name: current.name, slug });
6164
}, ['slug'])

‎packages/mosaic/src/features/reverification/reverification.controller.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -176,20 +176,20 @@ const factorEvents = {
176176
},
177177
SELECT_METHOD: [
178178
{
179-
target: 'methodPickerPreparing',
179+
target: 'methodPickerPreparing' as const,
180180
guard: (ctx, event) => {
181181
const method = ctx.methods.find(candidate => candidate.id === event.id);
182182
return topOverlay(ctx) === 'method-picker' && Boolean(method && needsPrepare(method));
183183
},
184184
actions: assign((ctx, event) => selectMethod(ctx, event.id)),
185185
},
186186
{
187-
target: 'verifying',
187+
target: 'verifying' as const,
188188
guard: ctx => topOverlay(ctx) === 'method-picker',
189189
actions: assign((ctx, event) => ({ ...selectMethod(ctx, event.id), overlay: [] })),
190190
},
191191
],
192-
RESET: 'inactive',
192+
RESET: 'inactive' as const,
193193
} satisfies NonNullable<StateConfig<ReverificationContext, ReverificationEvent>['on']>;
194194

195195
export const reverificationMachine = createMachine({

‎packages/mosaic/src/features/user-profile/user-profile-email-section/user-profile-add-email.controller.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,12 +64,12 @@ const LINK_RESEND_SECONDS = 60;
6464
const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) };
6565
const fail = assign<ErrorInvokeEvent>((_, event) => ({ error: toFormError(event.error) }));
6666
const resend = {
67-
target: 'starting',
67+
target: 'starting' as const,
6868
guard: (context: Context) => context.resendSeconds === 0,
6969
actions: assign(() => ({ error: undefined })),
7070
};
7171
const start = {
72-
target: 'starting',
72+
target: 'starting' as const,
7373
actions: assign<Extract<Event, { type: 'START' }>>((_, event) => ({
7474
verifyingId: event.id,
7575
emailAddress: event.emailAddress,

‎packages/mosaic/src/features/user-profile/user-profile-passkeys-section/user-profile-passkeys-section.model.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
173173
if (feedback) {
174174
throw new FormSubmitError<UserProfileRenamePasskeyValues>({ fields: { name: feedback.message } });
175175
}
176-
await save(() => passkey.update({ name }), ['name']);
176+
await save<keyof UserProfileRenamePasskeyValues>(() => passkey.update({ name }), ['name']);
177177
},
178178
onRemove: async id => {
179179
const passkey = currentUser().passkeys.find(candidate => candidate.id === id);

0 commit comments

Comments
 (0)