diff --git a/docs/guide/data-loading.md b/docs/guide/data-loading.md index 9d06a0ba76..ac5209cc80 100644 --- a/docs/guide/data-loading.md +++ b/docs/guide/data-loading.md @@ -6,13 +6,13 @@ Data loading is a common concern for web applications and is extremely related t You may be familiar with `getServerSideProps` from Next.js or or `loaders` from Remix/React-Router. Both of these APIs assumes that **the router will store and manage your data**. This approach is great for use cases covered by both of those libraries, but TanStack Router is designed to function a bit differently than you're used to. Let's dig in! -## TanStack Router **does not store your data**. +## TanStack Router **should not store your data**. -Most routers that support data fetching will store the data for you in memory on the client. This is fine, but puts a large responsibility and stress on the router to handle [many cross-cutting and complex challenges that come with managing server-data, client-side caches and mutations](https://tanstack.com/query/latest/docs/react/overview#motivation). +Most routers that support data fetching will store and manage the data for you as you navigate. This is fine, but puts a large responsibility and stress on the router to handle [many cross-cutting and complex challenges that come with managing server-data, client-side caches and mutations](https://tanstack.com/query/latest/docs/react/overview#motivation). ## TanStack Router **orchestrates your data fetching**. -Instead of storing your data, TanStack Router is designed to **coordinate** your data fetching. This means that you can use any data fetching library you want, and the router will coordinate the fetching of your data in a way that aligns with your users' navigation. +Instead of storing and managing your data, TanStack Router is designed to **coordinate** your data fetching. This means that you can use any data fetching library you want, and the router will coordinate the fetching of your data in a way that aligns with your users' navigation. ## What data fetching libraries are supported? diff --git a/docs/guide/route-paths.md b/docs/guide/route-paths.md index dcbbb5e4fb..1a42dabd5b 100644 --- a/docs/guide/route-paths.md +++ b/docs/guide/route-paths.md @@ -95,9 +95,7 @@ const userRoute = new Route({ path: '$userId', }) -const routeConfig = rootRoute.addChildren([ - usersRoute.addChildren([userRoute]) -]) +const routeConfig = rootRoute.addChildren([usersRoute.addChildren([userRoute])]) ``` Dynamic segments can be accessed via the `params` object using the label you provided as the property key. For example, a path of `/users/$userId` would produce a `userId` param of `123` for the path `/users/123/details`: @@ -179,4 +177,18 @@ In the above example, the `layout` route will not add or match any path in the U > 🧠 An ID is required because every route must be uniquely identifiable, especially when using TypeScript so as to avoid type errors and accomplish autocomplete effectively. +## Identifying Routes via Search Params + +Search Params by default are not used to identify matching paths mostly because they are extremely flexible, flat and can contain a lot of unrelated data to your actual route definition. However, in some cases you may choose to use them to uniquely identify a route match. For example, you may want to use a search param to identify a specific user in your application, you might model your url like this: `/user?userId=123`. This means that in your `user` route would need some extra help to identify a specific user. You can do this by adding a `getKey` function to your route: + +```tsx +const userRoute = new Route({ + getParentRoute: () => usersRoute, + path: 'user', + getKey: ({ search }) => search.userId, +}) +``` + +--- + Route paths are just the beginning of what you can do with route configuration. We'll explore more of those features later on. diff --git a/docs/guide/router-context.md b/docs/guide/router-context.md index 05cf9f55a4..eae7584e35 100644 --- a/docs/guide/router-context.md +++ b/docs/guide/router-context.md @@ -183,7 +183,7 @@ const rootRoute = RootRoute({ component: () => { const router = useRouter() - const breadcrumbs = router.state.currentMatches.map((match) => { + const breadcrumbs = router.state.matches.map((match) => { const { routeContext } = match return { title: routeContext.getTitle(), @@ -203,7 +203,7 @@ const rootRoute = RootRoute({ component: () => { const router = useRouter() - const matchWithTitle = [...router.state.currentMatches] + const matchWithTitle = [...router.state.matches] .reverse() .find((d) => d.routeContext.getTitle) diff --git a/docs/guide/search-params.md b/docs/guide/search-params.md index 34603f6924..78439d80e8 100644 --- a/docs/guide/search-params.md +++ b/docs/guide/search-params.md @@ -243,11 +243,8 @@ const ProductList = () => { You can access your route's validated search params anywhere in your app using: -- `router.state.currentLocation.state` -- `router.state.pendingLocation.state` -- `router.state.latestLocation.state` - -Each one represent different states of the router. `currentLocation` is the current location of the router, `pendingLocation` is the location that the router is transitioning to, and `latestLocation` is most up-to-date representation of the location that the router has synced from the URL. +- `router.state.location.state` - The most up-to-date location of the router, regardless of loading state. +- `router.state.currentLocation.state` - The latest **loaded/resolved** location. This location is only set after a navigation has completed. ## Writing Search Params diff --git a/examples/react/basic-ssr-streaming/package.json b/examples/react/basic-ssr-streaming/package.json index a996a56624..e1f11b8481 100644 --- a/examples/react/basic-ssr-streaming/package.json +++ b/examples/react/basic-ssr-streaming/package.json @@ -13,6 +13,7 @@ }, "dependencies": { "@tanstack/react-loaders": "0.0.1-beta.105", + "@tanstack/react-start": "0.0.1-beta.96", "@tanstack/router": "0.0.1-beta.104", "@tanstack/router-devtools": "0.0.1-beta.104", "@tanstack/router-cli": "0.0.1-beta.69", diff --git a/examples/react/basic-ssr-streaming/src/entry-client.tsx b/examples/react/basic-ssr-streaming/src/entry-client.tsx index 6066f1e69a..c45d518ab3 100644 --- a/examples/react/basic-ssr-streaming/src/entry-client.tsx +++ b/examples/react/basic-ssr-streaming/src/entry-client.tsx @@ -1,20 +1,10 @@ import * as React from 'react' import ReactDOM from 'react-dom/client' -import { App } from '.' -import { router } from './router' -import { loaderClient } from './loaderClient' +import { StartClient } from '@tanstack/react-start/client' +import { createRouter } from './router' -const { dehydratedRouter, dehydratedLoaderClient } = (window as any) - .__TSR_DEHYDRATED__ +const router = createRouter() +router.hydrate() -// Hydrate the loader client first -loaderClient.hydrate(dehydratedLoaderClient) - -// Hydrate the router next -router.hydrate(dehydratedRouter) - -ReactDOM.hydrateRoot( - document, - , -) +ReactDOM.hydrateRoot(document, ) diff --git a/examples/react/basic-ssr-streaming/src/entry-server.tsx b/examples/react/basic-ssr-streaming/src/entry-server.tsx index ea948bcb54..41bf127728 100644 --- a/examples/react/basic-ssr-streaming/src/entry-server.tsx +++ b/examples/react/basic-ssr-streaming/src/entry-server.tsx @@ -1,74 +1,39 @@ import * as React from 'react' import ReactDOMServer from 'react-dom/server' -import { createMemoryHistory, Router, RouterProvider } from '@tanstack/router' +import { createMemoryHistory } from '@tanstack/router' +import { StartServer } from '@tanstack/react-start/server' import isbot from 'isbot' -import jsesc from 'jsesc' import { ServerResponse } from 'http' import express from 'express' // index.js -import '../src-old/fetch-polyfill' -import { createLoaderClient } from './loaderClient' -import { routeTree } from './routeTree' +import './fetch-polyfill' +import { createRouter } from './router' -async function getRouter(opts: { url: string }) { - const loaderClient = createLoaderClient() - - const router = new Router({ - routeTree: routeTree, - context: { - loaderClient, - }, - }) +export async function render(opts: { + url: string + head: string + req: express.Request + res: ServerResponse +}) { + const router = createRouter() const memoryHistory = createMemoryHistory({ initialEntries: [opts.url], }) + // Update the history and context router.update({ history: memoryHistory, + context: { + ...router.context, + head: opts.head, + }, }) - return { router, loaderClient } -} - -export async function render(opts: { - url: string - head: string - req: express.Request - res: ServerResponse -}) { - const { router, loaderClient } = await getRouter(opts) - - // ssrFooter: () => { - // // After the router has been fully loaded, serialize its - // // state right into the HTML. This way, the client can - // // hydrate the router with the same state that the server - // // used to render the HTML. - // const routerState = router.dehydrate() - // return ( - // <> - // - // - // ) - // }, - - // Kick off the router loading sequence, but don't wait for it to finish - router.load() + // Wait for the router to load critical data + // (streamed data will continue to load in the background) + await router.load() // Track errors let didError = false @@ -79,11 +44,7 @@ export async function render(opts: { : 'onShellReady' const stream = ReactDOMServer.renderToPipeableStream( - , + , { [callbackName]: () => { opts.res.statusCode = didError ? 500 : 200 diff --git a/examples/react/basic-ssr-streaming/src/loaderClient.tsx b/examples/react/basic-ssr-streaming/src/loaderClient.tsx index b64231a14d..0b0635e4f5 100644 --- a/examples/react/basic-ssr-streaming/src/loaderClient.tsx +++ b/examples/react/basic-ssr-streaming/src/loaderClient.tsx @@ -1,49 +1,13 @@ -import { Loader, LoaderClient } from '@tanstack/react-loaders' - -export type PostType = { - id: string - title: string - body: string -} - -export const postsLoader = new Loader({ - fn: async () => { - console.log('Fetching posts...') - await new Promise((r) => - setTimeout(r, 300 + Math.round(Math.random() * 300)), - ) - return fetch('https://jsonplaceholder.typicode.com/posts') - .then((d) => d.json() as Promise) - .then((d) => d.slice(0, 10)) - }, -}) - -export const postLoader = new Loader({ - fn: async (postId: string) => { - console.log(`Fetching post with id ${postId}...`) - - await new Promise((r) => setTimeout(r, Math.round(Math.random() * 300))) - - return fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`).then( - (r) => r.json() as Promise, - ) - }, - onInvalidate: async () => { - await postsLoader.invalidate() - }, -}) +import { LoaderClient } from '@tanstack/react-loaders' +import { postsLoader } from './routes/posts' +import { postLoader } from './routes/posts/$postId' export const createLoaderClient = () => { return new LoaderClient({ - getLoaders: () => ({ - postsLoader, - postLoader, - }), + getLoaders: () => ({ postsLoader, postLoader }), }) } -export const loaderClient = createLoaderClient() - // Register things for typesafety declare module '@tanstack/react-loaders' { interface Register { diff --git a/examples/react/basic-ssr-streaming/src/routeTree.ts b/examples/react/basic-ssr-streaming/src/routeTree.ts deleted file mode 100644 index 733adcb53d..0000000000 --- a/examples/react/basic-ssr-streaming/src/routeTree.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rootRoute } from './routes/__root' -import { indexRoute } from './routes/index' -import { postsRoute } from './routes/posts' -import { postsIndexRoute } from './routes/posts/index' -import { postIdRoute } from './routes/posts/$postId' - -export const routeTree = rootRoute.addChildren([ - indexRoute, - postsRoute.addChildren([postsIndexRoute, postIdRoute]), -]) diff --git a/examples/react/basic-ssr-streaming/src/router.tsx b/examples/react/basic-ssr-streaming/src/router.tsx index 98fff160eb..5dd5e42baf 100644 --- a/examples/react/basic-ssr-streaming/src/router.tsx +++ b/examples/react/basic-ssr-streaming/src/router.tsx @@ -1,21 +1,57 @@ -import { RegisteredLoaderClient } from '@tanstack/react-loaders' import { Router } from '@tanstack/router' -import { loaderClient } from './loaderClient' -import { routeTree } from './routeTree' +import { LoaderClientProvider } from '@tanstack/react-loaders' -export interface RouterContext { - loaderClient: RegisteredLoaderClient +import { rootRoute } from './routes/root' +import { indexRoute } from './routes/index' +import { postsRoute } from './routes/posts' +import { postsIndexRoute } from './routes/posts/index' +import { postIdRoute } from './routes/posts/$postId' + +import { createLoaderClient } from './loaderClient' +import React from 'react' + +export type RouterContext = { + loaderClient: ReturnType + head: string } -export const router = new Router({ - routeTree, - context: { - loaderClient, - }, -}) +export const routeTree = rootRoute.addChildren([ + indexRoute, + postsRoute.addChildren([postsIndexRoute, postIdRoute]), +]) + +export function createRouter() { + const loaderClient = createLoaderClient() + + return new Router({ + routeTree, + context: { + loaderClient, + head: '', + }, + // On the server, dehydrate the loader client + dehydrate: () => { + return { + loaderClient: loaderClient.dehydrate(), + } + }, + // On the client, rehydrate the loader client + hydrate: (dehydrated) => { + loaderClient.hydrate(dehydrated.loaderClient) + }, + // Wrap our router in the loader client provider + Wrap: ({ children }) => { + return ( + + {children} + + ) + }, + }) +} declare module '@tanstack/router' { interface Register { - router: typeof router + router: ReturnType } } diff --git a/examples/react/basic-ssr-streaming/src/routes/__root.tsx b/examples/react/basic-ssr-streaming/src/routes/__root.tsx deleted file mode 100644 index af9354a75c..0000000000 --- a/examples/react/basic-ssr-streaming/src/routes/__root.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { TanStackRouterDevtools } from '@tanstack/router-devtools' -import * as React from 'react' -import { Link, Outlet, RootRoute, useRouter } from '@tanstack/router' -import { RouterContext } from '../router' -import { useHead } from '../head' - -export const rootRoute = RootRoute.withRouterContext()({ - component: Root, -}) - -function Root() { - const head = useHead() - const router = useRouter() - // This is weak sauce, but it's just an example. - // In the future, we'll make meta an official thing - // and make it async as well to support data - const titleMatch = [...router.state.currentMatches] - .reverse() - .find((d) => d.context?.title) - - return ( - - - - - - {titleMatch ? titleMatch.context?.title : 'Vite App'} - - - - - - ) -} diff --git a/examples/react/basic-ssr-streaming/src/routes/index.tsx b/examples/react/basic-ssr-streaming/src/routes/index.tsx index 7e9ac4067a..aa5828571b 100644 --- a/examples/react/basic-ssr-streaming/src/routes/index.tsx +++ b/examples/react/basic-ssr-streaming/src/routes/index.tsx @@ -1,6 +1,6 @@ import { Route } from '@tanstack/router' import * as React from 'react' -import { rootRoute } from './__root' +import { rootRoute } from './root' export const indexRoute = new Route({ getParentRoute: () => rootRoute, diff --git a/examples/react/basic-ssr-streaming/src/routes/posts.tsx b/examples/react/basic-ssr-streaming/src/routes/posts.tsx index 72fc4b7bb2..467cf61357 100644 --- a/examples/react/basic-ssr-streaming/src/routes/posts.tsx +++ b/examples/react/basic-ssr-streaming/src/routes/posts.tsx @@ -1,49 +1,74 @@ import * as React from 'react' import { Link, Outlet, Route } from '@tanstack/router' -import { rootRoute } from './__root' -import { useLoader } from '@tanstack/react-loaders' +import { rootRoute } from './root' +// import { loaderClient } from '../entry-client' +import { Loader } from '@tanstack/react-loaders' import { postIdRoute } from './posts/$postId' -import { postsLoader } from '../loaderClient' + +export type PostType = { + id: string + title: string + body: string +} + +export const postsLoader = new Loader({ + fn: async () => { + console.log('Fetching posts...') + await new Promise((r) => + setTimeout(r, 500 + Math.round(Math.random() * 300)), + ) + + return fetch('https://jsonplaceholder.typicode.com/posts') + .then((d) => d.json() as Promise) + .then((d) => d.slice(0, 10)) + }, +}) + +const testLoader = new Loader({ + fn: async () => { + await new Promise((r) => setTimeout(r, 3000)) + return { + test: true, + } + }, +}) export const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - component: Posts, - errorComponent: () => 'Oh crap', - getContext: () => ({ - title: 'Posts', - }), - loader: ({ context, preload }) => - context.loaderClient.loaders.postsLoader.load({ preload }), -}) - -function Posts() { - const { - state: { data: posts }, - } = useLoader({ loader: postsLoader }) + loader: async ({ context, preload }) => { + const { postsLoader } = context.loaderClient.loaders + await postsLoader.load({ preload }) + return () => postsLoader.useLoader() + }, + component: function Posts({ useLoader }) { + const { + state: { data: posts }, + } = useLoader()() - return ( -
-
    - {posts?.map((post) => { - return ( -
  • - -
    {post.title.substring(0, 20)}
    - -
  • - ) - })} -
