Skip to content

Commit 27e7aca

Browse files
Page load time regression (#694)
Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent b217602 commit 27e7aca

9 files changed

Lines changed: 303 additions & 32 deletions

app/root.tsx

Lines changed: 37 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ import { getSession } from './utils/session.server.ts'
5454
import { TeamProvider, useTeam } from './utils/team-provider.tsx'
5555
import { getTheme } from './utils/theme.server.ts'
5656
import { useTheme } from './utils/theme.tsx'
57-
import { getServerTimeHeader } from './utils/timing.server.ts'
57+
import { getServerTimeHeader, time, withTimeout } from './utils/timing.server.ts'
5858
import { getUserInfo } from './utils/user-info.server.ts'
5959

6060
export const handle: KCDHandle & { id: string } = {
@@ -116,8 +116,15 @@ export const links: LinksFunction = () => {
116116
]
117117
}
118118

119+
const PODCAST_LINKS_FALLBACK = {
120+
chats: { latestSeasonNumber: null, latestSeasonPath: '/chats' },
121+
calls: { latestSeasonNumber: null, latestSeasonPath: '/calls' },
122+
} as const
123+
119124
export async function loader({ request }: Route.LoaderArgs) {
120125
const timings = {}
126+
const loaderStart = performance.now()
127+
const podcastLinksAbortController = new AbortController()
121128
const session = await getSession(request)
122129
const [
123130
user,
@@ -130,10 +137,26 @@ export async function loader({ request }: Route.LoaderArgs) {
130137
getClientSession(request, session.getUser({ timings })),
131138
getLoginInfoSession(request),
132139
getInstanceInfo().then((i) => i.primaryInstance),
133-
getLatestPodcastSeasonLinks({ request, timings }).catch(() => ({
134-
chats: { latestSeasonNumber: null, latestSeasonPath: '/chats' },
135-
calls: { latestSeasonNumber: null, latestSeasonPath: '/calls' },
136-
})),
140+
time(
141+
withTimeout(
142+
getLatestPodcastSeasonLinks({
143+
request,
144+
timings,
145+
signal: podcastLinksAbortController.signal,
146+
}),
147+
{
148+
timeoutMs: 2000,
149+
fallback: PODCAST_LINKS_FALLBACK,
150+
label: 'root:podcast-season-links',
151+
onTimeout: () => podcastLinksAbortController.abort(),
152+
},
153+
),
154+
{
155+
timings,
156+
type: 'root:podcast-season-links',
157+
desc: 'podcast nav links (Simplecast + Transistor)',
158+
},
159+
),
137160
])
138161

139162
const randomFooterImageKeys = Object.keys(illustrationImages)
@@ -170,7 +193,15 @@ export async function loader({ request }: Route.LoaderArgs) {
170193
await session.getHeaders(headers)
171194
await clientSession.getHeaders(headers)
172195
await loginInfoSession.getHeaders(headers)
173-
headers.append('Server-Timing', getServerTimeHeader(timings))
196+
// Add root loader total for production diagnostics (visible in Server-Timing)
197+
const rootLoaderTotal = performance.now() - loaderStart
198+
const rootTimings = {
199+
...timings,
200+
'root:loader': [
201+
{ type: 'root:loader', desc: 'root loader total', time: rootLoaderTotal },
202+
],
203+
}
204+
headers.append('Server-Timing', getServerTimeHeader(rootTimings))
174205

175206
return json(data, { headers })
176207
}

app/utils/__tests__/fetch-json-with-retry-after.server.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,28 @@ test('fetchJsonWithRetryAfter retries when fetch throws (network error / abort)'
167167
expect(sleep).toHaveBeenCalledWith(456)
168168
})
169169

170+
test('fetchJsonWithRetryAfter throws AbortError when signal is already aborted', async () => {
171+
const controller = new AbortController()
172+
controller.abort()
173+
await expect(
174+
fetchJsonWithRetryAfter('https://example.com/test', {
175+
label: 'aborted-before-start',
176+
signal: controller.signal,
177+
}),
178+
).rejects.toMatchObject({ name: 'AbortError' })
179+
})
180+
181+
test('fetchJsonWithRetryAfter aborts while waiting to retry', async () => {
182+
const controller = new AbortController()
183+
const promise = fetchJsonWithRetryAfter('https://example.com/always-429', {
184+
label: 'aborted-during-delay',
185+
maxRetries: 2,
186+
signal: controller.signal,
187+
})
188+
setTimeout(() => controller.abort(), 10)
189+
await expect(promise).rejects.toMatchObject({ name: 'AbortError' })
190+
})
191+
170192
test('fetchJsonWithRetryAfter throws a labeled error on malformed JSON', async () => {
171193
const sleep = vi.fn(async () => {})
172194
await expect(

app/utils/abort-utils.server.ts

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
type Sleep = (ms: number) => Promise<void>
2+
3+
const defaultSleep: Sleep = async (ms) => {
4+
await new Promise((resolve) => setTimeout(resolve, ms))
5+
}
6+
7+
function createAbortError() {
8+
const error = new Error('Operation aborted')
9+
error.name = 'AbortError'
10+
return error
11+
}
12+
13+
function throwIfAborted(signal?: AbortSignal) {
14+
if (!signal?.aborted) return
15+
throw createAbortError()
16+
}
17+
18+
function isAbortError(error: unknown) {
19+
return typeof error === 'object' && error !== null && (error as any).name === 'AbortError'
20+
}
21+
22+
async function waitForDelay({
23+
sleep,
24+
delayMs,
25+
signal,
26+
}: {
27+
sleep?: Sleep
28+
delayMs: number
29+
signal?: AbortSignal
30+
}) {
31+
if (!signal) {
32+
const activeSleep = sleep ?? defaultSleep
33+
await activeSleep(delayMs)
34+
return
35+
}
36+
throwIfAborted(signal)
37+
if (!sleep) {
38+
await new Promise<void>((resolve, reject) => {
39+
const timeoutId = setTimeout(() => {
40+
signal.removeEventListener('abort', onAbort)
41+
resolve()
42+
}, delayMs)
43+
const onAbort = () => {
44+
clearTimeout(timeoutId)
45+
signal.removeEventListener('abort', onAbort)
46+
reject(createAbortError())
47+
}
48+
signal.addEventListener('abort', onAbort, { once: true })
49+
})
50+
return
51+
}
52+
await new Promise<void>((resolve, reject) => {
53+
let settled = false
54+
const onAbort = () => {
55+
if (settled) return
56+
settled = true
57+
signal.removeEventListener('abort', onAbort)
58+
reject(createAbortError())
59+
}
60+
signal.addEventListener('abort', onAbort, { once: true })
61+
sleep(delayMs).then(
62+
() => {
63+
if (settled) return
64+
settled = true
65+
signal.removeEventListener('abort', onAbort)
66+
resolve()
67+
},
68+
(error) => {
69+
if (settled) return
70+
settled = true
71+
signal.removeEventListener('abort', onAbort)
72+
reject(error)
73+
},
74+
)
75+
})
76+
}
77+
78+
export { defaultSleep, isAbortError, throwIfAborted, waitForDelay, type Sleep }

app/utils/fetch-json-with-retry-after.server.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
1+
import {
2+
defaultSleep,
3+
throwIfAborted,
4+
waitForDelay,
5+
type Sleep,
6+
} from './abort-utils.server.ts'
17
import { fetchWithTimeout } from './fetch-with-timeout.server'
28

3-
type Sleep = (ms: number) => Promise<void>
4-
5-
const defaultSleep: Sleep = async (ms) => {
6-
await new Promise((resolve) => setTimeout(resolve, ms))
7-
}
8-
99
type RetryDelayReason = 'retry-after' | 'rate-limit-reset' | 'default'
1010

1111
type RetryDelay = {
@@ -101,6 +101,7 @@ export async function fetchJsonWithRetryAfter<JsonResponse>(
101101
label,
102102
sleep = defaultSleep,
103103
retryOn5xx = false,
104+
signal,
104105
}: {
105106
headers?: Record<string, string>
106107
maxRetries?: number
@@ -110,14 +111,17 @@ export async function fetchJsonWithRetryAfter<JsonResponse>(
110111
label?: string
111112
sleep?: Sleep
112113
retryOn5xx?: boolean
114+
signal?: AbortSignal
113115
} = {},
114116
): Promise<JsonResponse> {
115117
for (let attempt = 0; attempt <= maxRetries; attempt++) {
118+
throwIfAborted(signal)
116119
let res: Response
117120
try {
118121
res = timeoutMs
119-
? await fetchWithTimeout(url, { headers }, timeoutMs)
120-
: await fetch(url, { headers })
122+
? await fetchWithTimeout(url, { headers, signal }, timeoutMs)
123+
: await fetch(url, { headers, signal })
124+
throwIfAborted(signal)
121125
} catch (cause) {
122126
if (attempt < maxRetries) {
123127
const delayMs = clampMs(defaultDelayMs * (attempt + 1), maxDelayMs)
@@ -126,7 +130,7 @@ export async function fetchJsonWithRetryAfter<JsonResponse>(
126130
maxRetries + 1
127131
}), waiting ${Math.round(delayMs)}ms`,
128132
)
129-
await sleep(delayMs)
133+
await waitForDelay({ sleep, delayMs, signal })
130134
continue
131135
}
132136
throw new Error(`${label ?? 'request'}: fetch failed`, { cause })
@@ -146,7 +150,7 @@ export async function fetchJsonWithRetryAfter<JsonResponse>(
146150
delayMs,
147151
)}ms (${reason})`,
148152
)
149-
await sleep(delayMs)
153+
await waitForDelay({ sleep, delayMs, signal })
150154
continue
151155
}
152156

@@ -159,7 +163,7 @@ export async function fetchJsonWithRetryAfter<JsonResponse>(
159163
maxRetries + 1
160164
}), waiting ${Math.round(delayMs)}ms`,
161165
)
162-
await sleep(delayMs)
166+
await waitForDelay({ sleep, delayMs, signal })
163167
continue
164168
}
165169

app/utils/podcast-latest-season.server.ts

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { z } from 'zod'
2+
import { isAbortError, throwIfAborted } from './abort-utils.server.ts'
23
import { cache, cachified } from './cache.server.ts'
34
import { type Timings } from './timing.server.ts'
45

@@ -20,20 +21,24 @@ function formatSeasonParam(seasonNumber: number) {
2021
async function getLatestChatsSeasonNumber({
2122
request,
2223
timings,
24+
signal,
2325
}: {
2426
request: Request
2527
timings?: Timings
28+
signal?: AbortSignal
2629
}) {
2730
try {
31+
throwIfAborted(signal)
2832
// Dynamic import so missing podcast env vars don't crash the whole app.
2933
const { getSeasonListItems } = await import('./simplecast.server.ts')
30-
const seasons = await getSeasonListItems({ request, timings })
34+
const seasons = await getSeasonListItems({ request, timings, signal })
3135
const latestSeasonNumber = seasons.reduce(
3236
(max, s) => Math.max(max, s.seasonNumber ?? 0),
3337
0,
3438
)
3539
return latestSeasonNumber || null
3640
} catch (error: unknown) {
41+
if (isAbortError(error)) return null
3742
console.error('podcast-latest-season: failed to load chats seasons', error)
3843
return null
3944
}
@@ -42,20 +47,24 @@ async function getLatestChatsSeasonNumber({
4247
async function getLatestCallsSeasonNumber({
4348
request,
4449
timings,
50+
signal,
4551
}: {
4652
request: Request
4753
timings?: Timings
54+
signal?: AbortSignal
4855
}) {
4956
try {
57+
throwIfAborted(signal)
5058
// Dynamic import so missing podcast env vars don't crash the whole app.
5159
const { getEpisodes } = await import('./transistor.server.ts')
52-
const episodes = await getEpisodes({ request, timings })
60+
const episodes = await getEpisodes({ request, timings, signal })
5361
const latestSeasonNumber = episodes.reduce(
5462
(max, e) => Math.max(max, e.seasonNumber ?? 0),
5563
0,
5664
)
5765
return latestSeasonNumber || null
5866
} catch (error: unknown) {
67+
if (isAbortError(error)) return null
5968
console.error('podcast-latest-season: failed to load calls episodes', error)
6069
return null
6170
}
@@ -64,9 +73,11 @@ async function getLatestCallsSeasonNumber({
6473
export async function getLatestPodcastSeasonLinks({
6574
request,
6675
timings,
76+
signal,
6777
}: {
6878
request: Request
6979
timings?: Timings
80+
signal?: AbortSignal
7081
}) {
7182
return cachified({
7283
cache,
@@ -78,10 +89,11 @@ export async function getLatestPodcastSeasonLinks({
7889
staleWhileRevalidate: 1000 * 60 * 60 * 24,
7990
checkValue: latestPodcastSeasonLinksSchema,
8091
getFreshValue: async () => {
92+
throwIfAborted(signal)
8193
const [latestChatsSeasonNumber, latestCallsSeasonNumber] =
8294
await Promise.all([
83-
getLatestChatsSeasonNumber({ request, timings }),
84-
getLatestCallsSeasonNumber({ request, timings }),
95+
getLatestChatsSeasonNumber({ request, timings, signal }),
96+
getLatestCallsSeasonNumber({ request, timings, signal }),
8597
])
8698

8799
return {

0 commit comments

Comments
 (0)