diff --git a/.changeset/floppy-glasses-share.md b/.changeset/floppy-glasses-share.md new file mode 100644 index 00000000000..e4989d572f1 --- /dev/null +++ b/.changeset/floppy-glasses-share.md @@ -0,0 +1,6 @@ +--- +'@clerk/clerk-js': patch +'@clerk/clerk-react': patch +--- + +Wait for pricing table data to be ready before hiding its fallback. diff --git a/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx b/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx index c6eee496a45..24c0f739284 100644 --- a/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx +++ b/packages/clerk-js/src/ui/components/PricingTable/PricingTable.tsx @@ -74,6 +74,7 @@ const PricingTableRoot = (props: PricingTableProps) => { return ( 0} sx={{ width: '100%', }} diff --git a/packages/clerk-js/src/ui/customizables/Flow.tsx b/packages/clerk-js/src/ui/customizables/Flow.tsx index 0a047edc845..df879845aa3 100644 --- a/packages/clerk-js/src/ui/customizables/Flow.tsx +++ b/packages/clerk-js/src/ui/customizables/Flow.tsx @@ -10,7 +10,7 @@ import { descriptors } from './index'; type FlowRootProps = React.PropsWithChildren & FlowMetadata & { sx?: ThemableCssProp }; -const Root = (props: FlowRootProps) => { +const Root = (props: FlowRootProps & { isFlowReady?: boolean }) => { return ( diff --git a/packages/clerk-js/src/ui/elements/InvisibleRootBox.tsx b/packages/clerk-js/src/ui/elements/InvisibleRootBox.tsx index de51ac4b71c..0cae2f62602 100644 --- a/packages/clerk-js/src/ui/elements/InvisibleRootBox.tsx +++ b/packages/clerk-js/src/ui/elements/InvisibleRootBox.tsx @@ -4,7 +4,7 @@ import { makeCustomizable } from '../customizables/makeCustomizable'; type RootBoxProps = React.PropsWithChildren<{ className: string }>; -const _InvisibleRootBox = React.memo((props: RootBoxProps) => { +const _InvisibleRootBox = React.memo((props: RootBoxProps & { isFlowReady?: boolean }) => { const [showSpan, setShowSpan] = React.useState(true); const parentRef = React.useRef(null); @@ -16,8 +16,10 @@ const _InvisibleRootBox = React.memo((props: RootBoxProps) => { if (showSpan) { setShowSpan(false); } - parent.className = props.className; - }, [props.className]); + + parent.setAttribute('class', props.className); + parent.setAttribute('data-component-status', props.isFlowReady ? 'ready' : 'awaiting-data'); + }, [props.className, props.isFlowReady]); return ( <> diff --git a/packages/clerk-js/src/ui/elements/contexts/index.tsx b/packages/clerk-js/src/ui/elements/contexts/index.tsx index 84aa3211f39..b449c95a93b 100644 --- a/packages/clerk-js/src/ui/elements/contexts/index.tsx +++ b/packages/clerk-js/src/ui/elements/contexts/index.tsx @@ -129,7 +129,7 @@ const [FlowMetadataCtx, useFlowMetadata] = createContextAndHook('F export const FlowMetadataProvider = (props: React.PropsWithChildren) => { const { flow, part } = props; - const value = React.useMemo(() => ({ value: props }), [flow, part]); + const value = React.useMemo(() => ({ value: { ...props } }), [flow, part]); return {props.children}; }; diff --git a/packages/react/src/components/uiComponents.tsx b/packages/react/src/components/uiComponents.tsx index 18623690ad1..cd0aa40cc09 100644 --- a/packages/react/src/components/uiComponents.tsx +++ b/packages/react/src/components/uiComponents.tsx @@ -577,7 +577,10 @@ export const Waitlist = withClerk( export const PricingTable = withClerk( ({ clerk, component, fallback, ...props }: WithClerkProp) => { - const mountingStatus = useWaitForComponentMount(component); + const mountingStatus = useWaitForComponentMount(component, { + // This attribute is added to the PricingTable root element after we've successfully fetched the plans asynchronously. + selector: '[data-component-status="ready"]', + }); const shouldShowFallback = mountingStatus === 'rendering' || !clerk.loaded; const rendererRootProps = { diff --git a/packages/react/src/utils/useWaitForComponentMount.ts b/packages/react/src/utils/useWaitForComponentMount.ts index dc6019b65fb..2b60900ff3b 100644 --- a/packages/react/src/utils/useWaitForComponentMount.ts +++ b/packages/react/src/utils/useWaitForComponentMount.ts @@ -1,62 +1,77 @@ 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; +const createAwaitableMutationObserver = ( + globalOptions: MutationObserverInit & { + isReady: (el: HTMLElement | null, selector: string) => boolean; + }, +) => { + const isReady = globalOptions?.isReady; - return new Promise((resolve, reject) => { - if (!root) { - reject(new Error('No root element provided')); - return; - } + return (options: { selector: string; root?: HTMLElement | null; timeout?: number }) => + new Promise((resolve, reject) => { + const { root = document?.body, selector, timeout = 0 } = options; - let elementToWatch: HTMLElement | null = root; - if (selector) { - elementToWatch = root?.querySelector(selector); - } + if (!root) { + reject(new Error('No root element provided')); + return; + } - // Check if the element already has child nodes - const isElementAlreadyPresent = elementToWatch?.childElementCount && elementToWatch.childElementCount > 0; - if (isElementAlreadyPresent) { - resolve(); - return; - } + let elementToWatch: HTMLElement | null = root; + if (selector) { + elementToWatch = root?.querySelector(selector); + } - // 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') { + // Initial readiness check + if (isReady(elementToWatch, selector)) { + resolve(); + return; + } + + // Set up a MutationObserver to detect when the element has children + const observer = new MutationObserver(mutationsList => { + for (const mutation of mutationsList) { if (!elementToWatch && selector) { elementToWatch = root?.querySelector(selector); } - if (elementToWatch?.childElementCount && elementToWatch.childElementCount > 0) { - observer.disconnect(); - resolve(); - return; + if ( + (globalOptions.childList && mutation.type === 'childList') || + (globalOptions.attributes && mutation.type === 'attributes') + ) { + if (isReady(elementToWatch, selector)) { + observer.disconnect(); + resolve(); + return; + } } } + }); + + observer.observe(root, globalOptions); + + // 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 ${selector}`)); + }, timeout); } }); +}; - 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); - } - }); -} +const waitForElementChildren = createAwaitableMutationObserver({ + childList: true, + subtree: true, + isReady: (el, selector) => !!el?.childElementCount && el?.matches?.(selector) && el.childElementCount > 0, +}); /** * 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) { +export function useWaitForComponentMount( + component?: string, + options?: { selector: string }, +): 'rendering' | 'rendered' | 'error' { const watcherRef = useRef>(); const [status, setStatus] = useState<'rendering' | 'rendered' | 'error'>('rendering'); @@ -66,7 +81,14 @@ export function useWaitForComponentMount(component?: string) { } if (typeof window !== 'undefined' && !watcherRef.current) { - watcherRef.current = waitForElementChildren({ selector: `[data-clerk-component="${component}"]` }) + const defaultSelector = `[data-clerk-component="${component}"]`; + const selector = options?.selector; + watcherRef.current = waitForElementChildren({ + selector: selector + ? // Allows for `[data-clerk-component="xxxx"][data-some-attribute="123"] .my-class` + defaultSelector + selector + : defaultSelector, + }) .then(() => { setStatus('rendered'); }) @@ -74,7 +96,7 @@ export function useWaitForComponentMount(component?: string) { setStatus('error'); }); } - }, [component]); + }, [component, options?.selector]); return status; }