From 8441ba5e8a74d1b3c9bcd4b75ae6d9d7a5c89637 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Thu, 5 Dec 2024 14:29:10 -0600 Subject: [PATCH 1/6] Support for fallback prop --- .../react/src/components/uiComponents.tsx | 129 ++++++++++++++---- packages/react/src/components/withClerk.tsx | 3 +- packages/react/src/types.ts | 1 + 3 files changed, 104 insertions(+), 29 deletions(-) diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index a0d2a3263d..3eb25d670f 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -46,6 +46,50 @@ import { } from '../utils'; import { withClerk } from './withClerk'; +/** + * Used to detect when a Clerk component has been added to the DOM. + */ +function waitForElementChildren(options: { root: HTMLElement | null; timeout?: number }) { + const { root = document, timeout = 0 } = options; + + return new Promise((resolve, reject) => { + if (!root) { + reject(new Error('No root element provided')); + return; + } + + // Check if the element already has child nodes + const isElementAlreadyPresent = root?.childElementCount && root.childElementCount > 0; + if (isElementAlreadyPresent) { + resolve(); + return; + } + + // Set up a MutationObserver to detect when the element has children + const observer = new MutationObserver(mutationsList => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + if (root?.childElementCount && root.childElementCount > 0) { + observer.disconnect(); + resolve(); + return; + } + } + } + }); + + observer.observe(root, { childList: true }); + + // Set up an optional timeout to reject the promise if the element never gets child nodes + if (timeout > 0) { + setTimeout(() => { + observer.disconnect(); + reject(new Error(`Timeout waiting for element children`)); + }, timeout); + } + }); +} + type UserProfileExportType = typeof _UserProfile & { Page: typeof UserProfilePage; Link: typeof UserProfileLink; @@ -117,7 +161,7 @@ const isOpenProps = (props: any): props is OpenProps => { return 'open' in props; }; -// README: should be a class pure component in order for mount and unmount +// README: 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 @@ -146,10 +190,18 @@ const isOpenProps = (props: any): props is OpenProps => { // Portal.displayName = 'ClerkPortal'; -class Portal extends React.PureComponent< - PropsWithChildren<(MountProps | OpenProps) & { hideRootHtmlElement?: boolean }> +/** + * 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. + */ +class ClerkHostRenderer extends React.PureComponent< + PropsWithChildren<(MountProps | OpenProps) & { component?: string; hideRootHtmlElement?: boolean }> > { - private portalRef = React.createRef(); + private rootRef = React.createRef(); + + state = { + rendering: true, + }; componentDidUpdate(_prevProps: Readonly) { if (!isMountProps(_prevProps) || !isMountProps(this.props)) { @@ -166,28 +218,39 @@ class Portal extends React.PureComponent< const customMenuItemsChanged = prevProps.customMenuItems?.length !== newProps.customMenuItems?.length; if (!isDeeplyEqual(prevProps, newProps) || customPagesChanged || customMenuItemsChanged) { - if (this.portalRef.current) { - this.props.updateProps({ node: this.portalRef.current, props: this.props.props }); + if (this.rootRef.current) { + this.props.updateProps({ node: this.rootRef.current, props: this.props.props }); } } } componentDidMount() { - if (this.portalRef.current) { + if (this.rootRef.current) { if (isMountProps(this.props)) { - this.props.mount(this.portalRef.current, this.props.props); + this.props.mount(this.rootRef.current, this.props.props); } if (isOpenProps(this.props)) { this.props.open(this.props.props); } } + + // Watch for the element to be added to the DOM so we can render a fallback. + if (this.props.component) { + waitForElementChildren({ root: this.rootRef.current }) + .then(() => { + this.setState({ rendering: false }); + }) + .catch(() => { + this.setState({ rendering: false }); + }); + } } componentWillUnmount() { - if (this.portalRef.current) { + if (this.rootRef.current) { if (isMountProps(this.props)) { - this.props.unmount(this.portalRef.current); + this.props.unmount(this.rootRef.current); } if (isOpenProps(this.props)) { this.props.close(); @@ -197,9 +260,16 @@ class Portal extends React.PureComponent< render() { const { hideRootHtmlElement = false } = this.props; + const rootAttributes = { + ref: this.rootRef, + ...(this.props.component ? { 'data-clerk-component': this.props.component } : {}), + ...(this.state.rendering && 'fallback' in this.props ? { style: { display: 'none' } } : {}), + }; + return ( <> - {!hideRootHtmlElement &&
} + {!hideRootHtmlElement &&
} + {this.state.rendering && 'fallback' in this.props && this.props.fallback} {this.props.children} ); @@ -215,20 +285,23 @@ const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => { ); }; -export const SignIn = withClerk(({ clerk, ...props }: WithClerkProp) => { +// @ts-expect-error -- FIXME +export const SignIn = withClerk(({ clerk, fallback, ...props }: WithClerkProp) => { return ( - ); }, 'SignIn'); export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( - >>) => { const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); return ( - - + ); }, 'UserProfile', @@ -297,14 +370,14 @@ const _UserButton = withClerk( return ( - {/*This mimics the previous behaviour before asProvider existed*/} {props.__experimental_asProvider ? sanitizedChildren : null} - + ); }, @@ -337,7 +410,7 @@ export function UserButtonOutlet(outletProps: Without; + return ; } export const UserButton: UserButtonExportType = Object.assign(_UserButton, { @@ -363,14 +436,14 @@ const _OrganizationProfile = withClerk( ({ clerk, ...props }: WithClerkProp>>) => { const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); return ( - - + ); }, 'OrganizationProfile', @@ -383,7 +456,7 @@ export const OrganizationProfile: OrganizationProfileExportType = Object.assign( export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( - - {/*This mimics the previous behaviour before asProvider existed*/} {props.__experimental_asProvider ? sanitizedChildren : null} - + ); }, @@ -447,7 +520,7 @@ export function OrganizationSwitcherOutlet( }, } satisfies MountProps; - return ; + return ; } export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assign(_OrganizationSwitcher, { @@ -458,7 +531,7 @@ export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assig export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { return ( - ) => { return ( - ) => { return ( - ( const clerk = useIsomorphicClerkContext(); if (!clerk.loaded) { - return null; + // @ts-expect-error -- FIXME + return props.fallback || null; } return ( diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index eb1326d8d7..3b7a8574f0 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -82,6 +82,7 @@ export interface MountProps { mount: (node: HTMLDivElement, props: any) => void; unmount: (node: HTMLDivElement) => void; updateProps: (props: any) => void; + fallback?: React.ReactNode; props?: any; } From d78aa6b203a7560ca5118e887409f78d7d15527f Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 9 Dec 2024 15:00:15 -0600 Subject: [PATCH 2/6] Refactor implementation to live outside of ClerkHostRenderer --- .../src/components/ClerkHostRenderer.tsx | 118 ++++++++ .../react/src/components/uiComponents.tsx | 252 +++++------------- packages/react/src/components/withClerk.tsx | 14 +- packages/react/src/types.ts | 3 +- .../src/utils/useWaitForComponentMount.ts | 80 ++++++ 5 files changed, 269 insertions(+), 198 deletions(-) create mode 100644 packages/react/src/components/ClerkHostRenderer.tsx create mode 100644 packages/react/src/utils/useWaitForComponentMount.ts diff --git a/packages/react/src/components/ClerkHostRenderer.tsx b/packages/react/src/components/ClerkHostRenderer.tsx new file mode 100644 index 0000000000..19b8187718 --- /dev/null +++ b/packages/react/src/components/ClerkHostRenderer.tsx @@ -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: 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(); + +// useEffect(() => { +// if (portalRef.current) { +// mount(portalRef.current, props); +// } +// return () => { +// if (portalRef.current) { +// unmount(portalRef.current); +// } +// }; +// }, []); + +// return
; +// }); + +// 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(); + + componentDidUpdate(_prevProps: Readonly) { + 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 &&
} + {this.props.children} + + ); + } +} diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 3eb25d670f..7a9c139ad1 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -1,5 +1,3 @@ -import { without } from '@clerk/shared/object'; -import { isDeeplyEqual } from '@clerk/shared/react'; import { logErrorInDevMode } from '@clerk/shared/utils'; import type { CreateOrganizationProps, @@ -14,7 +12,7 @@ import type { WaitlistProps, Without, } from '@clerk/types'; -import type { PropsWithChildren } from 'react'; +import type { PropsWithChildren, ReactElement } from 'react'; import React, { createContext, createElement, useContext } from 'react'; import { @@ -29,7 +27,6 @@ import { import type { CustomPortalsRendererProps, MountProps, - OpenProps, OrganizationProfileLinkProps, OrganizationProfilePageProps, UserButtonActionProps, @@ -44,51 +41,16 @@ import { useUserButtonCustomMenuItems, useUserProfileCustomPages, } from '../utils'; +import { useWaitForComponentMount } from '../utils/useWaitForComponentMount'; +import { ClerkHostRenderer } from './ClerkHostRenderer'; import { withClerk } from './withClerk'; -/** - * Used to detect when a Clerk component has been added to the DOM. - */ -function waitForElementChildren(options: { root: HTMLElement | null; timeout?: number }) { - const { root = document, timeout = 0 } = options; - - return new Promise((resolve, reject) => { - if (!root) { - reject(new Error('No root element provided')); - return; - } - - // Check if the element already has child nodes - const isElementAlreadyPresent = root?.childElementCount && root.childElementCount > 0; - if (isElementAlreadyPresent) { - resolve(); - return; - } - - // Set up a MutationObserver to detect when the element has children - const observer = new MutationObserver(mutationsList => { - for (const mutation of mutationsList) { - if (mutation.type === 'childList') { - if (root?.childElementCount && root.childElementCount > 0) { - observer.disconnect(); - resolve(); - return; - } - } - } - }); - - observer.observe(root, { childList: true }); - - // Set up an optional timeout to reject the promise if the element never gets child nodes - if (timeout > 0) { - setTimeout(() => { - observer.disconnect(); - reject(new Error(`Timeout waiting for element children`)); - }, timeout); - } - }); -} +type FallbackProp = { + /** + * An optional element to render while the component is mounting. + */ + fallback?: ReactElement; +}; type UserProfileExportType = typeof _UserProfile & { Page: typeof UserProfilePage; @@ -153,162 +115,70 @@ type OrganizationSwitcherPropsWithoutCustomPages = Without< __experimental_asProvider?: boolean; }; -const isMountProps = (props: any): props is MountProps => { - return 'mount' in props; +const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => { + return ( + <> + {props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} + {props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))} + + ); }; -const isOpenProps = (props: any): props is OpenProps => { - return 'open' in props; -}; +export const SignIn = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; -// README: 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(); - -// useEffect(() => { -// if (portalRef.current) { -// mount(portalRef.current, props); -// } -// return () => { -// if (portalRef.current) { -// unmount(portalRef.current); -// } -// }; -// }, []); - -// return
; -// }); - -// 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. - */ -class ClerkHostRenderer extends React.PureComponent< - PropsWithChildren<(MountProps | OpenProps) & { component?: string; hideRootHtmlElement?: boolean }> -> { - private rootRef = React.createRef(); - - state = { - rendering: true, - }; - - componentDidUpdate(_prevProps: Readonly) { - 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); - } - } - - // Watch for the element to be added to the DOM so we can render a fallback. - if (this.props.component) { - waitForElementChildren({ root: this.rootRef.current }) - .then(() => { - this.setState({ rendering: false }); - }) - .catch(() => { - this.setState({ rendering: false }); - }); - } - } - - 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.component ? { 'data-clerk-component': this.props.component } : {}), - ...(this.state.rendering && 'fallback' in this.props ? { style: { display: 'none' } } : {}), + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), }; return ( <> - {!hideRootHtmlElement &&
} - {this.state.rendering && 'fallback' in this.props && this.props.fallback} - {this.props.children} + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} ); - } -} + }, + { component: 'SignIn', renderWhileLoading: true }, +); -const CustomPortalsRenderer = (props: CustomPortalsRendererProps) => { - return ( - <> - {props?.customPagesPortals?.map((portal, index) => createElement(portal, { key: index }))} - {props?.customMenuItemsPortals?.map((portal, index) => createElement(portal, { key: index }))} - - ); -}; +export const SignUp = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; -// @ts-expect-error -- FIXME -export const SignIn = withClerk(({ clerk, fallback, ...props }: WithClerkProp) => { - return ( - - ); -}, 'SignIn'); + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; -export const SignUp = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'SignUp'); + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'SignUp', renderWhileLoading: true }, +); export function UserProfilePage({ children }: PropsWithChildren) { logErrorInDevMode(userProfilePageRenderedError); diff --git a/packages/react/src/components/withClerk.tsx b/packages/react/src/components/withClerk.tsx index f3a1a9c21c..6fa971c939 100644 --- a/packages/react/src/components/withClerk.tsx +++ b/packages/react/src/components/withClerk.tsx @@ -8,18 +8,22 @@ import { useAssertWrappedByClerkProvider } from '../hooks/useAssertWrappedByCler export const withClerk =