-
- -
- ) -} + return ( +
+
    + {posts?.map((post) => { + return ( +
  • + +
    {post.title.substring(0, 20)}
    + +
  • + ) + })} +
+
+ +
+ ) + }, +}) diff --git a/examples/react/basic-ssr-streaming/src/routes/posts/$postId.tsx b/examples/react/basic-ssr-streaming/src/routes/posts/$postId.tsx index b20ade31d1..25075957ca 100644 --- a/examples/react/basic-ssr-streaming/src/routes/posts/$postId.tsx +++ b/examples/react/basic-ssr-streaming/src/routes/posts/$postId.tsx @@ -1,32 +1,43 @@ -import { useLoader } from '@tanstack/react-loaders' -import { Route, RouteComponent, useParams } from '@tanstack/router' +import { Loader } from '@tanstack/react-loaders' +import { Route } from '@tanstack/router' import * as React from 'react' // import { loaderClient } from '../../entry-client' -import { postsRoute } from '../posts' +import { postsLoader, postsRoute, PostType } from '../posts' + +export const postLoader = new Loader({ + fn: async (postId: string) => { + console.log(`Fetching post with id ${postId}...`) + + await new Promise((r) => + setTimeout(r, 1000 + Math.round(Math.random() * 300)), + ) + + return fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`).then( + (r) => r.json() as Promise, + ) + }, + onInvalidate: async () => { + await postsLoader.invalidate() + }, +}) export const postIdRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', - loader: async ({ - context: { loaderClient }, - params: { postId }, - preload, - }) => { - const { postLoader } = loaderClient.loaders - - await postLoader.load({ + loader: async ({ context, params: { postId }, preload }) => { + const { postLoader } = context.loaderClient.loaders + + const instance = postLoader.getInstance({ variables: postId, + }) + + await instance.load({ preload, }) - // Return a curried hook! - return () => - useLoader({ - loader: postLoader, - variables: postId, - }) + return () => instance.useInstance() }, - component: ({ useLoader }) => { + component: function Post({ useLoader }) { const { state: { data: post }, } = useLoader()() diff --git a/examples/react/basic-ssr-streaming/src/routes/root.tsx b/examples/react/basic-ssr-streaming/src/routes/root.tsx new file mode 100644 index 0000000000..189c079586 --- /dev/null +++ b/examples/react/basic-ssr-streaming/src/routes/root.tsx @@ -0,0 +1,62 @@ +import { TanStackRouterDevtools } from '@tanstack/router-devtools' +import * as React from 'react' +import { Link, Outlet, RootRoute } from '@tanstack/router' +import { RouterContext } from '../router' +import { RouterScripts } from '@tanstack/react-start/client' + +export const rootRoute = RootRoute.withRouterContext()({ + component: Root, +}) + +function Root() { + return ( + + + + + Vite App + ` - - const appHtml = ReactDOMServer.renderToString( - , - ) - - opts.res.statusCode = 200 - opts.res.setHeader('Content-Type', 'text/html') - opts.res.end(`${appHtml}`) -} diff --git a/examples/react/basic-ssr-with-html/src/fetch-polyfill.js b/examples/react/basic-ssr-with-html/src/fetch-polyfill.js deleted file mode 100644 index 0b76df369a..0000000000 --- a/examples/react/basic-ssr-with-html/src/fetch-polyfill.js +++ /dev/null @@ -1,20 +0,0 @@ -// fetch-polyfill.js -import fetch, { - Blob, - blobFrom, - blobFromSync, - File, - fileFrom, - fileFromSync, - FormData, - Headers, - Request, - Response, -} from 'node-fetch' - -if (!globalThis.fetch) { - globalThis.fetch = fetch - globalThis.Headers = Headers - globalThis.Request = Request - globalThis.Response = Response -} diff --git a/examples/react/basic-ssr-with-html/src/head.ts b/examples/react/basic-ssr-with-html/src/head.ts deleted file mode 100644 index 46d7213aa1..0000000000 --- a/examples/react/basic-ssr-with-html/src/head.ts +++ /dev/null @@ -1,5 +0,0 @@ -import * as React from 'react' - -const context = React.createContext('') -export const HeadProvider = context.Provider -export const useHead = () => React.useContext(context) diff --git a/examples/react/basic-ssr-with-html/src/index.tsx b/examples/react/basic-ssr-with-html/src/index.tsx deleted file mode 100644 index aa94f2c619..0000000000 --- a/examples/react/basic-ssr-with-html/src/index.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import * as React from 'react' - -import { RegisteredRouter, RouterProvider } from '@tanstack/router' -import { - LoaderClientProvider, - RegisteredLoaderClient, -} from '@tanstack/react-loaders' -import { HeadProvider } from './head' - -export function App({ - router, - loaderClient, - head, -}: { - router: RegisteredRouter - loaderClient: RegisteredLoaderClient - head: string -}) { - return ( - - - - - - ) -} diff --git a/examples/react/basic-ssr-with-html/src/loaderClient.tsx b/examples/react/basic-ssr-with-html/src/loaderClient.tsx deleted file mode 100644 index bde5f8ad57..0000000000 --- a/examples/react/basic-ssr-with-html/src/loaderClient.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { Loader, LoaderClient } from '@tanstack/react-loaders' -import { postsLoader } from './routes/posts' -import { postLoader } from './routes/posts/$postId' - -export const createLoaderClient = () => { - return new LoaderClient({ - getLoaders: () => ({ postsLoader, postLoader }), - }) -} - -export const loaderClient = createLoaderClient() - -// Register things for typesafety -declare module '@tanstack/react-loaders' { - interface Register { - loaderClient: ReturnType - } -} diff --git a/examples/react/basic-ssr-with-html/src/routeTree.ts b/examples/react/basic-ssr-with-html/src/routeTree.ts deleted file mode 100644 index 9bd6daf8fd..0000000000 --- a/examples/react/basic-ssr-with-html/src/routeTree.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { rootRoute } from './routes/root' -import { indexRoute } from './routes/index' -import { postsRoute } from './routes/posts' -import { postsIndexRoute } from './routes/posts/index' -import { postIdRoute } from './routes/posts/$postId' - -export const routeTree = rootRoute.addChildren([ - indexRoute, - postsRoute.addChildren([postsIndexRoute, postIdRoute]), -]) diff --git a/examples/react/basic-ssr-with-html/src/router.tsx b/examples/react/basic-ssr-with-html/src/router.tsx deleted file mode 100644 index 98fff160eb..0000000000 --- a/examples/react/basic-ssr-with-html/src/router.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import { RegisteredLoaderClient } from '@tanstack/react-loaders' -import { Router } from '@tanstack/router' -import { loaderClient } from './loaderClient' -import { routeTree } from './routeTree' - -export interface RouterContext { - loaderClient: RegisteredLoaderClient -} - -export const router = new Router({ - routeTree, - context: { - loaderClient, - }, -}) - -declare module '@tanstack/router' { - interface Register { - router: typeof router - } -} diff --git a/examples/react/basic-ssr-with-html/src/routes/index.tsx b/examples/react/basic-ssr-with-html/src/routes/index.tsx deleted file mode 100644 index 4404bbb747..0000000000 --- a/examples/react/basic-ssr-with-html/src/routes/index.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { Route } from '@tanstack/router' -import * as React from 'react' -import { rootRoute } from './root' - -export const indexRoute = new Route({ - getParentRoute: () => rootRoute, - path: '/', - getContext: () => ({ - getTitle: () => 'Home', - }), - component: () => ( -
-

