Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .changeset/mosaic-machine-state-names.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
---
---
2 changes: 2 additions & 0 deletions packages/mosaic/docs/controllers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:

Expand Down
11 changes: 10 additions & 1 deletion packages/mosaic/eslint/config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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, '..');

Expand All @@ -30,6 +30,7 @@ export default [
'no-model-react-state': noModelReactState,
'no-dynamic-message-catalogs': noDynamicMessageCatalogs,
'forward-ref-parts': forwardRefParts,
'no-raw-invoke': noRawInvoke,
},
},
},
Expand Down Expand Up @@ -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}'],
Expand Down
42 changes: 42 additions & 0 deletions packages/mosaic/eslint/import-guards.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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<number>) => 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(
Expand Down Expand Up @@ -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),
]) {
Expand Down
28 changes: 28 additions & 0 deletions packages/mosaic/eslint/rules.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
) {
context.report({ node: invoke, messageId: 'useFromPromise' });
}
},
};
},
};
2 changes: 1 addition & 1 deletion packages/mosaic/src/components/action-bar/action-bar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -263,8 +263,8 @@ const Action = React.forwardRef<HTMLButtonElement, ActionBarActionProps>(functio
<Button
variant='ghost'
size='md'
color={color}
{...merged}
color={color}
// 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.
ref={useMergeRefs<unknown>([ref, isRef(itemRef) ? itemRef : undefined])}
xstyle={[color === 'negative' && styles.destructive, xstyle]}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { OrganizationResource } from '@clerk/shared/types';
import { useMosaicEnvironment } from '../../../hooks/use-mosaic-environment';
import { save, SaveError, UNEXPECTED_ERROR } from '../../../utils/errors';
import { ORGANIZATION_PERMISSIONS } from '../../../utils/permissions';
import type { OrganizationProfileEditNameValue } from './organization-profile-edit-name.dialog';
import type { OrganizationProfileEditSlugValue } from './organization-profile-edit-slug.dialog';
import type { OrganizationProfileProfileSectionViewProps } from './organization-profile-profile-section.view';

type OrganizationProfileProfileSectionModel =
Expand Down Expand Up @@ -52,10 +54,11 @@ export function useOrganizationProfileProfileSectionModel(): OrganizationProfile
...profile,
onLogoChange: file => save(() => currentOrganization().setLogo({ file })),
onRemoveLogo: organization.hasImage ? () => save(() => currentOrganization().setLogo({ file: null })) : undefined,
onSubmitName: name => save(() => currentOrganization().update({ name }), ['name']),
onSubmitName: name =>
save<keyof OrganizationProfileEditNameValue>(() => currentOrganization().update({ name }), ['name']),
onSubmitSlug: !environment.organizationSettings.slug.disabled
? slug =>
save(() => {
save<keyof OrganizationProfileEditSlugValue>(() => {
const current = currentOrganization();
return current.update({ name: current.name, slug });
}, ['slug'])
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,20 +176,20 @@ const factorEvents = {
},
SELECT_METHOD: [
{
target: 'methodPickerPreparing',
target: 'methodPickerPreparing' as const,
guard: (ctx, event) => {
const method = ctx.methods.find(candidate => candidate.id === event.id);
return topOverlay(ctx) === 'method-picker' && Boolean(method && needsPrepare(method));
},
actions: assign((ctx, event) => selectMethod(ctx, event.id)),
},
{
target: 'verifying',
target: 'verifying' as const,
guard: ctx => topOverlay(ctx) === 'method-picker',
actions: assign((ctx, event) => ({ ...selectMethod(ctx, event.id), overlay: [] })),
},
],
RESET: 'inactive',
RESET: 'inactive' as const,
} satisfies NonNullable<StateConfig<ReverificationContext, ReverificationEvent>['on']>;

export const reverificationMachine = createMachine({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,12 +64,12 @@ const LINK_RESEND_SECONDS = 60;
const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) };
const fail = assign<ErrorInvokeEvent>((_, event) => ({ error: toFormError(event.error) }));
const resend = {
target: 'starting',
target: 'starting' as const,
guard: (context: Context) => context.resendSeconds === 0,
actions: assign(() => ({ error: undefined })),
};
const start = {
target: 'starting',
target: 'starting' as const,
actions: assign<Extract<Event, { type: 'START' }>>((_, event) => ({
verifyingId: event.id,
emailAddress: event.emailAddress,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ export function useUserProfilePasskeysModel(): UserProfilePasskeysModel {
if (feedback) {
throw new FormSubmitError<UserProfileRenamePasskeyValues>({ fields: { name: feedback.message } });
}
await save(() => passkey.update({ name }), ['name']);
await save<keyof UserProfileRenamePasskeyValues>(() => passkey.update({ name }), ['name']);
},
onRemove: async id => {
const passkey = currentUser().passkeys.find(candidate => candidate.id === id);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ function missingDependency(): Promise<never> {
const tick = { actions: assign(context => ({ resendSeconds: Math.max(0, context.resendSeconds - 1) })) };
const fail = assign<ErrorInvokeEvent>((_, event) => ({ error: toFormError(event.error) }));
const start = {
target: 'sending',
target: 'sending' as const,
actions: assign<Extract<Event, { type: 'START' }>>((_, event) => ({
verifyingId: event.id,
phoneNumber: event.phoneNumber,
Expand Down
1 change: 1 addition & 0 deletions packages/mosaic/src/hooks/use-spin-delay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function useSpinDelay<T>(value: T | null, options: SpinDelayOptions = {})
if (value !== shown) {
setShown(value);
}
return undefined;
}, [value, shown, delay, minDuration]);

return shown;
Expand Down
51 changes: 29 additions & 22 deletions packages/mosaic/src/machine/ADOPTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -115,10 +115,9 @@ Problems:
### After

```ts
import { createMachine } from '../../machine/create-machine';
import { assign } from '../../machine/assign';
import { setup } from '../../machine/setup';

type Context = { error: string | null };
type Context = { emailAddress: string; error: string | null };
type Event = { type: 'SUBMIT'; emailAddress: string } | { type: 'NAVIGATE_DONE' };

type WaitlistDeps = {
Expand All @@ -127,20 +126,25 @@ type WaitlistDeps = {
afterJoinWaitlistUrl?: string;
};

const { createMachine, assign, fromPromise } = setup<Context, Event>();

function createWaitlistMachine(deps: WaitlistDeps) {
return createMachine<Context, Event>({
return createMachine({
id: 'waitlist',
initial: 'idle',
context: { error: null },
context: { emailAddress: '', error: null },
states: {
idle: {
on: { SUBMIT: 'submitting' },
on: {
SUBMIT: {
target: 'submitting',
actions: assign((_ctx, e) => ({ emailAddress: e.emailAddress })),
},
},
},
submitting: {
// Double-submit is impossible: SUBMIT is not handled here.
invoke: {
src: (ctx, event) =>
deps.joinWaitlist({ emailAddress: (event as Extract<Event, { type: 'SUBMIT' }>).emailAddress }),
invoke: fromPromise(ctx => deps.joinWaitlist({ emailAddress: ctx.emailAddress }), {
onDone: {
target: 'success',
actions: assign(() => ({ error: null })),
Expand All @@ -149,7 +153,7 @@ function createWaitlistMachine(deps: WaitlistDeps) {
target: 'idle',
actions: assign((_ctx, e) => ({ error: String(e.error) })),
},
},
}),
},
success: {
// Optional auto-navigate on entry:
Expand Down Expand Up @@ -375,7 +379,9 @@ type SignInEvent =
| { type: 'CLEAR_PROVIDER' }
| { type: 'SUBMIT'; identifier: string };

const signInStartMachine = createMachine<SignInContext, SignInEvent>({
const { createMachine, assign, fromPromise } = setup<SignInContext, SignInEvent>();

const signInStartMachine = createMachine({
id: 'signInStart',
initial: 'idle',
context: { selectedProvider: null, identifier: '', error: null },
Expand Down Expand Up @@ -405,14 +411,13 @@ const signInStartMachine = createMachine<SignInContext, SignInEvent>({
},
},
submitting: {
invoke: {
src: ctx => signIn.create({ identifier: ctx.identifier }),
invoke: fromPromise(ctx => signIn.create({ identifier: ctx.identifier }), {
onDone: { target: 'idle', actions: assign(() => ({ error: null })) },
onError: {
target: 'idle',
actions: assign((_ctx, e) => ({ error: String(e.error) })),
},
},
}),
},
},
});
Expand Down Expand Up @@ -521,7 +526,7 @@ type SignInStartEvent =
| { type: 'TYPE_IDENTIFIER'; value: string }
| { type: 'SUBMIT_IDENTIFIER' };

const { createMachine, assign } = setup<SignInStartContext, SignInStartEvent>();
const { createMachine, assign, fromPromise } = setup<SignInStartContext, SignInStartEvent>();

export function createSignInStartMachine(deps: { signInFn: SignInStartContext['signInFn'] }) {
return createMachine({
Expand All @@ -546,17 +551,19 @@ export function createSignInStartMachine(deps: { signInFn: SignInStartContext['s
submitting: {
// Both entry points converge here. idle's on-handlers are inactive,
// so a second CLICK_SOCIAL while already submitting is dropped automatically.
invoke: {
src: ctx =>
invoke: fromPromise(
ctx =>
ctx.activeStrategy === 'email'
? ctx.signInFn({ identifier: ctx.identifier })
: ctx.signInFn({ strategy: ctx.activeStrategy! }),
onDone: 'success',
onError: {
target: 'idle',
actions: assign((_, e) => ({ error: String(e.error), activeStrategy: null })),
{
onDone: 'success',
onError: {
target: 'idle',
actions: assign((_, e) => ({ error: String(e.error), activeStrategy: null })),
},
},
},
),
},
success: { type: 'final' },
},
Expand Down
Loading
Loading