Skip to content

Commit 1d572c0

Browse files
Upgrade from Remix to React Router
* refactor recording submit to manual fetch with fly header Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Migrate to React Router v7 with auto-routes Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix podcast sorting and calls index Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix route metadata and form behaviors Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix single fetch handling for recording submit Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Stabilize Playwright calls route behavior Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Address PR feedback on recording submit handling Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Remove stale patches ADD from Dockerfile Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * fixup mocks and ignore .react-router * Fix season sort order context usage Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Realign auto-routes file structure to restore nesting Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix single-fetch redirect detection Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Adopt React Router route typegen typing patterns Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix action typing and single-fetch decoding Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Address valid PR review issues Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Update package metadata for router migration Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Harden recording submit fallback and navigation flow Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix Sentry capture for 5xx route error responses Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Address lint warnings in route error capture test Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Handle FileReader sync errors in recording submit form Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Adjust recording form test to bypass native validation Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Restore navbar Mod+Shift+P to useHotkey Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Use useEffect for navbar keydown listener Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Remove Mod+Shift+P navbar search shortcut Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Create resource route for call recording saves Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix submit recording test FileReader event typing Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix loadend listener invocation in RecordingForm test Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * refactor recording form into intent-driven full-stack resource Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * fix import order after recording form move Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Remove duplicated call action error helper Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Use manual redirect handling in call save fetch Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Prevent RecordingForm rerender prop resets Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix RecordingForm regression test assertions Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> * Fix recording submit redirect handling Co-authored-by: Kent C. Dodds <me+github@kentcdodds.com> --------- Co-authored-by: Cursor Agent <cursoragent@cursor.com>
1 parent 0bc3ecf commit 1d572c0

138 files changed

Lines changed: 11215 additions & 17927 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,4 +30,5 @@ other/postcss.ignored
3030

3131
*.db-journal
3232
.wrangler
33-
other/wrangler-logs/
33+
other/wrangler-logs/
34+
.react-router/

Dockerfile

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,6 @@ RUN mkdir /app/
1515
WORKDIR /app/
1616

1717
ADD package.json .npmrc package-lock.json ./
18-
ADD other/patches ./other/patches
1918
ADD prisma /app/prisma
2019
ADD prisma.config.ts /app/prisma.config.ts
2120
RUN npm install