Welcome Home!

-
- ), -}) diff --git a/examples/react/basic-ssr-with-html/src/routes/posts.tsx b/examples/react/basic-ssr-with-html/src/routes/posts.tsx deleted file mode 100644 index 60e0b2a20c..0000000000 --- a/examples/react/basic-ssr-with-html/src/routes/posts.tsx +++ /dev/null @@ -1,76 +0,0 @@ -import * as React from 'react' -import { Link, Outlet, Route } from '@tanstack/router' -import { rootRoute } from './root' -// import { loaderClient } from '../entry-client' -import { Loader, useLoader } from '@tanstack/react-loaders' -import { postIdRoute } from './posts/$postId' - -export type PostType = { - id: string - title: string - body: string -} - -export const postsLoader = new Loader({ - fn: async () => { - console.log('Fetching posts...') - await new Promise((r) => - setTimeout(r, 300 + Math.round(Math.random() * 300)), - ) - return fetch('https://jsonplaceholder.typicode.com/posts') - .then((d) => d.json() as Promise) - .then((d) => d.slice(0, 10)) - }, -}) - -export const postsRoute = new Route({ - getParentRoute: () => rootRoute, - path: 'posts', - getContext: ({ context }) => { - const { postsLoader } = context.loaderClient.loaders - - return { - postsLoader, - getTitle: () => { - const postsCount = postsLoader.getInstance().state.data?.length - - return `Posts (${postsCount})` - }, - } - }, - loader: async ({ context, preload }) => { - await context.postsLoader.load({ preload }) - return () => context.postsLoader.useLoader() - }, - errorComponent: () => 'Oh crap', - component: function Posts({ useLoader }) { - const { - state: { data: posts }, - } = useLoader()() - - return ( -
-
    - {posts?.map((post) => { - return ( -
  • - -
    {post.title.substring(0, 20)}
    - -
  • - ) - })} -
