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): Render button components before clerk-js load #4810

Merged
merged 12 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:


```tsx
<SignIn fallback={<LoadingSkeleton />} />
```
5 changes: 5 additions & 0 deletions .changeset/sixty-moose-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/clerk-react': minor
---

Allow `<SignInButton />`, <SignUpButton />`, `<SignOutButton />`, and `<SignInWithMetamaskButton />` to render while clerk-js is still loading. This reduces any layout shift that might be caused by these components not rendering immediately.
2 changes: 1 addition & 1 deletion integration/templates/next-app-router/src/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { ClientId } from './client-id';
export default function Home() {
return (
<main>
<UserButton />
<UserButton fallback={<>Loading user button</>} />
<ClientId />
<SignedIn>SignedIn</SignedIn>
<SignedOut>SignedOut</SignedOut>
Expand Down
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</>}
__experimental={{
combinedProps: {},
}}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export default function Page() {
routing={'path'}
path={'/sign-up'}
signInUrl={'/sign-in'}
fallback={<>Loading sign up</>}
/>
</div>
);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import { OrganizationSwitcher } from '@clerk/nextjs';

export default function Page() {
return <OrganizationSwitcher hidePersonal={true} />;
return (
<OrganizationSwitcher
hidePersonal={true}
fallback={<>Loading organization switcher</>}
/>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { UserButton } from '@clerk/nextjs';

export default function Page() {
return (
<div>
<UserButton fallback={<>Loading user button</>} />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { UserProfile } from '@clerk/nextjs';
export default function Page() {
return (
<div>
<UserProfile />
<UserProfile fallback={<>Loading user profile</>} />
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ function Page1() {
export default function Page() {
return (
<PageContextProvider>
<UserButton>
<UserButton fallback={<>Loading user button</>}>
<UserButton.UserProfilePage
label={'Page 1'}
labelIcon={<p data-label-icon={'page-1'}>🙃</p>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,10 @@ function Page1() {
export default function Page() {
return (
<PageContextProvider>
<UserProfile path={'/custom-user-profile'}>
<UserProfile
fallback={<>Loading user profile</>}
path={'/custom-user-profile'}
>
<UserProfile.Page
label={'Page 1'}
labelIcon={<p data-label-icon={'page-1'}>🙃</p>}
Expand Down
5 changes: 5 additions & 0 deletions integration/templates/react-vite/src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import UserProfile from './user';
import UserProfileCustom from './custom-user-profile';
import UserButtonCustom from './custom-user-button';
import UserButtonCustomTrigger from './custom-user-button-trigger';
import UserButton from './user-button';

const Root = () => {
const navigate = useNavigate();
Expand Down Expand Up @@ -53,6 +54,10 @@ const router = createBrowserRouter([
path: '/user/*',
element: <UserProfile />,
},
{
path: '/user-button',
element: <UserButton />,
},
{
path: '/protected',
element: <Protected />,
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
1 change: 1 addition & 0 deletions integration/templates/react-vite/src/sign-up/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ export default function Page() {
<SignUp
path={'/sign-up'}
signInUrl={'/sign-in'}
fallback={<>Loading sign up</>}
/>
</div>
);
Expand Down
9 changes: 9 additions & 0 deletions integration/templates/react-vite/src/user-button/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { UserButton } from '@clerk/clerk-react';

export default function Page() {
return (
<div>
<UserButton fallback={<>Loading user button</>} />
</div>
);
}
5 changes: 4 additions & 1 deletion integration/templates/react-vite/src/user/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { UserProfile } from '@clerk/clerk-react';
export default function Page() {
return (
<div>
<UserProfile path={'/user'} />
<UserProfile
path={'/user'}
fallback={<>Loading user profile</>}
/>
</div>
);
}
77 changes: 77 additions & 0 deletions integration/tests/components.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, test } from '@playwright/test';

import { appConfigs } from '../presets';
import type { FakeUser } from '../testUtils';
import { createTestUtils, testAgainstRunningApps } from '../testUtils';

testAgainstRunningApps({ withEnv: [appConfigs.envs.withEmailCodes] })('component smoke tests @generic', ({ app }) => {
let fakeUser: FakeUser;

test.beforeAll(async () => {
const u = createTestUtils({ app });
fakeUser = u.services.users.createFakeUser({
withPhoneNumber: true,
withUsername: true,
});
await u.services.users.createBapiUser(fakeUser);
});

test.afterAll(async () => {
await app.teardown();
await fakeUser.deleteIfExists();
});

const components = [
{
name: 'SignIn',
path: '/sign-in',
fallback: 'Loading sign in',
},
{
name: 'SignUp',
path: '/sign-up',
fallback: 'Loading sign up',
},
{
name: 'UserProfile',
path: '/user',
protected: true,
fallback: 'Loading user profile',
},
{
name: 'UserButton',
path: '/user-button',
protected: true,
fallback: 'Loading user button',
},
];

const signIn = async ({ app, page, context }) => {
const u = createTestUtils({ app, page, context });
await u.po.signIn.goTo();
await u.po.signIn.signInWithEmailAndInstantPassword({ email: fakeUser.email, password: fakeUser.password });
await u.po.expect.toBeSignedIn();
};

const signOut = async ({ app, page, context }) => {
const u = createTestUtils({ app, page, context });
await u.page.evaluate(async () => {
await window.Clerk.signOut();
});
};

for (const component of components) {
test(`${component.name} supports fallback`, async ({ page, context }) => {
// eslint-disable-next-line playwright/no-conditional-in-test
if (component.protected) {
await signIn({ app, page, context });
}

const u = createTestUtils({ app, page, context });
await u.page.goToRelative(component.path);
await expect(u.page.getByText(component.fallback)).toBeVisible();

await signOut({ app, page, context });
});
}
});
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';
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<
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