diff --git a/packages/router-core/tests/load.test.ts b/packages/router-core/tests/load.test.ts new file mode 100644 index 0000000000..5633c64abb --- /dev/null +++ b/packages/router-core/tests/load.test.ts @@ -0,0 +1,465 @@ +import { describe, expect, test, vi } from 'vitest' +import { createMemoryHistory } from '@tanstack/history' +import { + BaseRootRoute, + BaseRoute, + RouterCore, + notFound, + redirect, +} from '../src' +import type { RouteOptions } from '../src' + +type AnyRouteOptions = RouteOptions +type BeforeLoad = NonNullable +type Loader = NonNullable + +describe('beforeLoad skip or exec', () => { + const setup = ({ beforeLoad }: { beforeLoad?: BeforeLoad }) => { + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + beforeLoad, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + }) + + return router + } + + test('baseline', async () => { + const beforeLoad = vi.fn() + const router = setup({ beforeLoad }) + await router.load() + expect(beforeLoad).toHaveBeenCalledTimes(0) + }) + + test('exec on regular nav', async () => { + const beforeLoad = vi.fn(() => Promise.resolve({ hello: 'world' })) + const router = setup({ beforeLoad }) + const navigation = router.navigate({ to: '/foo' }) + expect(beforeLoad).toHaveBeenCalledTimes(1) + expect(router.state.pendingMatches).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo' })]), + ) + await navigation + expect(router.state.location.pathname).toBe('/foo') + expect(router.state.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '/foo', + context: { + hello: 'world', + }, + }), + ]), + ) + expect(beforeLoad).toHaveBeenCalledTimes(1) + }) + + test('exec if resolved preload (success)', async () => { + const beforeLoad = vi.fn() + const router = setup({ beforeLoad }) + await router.preloadRoute({ to: '/foo' }) + expect(router.state.cachedMatches).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo' })]), + ) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) + + test('exec if pending preload (success)', async () => { + const beforeLoad = vi.fn(() => sleep(100)) + const router = setup({ beforeLoad }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + expect(router.state.cachedMatches).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo' })]), + ) + await router.navigate({ to: '/foo' }) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) + + test('exec if rejected preload (notFound)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { + if (preload) throw notFound() + await Promise.resolve() + }) + const router = setup({ + beforeLoad, + }) + await router.preloadRoute({ to: '/foo' }) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) + + test('exec if pending preload (notFound)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw notFound() + }) + const router = setup({ + beforeLoad, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) + + test('exec if rejected preload (redirect)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { + if (preload) throw redirect({ to: '/bar' }) + await Promise.resolve() + }) + const router = setup({ + beforeLoad, + }) + await router.preloadRoute({ to: '/foo' }) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(router.state.location.pathname).toBe('/foo') + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) + + test('exec if pending preload (redirect)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw redirect({ to: '/bar' }) + }) + const router = setup({ + beforeLoad, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(router.state.location.pathname).toBe('/foo') + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) + + test('exec if rejected preload (error)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { + if (preload) throw new Error('error') + await Promise.resolve() + }) + const router = setup({ + beforeLoad, + }) + await router.preloadRoute({ to: '/foo' }) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) + + test('exec if pending preload (error)', async () => { + const beforeLoad = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw new Error('error') + }) + const router = setup({ + beforeLoad, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(beforeLoad).toHaveBeenCalledTimes(2) + }) +}) + +describe('loader skip or exec', () => { + const setup = ({ + loader, + staleTime, + }: { + loader?: Loader + staleTime?: number + }) => { + const rootRoute = new BaseRootRoute({}) + + const fooRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/foo', + loader, + staleTime, + gcTime: staleTime, + }) + + const barRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([fooRoute, barRoute]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + }) + + return router + } + + test('baseline', async () => { + const loader = vi.fn() + const router = setup({ loader }) + await router.load() + expect(loader).toHaveBeenCalledTimes(0) + }) + + test('exec on regular nav', async () => { + const loader = vi.fn(() => Promise.resolve({ hello: 'world' })) + const router = setup({ loader }) + const navigation = router.navigate({ to: '/foo' }) + expect(loader).toHaveBeenCalledTimes(1) + expect(router.state.pendingMatches).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo' })]), + ) + await navigation + expect(router.state.location.pathname).toBe('/foo') + expect(router.state.matches).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + id: '/foo', + loaderData: { + hello: 'world', + }, + }), + ]), + ) + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('exec if resolved preload (success)', async () => { + const loader = vi.fn() + const router = setup({ loader }) + await router.preloadRoute({ to: '/foo' }) + expect(router.state.cachedMatches).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo' })]), + ) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('skip if resolved preload (success) within staleTime duration', async () => { + const loader = vi.fn() + const router = setup({ loader, staleTime: 1000 }) + await router.preloadRoute({ to: '/foo' }) + expect(router.state.cachedMatches).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo' })]), + ) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('skip if pending preload (success)', async () => { + const loader = vi.fn(() => sleep(100)) + const router = setup({ loader }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + expect(router.state.cachedMatches).toEqual( + expect.arrayContaining([expect.objectContaining({ id: '/foo' })]), + ) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('exec if rejected preload (notFound)', async () => { + const loader = vi.fn(async ({ preload }) => { + if (preload) throw notFound() + await Promise.resolve() + }) + const router = setup({ + loader, + }) + await router.preloadRoute({ to: '/foo' }) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('skip if pending preload (notFound)', async () => { + const loader = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw notFound() + }) + const router = setup({ + loader, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('exec if rejected preload (redirect)', async () => { + const loader = vi.fn(async ({ preload }) => { + if (preload) throw redirect({ to: '/bar' }) + await Promise.resolve() + }) + const router = setup({ + loader, + }) + await router.preloadRoute({ to: '/foo' }) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(router.state.location.pathname).toBe('/foo') + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('skip if pending preload (redirect)', async () => { + const loader = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw redirect({ to: '/bar' }) + }) + const router = setup({ + loader, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(router.state.location.pathname).toBe('/bar') + expect(loader).toHaveBeenCalledTimes(1) + }) + + test('exec if rejected preload (error)', async () => { + const loader = vi.fn(async ({ preload }) => { + if (preload) throw new Error('error') + await Promise.resolve() + }) + const router = setup({ + loader, + }) + await router.preloadRoute({ to: '/foo' }) + await sleep(10) + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(2) + }) + + test('skip if pending preload (error)', async () => { + const loader = vi.fn(async ({ preload }) => { + await sleep(100) + if (preload) throw new Error('error') + }) + const router = setup({ + loader, + }) + router.preloadRoute({ to: '/foo' }) + await Promise.resolve() + await router.navigate({ to: '/foo' }) + + expect(loader).toHaveBeenCalledTimes(1) + }) +}) + +test('exec on stay (beforeLoad & loader)', async () => { + let rootBeforeLoadResolved = false + const rootBeforeLoad = vi.fn(async () => { + await sleep(10) + rootBeforeLoadResolved = true + }) + const rootLoader = vi.fn(() => sleep(10)) + const rootRoute = new BaseRootRoute({ + beforeLoad: rootBeforeLoad, + loader: rootLoader, + }) + + let layoutBeforeLoadResolved = false + const layoutBeforeLoad = vi.fn(async () => { + await sleep(10) + layoutBeforeLoadResolved = true + }) + const layoutLoader = vi.fn(() => sleep(10)) + const layoutRoute = new BaseRoute({ + getParentRoute: () => rootRoute, + beforeLoad: layoutBeforeLoad, + loader: layoutLoader, + id: '/_layout', + }) + + const fooRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/foo', + }) + const barRoute = new BaseRoute({ + getParentRoute: () => layoutRoute, + path: '/bar', + }) + + const routeTree = rootRoute.addChildren([ + layoutRoute.addChildren([fooRoute, barRoute]), + ]) + + const router = new RouterCore({ + routeTree, + history: createMemoryHistory(), + defaultStaleTime: 1000, + defaultGcTime: 1000, + }) + + await router.navigate({ to: '/foo' }) + expect(router.state.location.pathname).toBe('/foo') + + rootBeforeLoadResolved = false + layoutBeforeLoadResolved = false + vi.clearAllMocks() + + /* + * When navigating between sibling routes, + * do the parent routes get re-executed? + */ + + await router.navigate({ to: '/bar' }) + expect(router.state.location.pathname).toBe('/bar') + + // beforeLoads always re-execute + expect(rootBeforeLoad).toHaveBeenCalledTimes(1) + expect(layoutBeforeLoad).toHaveBeenCalledTimes(1) + + // beforeLoads are called in order + expect(rootBeforeLoad.mock.invocationCallOrder[0]).toBeLessThan( + layoutBeforeLoad.mock.invocationCallOrder[0]!, + ) + + // loaders are skipped because of staleTime + expect(rootLoader).toHaveBeenCalledTimes(0) + expect(layoutLoader).toHaveBeenCalledTimes(0) + + // beforeLoad calls were correctly awaited + expect(rootBeforeLoadResolved).toBe(true) + expect(layoutBeforeLoadResolved).toBe(true) +}) + +function sleep(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)) +}