Skip to content

Commit a915d60

Browse files
authored
fix(dotcom): show error page when Clerk auth service is unavailable (tldraw#8038)
In order to show users a helpful error page instead of a blank white screen when Clerk is down or rate-limited (429), this PR adds error boundaries and a loading timeout to the Clerk authentication flow. When Clerk is unavailable, `auth.isLoaded` stays `false` indefinitely and tldraw.com renders a blank white page. This PR catches that case with a 10-second timeout and also wraps the Clerk provider tree in error boundaries. ### Change type - [x] `bugfix` ### Test plan 1. Start `yarn dev-app` 2. Block Clerk's script loading (e.g. via DevTools network blocking for `clerk.tldraw.com`) 3. Reload the page 4. After ~10 seconds, the error page appears with "Unable to connect" and a "Refresh" button 5. Unblock Clerk and click "Refresh" — app loads normally ### Release notes - Show an error page instead of a blank screen when the authentication service is unavailable ### Code changes | Section | LOC change | | --------------- | ----------- | | Apps | +116 / -31 | | Automated files | +27 / -0 |
1 parent f3f66fe commit a915d60

6 files changed

Lines changed: 143 additions & 31 deletions

File tree

apps/dotcom/client/public/tla/locales-compiled/en.json

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -407,6 +407,12 @@
407407
"value": "Revoke this link and create a new one."
408408
}
409409
],
410+
"5d04a002ea": [
411+
{
412+
"type": 0,
413+
"value": "Unable to connect"
414+
}
415+
],
410416
"5d26ae7550": [
411417
{
412418
"type": 0,
@@ -449,6 +455,12 @@
449455
"value": "you"
450456
}
451457
],
458+
"63a6a88c06": [
459+
{
460+
"type": 0,
461+
"value": "Refresh"
462+
}
463+
],
452464
"6609dd239e": [
453465
{
454466
"type": 0,
@@ -799,6 +811,12 @@
799811
"value": "Delete file"
800812
}
801813
],
814+
"9ccb565b86": [
815+
{
816+
"type": 0,
817+
"value": "We're having trouble connecting to our authentication service. This is usually temporary. Please try refreshing the page."
818+
}
819+
],
802820
"9ceb927baa": [
803821
{
804822
"type": 0,

apps/dotcom/client/public/tla/locales/en.json

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,9 @@
182182
"5c6fe42bc2": {
183183
"translation": "Revoke this link and create a new one."
184184
},
185+
"5d04a002ea": {
186+
"translation": "Unable to connect"
187+
},
185188
"5d26ae7550": {
186189
"translation": "We failed to upload some of the content you created before you signed in."
187190
},
@@ -203,6 +206,9 @@
203206
"639bae9ac6": {
204207
"translation": "you"
205208
},
209+
"63a6a88c06": {
210+
"translation": "Refresh"
211+
},
206212
"6609dd239e": {
207213
"translation": "Something went wrong"
208214
},
@@ -353,6 +359,9 @@
353359
"9bef626805": {
354360
"translation": "Delete file"
355361
},
362+
"9ccb565b86": {
363+
"translation": "We're having trouble connecting to our authentication service. This is usually temporary. Please try refreshing the page."
364+
},
356365
"9ceb927baa": {
357366
"translation": "Publish this file"
358367
},

apps/dotcom/client/src/components/ErrorPage/ErrorPage.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { ReactNode } from 'react'
1+
import { Component, ReactNode } from 'react'
22
import { Link } from 'react-router-dom'
33
import translationsEnJson from '../../../public/tla/locales-compiled/en.json'
44
import { F, IntlProvider } from '../../tla/utils/i18n'
@@ -44,7 +44,7 @@ export function ErrorPage({
4444
cta = <GoBackLink />,
4545
}: {
4646
icon?: ReactNode
47-
messages: { header: string; para1: string; para2?: string }
47+
messages: { header: string; para1: string; para2?: string; cta?: string }
4848
cta?: ReactNode
4949
}) {
5050
return (
@@ -64,3 +64,27 @@ export function ErrorPage({
6464
</IntlProvider>
6565
)
6666
}
67+
68+
/** An error boundary that shows an ErrorPage with a refresh button. */
69+
export class RefreshErrorBoundary extends Component<
70+
{ children: ReactNode; messages: { header: string; para1: string; cta: string } },
71+
{ hasError: boolean }
72+
> {
73+
state = { hasError: false }
74+
75+
static getDerivedStateFromError() {
76+
return { hasError: true }
77+
}
78+
79+
render() {
80+
if (this.state.hasError) {
81+
return (
82+
<ErrorPage
83+
messages={this.props.messages}
84+
cta={<button onClick={() => window.location.reload()}>{this.props.messages.cta}</button>}
85+
/>
86+
)
87+
}
88+
return this.props.children
89+
}
90+
}

apps/dotcom/client/src/main.tsx

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,19 @@ import { HelmetProvider } from 'react-helmet-async'
44
import { RouterProvider, createBrowserRouter } from 'react-router-dom'
55
import '../sentry.client.config'
66
import '../styles/globals.css'
7+
import { RefreshErrorBoundary } from './components/ErrorPage/ErrorPage'
78
import { Head } from './components/Head/Head'
89
import { routes } from './routeDefs'
910
import { router } from './routes'
1011
import { showConsoleBranding } from './utils/consoleBranding'
1112

13+
const TOP_LEVEL_ERROR_MESSAGES = {
14+
header: 'Unable to connect',
15+
para1:
16+
'Something went wrong while loading the page. This is usually temporary. Please try refreshing.',
17+
cta: 'Refresh',
18+
}
19+
1220
const browserRouter = createBrowserRouter(router)
1321

1422
// @ts-ignore this is fine
@@ -19,18 +27,20 @@ if (!PUBLISHABLE_KEY) {
1927
}
2028

2129
createRoot(document.getElementById('root')!).render(
22-
<ClerkProvider
23-
publishableKey={PUBLISHABLE_KEY}
24-
afterSignOutUrl={routes.tlaRoot()}
25-
signInUrl="/"
26-
signInFallbackRedirectUrl={routes.tlaRoot()}
27-
signUpFallbackRedirectUrl={routes.tlaRoot()}
28-
>
29-
<HelmetProvider>
30-
<Head />
31-
<RouterProvider router={browserRouter} />
32-
</HelmetProvider>
33-
</ClerkProvider>
30+
<RefreshErrorBoundary messages={TOP_LEVEL_ERROR_MESSAGES}>
31+
<ClerkProvider
32+
publishableKey={PUBLISHABLE_KEY}
33+
afterSignOutUrl={routes.tlaRoot()}
34+
signInUrl="/"
35+
signInFallbackRedirectUrl={routes.tlaRoot()}
36+
signUpFallbackRedirectUrl={routes.tlaRoot()}
37+
>
38+
<HelmetProvider>
39+
<Head />
40+
<RouterProvider router={browserRouter} />
41+
</HelmetProvider>
42+
</ClerkProvider>
43+
</RefreshErrorBoundary>
3444
)
3545

3646
showConsoleBranding()

apps/dotcom/client/src/tla/providers/TlaRootProviders.tsx

Lines changed: 61 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import {
2323
useValue,
2424
} from 'tldraw'
2525
import translationsEnJson from '../../../public/tla/locales-compiled/en.json'
26-
import { ErrorPage } from '../../components/ErrorPage/ErrorPage'
26+
import { ErrorPage, RefreshErrorBoundary } from '../../components/ErrorPage/ErrorPage'
2727
import { SignedInAnalytics, SignedOutAnalytics, trackEvent } from '../../utils/analytics'
2828
import { globalEditor } from '../../utils/globalEditor'
2929
import { TlaCookieConsent } from '../components/dialogs/TlaCookieConsent'
@@ -66,6 +66,16 @@ export const appMessages = defineMessages({
6666
oldBrowser: {
6767
defaultMessage: 'Old browser detected. Please update your browser to use this app.',
6868
},
69+
clerkUnavailable: {
70+
defaultMessage: 'Unable to connect',
71+
},
72+
clerkUnavailablePara: {
73+
defaultMessage:
74+
"We're having trouble connecting to our authentication service. This is usually temporary. Please try refreshing the page.",
75+
},
76+
refresh: {
77+
defaultMessage: 'Refresh',
78+
},
6979
})
7080

7181
// @ts-ignore this is fine
@@ -75,6 +85,14 @@ if (!PUBLISHABLE_KEY) {
7585
throw new Error('Missing Publishable Key')
7686
}
7787

88+
const CLERK_LOAD_TIMEOUT_MS = 10_000
89+
90+
const CLERK_ERROR_MESSAGES = {
91+
header: appMessages.clerkUnavailable.defaultMessage,
92+
para1: appMessages.clerkUnavailablePara.defaultMessage,
93+
cta: appMessages.refresh.defaultMessage,
94+
}
95+
7896
export function Component() {
7997
const [container, setContainer] = useState<HTMLElement | null>(null)
8098
// TODO: this needs to default to the global setting of whatever the last chosen locale was, not 'en'
@@ -112,20 +130,22 @@ export function Component() {
112130
'tla-focus-mode': isFocusMode,
113131
})}
114132
>
115-
<IntlWrapper locale={locale}>
116-
<MaybeForceUserRefresh>
117-
<SignedInProvider onThemeChange={handleThemeChange} onLocaleChange={handleLocaleChange}>
118-
{container && (
119-
<ContainerProvider container={container}>
120-
<InsideOfContainerContext>
121-
<Outlet />
122-
<LegalTermsAcceptance />
123-
</InsideOfContainerContext>
124-
</ContainerProvider>
125-
)}
126-
</SignedInProvider>
127-
</MaybeForceUserRefresh>
128-
</IntlWrapper>
133+
<RefreshErrorBoundary messages={CLERK_ERROR_MESSAGES}>
134+
<IntlWrapper locale={locale}>
135+
<MaybeForceUserRefresh>
136+
<SignedInProvider onThemeChange={handleThemeChange} onLocaleChange={handleLocaleChange}>
137+
{container && (
138+
<ContainerProvider container={container}>
139+
<InsideOfContainerContext>
140+
<Outlet />
141+
<LegalTermsAcceptance />
142+
</InsideOfContainerContext>
143+
</ContainerProvider>
144+
)}
145+
</SignedInProvider>
146+
</MaybeForceUserRefresh>
147+
</IntlWrapper>
148+
</RefreshErrorBoundary>
129149
<WatermarkOverride />
130150
</div>
131151
)
@@ -234,7 +254,32 @@ function SignedInProvider({
234254
}
235255
}, [auth.userId, auth.isSignedIn, auth.isLoaded])
236256

237-
if (!auth.isLoaded) return null
257+
const [clerkTimedOut, setClerkTimedOut] = useState(false)
258+
259+
useEffect(() => {
260+
if (auth.isLoaded) return
261+
const timeout = setTimeout(() => setClerkTimedOut(true), CLERK_LOAD_TIMEOUT_MS)
262+
return () => clearTimeout(timeout)
263+
}, [auth.isLoaded])
264+
265+
if (!auth.isLoaded) {
266+
if (clerkTimedOut) {
267+
return (
268+
<ErrorPage
269+
messages={{
270+
header: intl.formatMessage(appMessages.clerkUnavailable),
271+
para1: intl.formatMessage(appMessages.clerkUnavailablePara),
272+
}}
273+
cta={
274+
<button onClick={() => window.location.reload()}>
275+
{intl.formatMessage(appMessages.refresh)}
276+
</button>
277+
}
278+
/>
279+
)
280+
}
281+
return null
282+
}
238283

239284
// Old browsers check.
240285
if (!('findLastIndex' in Array.prototype)) {

apps/dotcom/client/styles/globals.css

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -190,6 +190,8 @@ a {
190190
align-items: center;
191191
justify-content: center;
192192
text-align: center;
193+
max-width: 500px;
194+
text-wrap: balance;
193195
}
194196

195197
/* text-header mb-sm */
@@ -206,12 +208,16 @@ a {
206208
}
207209

208210
/* text-primary-bold text-grey */
209-
.error-page__container a {
211+
.error-page__container a,
212+
.error-page__container button {
210213
font-size: 14px;
211214
font-weight: 500;
212215
color: var(--text-color-2);
213216
padding: 12px 4px;
214217
text-decoration: underline;
218+
background: none;
219+
border: none;
220+
cursor: pointer;
215221
}
216222

217223
/* ------------------ Board history ----------------- */

0 commit comments

Comments
 (0)