diff --git a/packages/nextjs/src/utils/clerk-js-script.tsx b/packages/nextjs/src/utils/clerk-js-script.tsx index cc39f3534e6..e27f86d026d 100644 --- a/packages/nextjs/src/utils/clerk-js-script.tsx +++ b/packages/nextjs/src/utils/clerk-js-script.tsx @@ -1,20 +1,166 @@ import { useClerk } from '@clerk/clerk-react'; import { buildClerkJsScriptAttributes, clerkJsScriptUrl } from '@clerk/clerk-react/internal'; +import Head from 'next/head'; import NextScript from 'next/script'; -import React from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useClerkNextOptions } from '../client-boundary/NextOptionsContext'; +// TODO: This will work once the exports are properly set up +// For now, we'll use the blocking coordinator pattern with inline script + +type LoadingState = 'idle' | 'loading' | 'loaded' | 'error'; + type ClerkJSScriptProps = { router: 'app' | 'pages'; + onLoad?: () => void; + onError?: (error: Error) => void; + onLoadingStateChange?: (state: LoadingState) => void; }; +// Inline blocking coordinator script (until we can import properly) +function getBlockingCoordinatorScript(): string { + return ` +(function() { + 'use strict'; + + if (window.__clerkJSBlockingCoordinator) { + return; + } + + var coordinator = { + state: 'idle', + scriptUrl: null, + scriptElement: null, + error: null, + callbacks: [], + + shouldAllowScript: function(scriptElement) { + if (!scriptElement.hasAttribute('data-clerk-js-script')) { + return true; + } + + if (this.scriptElement && this.scriptElement.src === scriptElement.src) { + return false; + } + + if (window.Clerk) { + this.setState('loaded'); + return false; + } + + if (this.state === 'loading') { + return false; + } + + this.adoptScript(scriptElement); + return true; + }, + + adoptScript: function(scriptElement) { + this.scriptElement = scriptElement; + this.scriptUrl = scriptElement.src; + this.setState('loading'); + + var self = this; + + scriptElement.addEventListener('load', function() { + scriptElement.setAttribute('data-clerk-loaded', 'true'); + self.setState('loaded'); + }); + + scriptElement.addEventListener('error', function() { + self.setState('error', new Error('ClerkJS failed to load')); + }); + }, + + registerCallback: function(callback) { + this.callbacks.push(callback); + + if (callback.onStateChange) { + callback.onStateChange(this.state); + } + + if (this.state === 'loaded' && callback.onLoad) { + callback.onLoad(); + } else if (this.state === 'error' && callback.onError && this.error) { + callback.onError(this.error); + } + }, + + setState: function(newState, error) { + this.state = newState; + if (error) this.error = error; + + for (var i = 0; i < this.callbacks.length; i++) { + var callback = this.callbacks[i]; + try { + if (callback.onStateChange) { + callback.onStateChange(newState); + } + + if (newState === 'loaded' && callback.onLoad) { + callback.onLoad(); + } else if (newState === 'error' && callback.onError && error) { + callback.onError(error); + } + } catch (e) { + console.error('ClerkJS coordinator callback error:', e); + } + } + } + }; + + var originalAppendChild = Node.prototype.appendChild; + Node.prototype.appendChild = function(child) { + if (child.tagName === 'SCRIPT' && child.hasAttribute('data-clerk-js-script')) { + if (!coordinator.shouldAllowScript(child)) { + return child; + } + } + + return originalAppendChild.call(this, child); + }; + + window.__clerkJSBlockingCoordinator = coordinator; +})(); + `.trim(); +} + +// Hook to get the current ClerkJS loading state from the blocking coordinator +function useClerkJSLoadingState() { + const [loadingState, setLoadingState] = useState('idle'); + + useEffect(() => { + if (typeof window === 'undefined') return; + + const coordinator = (window as any).__clerkJSBlockingCoordinator; + if (coordinator) { + coordinator.registerCallback({ + onStateChange: (state: LoadingState) => { + setLoadingState(state); + }, + }); + } + }, []); + + return { loadingState }; +} + +/** + * Enhanced ClerkJS Script component with bulletproof load detection. + * + * This component ensures the blocking coordinator is loaded in the document head + * before any ClerkJS scripts, regardless of the router type. + */ function ClerkJSScript(props: ClerkJSScriptProps) { const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant, nonce } = useClerkNextOptions(); const { domain, proxyUrl } = useClerk(); + const scriptRef = useRef(null); + const coordinatorInjected = useRef(false); /** - * If no publishable key, avoid appending an invalid script in the DOM. + * If no publishable key, avoid appending invalid scripts in the DOM. */ if (!publishableKey) { return null; @@ -31,26 +177,90 @@ function ClerkJSScript(props: ClerkJSScriptProps) { }; const scriptUrl = clerkJsScriptUrl(options); - /** - * Notes: - * `next/script` in 13.x.x when used with App Router will fail to pass any of our `data-*` attributes, resulting in errors - * Nextjs App Router will automatically move inline scripts inside `` - * Using the `nextjs/script` for App Router with the `beforeInteractive` strategy will throw an error because our custom script will be mounted outside the `html` tag. - */ - const Script = props.router === 'app' ? 'script' : NextScript; - - return ( -