-
- -
- ) - }, -}) diff --git a/examples/react/basic-ssr-with-html/src/routes/posts/$postId.tsx b/examples/react/basic-ssr-with-html/src/routes/posts/$postId.tsx deleted file mode 100644 index 983a5cc835..0000000000 --- a/examples/react/basic-ssr-with-html/src/routes/posts/$postId.tsx +++ /dev/null @@ -1,58 +0,0 @@ -import { Loader } from '@tanstack/react-loaders' -import { Route } from '@tanstack/router' -import * as React from 'react' -// import { loaderClient } from '../../entry-client' -import { postsLoader, postsRoute, PostType } from '../posts' - -export const postLoader = new Loader({ - fn: async (postId: string) => { - console.log(`Fetching post with id ${postId}...`) - - await new Promise((r) => setTimeout(r, Math.round(Math.random() * 300))) - - return fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`).then( - (r) => r.json() as Promise, - ) - }, - onInvalidate: async () => { - await postsLoader.invalidate() - }, -}) - -export const postIdRoute = new Route({ - getParentRoute: () => postsRoute, - path: '$postId', - getContext: ({ context, params: { postId } }) => { - const { postLoader } = context.loaderClient.loaders - - const postLoaderInstance = postLoader.getInstance({ variables: postId }) - - return { - postLoaderInstance, - getTitle: () => { - const title = postLoaderInstance.state.data?.title - - return `${title} | Post` - }, - } - }, - loader: async ({ context: { postLoaderInstance }, preload }) => { - await postLoaderInstance.load({ - preload, - }) - - return () => postLoaderInstance.useInstance() - }, - component: function Post({ useLoader }) { - const { - state: { data: post }, - } = useLoader()() - - return ( -
-

