From d09402889f5af1e84a820a5ce69c62b4b52e6ba7 Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Wed, 10 Jan 2024 00:12:18 -0700 Subject: [PATCH] feat: support errorComponent and pendingComponent code splitting --- .../src/routeTree.gen.ts | 85 ++++++++++++---- .../routes/posts.$postId/errorComponent.tsx | 13 +++ .../src/routes/posts.$postId/route.tsx | 18 +--- .../routes/posts_.$postId.deep.component.tsx | 21 ++++ .../posts_.$postId.deep.errorComponent.tsx | 3 + .../src/routes/posts_.$postId.deep.loader.tsx | 6 ++ .../src/routes/posts_.$postId.deep.tsx | 27 ----- packages/react-router/src/route.ts | 98 +++++++++++++++++++ packages/router-cli/src/generator.ts | 95 +++++++++++++----- 9 files changed, 281 insertions(+), 85 deletions(-) create mode 100644 examples/react/basic-file-based-codesplitting/src/routes/posts.$postId/errorComponent.tsx create mode 100644 examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.component.tsx create mode 100644 examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.errorComponent.tsx create mode 100644 examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.loader.tsx delete mode 100644 examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.tsx diff --git a/examples/react/basic-file-based-codesplitting/src/routeTree.gen.ts b/examples/react/basic-file-based-codesplitting/src/routeTree.gen.ts index cec6cedf06..8da10718ce 100644 --- a/examples/react/basic-file-based-codesplitting/src/routeTree.gen.ts +++ b/examples/react/basic-file-based-codesplitting/src/routeTree.gen.ts @@ -4,11 +4,17 @@ import { Route as rootRoute } from './routes/__root' import { Route as LayoutImport } from './routes/_layout' import { Route as IndexImport } from './routes/index' import { Route as PostsPostIdRouteImport } from './routes/posts.$postId/route' +import { Route as PostsIndexImport } from './routes/posts.index' import { Route as LayoutLayoutBImport } from './routes/_layout/layout-b' import { Route as LayoutLayoutAImport } from './routes/_layout/layout-a' -import { Route as PostsPostIdDeepImport } from './routes/posts_.$postId.deep' const PostsComponentImport = new FileRoute('/posts').createRoute() +const PostsPostIdDeepComponentImport = new FileRoute( + '/posts/$postId/deep', +).createRoute() +const LayoutLayoutBTestComponentImport = new FileRoute( + '/_layout/layout-b/test', +).createRoute() const PostsComponentRoute = PostsComponentImport.update({ path: '/posts', @@ -35,8 +41,8 @@ const IndexRoute = IndexImport.update({ } as any) const PostsPostIdRouteRoute = PostsPostIdRouteImport.update({ - path: '/posts/$postId', - getParentRoute: () => rootRoute, + path: '/$postId', + getParentRoute: () => PostsComponentRoute, } as any) .updateLoader({ loader: lazyFn(() => import('./routes/posts.$postId/loader'), 'loader'), @@ -46,8 +52,17 @@ const PostsPostIdRouteRoute = PostsPostIdRouteImport.update({ () => import('./routes/posts.$postId/component'), 'component', ), + errorComponent: lazyRouteComponent( + () => import('./routes/posts.$postId/errorComponent'), + 'errorComponent', + ), }) +const PostsIndexRoute = PostsIndexImport.update({ + path: '/', + getParentRoute: () => PostsComponentRoute, +} as any) + const LayoutLayoutBRoute = LayoutLayoutBImport.update({ path: '/layout-b', getParentRoute: () => LayoutRoute, @@ -58,11 +73,38 @@ const LayoutLayoutARoute = LayoutLayoutAImport.update({ getParentRoute: () => LayoutRoute, } as any) -const PostsPostIdDeepRoute = PostsPostIdDeepImport.update({ +const PostsPostIdDeepComponentRoute = PostsPostIdDeepComponentImport.update({ path: '/posts/$postId/deep', getParentRoute: () => rootRoute, } as any) + .updateLoader({ + loader: lazyFn( + () => import('./routes/posts_.$postId.deep.loader'), + 'loader', + ), + }) + .update({ + component: lazyRouteComponent( + () => import('./routes/posts_.$postId.deep.component'), + 'component', + ), + errorComponent: lazyRouteComponent( + () => import('./routes/posts_.$postId.deep.errorComponent'), + 'errorComponent', + ), + }) +const LayoutLayoutBTestComponentRoute = LayoutLayoutBTestComponentImport.update( + { + path: '/test', + getParentRoute: () => LayoutLayoutBRoute, + } as any, +).update({ + component: lazyRouteComponent( + () => import('./routes/_layout/layout-b.test.component'), + 'component', + ), +}) declare module '@tanstack/react-router' { interface FileRoutesByPath { '/': { @@ -75,31 +117,40 @@ declare module '@tanstack/react-router' { } '/_layout/layout-a': { preLoaderRoute: typeof LayoutLayoutAImport - parentRoute: typeof LayoutRoute + parentRoute: typeof LayoutImport } '/_layout/layout-b': { preLoaderRoute: typeof LayoutLayoutBImport - parentRoute: typeof LayoutRoute + parentRoute: typeof LayoutImport + } + '/posts': { + preLoaderRoute: typeof PostsComponentImport + parentRoute: typeof rootRoute + } + '/posts/': { + preLoaderRoute: typeof PostsIndexImport + parentRoute: typeof PostsComponentImport } '/posts/$postId': { preLoaderRoute: typeof PostsPostIdRouteImport - parentRoute: typeof rootRoute + parentRoute: typeof PostsComponentImport } - '/posts_/$postId/deep': { - preLoaderRoute: typeof PostsPostIdDeepImport - parentRoute: typeof rootRoute + '/_layout/layout-b/test': { + preLoaderRoute: typeof LayoutLayoutBTestComponentImport + parentRoute: typeof LayoutLayoutBImport } - '/posts': { - preLoaderRoute: typeof PostsComponentImport + '/posts/$postId/deep': { + preLoaderRoute: typeof PostsPostIdDeepComponentImport parentRoute: typeof rootRoute } } } - export const routeTree = rootRoute.addChildren([ IndexRoute, - LayoutRoute.addChildren([LayoutLayoutARoute, LayoutLayoutBRoute]), - PostsPostIdRouteRoute, - PostsPostIdDeepRoute, - PostsComponentRoute, + LayoutRoute.addChildren([ + LayoutLayoutARoute, + LayoutLayoutBRoute.addChildren([LayoutLayoutBTestComponentRoute]), + ]), + PostsComponentRoute.addChildren([PostsIndexRoute, PostsPostIdRouteRoute]), + PostsPostIdDeepComponentRoute, ]) diff --git a/examples/react/basic-file-based-codesplitting/src/routes/posts.$postId/errorComponent.tsx b/examples/react/basic-file-based-codesplitting/src/routes/posts.$postId/errorComponent.tsx new file mode 100644 index 0000000000..b597361bb9 --- /dev/null +++ b/examples/react/basic-file-based-codesplitting/src/routes/posts.$postId/errorComponent.tsx @@ -0,0 +1,13 @@ +import * as React from 'react' +import { ErrorComponent, ErrorRouteProps } from '@tanstack/react-router' +import { PostNotFoundError } from '../../posts' + +export const errorComponent = function PostErrorComponent({ + error, +}: ErrorRouteProps) { + if (error instanceof PostNotFoundError) { + return
{error.message}
+ } + + return +} diff --git a/examples/react/basic-file-based-codesplitting/src/routes/posts.$postId/route.tsx b/examples/react/basic-file-based-codesplitting/src/routes/posts.$postId/route.tsx index 1114f58959..1473547287 100644 --- a/examples/react/basic-file-based-codesplitting/src/routes/posts.$postId/route.tsx +++ b/examples/react/basic-file-based-codesplitting/src/routes/posts.$postId/route.tsx @@ -1,23 +1,7 @@ -import * as React from 'react' -import { - ErrorComponent, - ErrorRouteProps, - FileRoute, -} from '@tanstack/react-router' -import { PostNotFoundError } from '../../posts' +import { FileRoute } from '@tanstack/react-router' export const Route = new FileRoute('/posts/$postId').createRoute({ - errorComponent: PostErrorComponent, loaderDeps: () => ({ test: 'tanner' as const, }), - loader: () => 'tanner', }) - -export function PostErrorComponent({ error }: ErrorRouteProps) { - if (error instanceof PostNotFoundError) { - return
{error.message}
- } - - return -} diff --git a/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.component.tsx b/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.component.tsx new file mode 100644 index 0000000000..71eaa7df3d --- /dev/null +++ b/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.component.tsx @@ -0,0 +1,21 @@ +import * as React from 'react' +import { Link, RouteApi } from '@tanstack/react-router' + +const api = new RouteApi({ id: '/posts/$postId/deep' }) + +export const component = function PostDeepComponent() { + const post = api.useLoaderData() + + return ( +
+ + ← All Posts + +

{post.title}

+
{post.body}
+
+ ) +} diff --git a/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.errorComponent.tsx b/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.errorComponent.tsx new file mode 100644 index 0000000000..c05f7c7d18 --- /dev/null +++ b/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.errorComponent.tsx @@ -0,0 +1,3 @@ +import { errorComponent } from './posts.$postId/errorComponent' + +export { errorComponent } diff --git a/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.loader.tsx b/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.loader.tsx new file mode 100644 index 0000000000..4cc0efc050 --- /dev/null +++ b/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.loader.tsx @@ -0,0 +1,6 @@ +import { FileRouteLoader } from '@tanstack/react-router' +import { fetchPost } from '../posts' + +export const loader = FileRouteLoader('/posts/$postId/deep')( + async ({ params: { postId } }) => fetchPost(postId), +) diff --git a/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.tsx b/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.tsx deleted file mode 100644 index 9a4cbf375d..0000000000 --- a/examples/react/basic-file-based-codesplitting/src/routes/posts_.$postId.deep.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import * as React from 'react' -import { FileRoute, Link } from '@tanstack/react-router' -import { fetchPost } from '../posts' -import { PostErrorComponent } from './posts.$postId/route' - -export const Route = new FileRoute('/posts_/$postId/deep').createRoute({ - loader: async ({ params: { postId } }) => fetchPost(postId), - errorComponent: PostErrorComponent as any, - component: PostDeepComponent, -}) - -function PostDeepComponent() { - const post = Route.useLoaderData() - - return ( -
- - ← All Posts - -

{post.title}

-
{post.body}
-
- ) -} diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index 2c0f085ba4..3245b5c951 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -366,6 +366,104 @@ export type RouteConstraints = { TRouteTree: AnyRoute } +// TODO: This is part of a future APi to move away from classes and +// towards a more functional API. It's not ready yet. + +// type RouteApiInstance< +// TId extends RouteIds, +// TRoute extends AnyRoute = RouteById, +// TFullSearchSchema extends Record< +// string, +// any +// > = TRoute['types']['fullSearchSchema'], +// TAllParams extends AnyPathParams = TRoute['types']['allParams'], +// TAllContext extends Record = TRoute['types']['allContext'], +// TLoaderDeps extends Record = TRoute['types']['loaderDeps'], +// TLoaderData extends any = TRoute['types']['loaderData'], +// > = { +// id: TId +// useMatch: (opts?: { +// select?: (s: TAllContext) => TSelected +// }) => TSelected + +// useRouteContext: (opts?: { +// select?: (s: TAllContext) => TSelected +// }) => TSelected + +// useSearch: (opts?: { +// select?: (s: TFullSearchSchema) => TSelected +// }) => TSelected + +// useParams: (opts?: { +// select?: (s: TAllParams) => TSelected +// }) => TSelected + +// useLoaderDeps: (opts?: { +// select?: (s: TLoaderDeps) => TSelected +// }) => TSelected + +// useLoaderData: (opts?: { +// select?: (s: TLoaderData) => TSelected +// }) => TSelected +// } + +// export function RouteApi_v2< +// TId extends RouteIds, +// TRoute extends AnyRoute = RouteById, +// TFullSearchSchema extends Record< +// string, +// any +// > = TRoute['types']['fullSearchSchema'], +// TAllParams extends AnyPathParams = TRoute['types']['allParams'], +// TAllContext extends Record = TRoute['types']['allContext'], +// TLoaderDeps extends Record = TRoute['types']['loaderDeps'], +// TLoaderData extends any = TRoute['types']['loaderData'], +// >({ +// id, +// }: { +// id: TId +// }): RouteApiInstance< +// TId, +// TRoute, +// TFullSearchSchema, +// TAllParams, +// TAllContext, +// TLoaderDeps, +// TLoaderData +// > { +// return { +// id, + +// useMatch: (opts) => { +// return useMatch({ ...opts, from: id }) +// }, + +// useRouteContext: (opts) => { +// return useMatch({ +// ...opts, +// from: id, +// select: (d: any) => (opts?.select ? opts.select(d.context) : d.context), +// } as any) +// }, + +// useSearch: (opts) => { +// return useSearch({ ...opts, from: id } as any) +// }, + +// useParams: (opts) => { +// return useParams({ ...opts, from: id } as any) +// }, + +// useLoaderDeps: (opts) => { +// return useLoaderDeps({ ...opts, from: id } as any) as any +// }, + +// useLoaderData: (opts) => { +// return useLoaderData({ ...opts, from: id } as any) as any +// }, +// } +// } + export class RouteApi< TId extends RouteIds, TRoute extends AnyRoute = RouteById, diff --git a/packages/router-cli/src/generator.ts b/packages/router-cli/src/generator.ts index 85deaf97b8..e50c6eab67 100644 --- a/packages/router-cli/src/generator.ts +++ b/packages/router-cli/src/generator.ts @@ -20,6 +20,8 @@ export type RouteNode = { isRoute?: boolean isLoader?: boolean isComponent?: boolean + isErrorComponent?: boolean + isPendingComponent?: boolean isVirtual?: boolean isRoot?: boolean children?: RouteNode[] @@ -98,6 +100,8 @@ let skipMessage = false type RouteSubNode = { component?: RouteNode + errorComponent?: RouteNode + pendingComponent?: RouteNode loader?: RouteNode } @@ -154,11 +158,19 @@ export async function generator(config: Config) { if (config.future?.unstable_codeSplitting) { node.isRoute = node.routePath?.endsWith('/route') node.isComponent = node.routePath?.endsWith('/component') + node.isErrorComponent = node.routePath?.endsWith('/errorComponent') + node.isPendingComponent = node.routePath?.endsWith('/pendingComponent') node.isLoader = node.routePath?.endsWith('/loader') - if (node.isComponent || node.isLoader || node.isRoute) { + if ( + node.isComponent || + node.isErrorComponent || + node.isPendingComponent || + node.isLoader || + node.isRoute + ) { node.routePath = node.routePath?.replace( - /\/(component|loader|route)$/, + /\/(component|errorComponent|pendingComponent|loader|route)$/, '', ) } @@ -182,12 +194,23 @@ export async function generator(config: Config) { node.cleanedPath = removeUnderscores(node.path) ?? '' if (config.future?.unstable_codeSplitting) { - if (node.isLoader || node.isComponent) { + if ( + node.isLoader || + node.isComponent || + node.isErrorComponent || + node.isPendingComponent + ) { routePiecesByPath[node.routePath!] = routePiecesByPath[node.routePath!] || {} routePiecesByPath[node.routePath!]![ - node.isLoader ? 'loader' : 'component' + node.isLoader + ? 'loader' + : node.isErrorComponent + ? 'errorComponent' + : node.isPendingComponent + ? 'pendingComponent' + : 'component' ] = node const anchorRoute = routeNodes.find( @@ -199,11 +222,6 @@ export async function generator(config: Config) { isVirtual: true, }) } - // if (!node.parent) { - // } - - // componentOrLoader.isVirtual = true - // handleNode(componentOrLoader) return } } @@ -237,7 +255,9 @@ export async function generator(config: Config) { // so we have to escape it by turning all $ into $$. But since we do it through a replace call // we have to double escape it into $$$$. For more information, see // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/String/replace#specifying_a_string_as_the_replacement - const escapedRoutePath = node.routePath?.replaceAll('$', '$$$$') ?? '' + const escapedRoutePath = removeTrailingUnderscores( + node.routePath?.replaceAll('$', '$$$$') ?? '', + ) const quote = config.quoteStyle === 'single' ? `'` : `"` const replaced = routeCode.replace( fileRouteRegex, @@ -277,7 +297,10 @@ export async function generator(config: Config) { (node) => routePiecesByPath[node.routePath!]?.loader, ), lazyRouteComponent: sortedRouteNodes.some( - (node) => routePiecesByPath[node.routePath!]?.component, + (node) => + routePiecesByPath[node.routePath!]?.component || + routePiecesByPath[node.routePath!]?.errorComponent || + routePiecesByPath[node.routePath!]?.pendingComponent, ), }) .filter((d) => d[1]) @@ -311,7 +334,11 @@ export async function generator(config: Config) { sortedRouteNodes .filter((d) => d.isVirtual) .map((node) => { - return `const ${node.variableName}Import = new FileRoute('${node.routePath}').createRoute()` + return `const ${ + node.variableName + }Import = new FileRoute('${removeTrailingUnderscores( + node.routePath, + )}').createRoute()` }) .join('\n'), '\n', @@ -319,6 +346,10 @@ export async function generator(config: Config) { .map((node) => { const loaderNode = routePiecesByPath[node.routePath!]?.loader const componentNode = routePiecesByPath[node.routePath!]?.component + const errorComponentNode = + routePiecesByPath[node.routePath!]?.errorComponent + const pendingComponentNode = + routePiecesByPath[node.routePath!]?.pendingComponent return [ `const ${node.variableName}Route = ${node.variableName}Import.update({ @@ -341,18 +372,30 @@ export async function generator(config: Config) { ), )}'), 'loader') })` : '', - componentNode - ? `.update({ component: lazyRouteComponent(() => import('./${sanitize( - removeExt( - path.relative( - path.dirname(config.generatedRouteTree), - path.resolve( - config.routesDirectory, - componentNode.filePath, + componentNode || errorComponentNode || pendingComponentNode + ? `.update({ + ${( + [ + ['component', componentNode], + ['errorComponent', errorComponentNode], + ['pendingComponent', pendingComponentNode], + ] as const + ) + .filter((d) => d[1]) + .map((d) => { + return `${ + d[0] + }: lazyRouteComponent(() => import('./${sanitize( + removeExt( + path.relative( + path.dirname(config.generatedRouteTree), + path.resolve(config.routesDirectory, d[1]!.filePath), + ), ), - ), - ), - )}'), 'component') })` + )}'), '${d[0]}')` + }) + .join('\n,')} + })` : '', ].join('') }) @@ -361,7 +404,7 @@ export async function generator(config: Config) { interface FileRoutesByPath { ${routeNodes .map((routeNode) => { - return `'${routeNode.routePath}': { + return `'${removeTrailingUnderscores(routeNode.routePath)}': { preLoaderRoute: typeof ${routeNode.variableName}Import parentRoute: typeof ${ routeNode.parent?.variableName @@ -473,6 +516,10 @@ function removeUnderscores(s?: string) { return s?.replace(/(^_|_$)/, '').replace(/(\/_|_\/)/, '/') } +function removeTrailingUnderscores(s?: string) { + return s?.replace(/(_$)/, '').replace(/(_\/)/, '/') +} + function replaceBackslash(s?: string) { return s?.replace(/\\/gi, '/') }