app/components/app-hotkeys.tsx

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { useLocation, useNavigate } from '@remix-run/react'
21
import { useHotkey, useHotkeySequence } from '@tanstack/react-hotkeys'
32
import * as React from 'react'
3+
import { useLocation, useNavigate } from 'react-router';
44
import {
55
HOTKEY_GOTO_ABOUT,
66
HOTKEY_GOTO_BLOG,
@@ -47,7 +47,7 @@ function AppHotkeys() {
4747
HOTKEY_GOTO_HOME,
4848
() => {
4949
setDialogOpen(false)
50-
navigate('/')
50+
void navigate('/')
5151
},
5252
navSequenceOptions,
5353
)
@@ -56,7 +56,7 @@ function AppHotkeys() {
5656
HOTKEY_GOTO_BLOG,
5757
() => {
5858
setDialogOpen(false)
59-
navigate('/blog')
59+
void navigate('/blog')
6060
},
6161
navSequenceOptions,
6262
)
@@ -65,7 +65,7 @@ function AppHotkeys() {
6565
HOTKEY_GOTO_COURSES,
6666
() => {
6767
setDialogOpen(false)
68-
navigate('/courses')
68+
void navigate('/courses')
6969
},
7070
navSequenceOptions,
7171
)
@@ -74,7 +74,7 @@ function AppHotkeys() {
7474
HOTKEY_GOTO_DISCORD,
7575
() => {
7676
setDialogOpen(false)
77-
navigate('/discord')
77+
void navigate('/discord')
7878
},
7979
navSequenceOptions,
8080
)
@@ -83,7 +83,7 @@ function AppHotkeys() {
8383
HOTKEY_GOTO_WORKSHOPS,
8484
() => {
8585
setDialogOpen(false)
86-
navigate('/workshops')
86+
void navigate('/workshops')
8787
},
8888
navSequenceOptions,
8989
)
@@ -92,7 +92,7 @@ function AppHotkeys() {
9292
HOTKEY_GOTO_ABOUT,
9393
() => {
9494
setDialogOpen(false)
95-
navigate('/about')
95+
void navigate('/about')
9696
},
9797
navSequenceOptions,
9898
)
@@ -101,7 +101,7 @@ function AppHotkeys() {
101101
HOTKEY_GOTO_TALKS,
102102
() => {
103103
setDialogOpen(false)
104-
navigate('/talks')
104+
void navigate('/talks')
105105
},
106106
navSequenceOptions,
107107
)
@@ -110,7 +110,7 @@ function AppHotkeys() {
110110
HOTKEY_GOTO_SEARCH,
111111
() => {
112112
setDialogOpen(false)
113-
navigate('/search')
113+
void navigate('/search')
114114
},
115115
navSequenceOptions,
116116
)

app/components/arrow-button.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
1-
import { Link, type LinkProps } from '@remix-run/react'
21
import { clsx } from 'clsx'
32
import { motion, useReducedMotion, type Variant } from 'framer-motion'
3+
import { Link, type LinkProps } from 'react-router';
44
import {
55
useElementState,
66
type ElementState,

app/components/article-card.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Link } from '@remix-run/react'
21
import { clsx } from 'clsx'
2+
import { Link } from 'react-router';
33
import { getImageBuilder, getImgProps } from '#app/images.tsx'
44
import { type MdxListItem, type Team } from '#app/types.ts'
55
import { getBannerAltProp, getBannerTitleProp } from '#app/utils/mdx.tsx'
Lines changed: 279 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,279 @@
1+
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
2+
import { describe, expect, it, vi } from 'vitest'
3+
4+
const { mockNavigate, mockRevalidate, mockUseRootData } = vi.hoisted(() => ({
5+
mockNavigate: vi.fn(),
6+
mockRevalidate: vi.fn(),
7+
mockUseRootData: vi.fn(),
8+
}))
9+
10+
vi.mock('react-router', async () => {
11+
const actual = await vi.importActual('react-router')
12+
return {
13+
...actual,
14+
useNavigate: () => mockNavigate,
15+
useRevalidator: () => ({ revalidate: mockRevalidate }),
16+
}
17+
})
18+
19+
vi.mock('#app/utils/use-root-data.ts', () => ({
20+
useRootData: () => mockUseRootData(),
21+
}))
22+
23+
import { RecordingForm } from '#app/routes/resources/calls/save.tsx'
24+
25+
describe('RecordingForm', () => {
26+
it('recovers when FileReader.readAsDataURL throws synchronously', async () => {
27+
vi.clearAllMocks()
28+
mockUseRootData.mockReturnValue({
29+
requestInfo: { flyPrimaryInstance: null },
30+
})
31+
const readAsDataURL = vi.fn(() => {
32+
throw new TypeError('Unexpected blob state')
33+
})
34+
const addEventListener = vi.fn()
35+
const removeEventListener = vi.fn()
36+
class ThrowingFileReader {
37+
result: string | ArrayBuffer | null = null
38+
addEventListener = addEventListener
39+
removeEventListener = removeEventListener
40+
readAsDataURL = readAsDataURL
41+
}
42+
vi.stubGlobal(
43+
'FileReader',
44+
ThrowingFileReader as unknown as typeof FileReader,
45+
)
46+
const createObjectURL = vi
47+
.spyOn(URL, 'createObjectURL')
48+
.mockReturnValue('blob:recording')
49+
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
50+
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
51+
52+
try {
53+
const { container } = render(
54+
<RecordingForm audio={new Blob(['audio'])} intent="create-call" />,
55+
)
56+
57+
const submitButton = screen.getByRole('button', { name: 'Submit Recording' })
58+
const form = container.querySelector('form')
59+
expect(form).not.toBeNull()
60+
fireEvent.submit(form as HTMLFormElement)
61+
62+
await waitFor(() => expect(submitButton).toBeEnabled())
63+
expect(submitButton).toHaveTextContent('Submit Recording')
64+
expect(
65+
screen.getByText('Unable to read recording. Please try again.'),
66+
).toBeInTheDocument()
67+
expect(readAsDataURL).toHaveBeenCalledTimes(1)
68+
expect(addEventListener).toHaveBeenCalledWith(
69+
'loadend',
70+
expect.any(Function),
71+
{ once: true },
72+
)
73+
expect(removeEventListener).toHaveBeenCalledWith(
74+
'loadend',
75+
expect.any(Function),
76+
)
77+
expect(errorSpy).toHaveBeenCalledWith(
78+
'Unable to read recording',
79+
expect.any(TypeError),
80+
)
81+
} finally {
82+
createObjectURL.mockRestore()
83+
revokeObjectURL.mockRestore()
84+
errorSpy.mockRestore()
85+
vi.unstubAllGlobals()
86+
}
87+
})
88+
89+
it('submits and navigates using the redirected response URL', async () => {
90+
vi.clearAllMocks()
91+
mockUseRootData.mockReturnValue({
92+
requestInfo: { flyPrimaryInstance: 'primary-abc123' },
93+
})
94+
95+
let loadEndListener: (() => void) | null = null
96+
const readAsDataURL = vi.fn(function (this: SuccessfulFileReader) {
97+
this.result = 'data:audio/wav;base64,ZmFrZQ=='
98+
loadEndListener?.()
99+
})
100+
101+
class SuccessfulFileReader {
102+
result: string | ArrayBuffer | null = null
103+
addEventListener(
104+
eventName: string,
105+
listener: EventListenerOrEventListenerObject,
106+
) {
107+
if (eventName === 'loadend') {
108+
loadEndListener =
109+
typeof listener === 'function' ? () => listener(new Event('loadend')) : null
110+
}
111+
}
112+
removeEventListener() {}
113+
readAsDataURL = readAsDataURL
114+
}
115+
116+
const jsonMock = vi.fn()
117+
const fetchMock = vi.fn().mockResolvedValue({
118+
ok: true,
119+
redirected: true,
120+
url: 'http://localhost/calls/record/fake-call-id?ok=1#done',
121+
headers: new Headers(),
122+
json: jsonMock,
123+
} as Response)
124+
125+
vi.stubGlobal(
126+
'FileReader',
127+
SuccessfulFileReader as unknown as typeof FileReader,
128+
)
129+
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
130+
const createObjectURL = vi
131+
.spyOn(URL, 'createObjectURL')
132+
.mockReturnValue('blob:recording')
133+
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
134+
135+
try {
136+
const { container } = render(
137+
<RecordingForm audio={new Blob(['audio'])} intent="create-call" />,
138+
)
139+
140+
const titleInput = screen.getByLabelText('Title')
141+
fireEvent.change(titleInput, { target: { value: 'My First Call' } })
142+
const form = container.querySelector('form')
143+
expect(form).not.toBeNull()
144+
fireEvent.submit(form as HTMLFormElement)
145+
146+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
147+
const [requestUrl, requestInit] = fetchMock.mock.calls[0] ?? []
148+
expect(requestUrl).toBe('/resources/calls/save')
149+
expect(requestInit?.method).toBe('POST')
150+
expect(requestInit?.redirect).toBeUndefined()
151+
const requestHeaders = requestInit?.headers as Headers
152+
expect(requestHeaders.get('Content-Type')).toContain(
153+
'application/x-www-form-urlencoded',
154+
)
155+
expect(requestHeaders.get('fly-force-instance-id')).toBe('primary-abc123')
156+
157+
const requestBody = requestInit?.body as URLSearchParams
158+
expect(requestBody.get('intent')).toBe('create-call')
159+
expect(requestBody.get('audio')).toBe('data:audio/wav;base64,ZmFrZQ==')
160+
expect(requestBody.get('title')).toBe('My First Call')
161+
162+
await waitFor(() =>
163+
expect(mockNavigate).toHaveBeenCalledWith(
164+
'/calls/record/fake-call-id?ok=1#done',
165+
),
166+
)
167+
expect(mockRevalidate).not.toHaveBeenCalled()
168+
expect(jsonMock).not.toHaveBeenCalled()
169+
} finally {
170+
createObjectURL.mockRestore()
171+
revokeObjectURL.mockRestore()
172+
vi.unstubAllGlobals()
173+
}
174+
})
175+
176+
it('preserves server validation errors across equivalent data prop rerenders', async () => {
177+
vi.clearAllMocks()
178+
mockUseRootData.mockReturnValue({
179+
requestInfo: { flyPrimaryInstance: null },
180+
})
181+
182+
let loadEndListener: (() => void) | null = null
183+
const readAsDataURL = vi.fn(function (this: SuccessfulFileReader) {
184+
this.result = 'data:audio/wav;base64,ZmFrZQ=='
185+
loadEndListener?.()
186+
})
187+
188+
class SuccessfulFileReader {
189+
result: string | ArrayBuffer | null = null
190+
addEventListener(
191+
eventName: string,
192+
listener: EventListenerOrEventListenerObject,
193+
) {
194+
if (eventName === 'loadend') {
195+
loadEndListener =
196+
typeof listener === 'function' ? () => listener(new Event('loadend')) : null
197+
}
198+
}
199+
removeEventListener() {}
200+
readAsDataURL = readAsDataURL
201+
}
202+
203+
const fetchMock = vi.fn().mockResolvedValue({
204+
ok: false,
205+
redirected: false,
206+
headers: new Headers(),
207+
json: vi.fn().mockResolvedValue({
208+
fields: {
209+
title: '',
210+
description: 'desc',
211+
keywords: 'a,b',
212+
},
213+
errors: {
214+
title: 'Title is required',
215+
},
216+
}),
217+
} as Response)
218+
219+
vi.stubGlobal(
220+
'FileReader',
221+
SuccessfulFileReader as unknown as typeof FileReader,
222+
)
223+
vi.stubGlobal('fetch', fetchMock as unknown as typeof fetch)
224+
const createObjectURL = vi
225+
.spyOn(URL, 'createObjectURL')
226+
.mockReturnValue('blob:recording')
227+
const revokeObjectURL = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
228+
229+
const initialData = {
230+
fields: {
231+
title: 'Original title',
232+
description: 'Original description',
233+
keywords: 'test,call',
234+
},
235+
errors: {},
236+
}
237+
const audio = new Blob(['audio'])
238+
239+
try {
240+
const { container, rerender } = render(
241+
<RecordingForm
242+
audio={audio}
243+
intent="publish-call"
244+
callId="call-123"
245+
data={{
246+
fields: { ...initialData.fields },
247+
errors: { ...initialData.errors },
248+
}}
249+
/>,
250+
)
251+
252+
const form = container.querySelector('form')
253+
expect(form).not.toBeNull()
254+
fireEvent.submit(form as HTMLFormElement)
255+
256+
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1))
257+
await screen.findByText('Title is required')
258+
259+
// Simulate parent rerendering with a fresh but equivalent data object.
260+
rerender(
261+
<RecordingForm
262+
audio={audio}
263+
intent="publish-call"
264+
callId="call-123"
265+
data={{
266+
fields: { ...initialData.fields },
267+
errors: { ...initialData.errors },
268+
}}
269+
/>,
270+
)
271+
272+
await screen.findByText('Title is required')
273+
} finally {
274+
createObjectURL.mockRestore()
275+
revokeObjectURL.mockRestore()
276+
vi.unstubAllGlobals()
277+
}
278+
})
279+
})

0 commit comments

Comments
 (0)