{post.title}

-
{post.body}
-
- ) - }, -}) diff --git a/examples/react/basic-ssr-with-html/src/routes/posts/index.tsx b/examples/react/basic-ssr-with-html/src/routes/posts/index.tsx deleted file mode 100644 index aa96f1fbb5..0000000000 --- a/examples/react/basic-ssr-with-html/src/routes/posts/index.tsx +++ /dev/null @@ -1,9 +0,0 @@ -import { Route } from '@tanstack/router' -import * as React from 'react' -import { postsRoute } from '../posts' - -export const postsIndexRoute = new Route({ - getParentRoute: () => postsRoute, - path: '/', - component: () =>
Select a post.
, -}) diff --git a/examples/react/basic-ssr-with-html/src/routes/root.tsx b/examples/react/basic-ssr-with-html/src/routes/root.tsx deleted file mode 100644 index 6973323e6e..0000000000 --- a/examples/react/basic-ssr-with-html/src/routes/root.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { TanStackRouterDevtools } from '@tanstack/router-devtools' -import * as React from 'react' -import { Link, Outlet, RootRoute, useRouter } from '@tanstack/router' -import { RouterContext } from '../router' -import { useHead } from '../head' - -export const rootRoute = RootRoute.withRouterContext()({ - component: Root, -}) - -function Root() { - const head = useHead() - const router = useRouter() - // This is weak sauce, but it's just an example. - // In the future, we'll make meta an official thing - // and make it async as well to support data - const titleMatch = [...router.state.currentMatches] - .reverse() - .find((d) => d.context.getTitle) - - return ( - - - - - - - {titleMatch ? titleMatch.context?.getTitle?.() : 'Vite App'} - - - - - - - ) -} diff --git a/examples/react/basic-ssr-with-html/tsconfig.dev.json b/examples/react/basic-ssr-with-html/tsconfig.dev.json deleted file mode 100644 index c09bc865f0..0000000000 --- a/examples/react/basic-ssr-with-html/tsconfig.dev.json +++ /dev/null @@ -1,12 +0,0 @@ -{ - "composite": true, - "extends": "../../../tsconfig.base.json", - "compilerOptions": { - "outDir": "./build/types" - }, - "files": ["src/main.tsx"], - "include": [ - "src" - // "__tests__/**/*.test.*" - ] -} diff --git a/examples/react/basic-ssr-with-html/tsconfig.json b/examples/react/basic-ssr-with-html/tsconfig.json deleted file mode 100644 index 0453a66fb9..0000000000 --- a/examples/react/basic-ssr-with-html/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "compilerOptions": { - "strict": true, - "esModuleInterop": true, - "jsx": "react" - } -} diff --git a/examples/react/basic-ssr-with-html/vite.config.js b/examples/react/basic-ssr-with-html/vite.config.js deleted file mode 100644 index 266400b86c..0000000000 --- a/examples/react/basic-ssr-with-html/vite.config.js +++ /dev/null @@ -1,9 +0,0 @@ -import { defineConfig } from 'vite' -import react from '@vitejs/plugin-react' - -export default defineConfig({ - plugins: [react()], - build: { - minify: false, - }, -}) diff --git a/examples/react/basic-ssr/server.js b/examples/react/basic-ssr/server.js index 9cf626917f..7f1da2820b 100644 --- a/examples/react/basic-ssr/server.js +++ b/examples/react/basic-ssr/server.js @@ -2,8 +2,6 @@ import express from 'express' const isTest = process.env.NODE_ENV === 'test' || !!process.env.VITE_TEST_BUILD -process.env.MY_CUSTOM_SECRET = 'API_KEY_qwertyuiop' - export async function createServer( root = process.cwd(), isProd = process.env.NODE_ENV === 'production', @@ -73,19 +71,6 @@ export async function createServer( } })() - // // Since the router can also be used simply to fulfill data, - // // Request for data, not html - // if (url.includes('__data=')) { - // const data = await entry.load({ - // url, - // }) - // return res.json(data) - // } - - // Control/hydrate all the way up to - // Modify head - // Request/Response control at the route level - console.log('Rendering: ', url, '...') entry.render({ req, res, url, head: viteHead }) } catch (e) { diff --git a/examples/react/basic-ssr/src/entry-server.tsx b/examples/react/basic-ssr/src/entry-server.tsx index 7dc159e477..35f01d210c 100644 --- a/examples/react/basic-ssr/src/entry-server.tsx +++ b/examples/react/basic-ssr/src/entry-server.tsx @@ -21,6 +21,7 @@ export async function render(opts: { initialEntries: [opts.url], }) + // Update the history and context router.update({ history: memoryHistory, context: { @@ -29,8 +30,10 @@ export async function render(opts: { }, }) + // Since we're using renderToString, Wait for the router to finish loading await router.load() + // Render the app const appHtml = ReactDOMServer.renderToString() opts.res.statusCode = 200 diff --git a/examples/react/kitchen-sink-single-file/src/main.tsx b/examples/react/kitchen-sink-single-file/src/main.tsx index 589f266d1e..35cda74e3f 100644 --- a/examples/react/kitchen-sink-single-file/src/main.tsx +++ b/examples/react/kitchen-sink-single-file/src/main.tsx @@ -12,7 +12,6 @@ import { RootRoute, Route, redirect, - useLoader, } from '@tanstack/router' import { Action, @@ -147,8 +146,10 @@ const rootRoute = RootRoute.withRouterContext<{ auth: AuthContext }>()({

Kitchen Sink

{/* Show a global spinner when the router is transitioning */}
@@ -307,12 +308,6 @@ const dashboardIndexRoute = new Route({ const invoicesRoute = new Route({ getParentRoute: () => dashboardRoute, path: 'invoices', - validateSearch: (search) => - z - .object({ - test: z.string().optional(), - }) - .parse(search), component: () => { const { state: { data: invoices }, @@ -457,9 +452,6 @@ const invoiceRoute = new Route({ notes: z.string().optional(), }) .parse(search), - // getLoaderKey: ({ params, search }) => { - // return [params.invoiceId] - // }, loader: async ({ params: { invoiceId }, preload }) => { const invoicesLoaderInstance = invoiceLoader.getInstance({ variables: invoiceId, @@ -747,11 +739,12 @@ const usersIndexRoute = new Route({ const userRoute = new Route({ getParentRoute: () => usersRoute, path: 'user', - // parseParams: ({ userId }) => ({ userId: Number(userId) }), - // stringifyParams: ({ userId }) => ({ userId: `${userId}` }), validateSearch: z.object({ userId: z.number(), }), + // Since our userId isn't part of our pathname, make sure we + // augment the userId as the key for this route + getKey: ({ search: { userId } }) => userId, loader: async ({ search: { userId }, preload }) => { const userLoaderInstance = userLoader.getInstance({ variables: userId }) @@ -797,15 +790,14 @@ const authenticatedRoute = new Route({ // If navigation is attempted while not authenticated, redirect to login // If the error is from a prefetch, redirects will be ignored onBeforeLoadError: (error) => { - console.log('tanner', error) if (error === AuthError) { throw redirect({ to: loginRoute.to, search: { - // Use latestLocation (not currentLocation) to get the live url + // Use location (not location) to get the live url // (as opposed to the committed url, which is technically async // and resolved after the pending state) - redirect: router.state.latestLocation.href, + redirect: router.state.location.href, }, }) } diff --git a/examples/react/start-basic/src/app/routes/root.tsx b/examples/react/start-basic/src/app/routes/root.tsx index 09b3e1a976..cc20b102e6 100644 --- a/examples/react/start-basic/src/app/routes/root.tsx +++ b/examples/react/start-basic/src/app/routes/root.tsx @@ -21,7 +21,7 @@ export const rootRoute = RootRoute.withRouterContext()({ component: function Root() { const router = useRouter() - const titleMatch = [...router.state.currentMatches] + const titleMatch = [...router.state.matches] .reverse() .find((d) => d.routeContext?.getTitle) diff --git a/examples/react/start-kitchen-sink-single-file/src/main.tsx b/examples/react/start-kitchen-sink-single-file/src/main.tsx index 1c38479d62..b4ed83a11c 100644 --- a/examples/react/start-kitchen-sink-single-file/src/main.tsx +++ b/examples/react/start-kitchen-sink-single-file/src/main.tsx @@ -784,10 +784,7 @@ const authenticatedRoute = new Route({ router.navigate({ to: loginRoute.to, search: { - // Use latestLocation (not currentLocation) to get the live url - // (as opposed to the committed url, which is technically async - // and resolved after the pending state) - redirect: router.state.latestLocation.href, + redirect: router.state.location.href, }, }) } diff --git a/packages/react-loaders/src/index.tsx b/packages/react-loaders/src/index.tsx index 7db8d95850..b8fa30f50d 100644 --- a/packages/react-loaders/src/index.tsx +++ b/packages/react-loaders/src/index.tsx @@ -91,15 +91,9 @@ LoaderInstance.onCreateFns.push((loaderInstance) => { } if (opts?.strict ?? true) { - invariant( - typeof loaderInstance.state.data !== 'undefined', - `useLoader: - Loader instance { key: ${loaderInstance.loader.key}, variables: ${loaderInstance.variables} }) is currently in a "${loaderInstance.state.status}" state. By default useLoader will throw an error if the loader instance is not in a "success" state. To avoid this error: - - - Load the loader instance before using it (e.g. via your router's loader or loader option) - - - Set opts.strict to false and handle the loading state in your component`, - ) + if (loaderInstance.state.status === 'pending') { + throw loaderInstance.__loadPromise || loaderInstance.load() + } } React.useEffect(() => { diff --git a/packages/react-start/src/client.tsx b/packages/react-start/src/client.tsx index 0fd6a01373..1414f63d83 100644 --- a/packages/react-start/src/client.tsx +++ b/packages/react-start/src/client.tsx @@ -24,6 +24,7 @@ export function RouterScripts() { dangerouslySetInnerHTML={{ __html: ` window.__TSR_DEHYDRATED__ = ${JSON.stringify(dehydrated)} + window.__TSR_DEHYDRATED_MATCHES__ = {} `, }} /> diff --git a/packages/react-start/src/server.tsx b/packages/react-start/src/server.tsx index 92acecd922..57e7f8be3b 100644 --- a/packages/react-start/src/server.tsx +++ b/packages/react-start/src/server.tsx @@ -106,11 +106,11 @@ export function StartServer(props: { [], ) - React.useState(() => { - if (hydrationCtxValue) { - props.router.hydrate(hydrationCtxValue) - } - }) + // React.useState(() => { + // if (hydrationCtxValue) { + // props.router.hydrate(hydrationCtxValue) + // } + // }) return ( // Provide the hydration context still, since `` needs it. diff --git a/packages/router-devtools/src/devtools.tsx b/packages/router-devtools/src/devtools.tsx index 708e3fdb63..07a59a3001 100644 --- a/packages/router-devtools/src/devtools.tsx +++ b/packages/router-devtools/src/devtools.tsx @@ -520,17 +520,14 @@ export const TanStackRouterDevtoolsPanel = React.forwardRef< ) const allMatches: RouteMatch[] = React.useMemo( - () => [ - ...Object.values(router.state.currentMatches), - ...Object.values(router.state.pendingMatches ?? []), - ], - [router.state.currentMatches, router.state.pendingMatches], + () => [...Object.values(router.state.matches)], + [router.state.matches], ) const activeMatch = allMatches?.find((d) => d.route.id === activeRouteId) const hasSearch = Object.keys( - last(router.state.currentMatches)?.state.search || {}, + last(router.state.matches)?.state.search || {}, ).length return ( @@ -707,7 +704,7 @@ export const TanStackRouterDevtoolsPanel = React.forwardRef< /> ) : (
- {router.state.currentMatches.map((match, i) => { + {router.state.matches.map((match, i) => { return (
) })} - {router.state.pendingMatches?.length ? ( - <> -
- Pending Matches -
- {router.state.pendingMatches?.map((match, i) => { - return ( -
- setActiveRouteId( - activeRouteId === match.route.id - ? '' - : match.route.id, - ) - } - style={{ - display: 'flex', - borderBottom: `solid 1px ${theme.grayAlt}`, - cursor: 'pointer', - background: - match === activeMatch - ? 'rgba(255,255,255,.1)' - : undefined, - }} - > -
- - - {`${match.id}`} - -
- ) - })} - - ) : null}
)}
@@ -953,9 +886,9 @@ export const TanStackRouterDevtoolsPanel = React.forwardRef< }} > { obj[next] = {} return obj diff --git a/packages/router/__tests__/index.test.tsx b/packages/router/__tests__/index.test.tsx index 245ac3d845..20c7bf697f 100644 --- a/packages/router/__tests__/index.test.tsx +++ b/packages/router/__tests__/index.test.tsx @@ -55,7 +55,7 @@ function createLocation(location: Partial): ParsedLocation { // expect(router.store.pendingMatches[0].id).toBe('/') // await promise -// expect(router.state.currentMatches[0].id).toBe('/') +// expect(router.state.matches[0].id).toBe('/') // }) // test('mounts to /a', async () => { @@ -77,7 +77,7 @@ function createLocation(location: Partial): ParsedLocation { // expect(router.store.pendingMatches[0].id).toBe('/a') // await promise -// expect(router.state.currentMatches[0].id).toBe('/a') +// expect(router.state.matches[0].id).toBe('/a') // }) // test('mounts to /a/b', async () => { @@ -107,7 +107,7 @@ function createLocation(location: Partial): ParsedLocation { // expect(router.store.pendingMatches[1].id).toBe('/a/b') // await promise -// expect(router.state.currentMatches[1].id).toBe('/a/b') +// expect(router.state.matches[1].id).toBe('/a/b') // }) // test('navigates to /a', async () => { @@ -130,14 +130,14 @@ function createLocation(location: Partial): ParsedLocation { // expect(router.store.pendingMatches[0].id).toBe('/') // await promise -// expect(router.state.currentMatches[0].id).toBe('/') +// expect(router.state.matches[0].id).toBe('/') // promise = router.navigate({ to: 'a' }) -// expect(router.state.currentMatches[0].id).toBe('/') +// expect(router.state.matches[0].id).toBe('/') // expect(router.store.pendingMatches[0].id).toBe('a') // await promise -// expect(router.state.currentMatches[0].id).toBe('a') +// expect(router.state.matches[0].id).toBe('a') // expect(router.store.pending).toBe(undefined) // }) @@ -162,17 +162,17 @@ function createLocation(location: Partial): ParsedLocation { // }) // await router.mount() -// expect(router.state.currentLocation.href).toBe('/') +// expect(router.state.location.href).toBe('/') // let promise = router.navigate({ to: 'a' }) // expect(router.store.pendingLocation.href).toBe('/a') // await promise -// expect(router.state.currentLocation.href).toBe('/a') +// expect(router.state.location.href).toBe('/a') // promise = router.navigate({ to: './b' }) // expect(router.store.pendingLocation.href).toBe('/a/b') // await promise -// expect(router.state.currentLocation.href).toBe('/a/b') +// expect(router.state.location.href).toBe('/a/b') // expect(router.store.pending).toBe(undefined) // }) diff --git a/packages/router/src/history.ts b/packages/router/src/history.ts index 9a1daaf759..8486a4742f 100644 --- a/packages/router/src/history.ts +++ b/packages/router/src/history.ts @@ -52,7 +52,7 @@ function createHistory(opts: { forward: () => void createHref: (path: string) => string }): RouterHistory { - let currentLocation = opts.getLocation() + let location = opts.getLocation() let unsub = () => {} let listeners = new Set<() => void>() let blockers: BlockerFn[] = [] @@ -80,13 +80,13 @@ function createHistory(opts: { } const onUpdate = () => { - currentLocation = opts.getLocation() + location = opts.getLocation() listeners.forEach((listener) => listener()) } return { get location() { - return currentLocation + return location }, listen: (cb: () => void) => { if (listeners.size === 0) { diff --git a/packages/router/src/path.ts b/packages/router/src/path.ts index 210b9ae5e2..d5b74eb2c3 100644 --- a/packages/router/src/path.ts +++ b/packages/router/src/path.ts @@ -152,7 +152,7 @@ export function matchPathname( matchLocation: Pick, ): AnyPathParams | undefined { const pathParams = matchByPath(basepath, currentPathname, matchLocation) - // const searchMatched = matchBySearch(currentLocation.search, matchLocation) + // const searchMatched = matchBySearch(location.search, matchLocation) if (matchLocation.to && !pathParams) { return diff --git a/packages/router/src/react.tsx b/packages/router/src/react.tsx index e0f7ca9e53..64a7698adc 100644 --- a/packages/router/src/react.tsx +++ b/packages/router/src/react.tsx @@ -304,6 +304,8 @@ export type RouterProps< router: Router } +const useDeferredValue = React.useDeferredValue || ((d) => d) + export function RouterProvider< TRouteConfig extends AnyRoute = AnyRoute, TRoutesInfo extends AnyRoutesInfo = DefaultRoutesInfo, @@ -311,13 +313,17 @@ export function RouterProvider< >({ router, ...rest }: RouterProps) { router.update(rest) - const currentMatches = useStore(router.__store, (s) => s.currentMatches) + const matches = useDeferredValue( + useStore(router.__store, (s) => { + return s.matches + }), + ) React.useEffect(router.mount, [router]) return ( - + { @@ -369,8 +375,9 @@ export function useMatch< }): TStrict extends true ? TRouteMatch : TRouteMatch | undefined { const router = useRouterContext() const nearestMatch = useMatches()[0]! + const matches = useDeferredValue(router.state.matches) const match = opts?.from - ? router.state.currentMatches.find((d) => d.route.id === opts?.from) + ? matches.find((d) => d.route.id === opts?.from) : nearestMatch invariant( @@ -400,22 +407,6 @@ export function useMatch< return match as any } -// export function useRoute< -// TId extends keyof RegisteredRoutesInfo['routesById'] = '/', -// >(routeId: TId): RegisteredRoutesInfo['routesById'][TId] { -// const router = useRouterContext() -// const resolvedRoute = router.getRoute(routeId as any) - -// invariant( -// resolvedRoute, -// `Could not find a route for route "${ -// routeId as string -// }"! Did you forget to add it to your route?`, -// ) - -// return resolvedRoute as any -// } - export type RouteFromIdOrRoute = T extends RegisteredRoutesInfo['routeUnion'] ? T : T extends keyof RegisteredRoutesInfo['routesById'] @@ -424,14 +415,6 @@ export type RouteFromIdOrRoute = T extends RegisteredRoutesInfo['routeUnion'] ? keyof RegisteredRoutesInfo['routesById'] : never -// export function useRoute( -// route: TRouteOrId extends string -// ? keyof RegisteredRoutesInfo['routeIds'] -// : RegisteredRoutesInfo['routeUnion'], -// ): RouteFromIdOrRoute { -// return null as any -// } - export function useLoader< TFrom extends keyof RegisteredRoutesInfo['routesById'], TStrict extends boolean = true, @@ -476,11 +459,11 @@ export function useParams< }): TSelected { const router = useRouterContext() useStore(router.__store, (d) => { - const params = last(d.currentMatches)?.params as any + const params = last(d.matches)?.params as any return opts?.track?.(params) ?? params }) - return last(router.state.currentMatches)?.params as any + return last(router.state.matches)?.params as any } export function useNavigate< @@ -569,8 +552,28 @@ function SubOutlet({ match.route.options.wrapInSuspense ?? !match.route.isRoot ? React.Suspense : SafeFragment + const ResolvedCatchBoundary = errorComponent ? CatchBoundary : SafeFragment + const dehydrated: Record = {} + + if (typeof document === 'undefined') { + if (match.state.loader) { + Object.keys(match.state.loader).forEach((key) => { + let value = match.state.loader[key] + + if (value instanceof Promise || value.then) { + value = { + __isPromise: true, + key: key, + } + } + + dehydrated[key] = value + }) + } + } + return ( }> @@ -581,6 +584,20 @@ function SubOutlet({ warning(false, `Error in route match: ${match.id}`) }} > + {/* {!match.route.isRoot ? ( +