Skip to content

Commit

Permalink
feat(clerk-react,nextjs): Speed up clerk-js loading by using a `<scri…
Browse files Browse the repository at this point in the history
…pt/>` tag (#3156)

* feat(clerk-react,nextjs): Inject script tag into document to speed up clerk-js loading

* feat(clerk-react,nextjs): Use native script for App Router

* chore(clerk-react): Cleanup

* chore(clerk-react): Add changelog

* chore(clerk-react,nextjs): Move clerk-script component directly inside the next package
  • Loading branch information
panteliselef authored Apr 15, 2024
1 parent e6fc58a commit f98e480
Show file tree
Hide file tree
Showing 6 changed files with 104 additions and 12 deletions.
9 changes: 9 additions & 0 deletions .changeset/serious-balloons-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@clerk/nextjs': minor
'@clerk/clerk-react': minor
---

Speed up loading of clerk-js by using a `<script/>` tag when html is generated.
This is supported during SSR, SSG in
- Next.js Pages Router
- Next.js App Router
6 changes: 5 additions & 1 deletion packages/nextjs/src/app-router/client/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import React, { useEffect, useTransition } from 'react';
import { ClerkNextOptionsProvider } from '../../client-boundary/NextOptionsContext';
import { useSafeLayoutEffect } from '../../client-boundary/useSafeLayoutEffect';
import type { NextClerkProviderProps } from '../../types';
import { ClerkJSScript } from '../../utils/clerk-js-script';
import { mergeNextClerkPropsWithEnv } from '../../utils/mergeNextClerkPropsWithEnv';
import { useAwaitableNavigate } from './useAwaitableNavigate';

Expand Down Expand Up @@ -73,7 +74,10 @@ export const ClientClerkProvider = (props: NextClerkProviderProps) => {

return (
<ClerkNextOptionsProvider options={mergedProps}>
<ReactClerkProvider {...mergedProps}>{children}</ReactClerkProvider>
<ReactClerkProvider {...mergedProps}>
<ClerkJSScript router='app' />
{children}
</ReactClerkProvider>
</ClerkNextOptionsProvider>
);
};
3 changes: 3 additions & 0 deletions packages/nextjs/src/pages/ClerkProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import React from 'react';
import { ClerkNextOptionsProvider } from '../client-boundary/NextOptionsContext';
import { useSafeLayoutEffect } from '../client-boundary/useSafeLayoutEffect';
import type { NextClerkProviderProps } from '../types';
import { ClerkJSScript } from '../utils/clerk-js-script';
import { invalidateNextRouterCache } from '../utils/invalidateNextRouterCache';
import { mergeNextClerkPropsWithEnv } from '../utils/mergeNextClerkPropsWithEnv';

setErrorThrowerOptions({ packageName: PACKAGE_NAME });

export function ClerkProvider({ children, ...props }: NextClerkProviderProps): JSX.Element {
Expand Down Expand Up @@ -45,6 +47,7 @@ export function ClerkProvider({ children, ...props }: NextClerkProviderProps): J
{...mergedProps}
initialState={initialState}
>
<ClerkJSScript router='pages' />
{children}
</ReactClerkProvider>
</ClerkNextOptionsProvider>
Expand Down
47 changes: 47 additions & 0 deletions packages/nextjs/src/utils/clerk-js-script.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { useClerk } from '@clerk/clerk-react';
import { buildClerkJsScriptAttributes, clerkJsScriptUrl } from '@clerk/clerk-react/internal';
import NextScript from 'next/script';
import React from 'react';

import { useClerkNextOptions } from '../client-boundary/NextOptionsContext';

type ClerkJSScriptProps = {
router: 'app' | 'pages';
};

function ClerkJSScript(props: ClerkJSScriptProps) {
const { publishableKey, clerkJSUrl, clerkJSVersion, clerkJSVariant } = useClerkNextOptions();
const { domain, proxyUrl } = useClerk();
const options = {
domain,
proxyUrl,
publishableKey: publishableKey!,
clerkJSUrl,
clerkJSVersion,
clerkJSVariant,
};
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)}
/>
);
}

export { ClerkJSScript };
2 changes: 2 additions & 0 deletions packages/react/src/internal.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
export { setErrorThrowerOptions } from './errors/errorThrower';
export { MultisessionAppSupport } from './components/controlComponents';
export { useRoutingProps } from './hooks/useRoutingProps';

export { clerkJsScriptUrl, buildClerkJsScriptAttributes } from './utils/loadClerkJsScript';
49 changes: 38 additions & 11 deletions packages/react/src/utils/loadClerkJsScript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,31 @@ import { versionSelector } from './versionSelector';
const FAILED_TO_LOAD_ERROR = 'Clerk: Failed to load Clerk';

type LoadClerkJsScriptOptions = Omit<IsomorphicClerkOptions, 'proxyUrl' | 'domain'> & {
proxyUrl: string;
domain: string;
proxyUrl?: string;
domain?: string;
};

export const loadClerkJsScript = (opts: LoadClerkJsScriptOptions) => {
const loadClerkJsScript = (opts: LoadClerkJsScriptOptions) => {
const { publishableKey } = opts;

if (!publishableKey) {
errorThrower.throwMissingPublishableKeyError();
}

const existingScript = document.querySelector<HTMLScriptElement>('script[data-clerk-js-script]');

if (existingScript) {
return new Promise((resolve, reject) => {
existingScript.addEventListener('load', () => {
resolve(existingScript);
});

existingScript.addEventListener('error', () => {
reject(FAILED_TO_LOAD_ERROR);
});
});
}

return loadScript(clerkJsScriptUrl(opts), {
async: true,
crossOrigin: 'anonymous',
Expand Down Expand Up @@ -52,17 +66,30 @@ const clerkJsScriptUrl = (opts: LoadClerkJsScriptOptions) => {
return `https://${scriptHost}/npm/@clerk/clerk-js@${version}/dist/clerk.${variant}browser.js`;
};

const applyClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => (script: HTMLScriptElement) => {
const { publishableKey, proxyUrl, domain } = options;
if (publishableKey) {
script.setAttribute('data-clerk-publishable-key', publishableKey);
const buildClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => {
const obj: Record<string, string> = {};

if (options.publishableKey) {
obj['data-clerk-publishable-key'] = options.publishableKey;
}

if (proxyUrl) {
script.setAttribute('data-clerk-proxy-url', proxyUrl);
if (options.proxyUrl) {
obj['data-clerk-proxy-url'] = options.proxyUrl;
}

if (domain) {
script.setAttribute('data-clerk-domain', domain);
if (options.domain) {
obj['data-clerk-clerk-domain'] = options.domain;
}

return obj;
};

const applyClerkJsScriptAttributes = (options: LoadClerkJsScriptOptions) => (script: HTMLScriptElement) => {
const attributes = buildClerkJsScriptAttributes(options);
for (const attribute in attributes) {
script.setAttribute(attribute, attributes[attribute]);
}
};

export { loadClerkJsScript, buildClerkJsScriptAttributes, clerkJsScriptUrl };
export type { LoadClerkJsScriptOptions };

0 comments on commit f98e480

Please sign in to comment.