diff --git a/.changeset/mean-months-lick.md b/.changeset/mean-months-lick.md new file mode 100644 index 00000000000..8d3feedeb8c --- /dev/null +++ b/.changeset/mean-months-lick.md @@ -0,0 +1,5 @@ +--- +"@remix-run/react": patch +--- + +fix router race condition for hmr diff --git a/contributors.yml b/contributors.yml index 0b76eb9c311..0af5e3d95d9 100644 --- a/contributors.yml +++ b/contributors.yml @@ -556,4 +556,5 @@ - tanerijun - toufiqnuur - ally1002 +- defjosiah - AlemTuzlak diff --git a/packages/remix-react/browser.tsx b/packages/remix-react/browser.tsx index 3bad7c70855..65b394692cb 100644 --- a/packages/remix-react/browser.tsx +++ b/packages/remix-react/browser.tsx @@ -48,6 +48,19 @@ declare global { let router: Router; let hmrAbortController: AbortController | undefined; +let hmrRouterReadyResolve: ((router: Router) => void) | undefined; +// There's a race condition with HMR where the remix:manifest is signaled before +// the router is assigned in the RemixBrowser component. This promise gates the +// HMR handler until the router is ready +let hmrRouterReadyPromise = new Promise((resolve) => { + // body of a promise is executed immediately, so this can be resolved outside + // of the promise body + hmrRouterReadyResolve = resolve; +}).catch(() => { + // This is a noop catch handler to avoid unhandled promise rejection warnings + // in the console. The promise is never rejected. + return undefined; +}); if (import.meta && import.meta.hot) { import.meta.hot.accept( @@ -59,6 +72,15 @@ if (import.meta && import.meta.hot) { assetsManifest: EntryContext["manifest"]; needsRevalidation: Set; }) => { + let router = await hmrRouterReadyPromise; + // This should never happen, but just in case... + if (!router) { + console.error( + "Failed to accept HMR update because the router was not ready." + ); + return; + } + let routeIds = [ ...new Set( router.state.matches @@ -180,7 +202,7 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { window.__remixContext.future.v2_normalizeFormMethod, }, }); - + // Hard reload if the path we tried to load is not the current path. // This is usually the result of 2 rapid back/forward clicks from an // external site into a Remix app, where we initially start the load for @@ -197,6 +219,11 @@ export function RemixBrowser(_props: RemixBrowserProps): ReactElement { console.error(errorMsg); window.location.reload(); } + + // Notify that the router is ready for HMR + if (hmrRouterReadyResolve) { + hmrRouterReadyResolve(router); + } } let [location, setLocation] = React.useState(router.state.location);