( Component: React.ComponentType

, - displayName?: string, + displayNameOrOptions?: string | { component: string; renderWhileLoading?: boolean }, ) => { - displayName = displayName || Component.displayName || Component.name || 'Component'; + const passedDisplayedName = + typeof displayNameOrOptions === 'string' ? displayNameOrOptions : displayNameOrOptions?.component; + const displayName = passedDisplayedName || Component.displayName || Component.name || 'Component'; Component.displayName = displayName; + + const options = typeof displayNameOrOptions === 'string' ? undefined : displayNameOrOptions; + const HOC = (props: Without) => { useAssertWrappedByClerkProvider(displayName || 'withClerk'); const clerk = useIsomorphicClerkContext(); - if (!clerk.loaded) { - // @ts-expect-error -- FIXME - return props.fallback || null; + if (!clerk.loaded && !options?.renderWhileLoading) { + return null; } return ( diff --git a/packages/react/src/types.ts b/packages/react/src/types.ts index 3b7a8574f0..0c732dbc1c 100644 --- a/packages/react/src/types.ts +++ b/packages/react/src/types.ts @@ -70,7 +70,7 @@ export interface HeadlessBrowserClerkConstructor { new (publishableKey: string, options?: DomainOrProxyUrl): HeadlessBrowserClerk; } -export type WithClerkProp = T & { clerk: LoadedClerk }; +export type WithClerkProp = T & { clerk: LoadedClerk; component?: string }; export interface CustomPortalsRendererProps { customPagesPortals?: any[]; @@ -82,7 +82,6 @@ export interface MountProps { mount: (node: HTMLDivElement, props: any) => void; unmount: (node: HTMLDivElement) => void; updateProps: (props: any) => void; - fallback?: React.ReactNode; props?: any; } diff --git a/packages/react/src/utils/useWaitForComponentMount.ts b/packages/react/src/utils/useWaitForComponentMount.ts new file mode 100644 index 0000000000..dc6019b65f --- /dev/null +++ b/packages/react/src/utils/useWaitForComponentMount.ts @@ -0,0 +1,80 @@ +import { useEffect, useRef, useState } from 'react'; + +/** + * Used to detect when a Clerk component has been added to the DOM. + */ +function waitForElementChildren(options: { selector?: string; root?: HTMLElement | null; timeout?: number }) { + const { root = document?.body, selector, timeout = 0 } = options; + + return new Promise((resolve, reject) => { + if (!root) { + reject(new Error('No root element provided')); + return; + } + + let elementToWatch: HTMLElement | null = root; + if (selector) { + elementToWatch = root?.querySelector(selector); + } + + // Check if the element already has child nodes + const isElementAlreadyPresent = elementToWatch?.childElementCount && elementToWatch.childElementCount > 0; + if (isElementAlreadyPresent) { + resolve(); + return; + } + + // Set up a MutationObserver to detect when the element has children + const observer = new MutationObserver(mutationsList => { + for (const mutation of mutationsList) { + if (mutation.type === 'childList') { + if (!elementToWatch && selector) { + elementToWatch = root?.querySelector(selector); + } + + if (elementToWatch?.childElementCount && elementToWatch.childElementCount > 0) { + observer.disconnect(); + resolve(); + return; + } + } + } + }); + + observer.observe(root, { childList: true, subtree: true }); + + // Set up an optional timeout to reject the promise if the element never gets child nodes + if (timeout > 0) { + setTimeout(() => { + observer.disconnect(); + reject(new Error(`Timeout waiting for element children`)); + }, timeout); + } + }); +} + +/** + * Detect when a Clerk component has mounted by watching DOM updates to an element with a `data-clerk-component="${component}"` property. + */ +export function useWaitForComponentMount(component?: string) { + const watcherRef = useRef>(); + const [status, setStatus] = useState<'rendering' | 'rendered' | 'error'>('rendering'); + + useEffect(() => { + if (!component) { + throw new Error('Clerk: no component name provided, unable to detect mount.'); + } + + if (typeof window !== 'undefined' && !watcherRef.current) { + watcherRef.current = waitForElementChildren({ selector: `[data-clerk-component="${component}"]` }) + .then(() => { + setStatus('rendered'); + }) + .catch(() => { + setStatus('error'); + }); + } + }, [component]); + + return status; +} From ec8fa362bb245f17378b40265bc076ffa15e8689 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 9 Dec 2024 20:01:20 -0600 Subject: [PATCH 3/6] Adds fallback prop to remaining components --- .../react/src/components/uiComponents.tsx | 286 +++++++++++++----- packages/react/src/components/withClerk.tsx | 3 +- 2 files changed, 213 insertions(+), 76 deletions(-) diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 7a9c139ad1..7db0c06a05 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -65,8 +65,7 @@ type UserButtonExportType = typeof _UserButton & { Link: typeof MenuLink; /** * The `` component can be used in conjunction with `asProvider` in order to control rendering - * of the `` without affecting its configuration or any custom pages - * that could be mounted + * of the `` without affecting its configuration or any custom pages that could be mounted * @experimental This API is experimental and may change at any moment. */ __experimental_Outlet: typeof UserButtonOutlet; @@ -95,8 +94,7 @@ type OrganizationSwitcherExportType = typeof _OrganizationSwitcher & { OrganizationProfileLink: typeof OrganizationProfileLink; /** * The `` component can be used in conjunction with `asProvider` in order to control rendering - * of the `` without affecting its configuration or any custom pages - * that could be mounted + * of the `` without affecting its configuration or any custom pages that could be mounted * @experimental This API is experimental and may change at any moment. */ __experimental_Outlet: typeof OrganizationSwitcherOutlet; @@ -191,20 +189,34 @@ export function UserProfileLink({ children }: PropsWithChildren>>) => { + ({ + clerk, + component, + fallback, + ...props + }: WithClerkProp> & FallbackProp>) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); return ( ); }, - 'UserProfile', + { component: 'UserProfile', renderWhileLoading: true }, ); export const UserProfile: UserProfileExportType = Object.assign(_UserProfile, { @@ -219,7 +231,19 @@ const UserButtonContext = createContext({ }); const _UserButton = withClerk( - ({ clerk, ...props }: WithClerkProp>) => { + ({ + clerk, + component, + fallback, + ...props + }: WithClerkProp & FallbackProp>) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children, { allowForAnyChildren: !!props.__experimental_asProvider, }); @@ -240,18 +264,23 @@ const _UserButton = withClerk( return ( - - {/*This mimics the previous behaviour before asProvider existed*/} - {props.__experimental_asProvider ? sanitizedChildren : null} - - + {shouldShowFallback && fallback} + {clerk.loaded && ( + + {/*This mimics the previous behaviour before asProvider existed*/} + {props.__experimental_asProvider ? sanitizedChildren : null} + + + )} ); }, - 'UserButton', + { component: 'UserButton', renderWhileLoading: true }, ); export function MenuItems({ children }: PropsWithChildren) { @@ -303,20 +332,39 @@ export function OrganizationProfileLink({ children }: PropsWithChildren>>) => { + ({ + clerk, + component, + fallback, + ...props + }: WithClerkProp> & FallbackProp>) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children); return ( - - - + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + + + )} + ); }, - 'OrganizationProfile', + { component: 'OrganizationProfile', renderWhileLoading: true }, ); export const OrganizationProfile: OrganizationProfileExportType = Object.assign(_OrganizationProfile, { @@ -324,16 +372,33 @@ export const OrganizationProfile: OrganizationProfileExportType = Object.assign( Link: OrganizationProfileLink, }); -export const CreateOrganization = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'CreateOrganization'); +export const CreateOrganization = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'CreateOrganization', renderWhileLoading: true }, +); const OrganizationSwitcherContext = createContext({ mount: () => {}, @@ -342,7 +407,19 @@ const OrganizationSwitcherContext = createContext({ }); const _OrganizationSwitcher = withClerk( - ({ clerk, ...props }: WithClerkProp>) => { + ({ + clerk, + component, + fallback, + ...props + }: WithClerkProp & FallbackProp>) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + const { customPages, customPagesPortals } = useOrganizationProfileCustomPages(props.children, { allowForAnyChildren: !!props.__experimental_asProvider, }); @@ -354,6 +431,8 @@ const _OrganizationSwitcher = withClerk( unmount: clerk.unmountOrganizationSwitcher, updateProps: (clerk as any).__unstable__updateProps, props: { ...props, organizationProfileProps }, + rootProps: rendererRootProps, + component, }; /** @@ -363,18 +442,23 @@ const _OrganizationSwitcher = withClerk( return ( - - {/*This mimics the previous behaviour before asProvider existed*/} - {props.__experimental_asProvider ? sanitizedChildren : null} - - + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + {/*This mimics the previous behaviour before asProvider existed*/} + {props.__experimental_asProvider ? sanitizedChildren : null} + + + )} + ); }, - 'OrganizationSwitcher', + { component: 'OrganizationSwitcher', renderWhileLoading: true }, ); export function OrganizationSwitcherOutlet( @@ -399,34 +483,86 @@ export const OrganizationSwitcher: OrganizationSwitcherExportType = Object.assig __experimental_Outlet: OrganizationSwitcherOutlet, }); -export const OrganizationList = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'OrganizationList'); +export const OrganizationList = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; -export const GoogleOneTap = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'GoogleOneTap'); + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; -export const Waitlist = withClerk(({ clerk, ...props }: WithClerkProp) => { - return ( - - ); -}, 'Waitlist'); + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'OrganizationList', renderWhileLoading: true }, +); + +export const GoogleOneTap = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'GoogleOneTap', renderWhileLoading: true }, +); + +export const Waitlist = withClerk( + ({ clerk, component, fallback, ...props }: WithClerkProp) => { + const mountingStatus = useWaitForComponentMount(component); + const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; + + const rendererRootProps = { + ...(shouldShowFallback && fallback && { style: { display: 'none' } }), + }; + + return ( + <> + {shouldShowFallback && fallback} + {clerk.loaded && ( + + )} + + ); + }, + { component: 'Waitlist', renderWhileLoading: true }, +); diff --git a/packages/react/src/components/withClerk.tsx b/packages/react/src/components/withClerk.tsx index 6fa971c939..a78a1c82c8 100644 --- a/packages/react/src/components/withClerk.tsx +++ b/packages/react/src/components/withClerk.tsx @@ -6,7 +6,7 @@ import { errorThrower } from '../errors/errorThrower'; import { hocChildrenNotAFunctionError } from '../errors/messages'; import { useAssertWrappedByClerkProvider } from '../hooks/useAssertWrappedByClerkProvider'; -export const withClerk =

( +export const withClerk =

( Component: React.ComponentType

, displayNameOrOptions?: string | { component: string; renderWhileLoading?: boolean }, ) => { @@ -29,6 +29,7 @@ export const withClerk =

( return ( ); From c2f8a4981f064584a271b703c0e312c1c88ced0e Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 9 Dec 2024 21:00:19 -0600 Subject: [PATCH 4/6] Adds changeset --- .changeset/serious-stingrays-learn.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changeset/serious-stingrays-learn.md diff --git a/.changeset/serious-stingrays-learn.md b/.changeset/serious-stingrays-learn.md new file mode 100644 index 0000000000..ff11afd3bf --- /dev/null +++ b/.changeset/serious-stingrays-learn.md @@ -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 +} /> +``` From e10c5e4d1d731f4a4d7fb01fc8d52efd5e743568 Mon Sep 17 00:00:00 2001 From: Bryce Kalow Date: Mon, 9 Dec 2024 21:24:49 -0600 Subject: [PATCH 5/6] Adds simple tests for sign in fallback, and cleans up prop types of components exposed through clerk/nextjs --- .../src/app/sign-in/[[...catchall]]/page.tsx | 1 + integration/templates/react-vite/src/sign-in/index.tsx | 1 + integration/tests/sign-in-flow.test.ts | 6 ++++++ packages/nextjs/src/client-boundary/uiComponents.tsx | 10 +++++----- 4 files changed, 13 insertions(+), 5 deletions(-) diff --git a/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx b/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx index 3b4342ff8a..d193e28a46 100644 --- a/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx +++ b/integration/templates/next-app-router/src/app/sign-in/[[...catchall]]/page.tsx @@ -7,6 +7,7 @@ export default function Page() { routing={'path'} path={'/sign-in'} signUpUrl={'/sign-up'} + fallback={<>Loading sign in} />

); diff --git a/integration/templates/react-vite/src/sign-in/index.tsx b/integration/templates/react-vite/src/sign-in/index.tsx index 39caef7d00..7ec2593036 100644 --- a/integration/templates/react-vite/src/sign-in/index.tsx +++ b/integration/templates/react-vite/src/sign-in/index.tsx @@ -6,6 +6,7 @@ export default function Page() { Loading sign in} />
); diff --git a/integration/tests/sign-in-flow.test.ts b/integration/tests/sign-in-flow.test.ts index e04b5e0012..290758ba62 100644 --- a/integration/tests/sign-in-flow.test.ts +++ b/integration/tests/sign-in-flow.test.ts @@ -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(); diff --git a/packages/nextjs/src/client-boundary/uiComponents.tsx b/packages/nextjs/src/client-boundary/uiComponents.tsx index a4dde9da72..054e54a428 100644 --- a/packages/nextjs/src/client-boundary/uiComponents.tsx +++ b/packages/nextjs/src/client-boundary/uiComponents.tsx @@ -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'; @@ -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) => { return ; }, { ...BaseUserProfile }, @@ -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) => { return ; }, { ...BaseOrganizationProfile }, ); -export const SignIn = (props: SignInProps) => { +export const SignIn = (props: ComponentProps) => { return ; }; -export const SignUp = (props: SignUpProps) => { +export const SignUp = (props: ComponentProps) => { return ; }; From 67e8e2e2b8b89891c386d6df6eeff7ac3d5ac2be Mon Sep 17 00:00:00 2001 From: Alex Carpenter Date: Tue, 10 Dec 2024 10:45:50 -0500 Subject: [PATCH 6/6] render UserProfile fallback --- .../react/src/components/uiComponents.tsx | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 7db0c06a05..094f060707 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -204,16 +204,19 @@ const _UserProfile = withClerk( const { customPages, customPagesPortals } = useUserProfileCustomPages(props.children); return ( - - - + <> + {shouldShowFallback && fallback} + + + + ); }, { component: 'UserProfile', renderWhileLoading: true },