From 45f17ff1b20e6ee0b51aaf637ed330cdd04b38ab Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 11:57:09 +1200 Subject: [PATCH 01/10] chore(examples): cleanup the about route in "quickstart-file-based" --- examples/react/quickstart-file-based/src/routes/about.tsx | 8 -------- 1 file changed, 8 deletions(-) diff --git a/examples/react/quickstart-file-based/src/routes/about.tsx b/examples/react/quickstart-file-based/src/routes/about.tsx index 0eda76df7cb..492e6b85c25 100644 --- a/examples/react/quickstart-file-based/src/routes/about.tsx +++ b/examples/react/quickstart-file-based/src/routes/about.tsx @@ -3,14 +3,6 @@ import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/about')({ component: AboutComponent, - beforeLoad: async () => { - await new Promise((resolve) => setTimeout(resolve, 2000)) - return { someData: 'hello' } - }, - loader: async ({ context }) => { - await new Promise((resolve) => setTimeout(resolve, 1000)) - console.debug(context.someData) - }, }) function AboutComponent() { From acf39c3e4cbc5701d357ef96ed9ff66a4f35f39e Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:01:18 +1200 Subject: [PATCH 02/10] style(react-router): eslint on "createLazyRoute.test.tsx" --- packages/react-router/tests/createLazyRoute.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-router/tests/createLazyRoute.test.tsx b/packages/react-router/tests/createLazyRoute.test.tsx index bb3baeebf66..9a356aca069 100644 --- a/packages/react-router/tests/createLazyRoute.test.tsx +++ b/packages/react-router/tests/createLazyRoute.test.tsx @@ -1,12 +1,12 @@ import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup } from '@testing-library/react' import { - RouterHistory, createMemoryHistory, createRootRoute, createRoute, createRouter, } from '../src' -import { cleanup } from '@testing-library/react' +import type { RouterHistory } from '../src' afterEach(() => { vi.resetAllMocks() From 4afa5f9c1f8e15d9ed2a40de4fb9e3d6ced4821c Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:18:55 +1200 Subject: [PATCH 03/10] test(react-router): add a test to "createLazyRoute" to check that the heavy component is rendered in the DOM using testing-library --- .../tests/createLazyRoute.test.tsx | 48 ++++++++++++++++++- 1 file changed, 46 insertions(+), 2 deletions(-) diff --git a/packages/react-router/tests/createLazyRoute.test.tsx b/packages/react-router/tests/createLazyRoute.test.tsx index 9a356aca069..ce33c4c6bb8 100644 --- a/packages/react-router/tests/createLazyRoute.test.tsx +++ b/packages/react-router/tests/createLazyRoute.test.tsx @@ -1,6 +1,11 @@ +import React, { act } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' -import { cleanup } from '@testing-library/react' +import '@testing-library/jest-dom/vitest' +import { cleanup, configure, render, screen } from '@testing-library/react' import { + Link, + RouterProvider, + createBrowserHistory, createMemoryHistory, createRootRoute, createRoute, @@ -8,6 +13,10 @@ import { } from '../src' import type { RouterHistory } from '../src' +// TODO: Move this setup logic including the '@testing-library/jest-dom/vitest' into its own setup file +// @ts-expect-error +global.IS_REACT_ACT_ENVIRONMENT = true + afterEach(() => { vi.resetAllMocks() cleanup() @@ -18,7 +27,16 @@ function createTestRouter(initialHistory?: RouterHistory) { initialHistory ?? createMemoryHistory({ initialEntries: ['/'] }) const rootRoute = createRootRoute({}) - const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/' }) + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => ( +
+

Index Route

+ Link to heavy +
+ ), + }) const heavyRoute = createRoute({ getParentRoute: () => rootRoute, @@ -36,6 +54,8 @@ function createTestRouter(initialHistory?: RouterHistory) { } describe('preload: matched routes', { timeout: 20000 }, () => { + configure({ reactStrictMode: true }) + it('should wait for lazy options to be streamed in before ', async () => { const { router } = createTestRouter( createMemoryHistory({ initialEntries: ['/'] }), @@ -55,4 +75,28 @@ describe('preload: matched routes', { timeout: 20000 }, () => { expect(lazyRoute.options.component).toBeDefined() }) + + it('should render the heavy/lazy component', async () => { + const { router } = createTestRouter(createBrowserHistory()) + + await act(() => render()) + + const linkToHeavy = await screen.findByText('Link to heavy') + expect(linkToHeavy).toBeInTheDocument() + + expect(router.state.location.pathname).toBe('/') + expect(window.location.pathname).toBe('/') + + // click the link to navigate to the heavy route + act(() => linkToHeavy.click()) + + const heavyElement = await screen.findByText('I am sooo heavy') + expect(heavyElement).toBeInTheDocument() + + expect(router.state.location.pathname).toBe('/heavy') + expect(window.location.pathname).toBe('/heavy') + + const lazyRoute = router.routesByPath['/heavy'] + expect(lazyRoute.options.component).toBeDefined() + }) }) From 3b6b840f1c1ef441e6f98c867bd8a2f5f71f471e Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:31:45 +1200 Subject: [PATCH 04/10] test(react-router): duplicate the preload test using screen rendering from testing library --- .../react-router/tests/routeContext.test.tsx | 57 ++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/packages/react-router/tests/routeContext.test.tsx b/packages/react-router/tests/routeContext.test.tsx index eb6aadea6b9..77831ae7c90 100644 --- a/packages/react-router/tests/routeContext.test.tsx +++ b/packages/react-router/tests/routeContext.test.tsx @@ -381,7 +381,8 @@ describe('beforeLoad in the route definition', () => { expect(mock).toHaveBeenCalledTimes(1) }) - test("on navigate (with preload), loader isn't invoked with undefined context if beforeLoad is pending when navigation happens", async () => { + // TODO: Move this test to the loader section + test("on navigate (with preload using router methods), loader isn't invoked with undefined context if beforeLoad is pending when navigation happens", async () => { const mock = vi.fn() const rootRoute = createRootRoute() @@ -417,6 +418,60 @@ describe('beforeLoad in the route definition', () => { expect(mock).toHaveBeenCalledTimes(2) }) + // TODO: Move this test to the loader section + test("on navigate (with preload), loader isn't invoked with undefined context if beforeLoad is pending when navigation happens", async () => { + const mock = vi.fn() + + const rootRoute = createRootRoute() + const indexRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/', + component: () => { + return ( +
+

Index page

+ link to about +
+ ) + }, + }) + const aboutRoute = createRoute({ + getParentRoute: () => rootRoute, + path: '/about', + beforeLoad: async () => { + await sleep(WAIT_TIME) // Use a longer delay here + return { mock } + }, + loader: async ({ context }) => { + await sleep(WAIT_TIME) + context.mock() + }, + component: () =>
About page
, + }) + + const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) + const router = createRouter({ routeTree, context: { foo: 'bar' } }) + + await act(() => render()) + + const linkToAbout = await screen.findByRole('link', { + name: 'link to about', + }) + expect(linkToAbout).toBeInTheDocument() + + // Don't await, simulate user clicking before preload is done + linkToAbout.focus() + linkToAbout.click() + + const aboutElement = await screen.findByText('About page') + expect(aboutElement).toBeInTheDocument() + + expect(window.location.pathname).toBe('/about') + + // Expect double call: once from preload, once from navigate + expect(mock).toHaveBeenCalledTimes(2) + }) + // Check if context returned by /nested/about, is the same as its parent route /nested on navigate test('nested destination on navigate, route context in the /nested/about route is correctly inherited from the /nested parent', async () => { const mock = vi.fn() From a216762d9bc768bb1c5ec88692dee302a2d90883 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:34:52 +1200 Subject: [PATCH 05/10] test(react-router): forgot to add preload intent --- packages/react-router/tests/routeContext.test.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/react-router/tests/routeContext.test.tsx b/packages/react-router/tests/routeContext.test.tsx index 77831ae7c90..48a1222ed2f 100644 --- a/packages/react-router/tests/routeContext.test.tsx +++ b/packages/react-router/tests/routeContext.test.tsx @@ -404,7 +404,10 @@ describe('beforeLoad in the route definition', () => { }) const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) - const router = createRouter({ routeTree, context: { foo: 'bar' } }) + const router = createRouter({ + routeTree, + context: { foo: 'bar' }, + }) await router.load() @@ -430,7 +433,9 @@ describe('beforeLoad in the route definition', () => { return (

Index page

- link to about + + link to about +
) }, @@ -450,7 +455,11 @@ describe('beforeLoad in the route definition', () => { }) const routeTree = rootRoute.addChildren([aboutRoute, indexRoute]) - const router = createRouter({ routeTree, context: { foo: 'bar' } }) + const router = createRouter({ + routeTree, + defaultPreload: 'intent', + context: { foo: 'bar' }, + }) await act(() => render()) From 3859174994bd620ac0d8a166bdfbe161105646b6 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:37:06 +1200 Subject: [PATCH 06/10] chore(react-router): add the comment above the `runBeforeLoad` call stating that we're actually running it --- packages/react-router/src/router.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 73ae51dd2a5..6aa1b79645b 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -1912,6 +1912,7 @@ export class Router< handleSerialError(searchError, 'VALIDATE_SEARCH') } + // Actually run the beforeLoad function and get the context try { const beforeLoadContext = await runBeforeLoad() checkLatest() From f795191cfeb8dcbd49cd04b7a98d9631dbe28892 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:37:58 +1200 Subject: [PATCH 07/10] chore(react-router): add comment above the `runLoader` call --- packages/react-router/src/router.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 6aa1b79645b..307ec14bfb5 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -2042,6 +2042,7 @@ export class Router< } } + // Actually run the loader and handle the result try { let loaderData = await runLoader() if (this.serializeLoaderData) { From c012e73a485d171f68a77be9baf9a5546a6d741f Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:38:32 +1200 Subject: [PATCH 08/10] chore(react-router): remove the destructuring of `p` --- packages/react-router/src/router.ts | 402 ++++++++++++++-------------- 1 file changed, 197 insertions(+), 205 deletions(-) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 307ec14bfb5..5f7fa2bd261 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -1927,252 +1927,244 @@ export class Router< const validResolvedMatches = matches.slice(0, firstBadMatchIndex) const matchPromises: Array> = [] - validResolvedMatches.forEach( - ({ id: matchId, routeId, params: p }, index) => { - const getMatch = () => getRouteMatch(this.state, matchId)! - - const createValidateResolvedMatchPromise = async () => { - const parentMatchPromise = matchPromises[index - 1] - const route = this.looseRoutesById[routeId]! - - const { - params, - loaderDeps, - abortController, - context, - cause, - } = getMatch() - - const getLoaderContext = (): LoaderFnContext => ({ - params, - deps: loaderDeps, - preload: !!preload, - parentMatchPromise, - abortController: abortController, - context, - location, - navigate: (opts) => - this.navigate({ ...opts, _fromLocation: location }), - cause: preload ? 'preload' : cause, - route, - }) - - const runLoader = async () => { - let { loaderPromise } = getMatch() - - if (loaderPromise) { - return await loaderPromise - } - - loaderPromise = - createControlledPromise>() - - const lazyPromise = - route.lazyFn?.().then((lazyRoute) => { - Object.assign(route.options, lazyRoute.options) - }) || Promise.resolve() - - // If for some reason lazy resolves more lazy components... - // We'll wait for that before pre attempt to preload any - // components themselves. - const componentsPromise = lazyPromise.then(() => - Promise.all( - componentTypes.map(async (type) => { - const component = route.options[type] - - if ((component as any)?.preload) { - await (component as any).preload() - } - }), - ), - ) - - // Otherwise, load the route - updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'loader', - fetchCount: prev.fetchCount + 1, - loaderPromise, - componentsPromise, - })) + validResolvedMatches.forEach(({ id: matchId, routeId }, index) => { + const getMatch = () => getRouteMatch(this.state, matchId)! - // Lazy option can modify the route options, - // so we need to wait for it to resolve before - // we can use the options - await lazyPromise + const createValidateResolvedMatchPromise = async () => { + const parentMatchPromise = matchPromises[index - 1] + const route = this.looseRoutesById[routeId]! - checkLatest() + const { params, loaderDeps, abortController, context, cause } = + getMatch() - // Kick off the loader! - try { - const loaderData = - await route.options.loader?.(getLoaderContext()) - loaderPromise.resolve(loaderData) - } catch (err) { - loaderPromise.reject(err) - } + const getLoaderContext = (): LoaderFnContext => ({ + params, + deps: loaderDeps, + preload: !!preload, + parentMatchPromise, + abortController: abortController, + context, + location, + navigate: (opts) => + this.navigate({ ...opts, _fromLocation: location }), + cause: preload ? 'preload' : cause, + route, + }) - // Otherwise, load the route - updateMatch(matchId, (prev) => ({ - ...prev, - loaderPromise: undefined, - })) + const runLoader = async () => { + let { loaderPromise } = getMatch() + if (loaderPromise) { return await loaderPromise } - const fetchAndResolveInLoaderLifetime = async () => { - // If the Matches component rendered - // the pending component and needs to show it for - // a minimum duration, we''ll wait for it to resolve - // before committing to the match and resolving - // the loadPromise - const potentialPendingMinPromise = async () => { - const latestMatch = getRouteMatch(this.state, matchId) - - if (latestMatch?.minPendingPromise) { - await latestMatch.minPendingPromise - - checkLatest() - - updateMatch(latestMatch.id, (prev) => ({ - ...prev, - minPendingPromise: undefined, - })) - } - } + loaderPromise = createControlledPromise>() + + const lazyPromise = + route.lazyFn?.().then((lazyRoute) => { + Object.assign(route.options, lazyRoute.options) + }) || Promise.resolve() + + // If for some reason lazy resolves more lazy components... + // We'll wait for that before pre attempt to preload any + // components themselves. + const componentsPromise = lazyPromise.then(() => + Promise.all( + componentTypes.map(async (type) => { + const component = route.options[type] + + if ((component as any)?.preload) { + await (component as any).preload() + } + }), + ), + ) - // Actually run the loader and handle the result - try { - let loaderData = await runLoader() - if (this.serializeLoaderData) { - loaderData = this.serializeLoaderData(loaderData, { - router: this, - match: getMatch(), - }) - } - checkLatest() + // Otherwise, load the route + updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'loader', + fetchCount: prev.fetchCount + 1, + loaderPromise, + componentsPromise, + })) - handleRedirectAndNotFound(getMatch(), loaderData) + // Lazy option can modify the route options, + // so we need to wait for it to resolve before + // we can use the options + await lazyPromise - await potentialPendingMinPromise() - checkLatest() + checkLatest() - const meta = route.options.meta?.({ - matches, - params: getMatch().params, - loaderData, - }) + // Kick off the loader! + try { + const loaderData = + await route.options.loader?.(getLoaderContext()) + loaderPromise.resolve(loaderData) + } catch (err) { + loaderPromise.reject(err) + } - const headers = route.options.headers?.({ - loaderData, - }) + // Otherwise, load the route + updateMatch(matchId, (prev) => ({ + ...prev, + loaderPromise: undefined, + })) - updateMatch(matchId, (prev) => ({ - ...prev, - error: undefined, - status: 'success', - isFetching: false, - updatedAt: Date.now(), - loaderData, - meta, - headers, - })) - } catch (e) { - checkLatest() - let error = e + return await loaderPromise + } - await potentialPendingMinPromise() - checkLatest() + const fetchAndResolveInLoaderLifetime = async () => { + // If the Matches component rendered + // the pending component and needs to show it for + // a minimum duration, we''ll wait for it to resolve + // before committing to the match and resolving + // the loadPromise + const potentialPendingMinPromise = async () => { + const latestMatch = getRouteMatch(this.state, matchId) - handleRedirectAndNotFound(getMatch(), e) + if (latestMatch?.minPendingPromise) { + await latestMatch.minPendingPromise - try { - route.options.onError?.(e) - } catch (onErrorError) { - error = onErrorError - handleRedirectAndNotFound(getMatch(), onErrorError) - } + checkLatest() - updateMatch(matchId, (prev) => ({ + updateMatch(latestMatch.id, (prev) => ({ ...prev, - error, - status: 'error', - isFetching: false, + minPendingPromise: undefined, })) } + } - // Last but not least, wait for the the component - // to be preloaded before we resolve the match - await getMatch().componentsPromise - + // Actually run the loader and handle the result + try { + let loaderData = await runLoader() + if (this.serializeLoaderData) { + loaderData = this.serializeLoaderData(loaderData, { + router: this, + match: getMatch(), + }) + } checkLatest() - getMatch().loadPromise.resolve() - } + handleRedirectAndNotFound(getMatch(), loaderData) - // This is where all of the stale-while-revalidate magic happens - const age = Date.now() - getMatch().updatedAt + await potentialPendingMinPromise() + checkLatest() - const staleAge = preload - ? route.options.preloadStaleTime ?? - this.options.defaultPreloadStaleTime ?? - 30_000 // 30 seconds for preloads by default - : route.options.staleTime ?? - this.options.defaultStaleTime ?? - 0 + const meta = route.options.meta?.({ + matches, + params: getMatch().params, + loaderData, + }) - const shouldReloadOption = route.options.shouldReload + const headers = route.options.headers?.({ + loaderData, + }) - // Default to reloading the route all the time - // Allow shouldReload to get the last say, - // if provided. - const shouldReload = - typeof shouldReloadOption === 'function' - ? shouldReloadOption(getLoaderContext()) - : shouldReloadOption + updateMatch(matchId, (prev) => ({ + ...prev, + error: undefined, + status: 'success', + isFetching: false, + updatedAt: Date.now(), + loaderData, + meta, + headers, + })) + } catch (e) { + checkLatest() + let error = e - updateMatch(matchId, (prev) => ({ - ...prev, - preload: - !!preload && - !this.state.matches.find((d) => d.id === matchId), - })) + await potentialPendingMinPromise() + checkLatest() + + handleRedirectAndNotFound(getMatch(), e) - const fetchWithRedirectAndNotFound = async () => { try { - await fetchAndResolveInLoaderLifetime() - } catch (err) { - checkLatest() - handleRedirectAndNotFound(getMatch(), err) + route.options.onError?.(e) + } catch (onErrorError) { + error = onErrorError + handleRedirectAndNotFound(getMatch(), onErrorError) } + + updateMatch(matchId, (prev) => ({ + ...prev, + error, + status: 'error', + isFetching: false, + })) } - // If the route is successful and still fresh, just resolve - const { status, invalid } = getMatch() + // Last but not least, wait for the the component + // to be preloaded before we resolve the match + await getMatch().componentsPromise - if ( - status === 'success' && - (invalid || (shouldReload ?? age > staleAge)) - ) { - ;(async () => { - try { - await fetchWithRedirectAndNotFound() - } catch (err) {} - })() - return - } + checkLatest() - if (status !== 'success') { - await fetchWithRedirectAndNotFound() + getMatch().loadPromise.resolve() + } + + // This is where all of the stale-while-revalidate magic happens + const age = Date.now() - getMatch().updatedAt + + const staleAge = preload + ? route.options.preloadStaleTime ?? + this.options.defaultPreloadStaleTime ?? + 30_000 // 30 seconds for preloads by default + : route.options.staleTime ?? + this.options.defaultStaleTime ?? + 0 + + const shouldReloadOption = route.options.shouldReload + + // Default to reloading the route all the time + // Allow shouldReload to get the last say, + // if provided. + const shouldReload = + typeof shouldReloadOption === 'function' + ? shouldReloadOption(getLoaderContext()) + : shouldReloadOption + + updateMatch(matchId, (prev) => ({ + ...prev, + preload: + !!preload && + !this.state.matches.find((d) => d.id === matchId), + })) + + const fetchWithRedirectAndNotFound = async () => { + try { + await fetchAndResolveInLoaderLifetime() + } catch (err) { + checkLatest() + handleRedirectAndNotFound(getMatch(), err) } + } + + // If the route is successful and still fresh, just resolve + const { status, invalid } = getMatch() + if ( + status === 'success' && + (invalid || (shouldReload ?? age > staleAge)) + ) { + ;(async () => { + try { + await fetchWithRedirectAndNotFound() + } catch (err) {} + })() return } - matchPromises.push(createValidateResolvedMatchPromise()) - }, - ) + if (status !== 'success') { + await fetchWithRedirectAndNotFound() + } + + return + } + + matchPromises.push(createValidateResolvedMatchPromise()) + }) await Promise.all(matchPromises) From e083de8fa9c2b8151722ed9bf66750dffb911c82 Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:40:17 +1200 Subject: [PATCH 09/10] style(react-router): disable these eslint warnings in the `router.ts` file --- packages/react-router/src/router.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 5f7fa2bd261..476eea61327 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -1542,7 +1542,9 @@ export class Router< matches: pendingMatches, location: next, checkLatest: () => this.checkLatest(promise), + // eslint-disable-next-line ts/require-await onReady: async () => { + // eslint-disable-next-line ts/require-await this.startViewTransition(async () => { // this.viewTransitionPromise = createControlledPromise() From a7941177bc4d5c9bef26ad00edc5397f9ebb8bef Mon Sep 17 00:00:00 2001 From: SeanCassiere <33615041+SeanCassiere@users.noreply.github.com> Date: Sun, 7 Jul 2024 12:54:49 +1200 Subject: [PATCH 10/10] chore(react-router): undo the `params: p` destucturing change --- packages/react-router/src/router.ts | 402 ++++++++++++++-------------- 1 file changed, 205 insertions(+), 197 deletions(-) diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index 476eea61327..8b07e1244de 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -1929,244 +1929,252 @@ export class Router< const validResolvedMatches = matches.slice(0, firstBadMatchIndex) const matchPromises: Array> = [] - validResolvedMatches.forEach(({ id: matchId, routeId }, index) => { - const getMatch = () => getRouteMatch(this.state, matchId)! - - const createValidateResolvedMatchPromise = async () => { - const parentMatchPromise = matchPromises[index - 1] - const route = this.looseRoutesById[routeId]! + validResolvedMatches.forEach( + ({ id: matchId, routeId, params: p }, index) => { + const getMatch = () => getRouteMatch(this.state, matchId)! + + const createValidateResolvedMatchPromise = async () => { + const parentMatchPromise = matchPromises[index - 1] + const route = this.looseRoutesById[routeId]! + + const { + params, + loaderDeps, + abortController, + context, + cause, + } = getMatch() + + const getLoaderContext = (): LoaderFnContext => ({ + params, + deps: loaderDeps, + preload: !!preload, + parentMatchPromise, + abortController: abortController, + context, + location, + navigate: (opts) => + this.navigate({ ...opts, _fromLocation: location }), + cause: preload ? 'preload' : cause, + route, + }) - const { params, loaderDeps, abortController, context, cause } = - getMatch() + const runLoader = async () => { + let { loaderPromise } = getMatch() - const getLoaderContext = (): LoaderFnContext => ({ - params, - deps: loaderDeps, - preload: !!preload, - parentMatchPromise, - abortController: abortController, - context, - location, - navigate: (opts) => - this.navigate({ ...opts, _fromLocation: location }), - cause: preload ? 'preload' : cause, - route, - }) + if (loaderPromise) { + return await loaderPromise + } - const runLoader = async () => { - let { loaderPromise } = getMatch() + loaderPromise = + createControlledPromise>() + + const lazyPromise = + route.lazyFn?.().then((lazyRoute) => { + Object.assign(route.options, lazyRoute.options) + }) || Promise.resolve() + + // If for some reason lazy resolves more lazy components... + // We'll wait for that before pre attempt to preload any + // components themselves. + const componentsPromise = lazyPromise.then(() => + Promise.all( + componentTypes.map(async (type) => { + const component = route.options[type] + + if ((component as any)?.preload) { + await (component as any).preload() + } + }), + ), + ) - if (loaderPromise) { - return await loaderPromise - } + // Otherwise, load the route + updateMatch(matchId, (prev) => ({ + ...prev, + isFetching: 'loader', + fetchCount: prev.fetchCount + 1, + loaderPromise, + componentsPromise, + })) - loaderPromise = createControlledPromise>() - - const lazyPromise = - route.lazyFn?.().then((lazyRoute) => { - Object.assign(route.options, lazyRoute.options) - }) || Promise.resolve() - - // If for some reason lazy resolves more lazy components... - // We'll wait for that before pre attempt to preload any - // components themselves. - const componentsPromise = lazyPromise.then(() => - Promise.all( - componentTypes.map(async (type) => { - const component = route.options[type] - - if ((component as any)?.preload) { - await (component as any).preload() - } - }), - ), - ) + // Lazy option can modify the route options, + // so we need to wait for it to resolve before + // we can use the options + await lazyPromise - // Otherwise, load the route - updateMatch(matchId, (prev) => ({ - ...prev, - isFetching: 'loader', - fetchCount: prev.fetchCount + 1, - loaderPromise, - componentsPromise, - })) + checkLatest() - // Lazy option can modify the route options, - // so we need to wait for it to resolve before - // we can use the options - await lazyPromise + // Kick off the loader! + try { + const loaderData = + await route.options.loader?.(getLoaderContext()) + loaderPromise.resolve(loaderData) + } catch (err) { + loaderPromise.reject(err) + } - checkLatest() + // Otherwise, load the route + updateMatch(matchId, (prev) => ({ + ...prev, + loaderPromise: undefined, + })) - // Kick off the loader! - try { - const loaderData = - await route.options.loader?.(getLoaderContext()) - loaderPromise.resolve(loaderData) - } catch (err) { - loaderPromise.reject(err) + return await loaderPromise } - // Otherwise, load the route - updateMatch(matchId, (prev) => ({ - ...prev, - loaderPromise: undefined, - })) - - return await loaderPromise - } + const fetchAndResolveInLoaderLifetime = async () => { + // If the Matches component rendered + // the pending component and needs to show it for + // a minimum duration, we''ll wait for it to resolve + // before committing to the match and resolving + // the loadPromise + const potentialPendingMinPromise = async () => { + const latestMatch = getRouteMatch(this.state, matchId) + + if (latestMatch?.minPendingPromise) { + await latestMatch.minPendingPromise + + checkLatest() + + updateMatch(latestMatch.id, (prev) => ({ + ...prev, + minPendingPromise: undefined, + })) + } + } - const fetchAndResolveInLoaderLifetime = async () => { - // If the Matches component rendered - // the pending component and needs to show it for - // a minimum duration, we''ll wait for it to resolve - // before committing to the match and resolving - // the loadPromise - const potentialPendingMinPromise = async () => { - const latestMatch = getRouteMatch(this.state, matchId) + // Actually run the loader and handle the result + try { + let loaderData = await runLoader() + if (this.serializeLoaderData) { + loaderData = this.serializeLoaderData(loaderData, { + router: this, + match: getMatch(), + }) + } + checkLatest() - if (latestMatch?.minPendingPromise) { - await latestMatch.minPendingPromise + handleRedirectAndNotFound(getMatch(), loaderData) + await potentialPendingMinPromise() checkLatest() - updateMatch(latestMatch.id, (prev) => ({ - ...prev, - minPendingPromise: undefined, - })) - } - } - - // Actually run the loader and handle the result - try { - let loaderData = await runLoader() - if (this.serializeLoaderData) { - loaderData = this.serializeLoaderData(loaderData, { - router: this, - match: getMatch(), + const meta = route.options.meta?.({ + matches, + params: getMatch().params, + loaderData, }) - } - checkLatest() - handleRedirectAndNotFound(getMatch(), loaderData) + const headers = route.options.headers?.({ + loaderData, + }) - await potentialPendingMinPromise() - checkLatest() + updateMatch(matchId, (prev) => ({ + ...prev, + error: undefined, + status: 'success', + isFetching: false, + updatedAt: Date.now(), + loaderData, + meta, + headers, + })) + } catch (e) { + checkLatest() + let error = e - const meta = route.options.meta?.({ - matches, - params: getMatch().params, - loaderData, - }) + await potentialPendingMinPromise() + checkLatest() - const headers = route.options.headers?.({ - loaderData, - }) + handleRedirectAndNotFound(getMatch(), e) - updateMatch(matchId, (prev) => ({ - ...prev, - error: undefined, - status: 'success', - isFetching: false, - updatedAt: Date.now(), - loaderData, - meta, - headers, - })) - } catch (e) { - checkLatest() - let error = e + try { + route.options.onError?.(e) + } catch (onErrorError) { + error = onErrorError + handleRedirectAndNotFound(getMatch(), onErrorError) + } - await potentialPendingMinPromise() - checkLatest() + updateMatch(matchId, (prev) => ({ + ...prev, + error, + status: 'error', + isFetching: false, + })) + } - handleRedirectAndNotFound(getMatch(), e) + // Last but not least, wait for the the component + // to be preloaded before we resolve the match + await getMatch().componentsPromise - try { - route.options.onError?.(e) - } catch (onErrorError) { - error = onErrorError - handleRedirectAndNotFound(getMatch(), onErrorError) - } + checkLatest() - updateMatch(matchId, (prev) => ({ - ...prev, - error, - status: 'error', - isFetching: false, - })) + getMatch().loadPromise.resolve() } - // Last but not least, wait for the the component - // to be preloaded before we resolve the match - await getMatch().componentsPromise - - checkLatest() + // This is where all of the stale-while-revalidate magic happens + const age = Date.now() - getMatch().updatedAt - getMatch().loadPromise.resolve() - } + const staleAge = preload + ? route.options.preloadStaleTime ?? + this.options.defaultPreloadStaleTime ?? + 30_000 // 30 seconds for preloads by default + : route.options.staleTime ?? + this.options.defaultStaleTime ?? + 0 - // This is where all of the stale-while-revalidate magic happens - const age = Date.now() - getMatch().updatedAt + const shouldReloadOption = route.options.shouldReload - const staleAge = preload - ? route.options.preloadStaleTime ?? - this.options.defaultPreloadStaleTime ?? - 30_000 // 30 seconds for preloads by default - : route.options.staleTime ?? - this.options.defaultStaleTime ?? - 0 + // Default to reloading the route all the time + // Allow shouldReload to get the last say, + // if provided. + const shouldReload = + typeof shouldReloadOption === 'function' + ? shouldReloadOption(getLoaderContext()) + : shouldReloadOption - const shouldReloadOption = route.options.shouldReload + updateMatch(matchId, (prev) => ({ + ...prev, + preload: + !!preload && + !this.state.matches.find((d) => d.id === matchId), + })) - // Default to reloading the route all the time - // Allow shouldReload to get the last say, - // if provided. - const shouldReload = - typeof shouldReloadOption === 'function' - ? shouldReloadOption(getLoaderContext()) - : shouldReloadOption + const fetchWithRedirectAndNotFound = async () => { + try { + await fetchAndResolveInLoaderLifetime() + } catch (err) { + checkLatest() + handleRedirectAndNotFound(getMatch(), err) + } + } - updateMatch(matchId, (prev) => ({ - ...prev, - preload: - !!preload && - !this.state.matches.find((d) => d.id === matchId), - })) + // If the route is successful and still fresh, just resolve + const { status, invalid } = getMatch() - const fetchWithRedirectAndNotFound = async () => { - try { - await fetchAndResolveInLoaderLifetime() - } catch (err) { - checkLatest() - handleRedirectAndNotFound(getMatch(), err) + if ( + status === 'success' && + (invalid || (shouldReload ?? age > staleAge)) + ) { + ;(async () => { + try { + await fetchWithRedirectAndNotFound() + } catch (err) {} + })() + return } - } - // If the route is successful and still fresh, just resolve - const { status, invalid } = getMatch() + if (status !== 'success') { + await fetchWithRedirectAndNotFound() + } - if ( - status === 'success' && - (invalid || (shouldReload ?? age > staleAge)) - ) { - ;(async () => { - try { - await fetchWithRedirectAndNotFound() - } catch (err) {} - })() return } - if (status !== 'success') { - await fetchWithRedirectAndNotFound() - } - - return - } - - matchPromises.push(createValidateResolvedMatchPromise()) - }) + matchPromises.push(createValidateResolvedMatchPromise()) + }, + ) await Promise.all(matchPromises)