Skip to content

feat: failure detection #6026

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

Open
wants to merge 4 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
254 changes: 232 additions & 22 deletions packages/nextjs/src/utils/clerk-js-script.tsx
Original file line number Diff line number Diff line change
@@ -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<LoadingState>('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<HTMLScriptElement>(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;
Expand All @@ -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 `<head/>`
* 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 (
<Script
src={scriptUrl}
data-clerk-js-script
async
// `nextjs/script` will add defer by default and does not get removed when we async is true
defer={props.router === 'pages' ? false : undefined}
crossOrigin='anonymous'
strategy={props.router === 'pages' ? 'beforeInteractive' : undefined}
{...buildClerkJsScriptAttributes(options)}
/>
// Inject coordinator script into head manually to ensure it's there first
useEffect(() => {
if (typeof window === 'undefined' || coordinatorInjected.current) return;

// Check if coordinator already exists
if ((window as any).__clerkJSBlockingCoordinator) {
coordinatorInjected.current = true;
return;
}

// Create and inject coordinator script into head
const coordinatorScript = document.createElement('script');
coordinatorScript.id = 'clerk-blocking-coordinator';
coordinatorScript.innerHTML = getBlockingCoordinatorScript();

// Insert at the beginning of head to ensure it runs first
if (document.head.firstChild) {
document.head.insertBefore(coordinatorScript, document.head.firstChild);
} else {
document.head.appendChild(coordinatorScript);
}

coordinatorInjected.current = true;
}, []);

// Handle state changes from the blocking coordinator
const handleLoad = useCallback(() => {
props.onLoad?.();
}, [props]);

const handleError = useCallback(
(error: Error) => {
props.onError?.(error);
},
[props],
);

// Subscribe to blocking coordinator state changes
useEffect(() => {
if (typeof window === 'undefined') return;

const coordinator = (window as any).__clerkJSBlockingCoordinator;
if (coordinator) {
coordinator.registerCallback({
onLoad: handleLoad,
onError: handleError,
onStateChange: (state: LoadingState) => {
props.onLoadingStateChange?.(state);
},
});
}
}, [handleLoad, handleError, props]);

const scriptAttributes = buildClerkJsScriptAttributes(options);

if (props.router === 'app') {
// For App Router, use Next.js Head component to ensure script goes to head
return (
<Head>
<script
ref={scriptRef}
src={scriptUrl}
data-clerk-js-script='true'
async
crossOrigin='anonymous'
{...scriptAttributes}
/>
</Head>
);
} else {
// For Pages Router, use Next.js Script components with beforeInteractive
return (
<NextScript
src={scriptUrl}
data-clerk-js-script='true'
async
defer={false}
crossOrigin='anonymous'
strategy='beforeInteractive'
{...scriptAttributes}
/>
);
}
}

export { ClerkJSScript };
export { ClerkJSScript, useClerkJSLoadingState };
export type { ClerkJSScriptProps, LoadingState };
Loading
Loading