Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(clerk-react): Support for fallback prop #4723

Merged
merged 13 commits into from
Dec 20, 2024
10 changes: 10 additions & 0 deletions .changeset/serious-stingrays-learn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@clerk/clerk-react': minor
---

Adds support for a `fallback` prop on Clerk's components. This allows rendering of a placeholder element while Clerk's components are mounting. Use this to help mitigate mitigate layout shift when using Clerk's components. Example usage:
BRKalow marked this conversation as resolved.
Show resolved Hide resolved


```tsx
<SignIn fallback={<LoadingSkeleton />} />
```
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default function Page() {
routing={'path'}
path={'/sign-in'}
signUpUrl={'/sign-up'}
fallback={<>Loading sign in</>}
/>
</div>
);
Expand Down
1 change: 1 addition & 0 deletions integration/templates/react-vite/src/sign-in/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default function Page() {
<SignIn
path={'/sign-in'}
signUpUrl={'/sign-up'}
fallback={<>Loading sign in</>}
/>
</div>
);
Expand Down
6 changes: 6 additions & 0 deletions integration/tests/sign-in-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,12 @@ testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('sign in f
await app.teardown();
});

test('sign in supports fallback', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.goToRelative('/sign-in');
await expect(u.page.getByText('Loading sign in')).toBeVisible();
});

test('sign in with email and password', async ({ page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
Expand Down
10 changes: 5 additions & 5 deletions packages/nextjs/src/client-boundary/uiComponents.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
SignUp as BaseSignUp,
UserProfile as BaseUserProfile,
} from '@clerk/clerk-react';
import type { OrganizationProfileProps, SignInProps, SignUpProps, UserProfileProps } from '@clerk/types';
import type { ComponentProps } from 'react';
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🥇

import React from 'react';

import { useEnforceCorrectRoutingProps } from './hooks/useEnforceRoutingProps';
Expand All @@ -29,7 +29,7 @@ export {
// Also the `typeof BaseUserProfile` is used to resolve the following error:
// "The inferred type of 'UserProfile' cannot be named without a reference to ..."
export const UserProfile: typeof BaseUserProfile = Object.assign(
(props: UserProfileProps) => {
(props: ComponentProps<typeof BaseUserProfile>) => {
return <BaseUserProfile {...useEnforceCorrectRoutingProps('UserProfile', props)} />;
},
{ ...BaseUserProfile },
Expand All @@ -40,16 +40,16 @@ export const UserProfile: typeof BaseUserProfile = Object.assign(
// Also the `typeof BaseOrganizationProfile` is used to resolved the following error:
// "The inferred type of 'OrganizationProfile' cannot be named without a reference to ..."
export const OrganizationProfile: typeof BaseOrganizationProfile = Object.assign(
(props: OrganizationProfileProps) => {
(props: ComponentProps<typeof BaseOrganizationProfile>) => {
return <BaseOrganizationProfile {...useEnforceCorrectRoutingProps('OrganizationProfile', props)} />;
},
{ ...BaseOrganizationProfile },
);

export const SignIn = (props: SignInProps) => {
export const SignIn = (props: ComponentProps<typeof BaseSignIn>) => {
return <BaseSignIn {...useEnforceCorrectRoutingProps('SignIn', props, false)} />;
};

export const SignUp = (props: SignUpProps) => {
export const SignUp = (props: ComponentProps<typeof BaseSignUp>) => {
return <BaseSignUp {...useEnforceCorrectRoutingProps('SignUp', props, false)} />;
};
118 changes: 118 additions & 0 deletions packages/react/src/components/ClerkHostRenderer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import { without } from '@clerk/shared/object';
import { isDeeplyEqual } from '@clerk/shared/react';
import type { PropsWithChildren } from 'react';
import React from 'react';

import type { MountProps, OpenProps } from '../types';

const isMountProps = (props: any): props is MountProps => {
return 'mount' in props;
};

const isOpenProps = (props: any): props is OpenProps => {
return 'open' in props;
};
// README: <ClerkHostRenderer/> should be a class pure component in order for mount and unmount
// lifecycle props to be invoked correctly. Replacing the class component with a
// functional component wrapped with a React.memo is not identical to the original
// class implementation due to React intricacies such as the useEffect’s cleanup
// seems to run AFTER unmount, while componentWillUnmount runs BEFORE.

// More information can be found at https://clerk.slack.com/archives/C015S0BGH8R/p1624891993016300

// The function Portal implementation is commented out for future reference.

// const Portal = React.memo(({ props, mount, unmount }: MountProps) => {
// const portalRef = React.createRef<HTMLDivElement>();

// useEffect(() => {
// if (portalRef.current) {
// mount(portalRef.current, props);
// }
// return () => {
// if (portalRef.current) {
// unmount(portalRef.current);
// }
// };
// }, []);

// return <div ref={portalRef} />;
// });

// Portal.displayName = 'ClerkPortal';

/**
* Used to orchestrate mounting of Clerk components in a host React application.
* Components are rendered into a specific DOM node using mount/unmount methods provided by the Clerk class.
*/
export class ClerkHostRenderer extends React.PureComponent<
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is largely unchanged from the previous Portal component. I renamed it to avoid confusion with React's portal primitive.

PropsWithChildren<
(MountProps | OpenProps) & {
component?: string;
hideRootHtmlElement?: boolean;
rootProps?: JSX.IntrinsicElements['div'];
}
>
> {
private rootRef = React.createRef<HTMLDivElement>();

componentDidUpdate(_prevProps: Readonly<MountProps | OpenProps>) {
if (!isMountProps(_prevProps) || !isMountProps(this.props)) {
return;
}

// Remove children and customPages from props before comparing
// children might hold circular references which deepEqual can't handle
// and the implementation of customPages or customMenuItems relies on props getting new references
const prevProps = without(_prevProps.props, 'customPages', 'customMenuItems', 'children');
const newProps = without(this.props.props, 'customPages', 'customMenuItems', 'children');
// instead, we simply use the length of customPages to determine if it changed or not
const customPagesChanged = prevProps.customPages?.length !== newProps.customPages?.length;
const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length;

if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) {
if (this.rootRef.current) {
this.props.updateProps({ node: this.rootRef.current, props: this.props.props });
}
}
}

componentDidMount() {
if (this.rootRef.current) {
if (isMountProps(this.props)) {
this.props.mount(this.rootRef.current, this.props.props);
}

if (isOpenProps(this.props)) {
this.props.open(this.props.props);
}
}
}

componentWillUnmount() {
if (this.rootRef.current) {
if (isMountProps(this.props)) {
this.props.unmount(this.rootRef.current);
}
if (isOpenProps(this.props)) {
this.props.close();
}
}
}

render() {
const { hideRootHtmlElement = false } = this.props;
const rootAttributes = {
ref: this.rootRef,
...this.props.rootProps,
...(this.props.component && { 'data-clerk-component': this.props.component }),
};

return (
<>
{!hideRootHtmlElement && <div {...rootAttributes} />}
{this.props.children}
</>
);
}
}
Loading
Loading