From 722a3ce6b6024efd931ddb5136105f0638817b1d Mon Sep 17 00:00:00 2001 From: Tanner Linsley Date: Mon, 13 Nov 2023 22:24:48 -0700 Subject: [PATCH] fix: refactor to full suspense + useTransition --- docs/api/api.md | 2 +- docs/comparison.md | 93 +- docs/guide/authenticated-routes.md | 2 +- docs/guide/code-splitting.md | 2 +- .../custom-search-param-serialization.md | 46 +- docs/guide/data-loading.md | 337 ++---- docs/guide/data-mutations.md | 265 +---- docs/guide/deferred-data-loading.md | 2 +- docs/guide/external-data-loading.md | 45 +- docs/guide/preloading.md | 2 +- docs/guide/router-context.md | 10 +- docs/guide/search-params.md | 4 +- docs/overview.md | 8 +- docs/quick-start.md | 6 +- examples/react/basic/package.json | 2 +- examples/react/deferred-data/package.json | 2 +- examples/react/deferred-data/src/main.tsx | 2 +- examples/react/file-based-routes/package.json | 2 +- .../src/routes/posts.$postId.tsx | 2 +- .../src/routes/posts_.$postId.deep.tsx | 2 +- examples/react/focus-refetching/package.json | 2 +- examples/react/focus-refetching/src/main.tsx | 2 +- .../kitchen-sink-single-file/package.json | 11 +- .../kitchen-sink-single-file/src/main.tsx | 281 +++-- .../kitchen-sink-single-file/src/mockTodos.ts | 1 + examples/react/location-masking/package.json | 2 +- examples/react/location-masking/src/main.tsx | 4 +- examples/react/quickstart/package.json | 2 +- .../react/react-search-state/package.json | 2 +- .../react/scroll-restoration/package.json | 2 +- .../react/scroll-restoration/src/main.tsx | 6 +- .../src/routes/posts/$postId.tsx | 2 +- .../wip-trpc-react-query/client/main.tsx | 6 +- .../react/wip-with-framer-motion/package.json | 2 +- .../react/wip-with-framer-motion/src/main.tsx | 4 +- .../react/with-bling/src/routes/posts.tsx | 2 +- .../with-bling/src/routes/posts/$postId.tsx | 2 +- .../with-loaders-code-split/package.json | 2 +- .../src/routes/posts/post/postRoute.tsx | 2 +- .../src/routes/posts/postsRoute.tsx | 2 +- .../package.json | 2 +- .../src/mockTodos.ts | 1 + .../src/routes/dashboard/index.tsx | 2 +- .../src/routes/dashboard/invoices/index.tsx | 2 +- .../src/routes/dashboard/invoices/invoice.tsx | 2 +- .../src/routes/dashboard/users/index.tsx | 2 +- .../src/routes/dashboard/users/user.tsx | 2 +- .../src/routes/layout/index.tsx | 2 +- .../package.json | 2 +- .../src/main.tsx | 10 +- .../src/mockTodos.ts | 1 + .../src/routes/posts.tsx | 2 +- .../src/routes/posts/$postId.tsx | 2 +- .../with-loaders-ssr/src/routes/posts.tsx | 2 +- .../src/routes/posts/$postId.tsx | 2 +- examples/react/with-loaders/package.json | 2 +- examples/react/with-loaders/src/main.tsx | 4 +- examples/react/with-react-query/package.json | 2 +- examples/react/with-react-query/src/main.tsx | 4 +- examples/react/with-trpc/client/main.tsx | 6 +- packages/react-router/src/CatchBoundary.tsx | 97 ++ packages/react-router/src/Matches.tsx | 315 +++++ packages/react-router/src/RouteMatch.ts | 28 - packages/react-router/src/RouterProvider.tsx | 568 +++++---- packages/react-router/src/index.tsx | 25 +- packages/react-router/src/injectHtml.ts | 28 + .../react-router/src/lazyRouteComponent.tsx | 33 + .../react-router/src/{link.ts => link.tsx} | 166 ++- packages/react-router/src/location.ts | 1 + packages/react-router/src/react.tsx | 1013 ----------------- packages/react-router/src/route.ts | 102 +- packages/react-router/src/router.ts | 13 +- packages/react-router/src/searchParams.ts | 1 + packages/react-router/src/useBlocker.tsx | 34 + packages/react-router/src/useNavigate.tsx | 109 ++ packages/react-router/src/useParams.tsx | 25 + packages/react-router/src/useSearch.tsx | 25 + packages/react-router/src/utils.ts | 86 +- .../react-router/tests/createRoute.test.ts | 8 +- packages/react-router/tests/index.test.ts | 16 +- pnpm-lock.yaml | 41 +- 81 files changed, 1772 insertions(+), 2191 deletions(-) create mode 100644 packages/react-router/src/CatchBoundary.tsx create mode 100644 packages/react-router/src/Matches.tsx delete mode 100644 packages/react-router/src/RouteMatch.ts create mode 100644 packages/react-router/src/injectHtml.ts create mode 100644 packages/react-router/src/lazyRouteComponent.tsx rename packages/react-router/src/{link.ts => link.tsx} (71%) delete mode 100644 packages/react-router/src/react.tsx create mode 100644 packages/react-router/src/useBlocker.tsx create mode 100644 packages/react-router/src/useNavigate.tsx create mode 100644 packages/react-router/src/useParams.tsx create mode 100644 packages/react-router/src/useSearch.tsx diff --git a/docs/api/api.md b/docs/api/api.md index 2f532d7989..a68cd8a580 100644 --- a/docs/api/api.md +++ b/docs/api/api.md @@ -70,7 +70,7 @@ const myRoute = new Route({ // TContext extends Record Promise | TContext // An async function to load or prepare any required prerequisites for the route. - loader: (match: { + load: (match: { // The abortController used internally by the router abortController: AbortController // A boolean indicating whether or not the route is being preloaded. diff --git a/docs/comparison.md b/docs/comparison.md index 1c9a897525..050b04ae2b 100644 --- a/docs/comparison.md +++ b/docs/comparison.md @@ -15,58 +15,53 @@ Feature/Capability Key: - 🔶 Possible, but requires custom code/implementation/casting - 🛑 Not officially supported -| | TanStack Router | React Router DOM [_(Website)_][router] | Next.JS [_(Website)_][nextjs] | -| ---------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------- | ----------------------------------------------------- | -| Github Repo / Stars | [![][stars-tanstack-router]][gh-tanstack-router] | [![][stars-router]][gh-router] | [![][stars-nextjs]][gh-nextjs] | -| Bundle Size | [![][bp-tanstack-router]][bpl-tanstack-router] | [![][bp-router]][bpl-router] | ❓ | -| History, Memory & Hash Routers | ✅ | ✅ | 🛑 | -| Nested / Layout Routes | ✅ | ✅ | ✅ | -| Suspense-like Route Transitions | ✅ | ✅ | ✅ | -| Typesafe Routes | ✅ | 🛑 | 🟡 | -| Loaders | ✅ | ✅ | ✅ | -| Typesafe Loaders | ✅ | 🔶 | 🛑 | -| Loader Caching (SWR + Invalidation) | ✅ | 🛑 | ✅ | -| Actions | 🔵 [![][bp-tanstack-actions]][bpl-tanstack-actions] | ✅ | 🛑 | -| Typesafe Actions | 🔵 | 🔶 | 🛑 | -| Route Prefetching | ✅ | ✅ | ✅ | -| Auto Route Prefetching | ✅ | 🛑 | ✅ | -| Route Prefetching Delay | ✅ | 🔶 | 🛑 | -| Path Params | ✅ | ✅ | ✅ | -| Typesafe Path Params | ✅ | 🛑 | 🛑 | -| Path Param Validation | ✅ | 🛑 | 🛑 | -| Custom Path Param Parsing/Serialization | ✅ | 🛑 | 🛑 | -| Ranked Routes | ✅ | ✅ | ✅ | -| Active Link Customization | ✅ | ✅ | ✅ | -| Optimistic UI | ✅ | ✅ | 🔶 | -| Typesafe Absolute + Relative Navigation | ✅ | 🛑 | 🛑 | -| Route Mount/Transition/Unmount Events | ✅ | 🛑 | 🛑 | -| Devtools | ✅ | 🛑 | 🛑 | -| Basic Search Params | ✅ | ✅ | ✅ | -| Search Param Hooks | ✅ | ✅ | ✅ | -| ``/`useNavigate` Search Param API | ✅ | 🟡 (search-string only via the `to`/`search` options) | 🟡 (search-string only via the `to`/`search` options) | -| JSON Search Params | ✅ | 🔶 | 🔶 | -| TypeSafe Search Params | ✅ | 🛑 | 🛑 | -| Search Param Schema Validation | ✅ | 🛑 | 🛑 | -| Search Param Immutability + Structural Sharing | ✅ | 🔶 | 🛑 | -| Custom Search Param parsing/serialization | ✅ | 🔶 | 🛑 | -| Search Param Middleware | ✅ | 🛑 | 🛑 | -| Async Route Elements | ✅ | 🛑 | ✅ | -| Suspense Route Elements | ✅ | ✅ | ✅ | -| Route Error Elements | ✅ | ✅ | ✅ | -| Route Pending Elements | ✅ | 🛑 | ✅ | -| ``/`usePrompt` | ✅ | 🔶 | | -| SSR | ✅ | ✅ | ✅ | -| Streaming SSR | ✅ | ✅ | 🔶 | -| Deferred Data Loading | ✅ | ✅ | 🔶 | -| Navigation Scroll Restoration | ✅ | ✅ | 🛑 | -| `
` API | 🛑 | ✅ | 🛑 | +| | TanStack Router | React Router DOM [_(Website)_][router] | Next.JS [_(Website)_][nextjs] | +| ---------------------------------------------- | ------------------------------------------------ | ----------------------------------------------------- | ----------------------------------------------------- | +| Github Repo / Stars | [![][stars-tanstack-router]][gh-tanstack-router] | [![][stars-router]][gh-router] | [![][stars-nextjs]][gh-nextjs] | +| Bundle Size | [![][bp-tanstack-router]][bpl-tanstack-router] | [![][bp-router]][bpl-router] | ❓ | +| History, Memory & Hash Routers | ✅ | ✅ | 🛑 | +| Nested / Layout Routes | ✅ | ✅ | ✅ | +| Suspense-like Route Transitions | ✅ | ✅ | ✅ | +| Typesafe Routes | ✅ | 🛑 | 🟡 | +| Load-Route Suspense | ✅ | ✅ | ✅ | +| Route Prefetching | ✅ | ✅ | ✅ | +| Auto Route Prefetching | ✅ | 🛑 | ✅ | +| Route Prefetching Delay | ✅ | 🔶 | 🛑 | +| Path Params | ✅ | ✅ | ✅ | +| Typesafe Path Params | ✅ | 🛑 | 🛑 | +| Path Param Validation | ✅ | 🛑 | 🛑 | +| Custom Path Param Parsing/Serialization | ✅ | 🛑 | 🛑 | +| Ranked Routes | ✅ | ✅ | ✅ | +| Active Link Customization | ✅ | ✅ | ✅ | +| Optimistic UI | ✅ | ✅ | 🔶 | +| Typesafe Absolute + Relative Navigation | ✅ | 🛑 | 🛑 | +| Route Mount/Transition/Unmount Events | ✅ | 🛑 | 🛑 | +| Devtools | ✅ | 🛑 | 🛑 | +| Basic Search Params | ✅ | ✅ | ✅ | +| Search Param Hooks | ✅ | ✅ | ✅ | +| ``/`useNavigate` Search Param API | ✅ | 🟡 (search-string only via the `to`/`search` options) | 🟡 (search-string only via the `to`/`search` options) | +| JSON Search Params | ✅ | 🔶 | 🔶 | +| TypeSafe Search Params | ✅ | 🛑 | 🛑 | +| Search Param Schema Validation | ✅ | 🛑 | 🛑 | +| Search Param Immutability + Structural Sharing | ✅ | 🔶 | 🛑 | +| Custom Search Param parsing/serialization | ✅ | 🔶 | 🛑 | +| Search Param Middleware | ✅ | 🛑 | 🛑 | +| Suspense Route Elements | ✅ | ✅ | ✅ | +| Route Error Elements | ✅ | ✅ | ✅ | +| Route Pending Elements | ✅ | ✅ | ✅ | +| ``/`usePrompt` | ✅ | 🔶 | ❓ | +| SSR | ✅ | ✅ | ✅ | +| Streaming SSR | ✅ | ✅ | ✅ | +| Deferred Primitives | ✅ | ✅ | ✅ | +| Navigation Scroll Restoration | ✅ | ✅ | ❓ | +| Integrated Data Loaders | 🔶 (TanStack Query is recommended) | ✅ | ✅ | +| Loader Caching (SWR + Invalidation) | 🔶 (TanStack Query is recommended) | 🛑 | ✅ | +| Actions | 🔶 (TanStack Query is recommended) | ✅ | ✅ | +| `` API | 🛑 | ✅ | ✅ | +| Full-Stack APIs | 🛑 | ✅ | ✅ | [bp-tanstack-router]: https://badgen.net/bundlephobia/minzip/@tanstack/react-router@beta [bpl-tanstack-router]: https://bundlephobia.com/result?p=@tanstack/react-router@beta -[bp-tanstack-loaders]: https://badgen.net/bundlephobia/minzip/@tanstack/react-loaders@beta?label=Loaders -[bpl-tanstack-loaders]: https://bundlephobia.com/result?p=@tanstack/react-loaders@beta -[bp-tanstack-actions]: https://badgen.net/bundlephobia/minzip/@tanstack/react-actions@beta?label=@tanstack/actions -[bpl-tanstack-actions]: https://bundlephobia.com/result?p=@tanstack/react-actions@beta [gh-tanstack-router]: https://github.com/tanstack/router [stars-tanstack-router]: https://img.shields.io/github/stars/tanstack/router?label=%F0%9F%8C%9F [_]: _ diff --git a/docs/guide/authenticated-routes.md b/docs/guide/authenticated-routes.md index 04c75a31a1..a41ddf836d 100644 --- a/docs/guide/authenticated-routes.md +++ b/docs/guide/authenticated-routes.md @@ -19,7 +19,7 @@ The `beforeLoad` function runs in relative order to these other route loading fu - `route.onError` - Route Loading (Parallel) - `route.component.preload?` - - `route.loader` + - `route.load` **It's important to know that the `beforeLoad` function for a route is called _before any of it's child routes' `beforeLoad` functions_.** It is essentially a middleware function for the route and all of it's children. diff --git a/docs/guide/code-splitting.md b/docs/guide/code-splitting.md index 1d83186bab..58f53b98f2 100644 --- a/docs/guide/code-splitting.md +++ b/docs/guide/code-splitting.md @@ -66,7 +66,7 @@ import { LoaderContext } from '@tanstack/react-router' const route = new Route({ path: '/my-route', component: MyComponent, - loader: (...args) => import('./loader').then((d) => d.loader(...args)), + load: (...args) => import('./loader').then((d) => d.loader(...args)), }) // In another file... diff --git a/docs/guide/custom-search-param-serialization.md b/docs/guide/custom-search-param-serialization.md index 17760d2a77..a963cd4b16 100644 --- a/docs/guide/custom-search-param-serialization.md +++ b/docs/guide/custom-search-param-serialization.md @@ -9,28 +9,28 @@ By default, TanStack Router parses and serializes your search params automatical To do so, [use `Router`'s `parseSearch` and `stringifySearch` options](../docs/api#search-param-parsing-and-serialization): ```tsx - import { - Router, - parseSearchWith, - stringifySearchWith, - } from '@tanstack/react-router'; - import qs from 'query-string'; - - // For example, we use `query-string` to render arrays in bracket notation: - // output: ?key[]=value1&key[]=value2 - - function customStringifier(searchObj) { - return qs.stringify(searchObj, { arrayFormat: 'bracket' }); - } - - function customParser(searchString) { - return qs.parse(searchString, { arrayFormat: 'bracket' }); - } - - const router = new Router({ - stringifySearch: customStringifier, - parseSearch: customParser, - }) +import { + Router, + parseSearchWith, + stringifySearchWith, +} from '@tanstack/react-router' +import qs from 'query-string' + +// For example, we use `query-string` to render arrays in bracket notation: +// output: ?key[]=value1&key[]=value2 + +function customStringifier(searchObj) { + return qs.stringify(searchObj, { arrayFormat: 'bracket' }) +} + +function customParser(searchString) { + return qs.parse(searchString, { arrayFormat: 'bracket' }) +} + +const router = new Router({ + stringifySearch: customStringifier, + parseSearch: customParser, +}) ``` Additionally, you can [use the `parseSearchWith` and `stringifySearchWith` utilities](../docs/api#search-param-parsing-and-serialization) to parse and serialize the search values specifically. @@ -77,6 +77,7 @@ export function decodeFromBinary(str: string): string { .join(''), ) } + export function encodeToBinary(str: string): string { return btoa( encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { @@ -118,6 +119,7 @@ export function decodeFromBinary(str: string): string { .join(''), ) } + export function encodeToBinary(str: string): string { return btoa( encodeURIComponent(str).replace(/%([0-9A-F]{2})/g, function (match, p1) { diff --git a/docs/guide/data-loading.md b/docs/guide/data-loading.md index 27213cebe2..e1afecee1f 100644 --- a/docs/guide/data-loading.md +++ b/docs/guide/data-loading.md @@ -5,83 +5,41 @@ title: Data Loading Data loading is a common concern for web applications and is extremely related to routing. When loading any page for your app, it's ideal if all of the async requirements for those routes are fetched and fulfilled as early as possible and in parallel. The router is the best place to coordinate all of these async dependencies as it's usually the only place in your app that knows about where users are headed before content is rendered. -You may be familiar with `getServerSideProps` from Next.js or `loaders` from Remix/React-Router. TanStack Router is designed with similar functionality to preload assets on a per-route basis in parallel and optionally store and retrieve it in your components as well. +You may be familiar with `getServerSideProps` from Next.js or `loader`s from Remix/React-Router. TanStack Router is designed with similar functionality to preload assets on a per-route basis in parallel allowing React to render as it fetches via suspense. -## TanStack Router's data loading lifecycle +## The route loading lifecycle -Most application routers, if they support route loading at all, will fetch data for **new routes matches** as they enter the application during navigation. Consider the following navigation flow: +Every time a URL/history update is detected, the router the following sequence is executed: -- The user lands in your app on the `/posts/123` pathname. - - The following route structure is matched - - / - - posts - - $postId - - All of the loaders for all three routes for `/`, `/posts` and `/posts/$postId` load in parallel: - - **Load** `/` - - **Load** `/posts` - - **Load** `/posts/$postId` (with `postId` === `123`) -- The user navigates to the `/posts/456` pathname - - The following route structure is matched - - / - - posts - - $postId - - The `/` and `/posts` loaders are skipped because they have already been loaded. - - The `$postId` match detects a change in params to `456` and the following loaders are called: - - **Load** `/posts/$postId` (with `postId` === `123`) -- The user navigates to the `/` pathname - - The following route structure is matched - - / - - The `/` loader is skipped because it has already been loaded. -- The user navigates to the `/posts` pathname - - The following route structure is matched - - / - - posts - - The `/` loader is skipped because it has already been loaded. - - The `/posts` match is detected as new and the following loaders are called: - - **Load** `/posts` +- Route Matching (Top-Down) + - `route.parseParams` + - `route.validateSearch` +- Route Pre-Loading (Serial) + - `route.beforeLoad` + - `route.onError` +- Route Loading (Parallel) + - `route.component.preload?` + - `route.load` -From the flow above, you'll notice that +## URL Update/Loading Frequency -- Route matches are, **by default, identified by their path params** -- Once a unique match is loaded, **by default, it is cached for `Infinity` until it is no longer in use (or invalidated)** -- When a route match is no longer in use, **by default, it is garbage collected immediately**. +Similar to TanStack Query, TanStack router errs on the side of thorough and truthful events about when the URL changes and when routes are matched and loaded. This means that your routes' **beforeLoad** and **load** function could potentially be called at the speed at which the URL changes. This includes: -## Atomic Defaults, Stale-While-Revalidate Capabilities +- User Navigation +- History Back/Forward Actions +- History Push/Replace Actions +- Search Parameter updates +- Hash updates -Defaults are what make tools great, but as you might have guessed, there are plenty of ways to configure route match caching and garbage collection. Let's go over some concepts and terminology. +Whatever you do in these route option function should be prepared to depupe and/or cancel any async operations that are no longer relevant. This is especially important for loaders that make expensive network requests. -- `maxAge` - The maximum amount of time in milliseconds a route match should be considered "fresh". Defaults to `Infinity`. -- `gcMaxAge` - The amount of time in milliseconds an **unused/inactive** route match will be held in memory before it is garbage collected. Defaults to `0`. -- `preloadMaxAge` - The amount of time in milliseconds an **unused/preloaded** route match will be held in memory before it is garbage collected. Defaults to `10_000`. +## No Caching -## Caching +TanStack Router does not provide data management or caching of data, but **is designed to work well with data caches like TanStack Query, SWR, etc.** -Similar to TanStack Query, TanStack Router has some awesome caching utilities built-in, but **contrary to Query, Router has the following defaults**: +## Route `load` -- `maxAge: Infinity` -- `gcMaxAge: 0` - -This means that, **by default, all route matches are cached forever until they are no longer in use at which point they are garbage collected immediately**. This is a great default for most applications using the router as a data fetcher since it compliments nested routing patterns and meets the user at their expectations that **only the parts of the screen change will be loaded**. These options can be changed though! - -## Route Caching Options - -The following options can modify the caching behavior of a route match: - -- `maxAge` -- `gcMaxAge` -- `preloadMaxAge` - -## Router-wide Caching Options - -The following options can modify the default behavior of all route matches: - -- `defaultMaxAge` -- `defaultGcMaxAge` -- `defaultPreloadMaxAge` - -## Route Loaders - -Route loaders are functions that are called when a route match is loaded. They are called with a single parameter which is an object containing many helpful properties. We'll go over those in a bit, but first, let's look at an example of a route loader: +Route `load` functions are called when a route match is loaded. They are called with a single parameter which is an object containing many helpful properties. We'll go over those in a bit, but first, let's look at an example of a route `load` function: ```tsx import { Route } from '@tanstack/react-router' @@ -89,50 +47,47 @@ import { Route } from '@tanstack/react-router' const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', - loader: async () => { - // Load our posts - const res = await fetch(`/api/posts`) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() + load: async () => { + await prefetchPosts() }, }) ``` -The data returned from the loader is stored in a unique `RouteMatch` that is identified by the route's `fullPath` and optionally, the result of the `routeOptions.loaderContext` function, which is required to use any path params, search params or hash in a loader. This ensures that routes with the same `fullPath` but different variables e.g. `/posts?page=1` and `/posts?page=2` are stored separately. In the case above, the pathName is sufficient to uniquely identify the route and it doesn't need any access to path params, search params or hash, so `loaderContext` is not required. +## `load` Parameters -## `loader` Parameters - -The `loader` function receives a single object with the following properties: +The `load` function receives a single object with the following properties: +- `params` - The route's path params +- `search` - The route's search params - `context` - The route's context object, which is a merged union of: - Parent route context - This route's context as provided by the `beforeLoad` option - - This route's loader context as provided by the `loaderContext` option -- `abortController` - The route's abortController. Its signal is cancelled when the route is unloaded or when the `loader` call becomes outdated. +- `abortController` - The route's abortController. Its signal is cancelled when the route is unloaded or when the Route is no longer relevant and the current invocation of the `load` function becomes outdated. +- `navigate` - A function that can be used to navigate to a new location +- `location` - The current location Using these parameters, we can do a lot of cool things. Let's take a look at a few examples ## Using Router Context -The `context` argument passed to the `loader` function is an object containing a merged union of: +The `context` argument passed to the `load` function is an object containing a merged union of: - Parent route context - This route's context as provided by the `beforeLoad` option -- This route's loader context as provided by the `loaderContext` option -Starting at the very top of the router, you can pass an initial context to the router via the `context` option. This context will be available to all routes in the router and get copied and extended by each route as they are matched. This happens by passing a context to a route via the `beforeLoad` option. This context will be available to all child routes of the route. Finally, you can pass a context to a route via the `loaderContext` option. This context will be available to the route's loader. +Starting at the very top of the router, you can pass an initial context to the router via the `context` option. This context will be available to all routes in the router and get copied and extended by each route as they are matched. This happens by passing a context to a route via the `beforeLoad` option. This context will be available to all child routes of the route. The resulting context will be available to the route's `load` function. -In this example, we'll create a function in our route context to fetch posts, then use it in our loader. +In this example, we'll create a function in our route context to fetch posts, then use it in our `load` function. -> 🧠 Context is a powerful tool for dependency injection. You can use it to inject services, loaders, and other objects into your router and routes. You can also additively pass data down the route tree at every route using a route's `beforeLoad` option. +> 🧠 Context is a powerful tool for dependency injection. You can use it to inject services, hooks, and other objects into your router and routes. You can also additively pass data down the route tree at every route using a route's `beforeLoad` option. ```tsx import { Route } from '@tanstack/react-router' const fetchPosts = async () => { const res = await fetch(`/api/posts?page=${pageIndex}`) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() + if (!res.ok) throw new Error('Failed to fetch posts') + return res.json() } // Create a new routerContext using new rootRouteWithContext<{...}>() function and pass it whatever types you would like to be available in your router context. @@ -146,7 +101,9 @@ const rootRoute = rootRouteWithContext<{ const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', - loader({ context: { fetchPosts } }) => fetchPosts(), + load: ({ context: { fetchPosts } }) => { + await fetchPosts() + }, }) const routeTree = rootRoute.addChildren([postsRoute]) @@ -164,7 +121,7 @@ const router = new Router({ ## Using Path Params -To use path params in your loader, access them via the `params` property on the loader's parameters. Here's an example: +To use path params in your `load` function, access them via the `params` property on the function's parameters. Here's an example: ```tsx import { Route } from '@tanstack/react-router' @@ -172,39 +129,37 @@ import { Route } from '@tanstack/react-router' const postRoute = new Route({ getParentPath: () => postsRoute, path: '$postId', - loader: ({ params: { postId } }) => { - const res = await fetch(`/api/posts/${postId}`) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() + load: ({ params: { postId } }) => { + await prefetchPostById(postId) }, }) ``` ## Using Route Context -Passing down global context to your router is great, but what if you want to provide context that is specific to a route? This is where the `beforeLoad` option comes in. The `beforeLoad` option is a function that runs right before attempting to load a route and receives the same `loader` parameters. Beyond its ability to redirect potential matches, it can also return an object that will be merged into the route's context. Let's take a look at an example where we provide `fetchPosts` to our route context via the `beforeLoad` option: +Passing down global context to your router is great, but what if you want to provide context that is specific to a route? This is where the `beforeLoad` option comes in. The `beforeLoad` option is a function that runs right before attempting to load a route and receives the same `load` function parameters. Beyond its ability to redirect potential matches, it can also return an object that will be merged into the route's context. Let's take a look at an example where we provide `fetchPosts` to our route context via the `beforeLoad` option: ```tsx import { Route } from '@tanstack/react-router' -const fetchPosts = async () => { - const res = await fetch(`/api/posts?page=${pageIndex}`) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() -} - const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', - // Pass the fetchPosts function to the route context - beforeLoad: () => ({ fetchPosts }), - loader({ context: { fetchPosts } }) => fetchPosts(), + // Pass the prefetchPosts function to the route context + beforeLoad: () => ({ + prefetchPosts: () => { + // ... + }, + }), + load: ({ context: { prefetchPosts } }) => { + prefetchPosts() + }, }) ``` ## Using Search Params -Search parameters can be accessed via the `loaderContext` function. The `loaderContext`'s `search` property contains _all_ of the search params including parent search params, of which you can choose which search params you specifically rely on in your loader. In this example, we'll use zod to validate and parse the search params for the `/posts` route that uses pagination, then pass them and use them in our loader. +Search parameters can be accessed via the `beforeLoad` and `load` functions. The `search` property provided to these functions contains _all_ of the search params including parent search params. In this example, we'll use zod to validate and parse the search params for the `/posts` route that uses pagination, then use them in our `load` function. ```tsx import { Route } from '@tanstack/react-router' @@ -214,22 +169,20 @@ const postsRoute = new Route({ path: 'posts', // Use zod to validate and parse the search params validateSearch: z.object({ - pageIndex: z.number().int().nonnegative().catch(0), + offset: z.number().int().nonnegative().catch(0), }), - // Pass the pageIndex to the loader context - loaderContext: ({ search: { pageIndex } }) => ({ pageIndex }), - // Use the pageIndex from context in the loader - loader: async ({ context: { pageIndex } }) => { - const res = await fetch(`/api/posts?page=${pageIndex}`) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() + // Use the offset from context in the load function + load: async ({ search: { offset } }) => { + await prefetchPosts({ + offset, + }) }, }) ``` ## Using the Abort Signal -The `abortController` property of the `loader` function is an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). Its signal is cancelled when the route is unloaded or when the `loader` call becomes outdated. This is useful for cancelling network requests when the route is unloaded or when the route's params change. Here is an example using it with a fetch call: +The `abortController` property of the `load` function is an [AbortController](https://developer.mozilla.org/en-US/docs/Web/API/AbortController). Its signal is cancelled when the route is unloaded or when the `load` call becomes outdated. This is useful for cancelling network requests when the route is unloaded or when the route's params change. Here is an example using it with a fetch call: ```tsx import { Route } from '@tanstack/react-router' @@ -237,66 +190,18 @@ import { Route } from '@tanstack/react-router' const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', - loader: ({ abortController }) => { - const res = await fetch(`/api/posts?page=${pageIndex}`, { + load: ({ abortController }) => { + await fetchPosts({ + // Pass this to an underlying fetch call or anything that supports signals signal: abortController.signal, }) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() }, }) ``` ## Using the `preload` flag -The `preload` property of the `loader` function is a boolean which is `true` when the route is being preloaded instead of loaded. Some data loading libraries may handle preloading differently than a standard fetch, so you may want to pass `preload` to your data loading library, or use it to execute the appropriate data loading logic. Here is an example using TanStack Loaders and its built-in `preload` flag: - -```tsx -import { Route } from '@tanstack/react-router' - -// Create a new loader -const postsLoader = new Loader({ - key: 'posts', - fn: async (params) => { - const res = await fetch(`/api/posts`) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() - }, -}) - -// Create a new loader client -const loaderClient = new LoaderClient({ - loaders: [postsLoader], -}) - -const postsRoute = new Route({ - getParentPath: () => rootRoute, - path: 'posts', - loader: async ({ preload }) => { - // Passing the preload flag to the loader client - // will enforce slightly different caching behavior - // in TanStack Loaders caching logic - await loaderClient.load({ key: 'posts', preload }) - }, - component: ({ useLoader }) => { - const { data: posts } = useLoaderInstance({ key: 'posts' }) - - return
...
- }, -}) -``` - -## Retrieving Loader Data - -The data returned from the loader can be retrieved a few different ways: - -- The `props.useLoader` hook -- The `route.useLoader` hook -- The `useLoader` hook - -Each is available to allow access to the loader data at different contexts and are in order from simplest to most flexible. - -Let's retrieve the data from our loader using the `props.useLoader` hook: +The `preload` property of the `load` function is a boolean which is `true` when the route is being preloaded instead of loaded. Some data loading libraries may handle preloading differently than a standard fetch, so you may want to pass `preload` to your data loading library, or use it to execute the appropriate data loading logic: ```tsx import { Route } from '@tanstack/react-router' @@ -304,110 +209,10 @@ import { Route } from '@tanstack/react-router' const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', - loader: async () => { - // ... - }, - component: ({ useLoader }) => { - const posts = useLoader() - return
...
- }, -}) -``` - -## Stale-While-Revalidate - -By manipulating the `maxAge` option, we can create a stale-while-revalidate pattern for our route matches and their loaders. This is useful for routes that may have frequently changing data caused by external events. Let's take a look at an example: - -```tsx -import { Route } from '@tanstack/react-router' - -const postsRoute = new Route({ - getParentPath: () => rootRoute, - path: 'posts', - loader: async () => { - // Load our posts - const res = await fetch(`/api/posts`) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() - }, - maxAge: 10_000, -}) -``` - -This route's `maxAge` is set to `10_000` milliseconds (10 seconds). This means that the route match data will be considered "fresh" for 10 seconds. - -- If the route is loaded again **within** 10 seconds, the cached data will be returned immediately and the loader will not be called. -- If the route is loaded again **after** 10 seconds, the cached data will be returned immediately and the loader will be called again in the background to refresh the data. - -## Using Match State - -Enabling patterns like `stale-while-revalidate` is great, but what if we want to know when our data is stale? This is where the match state comes in. Match state is available via a few different hooks: - -- `props.useMatch` -- `route.useMatch` -- `useMatch` - -They are ordered from simplest to most flexible. - -The result of these hooks has a lot of useful information about a route match, but on the topic of `stale-while-revalidate`, one property is particularly useful: - -- `isFetching` - - `boolean` - -This property is `true` when the route match is being loaded and `false` when it is not. This means that we can use it to display to our users that this particular route's loader data is being refreshed in the background. Let's take a look at an example: - -```tsx -import { Route } from '@tanstack/react-router' - -const postsRoute = new Route({ - getParentPath: () => rootRoute, - path: 'posts', - loader: async () => { - // ... - }, - maxAge: 10_000, - component: ({ useMatch }) => { - const { isFetching } = useMatch() - return
- {isFetching ? Loading... : null} - ... -
+ load: async ({ preload }) => { + await prefetchPosts({ + maxAge: preload ? 10_000 : 0, // Preloads should hang around a bit longer + }) }, }) ``` - -## Loader Invalidation - -By default, route matches are never considered stale and garbage collected when they are no longer in use. This is great mostly for loaders that only change when navigation occurs, but what about loaders that change when user events occur? - -Our apps often contain events that could modify the results of our loaders. For example, a user may create a new post, or a user may delete a post. In these cases, we would want to invalidate the loader data for `/posts` and `/posts/$postId` so the user will see the latest data. - -The easiest way to do this is by **invalidating all route matches with `router.invalidate`**: - -```tsx -function App() { - const router = useRouter - - const mutate = () => { - //... some mutation logic - router.invalidate() - } -} -``` - -`router.invalidate` will invalidate all route matches and **by default, reload the currently matched routes**. For any route matches that are not currently in use and not garbage collected, they will be marked as invalid and their loader will be called again when they are matched or preloaded. - -## Invalidating Specific Route Matches - -If you want to invalidate specific route matches, you can use the same `router.invalidate` method, but pass a `matchId` option to it: - -```tsx -function App() { - const router = useRouter - - const mutate = () => { - //... some mutation logic - router.invalidate({ matchId: '/posts' }) - } -} -``` diff --git a/docs/guide/data-mutations.md b/docs/guide/data-mutations.md index 51731c590f..1066a2a6ff 100644 --- a/docs/guide/data-mutations.md +++ b/docs/guide/data-mutations.md @@ -2,55 +2,17 @@ title: Data Mutations --- -## The Router's Role in Data Mutations +Since TanStack router does not store or cache data, it's role in data mutation is slim to none outside of reacting to potential URL side-effects from external mutation events. That said, we've compiled a list of mutation-related features you might find useful and libraries that implement them. -Data mutations in our applications can take on many forms. They can be simple actions like updating a user's profile, or they can be complex, multi-step processes like creating a new user. Regardless of the complexity, the router plays an important and simple role in data mutations: **It should reset any state related to the mutation when the mutation is complete**. +Look for and use mutation utilities that support: -This is a simple concept, but it's important to understand why it's important. Let's consider the following interactions: - -- User navigates to the `/posts/123/edit` screen to edit a post -- A list of posts is visible in the sidebar rendered by the `/posts` route -- User edits the `123` post's title and upon success, sees a success message below the editor that the post was updated -- The user **should now see the updated post title in the sidebar** - -This is a simple expectation, but requires a bit of coordination between the router and the data mutation logic used to update the post. Let's consider the following interactions. Let's create a simple hook-based mutation with a promise flow to update the post and then **invalidate the router's matches when the mutation is complete**: - -```tsx -function useUpdatePost() { - const router = useRouter() - - return React.useCallback(async (post: Post) => { - const response = await fetch(`/api/posts/${post.id}`, { - method: 'PATCH', - body: JSON.stringify(post), - }).then((response) => { - if (!response.ok) { - throw new Error('Failed to update post') - } - - // Invalidate the router's matches - router.invalidateMatches() - }) - }, []) -} -``` - -## What should I use for data mutations? - -We wouldn't recommend using the above pattern for data mutations as there are many mutation patterns that are not covered by such a simple example. - -Things that a data mutation library should consider: - -- Saving and handling submission state -- Providing optimistic update support +- Handling and caching submission state +- Providing both local and global optimistic UI support - Built-in hooks to wire up invalidation (or automatically support it) - Handling multiple in-flight mutations at once - Organizing mutation state as a globally accessible resource - Submission state history and garbage collection -There are a few different options for managing data mutations, here are our recommendations! - -- [TanStack Actions](#tanstack-actions) - [TanStack Query](https://tanstack.com/query/latest/docs/react/guides/mutations) - [SWR](https://swr.vercel.app/) - [RTK Query](https://redux-toolkit.js.org/rtk-query/overview) @@ -67,235 +29,32 @@ Or, even... Similar to data fetching, mutation state isn't a one-size-fits-all solution, so you'll need to pick a solution that fits your needs and your team's needs. We recommend trying out a few different solutions and seeing what works best for you. -## TanStack Actions - -Just like a fresh Zelda game, we would never send you into the wild without a sword _(fine... BotW and TotK bend this rule to varying degrees, but since they're the greatest games ever created, we'll let the lore slide a bit)_. - -We've created an extremely lightweight, framework agnostic action/mutation library called TanStack Actions that works really well with Router. It's a great place to start if you're not already using one of the more complex (but more powerful) tools above. - -## What are data mutations? - -From the context of routing, data mutations are usually related to **server state** or state that comes from an external, asynchronous source and is necessary to fetch before rendering some content. Data loading itself is covered in the [Data Loading](../data-loading) guide. This guide is about data mutations, or the process of triggering changes to that external state and displaying it's progress and effects to the user. - -## Simple Data Mutations with TanStack Actions - -Let's write a data mutation that will update a post on a server. We'll use TanStack Actions to manage this mutation. - -```tsx -import { Action } from '@tanstack/actions' - -const updatePostAction = new Action({ - key: 'updatePost', - fn: async (post: Post) => { - const response = await fetch(`/api/posts/${post.id}`, { - method: 'PATCH', - body: JSON.stringify(post), - }) - - if (!response.ok) { - throw new Error('Failed to update post') - } - - return response.json() - }, -}) - -const actionClient = new ActionClient() -``` - -Now that we have our action, we can use it in our component. We'll use the `useAction` hook from `@tanstack/react-actions` to subscribe to the action state and use the action in our component. - -```tsx -import { useAction } from '@tanstack/react-actions' - -function App() { - return ( - - - - ) -} - -function PostEditor({ post }: { post: Post }) { - const [postDraft, setPostDraft] = useState(() => post) - const [updatePostAction, updatePost] = useAction({ key: 'updatePost' }) - const latestPostSubmission = updatePostAction.state.latestSubmission - - return ( -
- setPostDraft({ ...postDraft, title: e.target.value })} - /> - -
- ) -} -``` - -## Data Invalidation - -So how does my data loader get the updated data? **Invalidation**. When you mutate data on the server, your data loading library needs to know that it might need to refetch some data. Depending on your data loading library and mutation library, this song and dance may differ, but we'll show you what it looks like if you're using the built-in loaders from TanStack Router. - -```tsx -import { Action } from '@tanstack/actions' - -const updatePostAction = new Action({ - key: 'updatePost', - fn: async (post: Post) => { - //... - }, - onEachSuccess: () => { - // Invalidate the router's matches - router.invalidate() - }, -}) -``` - -## Invalidating specific data - -For this example, let's assume we're using TanStack Actions here where it's possible to use the action submission state to invalidate specific data. Let's update our action to invalidate a specific post loader instance using the loader's `invalidateInstance` method. - -```tsx -import { Action } from '@tanstack/actions' - -const updatePostAction = new Action({ - key: 'updatePost', - fn: async (post: Post) => { - //... - }, - onEachSuccess: (submission) => { - // Use the submission payload to invalidate the specific post - const post = submission.payload - loaderClient.invalidateInstance({ key: 'post', variables: post.id }) - }, -}) -``` - -## Invalidating entire data sets +> ⚠️ Still here? Submission state is an interesting topic when it comes to persistence. Do you keep every mutation around forever? How do you know when to get rid of it? What if the user navigates away from the screen and then back? Let's dig in! -It's very common to invalidate an entire subset of data based on hierarchy when some subset of that data changes e.g. **This is the default functionality for `router.invalidate()`**: +## Mutation management can be augmented by router events -```tsx -import { Action } from '@tanstack/actions' - -const updatePostAction = new Action({ - key: 'updatePost', - fn: async (post: Post) => { - //... - }, - onEachSuccess: (submission) => { - // Invalidate everything - router.invalidate() - }, -}) -``` - -If you're using an external library like TanStack Loaders, you're method might be different. Here, we'll use TanStack Loaders `invalidateLoader` method to invalidate all posts when a single post is edited. - -```tsx -import { Action } from '@tanstack/actions' - -const updatePostAction = new Action({ - key: 'updatePost', - fn: async (post: Post) => { - //... - }, - onEachSuccess: (submission) => { - loaderClient.invalidateLoader({ key: 'post' }) - }, -}) -``` - -## Displaying success/error states - -When mutations are in flight, successful, or failed, it's important to display that information to the user. TanStack Actions makes this easy with the `latestSubmission` property on the action state. This property will always contain the latest submission state for the action. We can use this to display a loading indicator, success message, or error message. - -```tsx -import { useAction } from '@tanstack/react-actions' - -function PostEditor({ post }: { post: Post }) { - const [postDraft, setPostDraft] = useState(() => post) - const updatePost = useAction({ action: updatePostAction }) - - // Get the latest submission - const latestPostSubmission = updatePost.state.latestSubmission - - return ( -
- setPostDraft({ ...postDraft, title: e.target.value })} - /> - - {/* Show an error message if necessary */} - {latestPostSubmission.state.status === 'error' && ( -
{latestPostSubmission.state.error.message}
- )} - {/* Show a success message */} - {latestPostSubmission.state.status === 'success' && ( -
Post updated successfully!
- )} -
- ) -} -``` - -> ⚠️ Submission state is an interesting topic when it comes to persistence. Do you keep every mutation around forever? How do you know when to get rid of it? What if the user navigates away from the screen and then back? Let's dig in! - -## Action/mutations can be augmented by router events - -When actions are fired, regardless of the mutation library managing them, they create state related to the action submission. Most state managers will correctly keep this submission state around and expose it to make it possible to show UI elements like loading spinners, success messages, error messages, etc. Let's consider the following interactions: +Regardless of the mutation library used, mutations create state related to their submission. Most state managers will correctly keep this submission state around and expose it to make it possible to show UI elements like loading spinners, success messages, error messages, etc. Let's consider the following interactions: - User navigates to the `/posts/123/edit` screen to edit a post - User edits the `123` post and upon success, sees a success message below the editor that the post was updated - User navigates to the `/posts` screen - User navigates back to the `/posts/123/edit` screen again -Without notifying your mutation management library about the route change, it's likely your submission state will still be around and your user would still see the **"Post updated successfully"** message when they return to the previous screen. This is not ideal. Obviously, our intent wasn't to keep this mutation state around forever, right?! +Without notifying your mutation management library about the route change, it's possible that your submission state could still be around and your user would still see the **"Post updated successfully"** message when they return to the previous screen. This is not ideal. Obviously, our intent wasn't to keep this mutation state around forever, right?! -To solve this, we can use TanStack Router's `subscribe` method to clear your action states when the user is no longer in need of them. +To solve this, we can use TanStack Router's `subscribe` method to clear mutation states when the user is no longer in need of them. ## The `router.subscribe` method This method is a function that subscribes a callback to various router events. The event in particular that we'll use here is the `locationChange` event. It's important to understand that this event is fired when the location path is _changed (not just reloaded) and has finally resolved_. -This is a great place to reset your old mutation/actions states. We'll use TanStack Actions to demonstrate how to do this. +This is a great place to reset your old mutation states. Here's an example: ```tsx -const updatePostAction = new Action({ - key: 'updatePost', - fn: async (post: Post) => { - //... - }, - onEachSuccess: (submission) => { - loaderClient.invalidateLoader({ key: 'posts' }) - }, -}) - -const actionClient = new ActionClient({ - actions: [updatePostAction], -}) - const router = new Router() const unsubscribeFn = router.subscribe('onLoad', () => { - // Reset the action state when the route changes - actionClient.clearAll() + // Reset mutation states when the route changes + clearMutationCache() }) ``` - -This will clear all non-pending submissions from history for all actions on the client. You can also use the `actionClient.clearAction()` method to clear the submissions for a specific action. - -## Learn more about TanStack Loaders/Actions! - -There's plenty more to learn about TanStack Loaders (and Actions!). If you plan on using them with TanStack Router, it's highly recommended that you read through their documentation: - -- [TanStack Loaders](https://tanstack.com/loaders) -- [TanStack Actions](https://tanstack.com/actions) diff --git a/docs/guide/deferred-data-loading.md b/docs/guide/deferred-data-loading.md index 1901ee325a..2b660ac95e 100644 --- a/docs/guide/deferred-data-loading.md +++ b/docs/guide/deferred-data-loading.md @@ -17,7 +17,7 @@ import { defer } from '@tanstack/react-router' export const postIdRoute = new Route('post', { // ... - loader: () => { + load: () => { // Fetch some slower data, but do not await it const slowDataPromise = fetchSlowData() diff --git a/docs/guide/external-data-loading.md b/docs/guide/external-data-loading.md index 535dbeabd0..ee66d235ad 100644 --- a/docs/guide/external-data-loading.md +++ b/docs/guide/external-data-loading.md @@ -50,44 +50,25 @@ The easiest way to use integrate and external caching/data library into Router i > - No waterfall data fetching, caused by component based fetching > - Better for SEO. If you data is available at render time, it will be indexed by search engines. -Here is a simple example of using a Route `loader` to seed the cache for TanStack Loaders: +Here is a simple example of using a Route's `load` option to seed the cache for some data: ```tsx import { Route } from '@tanstack/react-router' -import { Loader, useLoader } from '@tanstack/react-loaders' - -// Create a new loader -const postsLoader = new Loader({ - key: 'posts', - fn: async (params) => { - const res = await fetch(`/api/posts`) - if (!res.ok) throw new Error('Failed to fetch posts') - return res.json() - }, -}) - -// Create a new loader client -const loaderClient = new LoaderClient({ - loaders: [postsLoader], -}) const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', - loader: async () => { - // Ensure our loader is loaded with an "await" - await loaderClient.load({ key: 'posts' }) + load: async () => { + await fetchPosts() }, - component: ({ useLoader }) => { - const { data: posts } = useLoaderInstance({ key: 'posts' }) + component: () => { + const posts = usePosts() return
...
}, }) ``` -> 🧠 TanStack Loaders uses the `preload` flag to determine cache freshness vs non-preload calls and also to determine if the global `isLoading` or `isPrefetching` flags should be incremented or not. - ## SSR Dehydration/Hydration Tools that are able can integrate with TanStack Router's convenient Dehydration/Hydration APIs to shuttle dehydrated data between the server and client and rehydrate it where needed. Let's go over how to do this with both 3rd party critical data and 3rd party deferred data. @@ -98,7 +79,7 @@ Tools that are able can integrate with TanStack Router's convenient Dehydration/ The `dehydrate` function can return any serializable JSON data which will get merged and injected into the dehydrated payload that is sent to the client. This payload is delivered via the `DehydrateRouter` component which, when rendered, provides the data back to you in the `hydrate` function on the client. -For example, let's dehydrate and hydrate a `LoaderClient` instance from `@tanstack/react-loaders` so that our loader data we fetched on the server in our router loaders will be available for hydration on the client. +For example, let's dehydrate and hydrate a TanStack Query `QueryClient` so that our data we fetched on the server will be available for hydration on the client. ```tsx // src/router.tsx @@ -108,7 +89,7 @@ export function createRouter() { // stores inside of your `createRouter` function. This ensures // that your data stores are unique to each request and // always present on both server and client. - const loaderClient = createLoaderClient() + const queryClient = new QueryClient() return new Router({ routeTree, @@ -116,26 +97,26 @@ export function createRouter() { // convenience (you can provide anything you want to the router // context!) context: { - loaderClient, + queryClient, }, // On the server, dehydrate the loader client and return it // to the router to get injected into `` dehydrate: () => { return { - loaderClient: loaderClient.dehydrate(), + queryClientState: dehydrate(queryClient), } }, // On the client, hydrate the loader client with the data // we dehydrated on the server hydrate: (dehydrated) => { - loaderClient.hydrate(dehydrated.loaderClient) + hydrate(client, dehydrated.queryClientState) }, // Optionally, we can use `Wrap` to wrap our router in the loader client provider Wrap: ({ children }) => { return ( - + {children} - + ) }, }) @@ -217,7 +198,7 @@ function Test() { ### Providing Dehydration/Hydration utilities to external tools -The `router.dehydrateData` and `router.hydrateData` functions are designed to be used by external tools to dehydrate and hydrate data. For example, the `@tanstack/react-loaders` package can use generic `dehydrate`/`hydrate` options to dehydrate and hydrate each loader as it is fetched on the server and rendered on the client: +The `router.dehydrateData` and `router.hydrateData` functions are designed to be used by external tools to dehydrate and hydrate data. For example, any separate NPM package can use the generic `dehydrateData` and `hydrateData` methods on the router to dehydrate and hydrate each route as it is fetched on the server and rendered on the client: ```tsx // src/router.tsx diff --git a/docs/guide/preloading.md b/docs/guide/preloading.md index ef47229abe..4586e86f29 100644 --- a/docs/guide/preloading.md +++ b/docs/guide/preloading.md @@ -47,7 +47,7 @@ const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', component: PostsComponent, - loader: async ({ preload }) => { + load: async ({ preload }) => { await loaderClient.load({ key: 'posts', preload }) }, }) diff --git a/docs/guide/router-context.md b/docs/guide/router-context.md index 6d1cec812b..f073cedc40 100644 --- a/docs/guide/router-context.md +++ b/docs/guide/router-context.md @@ -71,7 +71,7 @@ const userRoute = new Route({ getRootRoute: () => rootRoute, path: 'todos', component: Todos, - loader: ({ context }) => fetchTodosByUserId(context.user.id), + load: ({ context }) => fetchTodosByUserId(context.user.id), }) ``` @@ -106,7 +106,7 @@ const userRoute = new Route({ getRootRoute: () => rootRoute, path: 'todos', component: Todos, - loader: ({ context }) => context.fetchTodosByUserId(context.userId), + load: ({ context }) => context.fetchTodosByUserId(context.userId), }) ``` @@ -142,7 +142,7 @@ const userRoute = new Route({ getRootRoute: () => rootRoute, path: 'todos', component: Todos, - loader: ({ context }) => { + load: ({ context }) => { await context.queryClient.ensureQueryData({ queryKey: ['todos', { userId: user.id }], queryFn: fetchTodos, @@ -182,7 +182,7 @@ const userRoute = new Route({ bar: true, } } - loader: ({ context }) => { + load: ({ context }) => { context.foo // true context.bar // true }, @@ -209,7 +209,7 @@ export const postIdRoute = new Route({ getTitle: () => `${queryClient.getQueryData(queryOptions)?.title} | Post`, } }, - loader: async ({ preload, context: { queryClient, queryOptions } }) => { + load: async ({ preload, context: { queryClient, queryOptions } }) => { await queryClient.ensureQueryData(queryOptions) }, component: ({ useRouteContext }) => { diff --git a/docs/guide/search-params.md b/docs/guide/search-params.md index 87544eb341..b999b437da 100644 --- a/docs/guide/search-params.md +++ b/docs/guide/search-params.md @@ -187,7 +187,7 @@ const allProductsRoute = new Route({ getParentRoute: () => shopRoute, path: 'products', validateSearch: productSearchSchema, - loader: ({ search }) => { + load: ({ search }) => { search // ^? ProductSearch ✅ }, @@ -214,7 +214,7 @@ const allProductsRoute = new Route({ const productRoute = new Route({ getParentRoute: () => allProductsRoute, path: ':productId', - loader: ({ search }) => { + load: ({ search }) => { search // ^? ProductSearch ✅ }, diff --git a/docs/overview.md b/docs/overview.md index d640bacf33..a93674bf1f 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -7,18 +7,16 @@ TanStack Router is a router for building web applications using your favorite mo - 100% inferred TypeScript support - Typesafe absolute and relative navigation - Nested Routing and layout routes -- Integrated route loading APIs (data, assets, suspense) -- Built-in stale-while-revalidate loader caching -- Designed to support external caches (TanStack Loaders, TanStack Query, SWR, etc.) +- Route Load Suspense Transitions +- Designed to support data caches (TanStack Query, SWR, etc.) - Automatic route prefetching -- Suspense-like route transitions - Asynchronous route elements and error boundaries - Typesafe JSON-first Search Params state management APIs - Path and Search Parameter Schema Validation - Search Param Navigation APIs - Custom Search Param parser/serializer support - Search param middleware -- Route matching middleware +- Route matching/loading middleware ## Acknowledgements diff --git a/docs/quick-start.md b/docs/quick-start.md index e15ec98e34..d5662211bb 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -75,11 +75,7 @@ declare module '@tanstack/react-router' { const rootElement = document.getElementById('app')! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) - root.render( - - - , - ) + root.render() } ``` diff --git a/examples/react/basic/package.json b/examples/react/basic/package.json index c25e0c5394..cb673f66c3 100644 --- a/examples/react/basic/package.json +++ b/examples/react/basic/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/deferred-data/package.json b/examples/react/deferred-data/package.json index 776e9af405..b5720d8ec5 100644 --- a/examples/react/deferred-data/package.json +++ b/examples/react/deferred-data/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/deferred-data/src/main.tsx b/examples/react/deferred-data/src/main.tsx index f79204f3e5..be74f22c74 100644 --- a/examples/react/deferred-data/src/main.tsx +++ b/examples/react/deferred-data/src/main.tsx @@ -155,7 +155,7 @@ const postRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', key: false, - loader: async ({ params: { postId } }) => fetchPost(postId), + load: async ({ params: { postId } }) => fetchPost(postId), errorComponent: ({ error }) => { if (error instanceof NotFoundError) { return
{error.message}
diff --git a/examples/react/file-based-routes/package.json b/examples/react/file-based-routes/package.json index 6bb5df4bb3..909fc83bac 100644 --- a/examples/react/file-based-routes/package.json +++ b/examples/react/file-based-routes/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "concurrently \"tsr watch\" \"vite --port=3000\"", + "dev": "concurrently \"tsr watch\" \"vite --port=3001\"", "build": "tsr generate && vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/file-based-routes/src/routes/posts.$postId.tsx b/examples/react/file-based-routes/src/routes/posts.$postId.tsx index 553e05c5e9..3a747660f5 100644 --- a/examples/react/file-based-routes/src/routes/posts.$postId.tsx +++ b/examples/react/file-based-routes/src/routes/posts.$postId.tsx @@ -32,7 +32,7 @@ export const fetchPost = async (postId: string) => { // 'posts/$postId' is automatically inserted and managed // by the `tsr generate/watch` CLI command export const route = new FileRoute('/posts/$postId').createRoute({ - loader: async ({ params: { postId } }) => fetchPost(postId), + load: async ({ params: { postId } }) => fetchPost(postId), errorComponent: PostErrorComponent as any, component: PostComponent, }) diff --git a/examples/react/file-based-routes/src/routes/posts_.$postId.deep.tsx b/examples/react/file-based-routes/src/routes/posts_.$postId.deep.tsx index 9530987dcc..ad9faa4113 100644 --- a/examples/react/file-based-routes/src/routes/posts_.$postId.deep.tsx +++ b/examples/react/file-based-routes/src/routes/posts_.$postId.deep.tsx @@ -11,7 +11,7 @@ export type PostType = { // 'posts/$postId' is automatically inserted and managed // by the `tsr generate/watch` CLI command export const route = new FileRoute('/posts_/$postId/deep').createRoute({ - loader: async ({ params: { postId } }) => fetchPost(postId), + load: async ({ params: { postId } }) => fetchPost(postId), errorComponent: PostErrorComponent as any, component: () => { const post = route.useLoader() diff --git a/examples/react/focus-refetching/package.json b/examples/react/focus-refetching/package.json index 5cbbb22553..aa0bd31f47 100644 --- a/examples/react/focus-refetching/package.json +++ b/examples/react/focus-refetching/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/focus-refetching/src/main.tsx b/examples/react/focus-refetching/src/main.tsx index 3b8b321ce5..6ad786bbcc 100644 --- a/examples/react/focus-refetching/src/main.tsx +++ b/examples/react/focus-refetching/src/main.tsx @@ -154,7 +154,7 @@ const postRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', key: false, - loader: async ({ params: { postId } }) => fetchPost(postId), + load: async ({ params: { postId } }) => fetchPost(postId), errorComponent: ({ error }) => { if (error instanceof NotFoundError) { return
{error.message}
diff --git a/examples/react/kitchen-sink-single-file/package.json b/examples/react/kitchen-sink-single-file/package.json index afbf0a970e..64db78055d 100644 --- a/examples/react/kitchen-sink-single-file/package.json +++ b/examples/react/kitchen-sink-single-file/package.json @@ -3,24 +3,25 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" }, "dependencies": { "@tanstack/actions": "0.0.1-beta.203", - "@tanstack/history": "0.0.1-beta.209", + "@tanstack/history": "0.0.1-beta.208", "@tanstack/loaders": "0.0.1-beta.203", "@tanstack/react-actions": "0.0.1-beta.203", "@tanstack/react-loaders": "0.0.1-beta.203", "@tanstack/react-query": "^5.7.0", - "@tanstack/react-router": "0.0.1-beta.209", + "@tanstack/react-query-devtools": "^5.8.3", + "@tanstack/react-router": "0.0.1-beta.208", "@tanstack/react-search-state": "0.0.1-beta.203", "@tanstack/react-start": "0.0.1-beta.203", "@tanstack/react-store": "^0.0.1", - "@tanstack/router-cli": "0.0.1-beta.209", - "@tanstack/router-devtools": "0.0.1-beta.209", + "@tanstack/router-cli": "0.0.1-beta.208", + "@tanstack/router-devtools": "0.0.1-beta.208", "@tanstack/store": "^0.0.1", "@vitejs/plugin-react": "^1.1.3", "axios": "^1.1.3", diff --git a/examples/react/kitchen-sink-single-file/src/main.tsx b/examples/react/kitchen-sink-single-file/src/main.tsx index d2833ec636..e3b957ffed 100644 --- a/examples/react/kitchen-sink-single-file/src/main.tsx +++ b/examples/react/kitchen-sink-single-file/src/main.tsx @@ -5,25 +5,25 @@ import { RouterProvider, lazyRouteComponent, Link, - MatchRoute, useNavigate, useSearch, Router, Route, redirect, - RouterContext, ErrorComponent, - AnyRouter, - useRouterState, rootRouteWithContext, + useRouter, + MatchRoute, } from '@tanstack/react-router' -import { - ActionClientProvider, - ActionContext, - useAction, -} from '@tanstack/react-actions' import { TanStackRouterDevtools } from '@tanstack/router-devtools' - +import { + QueryClient, + QueryClientProvider, + queryOptions, + useMutation, + useSuspenseQuery, +} from '@tanstack/react-query' +import { ReactQueryDevtools } from '@tanstack/react-query-devtools' import { fetchInvoices, fetchInvoiceById, @@ -32,7 +32,6 @@ import { Invoice, postInvoice, patchInvoice, - fetchRandomNumber, } from './mockTodos' import { z } from 'zod' @@ -41,38 +40,43 @@ import { z } from 'zod' type UsersViewSortBy = 'name' | 'id' | 'email' -// Actions +const invoicesQueryOptions = () => + queryOptions({ + queryKey: ['invoices'], + queryFn: () => fetchInvoices(), + }) -const actionContext = new ActionContext<{ - router: AnyRouter -}>() +const invoiceQueryOptions = (invoiceId: number) => + queryOptions({ + queryKey: ['invoices', invoiceId], + queryFn: () => fetchInvoiceById(invoiceId), + }) -const createInvoiceAction = actionContext.createAction({ - key: 'createInvoice', - fn: postInvoice, - onEachSuccess: async ({ context: { router } }) => { - await router.invalidate() - }, -}) +const usersQueryOptions = () => + queryOptions({ + queryKey: ['users'], + queryFn: () => fetchUsers(), + }) -const updateInvoiceAction = actionContext.createAction({ - key: 'updateInvoice', - fn: patchInvoice, - onEachSuccess: async ({ context: { router } }) => { - await router.invalidate() - }, -}) +const userQueryOptions = (userId: number) => + queryOptions({ + queryKey: ['users', userId], + queryFn: () => fetchUserById(userId), + }) -const actionClient = actionContext.createClient({ - actions: [createInvoiceAction, updateInvoiceAction], - context: { router: undefined! }, -}) +const useCreateInvoiceMutation = () => { + return useMutation({ + // mutationKey: ['invoices', 'create'], + mutationFn: postInvoice, + }) +} -// Register things for typesafety -declare module '@tanstack/react-actions' { - interface Register { - actionClient: typeof actionClient - } +const useUpdateInvoiceMutation = () => { + return useMutation({ + // mutationKey: ['invoices', 'update'], + mutationFn: patchInvoice, + gcTime: 0, + }) } // Routes @@ -80,10 +84,10 @@ declare module '@tanstack/react-actions' { // Build our routes. We could do this in our component, too. const rootRoute = rootRouteWithContext<{ auth: Auth - actionClient: typeof actionClient + queryClient: QueryClient }>()({ component: () => { - const isFetching = useRouterState({ select: (s) => s.isFetching }) + const { state } = useRouter() return ( <> @@ -92,7 +96,7 @@ const rootRoute = rootRouteWithContext<{

Kitchen Sink

{/* Show a global spinner when the router is transitioning */}
- +
@@ -137,6 +141,7 @@ const rootRoute = rootRouteWithContext<{
+ ) }, @@ -218,9 +223,11 @@ const dashboardRoute = new Route({ const dashboardIndexRoute = new Route({ getParentRoute: () => dashboardRoute, path: '/', - loader: fetchInvoices, - component: ({ useLoader }) => { - const invoices = useLoader() + load: (opts) => + opts.context.queryClient.ensureQueryData(invoicesQueryOptions()), + component: () => { + const invoicesQuery = useSuspenseQuery(invoicesQueryOptions()) + const invoices = invoicesQuery.data return (
@@ -236,32 +243,29 @@ const dashboardIndexRoute = new Route({ const invoicesRoute = new Route({ getParentRoute: () => dashboardRoute, path: 'invoices', - loader: fetchInvoices, - component: ({ useLoader }) => { - const invoices = useLoader() - - const [{ pendingSubmissions: updateSubmissions }] = useAction({ - key: 'updateInvoice', - }) - - const [{ pendingSubmissions: createSubmissions }] = useAction({ - key: 'createInvoice', - }) + load: (opts) => + opts.context.queryClient.ensureQueryData(invoicesQueryOptions()), + component: () => { + const invoicesQuery = useSuspenseQuery(invoicesQueryOptions()) + const invoices = invoicesQuery.data + const updateInvoiceMutation = useUpdateInvoiceMutation() + const createInvoiceMutation = useCreateInvoiceMutation() return (
+ {/* {routerTransitionIsPending ? 'pending' : 'null'} */}
{invoices?.map((invoice) => { - const updateSubmission = updateSubmissions.find( - (d) => d.variables?.id === invoice.id, - ) + // const updateSubmission = createInvoiceMutation.find( + // (d) => d.variables?.id === invoice.id, + // ) - if (updateSubmission) { - invoice = { - ...invoice, - ...updateSubmission.variables, - } - } + // if (updateSubmission) { + // invoice = { + // ...invoice, + // ...updateSubmission.variables, + // } + // } return (
@@ -276,25 +280,25 @@ const invoicesRoute = new Route({ >
                     #{invoice.id} - {invoice.title.slice(0, 10)}{' '}
-                    {updateSubmission ? (
+                    {/* {updateSubmission ? (
                       
-                    ) : (
-                      
-                        {(match) => }
-                      
-                    )}
+                    ) : ( */}
+                    
+                      {(match) => }
+                    
+                    {/* )} */}
                   
) })} - {createSubmissions.map((action) => ( + {/* {createSubmissions.map((action) => ( - ))} + ))} */}
@@ -316,9 +320,7 @@ const invoicesIndexRoute = new Route({ getParentRoute: () => invoicesRoute, path: '/', component: () => { - const [{ latestSubmission }, submitCreateInvoice] = useAction({ - key: 'createInvoice', - }) + const createInvoiceMutation = useCreateInvoiceMutation() return ( <> @@ -328,11 +330,9 @@ const invoicesIndexRoute = new Route({ event.preventDefault() event.stopPropagation() const formData = new FormData(event.target as HTMLFormElement) - submitCreateInvoice({ - variables: { - title: formData.get('title') as string, - body: formData.get('body') as string, - }, + createInvoiceMutation.mutate({ + title: formData.get('title') as string, + body: formData.get('body') as string, }) }} className="space-y-2" @@ -347,11 +347,11 @@ const invoicesIndexRoute = new Route({ Create
- {latestSubmission?.status === 'success' ? ( + {createInvoiceMutation?.status === 'success' ? (
Created!
- ) : latestSubmission?.status === 'error' ? ( + ) : createInvoiceMutation?.status === 'error' ? (
Failed to create.
@@ -377,14 +377,17 @@ const invoiceRoute = new Route({ notes: z.string().optional(), }) .parse(search), - loader: async ({ params: { invoiceId } }) => fetchInvoiceById(invoiceId), - component: ({ useLoader, useSearch }) => { + load: (opts) => + opts.context.queryClient.ensureQueryData( + invoiceQueryOptions(opts.params.invoiceId), + ), + component: ({ useSearch, useParams }) => { + const params = useParams() const search = useSearch() const navigate = useNavigate() - const invoice = useLoader() - const [{ latestSubmission }, submitUpdateInvoice] = useAction({ - key: 'updateInvoice', - }) + const invoiceQuery = useSuspenseQuery(invoiceQueryOptions(params.invoiceId)) + const invoice = invoiceQuery.data + const updateInvoiceMutation = useUpdateInvoiceMutation() const [notes, setNotes] = React.useState(search.notes ?? '') React.useEffect(() => { @@ -404,19 +407,17 @@ const invoiceRoute = new Route({ event.preventDefault() event.stopPropagation() const formData = new FormData(event.target as HTMLFormElement) - submitUpdateInvoice({ - variables: { - id: invoice.id, - title: formData.get('title') as string, - body: formData.get('body') as string, - }, + updateInvoiceMutation.mutate({ + id: invoice.id, + title: formData.get('title') as string, + body: formData.get('body') as string, }) }} className="p-2 space-y-2" >
- {latestSubmission?.variables.id === invoice.id ? ( -
- {latestSubmission?.status === 'success' ? ( + {updateInvoiceMutation?.variables?.id === invoice.id ? ( +
+ {updateInvoiceMutation?.status === 'success' ? (
Saved!
- ) : latestSubmission?.status === 'error' ? ( + ) : updateInvoiceMutation?.status === 'error' ? (
Failed to save.
@@ -496,11 +497,12 @@ const usersRoute = new Route({ }, }), ], - loader: fetchUsers, - component: ({ useSearch, useLoader }) => { + load: (opts) => opts.context.queryClient.ensureQueryData(usersQueryOptions()), + component: ({ useSearch }) => { const navigate = useNavigate() const { usersView } = useSearch() - const users = useLoader() + const usersQuery = useSuspenseQuery(usersQueryOptions()) + const users = usersQuery.data const sortBy = usersView?.sortBy ?? 'name' const filterBy = usersView?.filterBy @@ -603,7 +605,7 @@ const usersRoute = new Route({ })} pending > - + {(match) => } @@ -654,12 +656,14 @@ const userRoute = new Route({ 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 - loaderContext: ({ search: { userId } }) => ({ userId }), - loader: async ({ context: { userId } }) => fetchUserById(userId), - component: ({ useLoader }) => { - const user = useLoader() + load: (opts) => + opts.context.queryClient.ensureQueryData( + userQueryOptions(opts.search.userId), + ), + component: ({ useSearch }) => { + const search = useSearch() + const userQuery = useSuspenseQuery(userQueryOptions(search.userId)) + const user = userQuery.data return ( <> @@ -684,7 +688,7 @@ const authRoute = new Route({ id: 'auth', // Before loading, authenticate the user via our auth context // This will also happen during prefetching (e.g. hovering over links, etc) - beforeLoad: ({ context }) => { + beforeLoad: ({ context, location }) => { // If the user is logged out, redirect them to the login page if (context.auth.status === 'loggedOut') { throw redirect({ @@ -693,7 +697,7 @@ const authRoute = new Route({ // Use the current location to power a redirect after login // (Do not use `router.state.resolvedLocation` as it can // potentially lag behind the actual current location) - redirect: router.state.location.href, + redirect: location.href, }, }) } @@ -708,21 +712,13 @@ const authRoute = new Route({ const profileRoute = new Route({ getParentRoute: () => authRoute, path: 'profile', - // Before loading, authenticate the user via our auth context - // This will also happen during prefetching (e.g. hovering over links, etc) - loader: async ({ context }) => { - await new Promise((r) => setTimeout(r, 1000)) - return `Hello ${context.auth.username}!` - }, - component: ({ useLoader, useRouteContext }) => { + component: ({ useRouteContext }) => { const { username } = useRouteContext() - const message = useLoader() return (
-
{message}
- Your username is {username} + Username:{username}
) @@ -736,6 +732,7 @@ const loginRoute = new Route({ redirect: z.string().optional(), }), component: ({ useRouteContext }) => { + const router = useRouter() const { auth } = useRouteContext() const search = useSearch({ from: loginRoute.id }) const [username, setUsername] = React.useState('') @@ -743,7 +740,7 @@ const loginRoute = new Route({ const onSubmit = (e: React.FormEvent) => { e.preventDefault() auth.login(username) - router.invalidate() + router.load() } // Ah, the subtle nuances of client side auth. 🙄 @@ -791,14 +788,10 @@ const loginRoute = new Route({ const layoutRoute = new Route({ getParentRoute: () => rootRoute, id: 'layout', - loader: fetchRandomNumber, - component: ({ useLoader }) => { - const data = useLoader() - + component: () => { return (
Layout
-
Random #: {data}

@@ -843,6 +836,8 @@ const routeTree = rootRoute.addChildren([ layoutRoute.addChildren([layoutARoute, layoutBRoute]), ]) +const queryClient = new QueryClient() + const router = new Router({ routeTree, defaultPendingComponent: () => ( @@ -852,17 +847,15 @@ const router = new Router({ ), defaultErrorComponent: ({ error }) => , context: { - actionClient, auth: undefined!, // We'll inject this when we render + queryClient, }, }) router.subscribe('onLoad', () => { - actionClient.clearAll() + queryClient.getMutationCache().clear() }) -actionClient.options.context.router = router - declare module '@tanstack/react-router' { interface Register { router: typeof router @@ -937,7 +930,7 @@ function App() { />
- + - + ) } @@ -995,7 +988,7 @@ function Spinner({ show, wait }: { show?: boolean; wait?: `delay-${number}` }) { className={`inline-block animate-spin px-3 transition ${ show ?? true ? `opacity-1 duration-500 ${wait ?? 'delay-300'}` - : 'duration-1000 opacity-0 delay-0' + : 'duration-500 opacity-0 delay-0' }`} > ⍥ @@ -1019,5 +1012,9 @@ function useSessionStorage(key: string, initialValue: T) { const rootElement = document.getElementById('app')! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) - root.render() + root.render( + + + , + ) } diff --git a/examples/react/kitchen-sink-single-file/src/mockTodos.ts b/examples/react/kitchen-sink-single-file/src/mockTodos.ts index a12c67b1dd..a90c25eaa7 100644 --- a/examples/react/kitchen-sink-single-file/src/mockTodos.ts +++ b/examples/react/kitchen-sink-single-file/src/mockTodos.ts @@ -19,6 +19,7 @@ export interface User { website: string company: Company } + export interface Address { street: string suite: string diff --git a/examples/react/location-masking/package.json b/examples/react/location-masking/package.json index b3a2b79b60..27a76e6848 100644 --- a/examples/react/location-masking/package.json +++ b/examples/react/location-masking/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/location-masking/src/main.tsx b/examples/react/location-masking/src/main.tsx index 0d93002a6d..e3fa3d535c 100644 --- a/examples/react/location-masking/src/main.tsx +++ b/examples/react/location-masking/src/main.tsx @@ -181,7 +181,7 @@ const photosRoute = new Route({ const photoRoute = new Route({ getParentRoute: () => rootRoute, path: 'photos/$photoId', - loader: async ({ params: { photoId } }) => fetchPhoto(photoId), + load: async ({ params: { photoId } }) => fetchPhoto(photoId), errorComponent: ({ error }) => { return (
@@ -208,7 +208,7 @@ const photoRoute = new Route({ const photoModalRoute = new Route({ getParentRoute: () => photosRoute, path: '$photoId/modal', - loader: async ({ params: { photoId } }) => fetchPhoto(photoId), + load: async ({ params: { photoId } }) => fetchPhoto(photoId), errorComponent: ({ error }) => { const navigate = useNavigate() diff --git a/examples/react/quickstart/package.json b/examples/react/quickstart/package.json index c6b8356889..101d06a5bb 100644 --- a/examples/react/quickstart/package.json +++ b/examples/react/quickstart/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/react-search-state/package.json b/examples/react/react-search-state/package.json index 7f20696122..793abab828 100644 --- a/examples/react/react-search-state/package.json +++ b/examples/react/react-search-state/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/scroll-restoration/package.json b/examples/react/scroll-restoration/package.json index 13d5a3be2a..c1e0335d4a 100644 --- a/examples/react/scroll-restoration/package.json +++ b/examples/react/scroll-restoration/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/scroll-restoration/src/main.tsx b/examples/react/scroll-restoration/src/main.tsx index 11421a5f8d..a8dfed9a7d 100644 --- a/examples/react/scroll-restoration/src/main.tsx +++ b/examples/react/scroll-restoration/src/main.tsx @@ -38,7 +38,7 @@ const rootRoute = new RootRoute({ const indexRoute = new Route({ getParentRoute: () => rootRoute, path: '/', - loader: () => new Promise((r) => setTimeout(r, 500)), + load: () => new Promise((r) => setTimeout(r, 500)), component: function Index() { return (
@@ -61,7 +61,7 @@ const indexRoute = new Route({ const aboutRoute = new Route({ getParentRoute: () => rootRoute, path: '/about', - loader: () => new Promise((r) => setTimeout(r, 500)), + load: () => new Promise((r) => setTimeout(r, 500)), component: function About() { return (
@@ -84,7 +84,7 @@ const aboutRoute = new Route({ const byElementRoute = new Route({ getParentRoute: () => rootRoute, path: '/by-element', - loader: () => new Promise((r) => setTimeout(r, 500)), + load: () => new Promise((r) => setTimeout(r, 500)), component: function About() { return (
diff --git a/examples/react/ssr-streaming/src/routes/posts/$postId.tsx b/examples/react/ssr-streaming/src/routes/posts/$postId.tsx index fea5ee703e..872017be3c 100644 --- a/examples/react/ssr-streaming/src/routes/posts/$postId.tsx +++ b/examples/react/ssr-streaming/src/routes/posts/$postId.tsx @@ -23,7 +23,7 @@ async function fetchComments(postId: string) { export const postIdRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', - loader: async ({ params: { postId } }) => { + load: async ({ params: { postId } }) => { const commentsPromise = fetchComments(postId) const post = await fetchPostById(postId) diff --git a/examples/react/wip-trpc-react-query/client/main.tsx b/examples/react/wip-trpc-react-query/client/main.tsx index c72f8c1b88..098aa2321f 100644 --- a/examples/react/wip-trpc-react-query/client/main.tsx +++ b/examples/react/wip-trpc-react-query/client/main.tsx @@ -81,7 +81,7 @@ const rootRoute = rootRouteWithContext<{ const indexRoute = new Route({ getParentRoute: () => rootRoute, path: '/', - loader: () => { + load: () => { // TODO: Prefetch hello using TRPC }, component: () => { @@ -95,7 +95,7 @@ const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', errorComponent: () => 'Oh crap!', - loader: async () => { + load: async () => { // TODO: Prefetch posts using TRPC }, component: ({ useLoader }) => { @@ -147,7 +147,7 @@ const postsIndexRoute = new Route({ const postRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', - loader: async ({ params: { postId } }) => { + load: async ({ params: { postId } }) => { // TODO: Prefetch post using TRPC }, component: ({ useParams }) => { diff --git a/examples/react/wip-with-framer-motion/package.json b/examples/react/wip-with-framer-motion/package.json index ccc8d90d2f..a7f355dbc9 100644 --- a/examples/react/wip-with-framer-motion/package.json +++ b/examples/react/wip-with-framer-motion/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/wip-with-framer-motion/src/main.tsx b/examples/react/wip-with-framer-motion/src/main.tsx index dae814747d..bf97719238 100644 --- a/examples/react/wip-with-framer-motion/src/main.tsx +++ b/examples/react/wip-with-framer-motion/src/main.tsx @@ -160,7 +160,7 @@ const postsRoute = new Route({ beforeLoad: () => ({ test: true, }), - loader: async ({ context: { loaderClient } }) => { + load: async ({ context: { loaderClient } }) => { await loaderClient.load({ key: 'posts' }) }, component: () => { @@ -221,7 +221,7 @@ const postRoute = new Route({ loaderOptions, } }, - loader: async ({ context: { loaderClient, loaderOptions }, preload }) => { + load: async ({ context: { loaderClient, loaderOptions }, preload }) => { await loaderClient.load({ ...loaderOptions, preload }) }, errorComponent: ({ error }) => { diff --git a/examples/react/with-bling/src/routes/posts.tsx b/examples/react/with-bling/src/routes/posts.tsx index 9117a218f0..87b89fce8d 100644 --- a/examples/react/with-bling/src/routes/posts.tsx +++ b/examples/react/with-bling/src/routes/posts.tsx @@ -35,7 +35,7 @@ const fetchPosts = server$(async () => { export const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: () => fetchPosts(), + load: () => fetchPosts(), gcMaxAge: 0, component: lazyRouteComponent(() => import$({ diff --git a/examples/react/with-bling/src/routes/posts/$postId.tsx b/examples/react/with-bling/src/routes/posts/$postId.tsx index 8ee5d10a73..ab98d61b09 100644 --- a/examples/react/with-bling/src/routes/posts/$postId.tsx +++ b/examples/react/with-bling/src/routes/posts/$postId.tsx @@ -34,7 +34,7 @@ const test = server$(() => { export const postIdRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', - loader: async ({ params: { postId } }) => { + load: async ({ params: { postId } }) => { const commentsPromise = fetchComments(postId) const post = await fetchPostById(postId) diff --git a/examples/react/with-loaders-code-split/package.json b/examples/react/with-loaders-code-split/package.json index 2331764a98..21e241e965 100644 --- a/examples/react/with-loaders-code-split/package.json +++ b/examples/react/with-loaders-code-split/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/with-loaders-code-split/src/routes/posts/post/postRoute.tsx b/examples/react/with-loaders-code-split/src/routes/posts/post/postRoute.tsx index 87066c3987..c09c30e993 100644 --- a/examples/react/with-loaders-code-split/src/routes/posts/post/postRoute.tsx +++ b/examples/react/with-loaders-code-split/src/routes/posts/post/postRoute.tsx @@ -21,7 +21,7 @@ export const postRoute = new Route({ loaderOptions, } }, - loader: async ({ context: { loaderClient, loaderOptions } }) => { + load: async ({ context: { loaderClient, loaderOptions } }) => { await loaderClient.load(loaderOptions) }, errorComponent: ({ error }) => { diff --git a/examples/react/with-loaders-code-split/src/routes/posts/postsRoute.tsx b/examples/react/with-loaders-code-split/src/routes/posts/postsRoute.tsx index 108de02128..4b86f78297 100644 --- a/examples/react/with-loaders-code-split/src/routes/posts/postsRoute.tsx +++ b/examples/react/with-loaders-code-split/src/routes/posts/postsRoute.tsx @@ -5,7 +5,7 @@ import { rootRoute } from '../root/rootRoute' export const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: async ({ context: { loaderClient } }) => { + load: async ({ context: { loaderClient } }) => { await loaderClient.load({ key: 'posts' }) }, }).update({ diff --git a/examples/react/with-loaders-kitchen-sink-code-split/package.json b/examples/react/with-loaders-kitchen-sink-code-split/package.json index e6f10ae952..f3be5dbf5d 100644 --- a/examples/react/with-loaders-kitchen-sink-code-split/package.json +++ b/examples/react/with-loaders-kitchen-sink-code-split/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/with-loaders-kitchen-sink-code-split/src/mockTodos.ts b/examples/react/with-loaders-kitchen-sink-code-split/src/mockTodos.ts index e480808758..8001d8b9d7 100644 --- a/examples/react/with-loaders-kitchen-sink-code-split/src/mockTodos.ts +++ b/examples/react/with-loaders-kitchen-sink-code-split/src/mockTodos.ts @@ -18,6 +18,7 @@ export interface User { website: string company: Company } + export interface Address { street: string suite: string diff --git a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/index.tsx b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/index.tsx index f1949891b9..295df8d4b3 100644 --- a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/index.tsx +++ b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/index.tsx @@ -5,7 +5,7 @@ import { rootRoute } from '../root' export const dashboardRoute = new Route({ getParentRoute: () => rootRoute, path: 'dashboard', - loader: ({ context: { loaderClient }, preload }) => { + load: ({ context: { loaderClient }, preload }) => { loaderClient.load({ key: 'invoices', preload }) }, component: function Dashboard() { diff --git a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/invoices/index.tsx b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/invoices/index.tsx index 7a39400e13..d1b174b33c 100644 --- a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/invoices/index.tsx +++ b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/invoices/index.tsx @@ -18,7 +18,7 @@ export const invoicesLoader = new Loader({ export const invoicesRoute = new Route({ getParentRoute: () => dashboardRoute, path: 'invoices', - loader: async ({ context: { loaderClient } }) => { + load: async ({ context: { loaderClient } }) => { await loaderClient.load({ key: 'invoices' }) }, component: function Invoices({ useLoader }) { diff --git a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/invoices/invoice.tsx b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/invoices/invoice.tsx index 8965992bfe..0939f614ec 100644 --- a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/invoices/invoice.tsx +++ b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/invoices/invoice.tsx @@ -76,7 +76,7 @@ export const invoiceRoute = new Route({ loaderOptions, } }, - loader: async ({ context: { loaderClient, loaderOptions }, preload }) => { + load: async ({ context: { loaderClient, loaderOptions }, preload }) => { await loaderClient.load({ ...loaderOptions, preload, diff --git a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/users/index.tsx b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/users/index.tsx index 9ece48e79e..f9c58c5762 100644 --- a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/users/index.tsx +++ b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/users/index.tsx @@ -55,7 +55,7 @@ export const usersRoute = new Route({ key: 'users', }), }), - loader: async ({ context: { loaderClient, loaderOpts }, preload }) => + load: async ({ context: { loaderClient, loaderOpts }, preload }) => loaderClient.load({ ...loaderOpts, preload }), component: function Users({ useContext, useSearch }) { const navigate = useNavigate() diff --git a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/users/user.tsx b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/users/user.tsx index e444d24f1a..59c63fcd0b 100644 --- a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/users/user.tsx +++ b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/dashboard/users/user.tsx @@ -28,7 +28,7 @@ export const userRoute = new Route({ beforeLoad: ({ params: { userId } }) => ({ loaderOpts: createLoaderOptions({ key: 'user', variables: userId }), }), - loader: async ({ context: { loaderClient, loaderOpts }, preload }) => + load: async ({ context: { loaderClient, loaderOpts }, preload }) => loaderClient.load({ ...loaderOpts, preload, diff --git a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/layout/index.tsx b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/layout/index.tsx index ab0cfac54d..7d9863599a 100644 --- a/examples/react/with-loaders-kitchen-sink-code-split/src/routes/layout/index.tsx +++ b/examples/react/with-loaders-kitchen-sink-code-split/src/routes/layout/index.tsx @@ -13,7 +13,7 @@ export const randomIdLoader = new Loader({ export const layoutRoute = new Route({ getParentRoute: () => rootRoute, id: 'layout', - loader: async ({ context: { loaderClient } }) => { + load: async ({ context: { loaderClient } }) => { await loaderClient.load({ key: 'random' }) }, component: function LayoutWrapper({ useLoader }) { diff --git a/examples/react/with-loaders-kitchen-sink-single-file/package.json b/examples/react/with-loaders-kitchen-sink-single-file/package.json index e8ab0e6daf..4ba8739aa5 100644 --- a/examples/react/with-loaders-kitchen-sink-single-file/package.json +++ b/examples/react/with-loaders-kitchen-sink-single-file/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/with-loaders-kitchen-sink-single-file/src/main.tsx b/examples/react/with-loaders-kitchen-sink-single-file/src/main.tsx index cea267ce81..2cf64d5447 100644 --- a/examples/react/with-loaders-kitchen-sink-single-file/src/main.tsx +++ b/examples/react/with-loaders-kitchen-sink-single-file/src/main.tsx @@ -258,7 +258,7 @@ const indexRoute = new Route({ const dashboardRoute = new Route({ getParentRoute: () => rootRoute, path: 'dashboard', - loader: ({ preload, context: { loaderClient } }) => + load: ({ preload, context: { loaderClient } }) => loaderClient.load({ key: 'invoices', preload }), component: () => { return ( @@ -473,7 +473,7 @@ const invoiceRoute = new Route({ return { loaderOptions } }, - loader: async ({ preload, context: { loaderClient, loaderOptions } }) => { + load: async ({ preload, context: { loaderClient, loaderOptions } }) => { await loaderClient.load({ ...loaderOptions, preload, @@ -599,7 +599,7 @@ const usersRoute = new Route({ }, }), ], - loader: async ({ preload, context: { loaderClient } }) => { + load: async ({ preload, context: { loaderClient } }) => { await loaderClient.load({ key: 'users', preload }) }, component: ({ useSearch, useLoader }) => { @@ -765,7 +765,7 @@ const userRoute = new Route({ return { loaderOptions } }, - loader: async ({ context: { loaderClient, loaderOptions }, preload }) => { + load: async ({ context: { loaderClient, loaderOptions }, preload }) => { await loaderClient.load({ ...loaderOptions, preload }) }, component: ({ useRouteContext }) => { @@ -894,7 +894,7 @@ const loginRoute = new Route({ const layoutRoute = new Route({ getParentRoute: () => rootRoute, id: 'layout', - loader: async ({ preload, context: { loaderClient } }) => { + load: async ({ preload, context: { loaderClient } }) => { await loaderClient.load({ key: 'random', preload }) }, component: ({ useLoader }) => { diff --git a/examples/react/with-loaders-kitchen-sink-single-file/src/mockTodos.ts b/examples/react/with-loaders-kitchen-sink-single-file/src/mockTodos.ts index a12c67b1dd..a90c25eaa7 100644 --- a/examples/react/with-loaders-kitchen-sink-single-file/src/mockTodos.ts +++ b/examples/react/with-loaders-kitchen-sink-single-file/src/mockTodos.ts @@ -19,6 +19,7 @@ export interface User { website: string company: Company } + export interface Address { street: string suite: string diff --git a/examples/react/with-loaders-ssr-streaming/src/routes/posts.tsx b/examples/react/with-loaders-ssr-streaming/src/routes/posts.tsx index 0ec983191f..4c6f74f5c5 100644 --- a/examples/react/with-loaders-ssr-streaming/src/routes/posts.tsx +++ b/examples/react/with-loaders-ssr-streaming/src/routes/posts.tsx @@ -37,7 +37,7 @@ export const testLoader = new Loader({ export const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: async ({ context: { loaderClient }, preload }) => { + load: async ({ context: { loaderClient }, preload }) => { await loaderClient.load({ key: 'posts', preload }) }, component: function Posts() { diff --git a/examples/react/with-loaders-ssr-streaming/src/routes/posts/$postId.tsx b/examples/react/with-loaders-ssr-streaming/src/routes/posts/$postId.tsx index 10d2bdb52b..ce95e70fea 100644 --- a/examples/react/with-loaders-ssr-streaming/src/routes/posts/$postId.tsx +++ b/examples/react/with-loaders-ssr-streaming/src/routes/posts/$postId.tsx @@ -33,7 +33,7 @@ export const postIdRoute = new Route({ beforeLoad: ({ params: { postId } }) => ({ loaderOpts: createLoaderOptions({ key: 'post', variables: postId }), }), - loader: async ({ context: { loaderClient, loaderOpts }, preload }) => + load: async ({ context: { loaderClient, loaderOpts }, preload }) => loaderClient.load({ ...loaderOpts, preload, diff --git a/examples/react/with-loaders-ssr/src/routes/posts.tsx b/examples/react/with-loaders-ssr/src/routes/posts.tsx index 77dff17cda..9094b89554 100644 --- a/examples/react/with-loaders-ssr/src/routes/posts.tsx +++ b/examples/react/with-loaders-ssr/src/routes/posts.tsx @@ -26,7 +26,7 @@ export const postsLoader = new Loader({ export const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: async ({ context: { loaderClient }, preload }) => { + load: async ({ context: { loaderClient }, preload }) => { await loaderClient.load({ key: 'posts', preload }) }, component: function Posts() { diff --git a/examples/react/with-loaders-ssr/src/routes/posts/$postId.tsx b/examples/react/with-loaders-ssr/src/routes/posts/$postId.tsx index 8e421d2b1a..010d732e30 100644 --- a/examples/react/with-loaders-ssr/src/routes/posts/$postId.tsx +++ b/examples/react/with-loaders-ssr/src/routes/posts/$postId.tsx @@ -38,7 +38,7 @@ export const postIdRoute = new Route({ loaderOptions, } }, - loader: async ({ context: { loaderClient, loaderOptions }, preload }) => { + load: async ({ context: { loaderClient, loaderOptions }, preload }) => { await loaderClient.load({ ...loaderOptions, preload, diff --git a/examples/react/with-loaders/package.json b/examples/react/with-loaders/package.json index 54ee2168ac..f7899ac139 100644 --- a/examples/react/with-loaders/package.json +++ b/examples/react/with-loaders/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/with-loaders/src/main.tsx b/examples/react/with-loaders/src/main.tsx index a8d5f83797..de2979c097 100644 --- a/examples/react/with-loaders/src/main.tsx +++ b/examples/react/with-loaders/src/main.tsx @@ -121,7 +121,7 @@ const indexRoute = new Route({ const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: async ({ context: { loaderClient } }) => { + load: async ({ context: { loaderClient } }) => { await loaderClient.load({ key: 'posts' }) }, component: () => { @@ -176,7 +176,7 @@ const postRoute = new Route({ }), } }, - loader: async ({ context: { loaderClient, loaderOptions } }) => { + load: async ({ context: { loaderClient, loaderOptions } }) => { await loaderClient.load(loaderOptions) }, errorComponent: ({ error }) => { diff --git a/examples/react/with-react-query/package.json b/examples/react/with-react-query/package.json index 128aec7837..633823e671 100644 --- a/examples/react/with-react-query/package.json +++ b/examples/react/with-react-query/package.json @@ -3,7 +3,7 @@ "version": "0.0.0", "private": true, "scripts": { - "dev": "vite --port=3000", + "dev": "vite --port=3001", "build": "vite build", "serve": "vite preview", "start": "vite" diff --git a/examples/react/with-react-query/src/main.tsx b/examples/react/with-react-query/src/main.tsx index 6fee993f7a..ba912ec274 100644 --- a/examples/react/with-react-query/src/main.tsx +++ b/examples/react/with-react-query/src/main.tsx @@ -78,7 +78,7 @@ const postsRoute = new Route({ beforeLoad: () => { return { queryOptions: { queryKey: ['posts'], queryFn: fetchPosts } } }, - loader: async ({ context: { queryClient, queryOptions } }) => { + load: async ({ context: { queryClient, queryOptions } }) => { await queryClient.ensureQueryData(queryOptions) }, component: ({ useRouteContext }) => { @@ -137,7 +137,7 @@ const postRoute = new Route({ return { queryOptions } }, - loader: async ({ context: { queryClient, queryOptions } }) => { + load: async ({ context: { queryClient, queryOptions } }) => { await queryClient.ensureQueryData(queryOptions) }, component: ({ useRouteContext }) => { diff --git a/examples/react/with-trpc/client/main.tsx b/examples/react/with-trpc/client/main.tsx index 76e1b7d58c..0bb87f5ca6 100644 --- a/examples/react/with-trpc/client/main.tsx +++ b/examples/react/with-trpc/client/main.tsx @@ -177,7 +177,7 @@ const dashboardRoute = new Route({ const dashboardIndexRoute = new Route({ getParentRoute: () => dashboardRoute, path: '/', - loader: () => trpc.posts.query(), + load: () => trpc.posts.query(), component: ({ useLoader }) => { const posts = useLoader() @@ -195,7 +195,7 @@ const dashboardIndexRoute = new Route({ const postsRoute = new Route({ getParentRoute: () => dashboardRoute, path: 'posts', - loader: () => trpc.posts.query(), + load: () => trpc.posts.query(), component: ({ useLoader }) => { const posts = useLoader() @@ -262,7 +262,7 @@ const postRoute = new Route({ showNotes: z.boolean().optional(), notes: z.string().optional(), }), - loader: async ({ params: { postId } }) => trpc.post.query(postId), + load: async ({ params: { postId } }) => trpc.post.query(postId), component: ({ useLoader }) => { const post = useLoader() const search = useSearch({ from: postRoute.id }) diff --git a/packages/react-router/src/CatchBoundary.tsx b/packages/react-router/src/CatchBoundary.tsx new file mode 100644 index 0000000000..39e6ae50ef --- /dev/null +++ b/packages/react-router/src/CatchBoundary.tsx @@ -0,0 +1,97 @@ +import * as React from 'react' + +export function CatchBoundary(props: { + resetKey: string + children: any + errorComponent?: any + onCatch: (error: any) => void +}) { + const errorComponent = props.errorComponent ?? ErrorComponent + + return ( + { + if (error) { + return React.createElement(errorComponent, { + error, + }) + } + + return props.children + }} + /> + ) +} + +export class CatchBoundaryImpl extends React.Component<{ + resetKey: string + children: (props: { error: any; reset: () => void }) => any + onCatch?: (error: any) => void +}> { + state = { error: null } as any + static getDerivedStateFromError(error: any) { + return { error } + } + componentDidUpdate( + prevProps: Readonly<{ + resetKey: string + children: (props: { error: any; reset: () => void }) => any + onCatch?: ((error: any, info: any) => void) | undefined + }>, + prevState: any, + ): void { + if (prevState.error && prevProps.resetKey !== this.props.resetKey) { + this.setState({ error: null }) + } + } + componentDidCatch(error: any) { + this.props.onCatch?.(error) + } + render() { + return this.props.children(this.state) + } +} + +export function ErrorComponent({ error }: { error: any }) { + const [show, setShow] = React.useState(process.env.NODE_ENV !== 'production') + + return ( +
+
+ Something went wrong! + +
+
+ {show ? ( +
+
+            {error.message ? {error.message} : null}
+          
+
+ ) : null} +
+ ) +} diff --git a/packages/react-router/src/Matches.tsx b/packages/react-router/src/Matches.tsx new file mode 100644 index 0000000000..c81ca41d14 --- /dev/null +++ b/packages/react-router/src/Matches.tsx @@ -0,0 +1,315 @@ +import * as React from 'react' +import invariant from 'tiny-invariant' +import warning from 'tiny-warning' +import { CatchBoundary, ErrorComponent } from './CatchBoundary' +import { RouteMatch } from './RouterProvider' +import { useRouter, useRouterState } from './RouterProvider' +import { ResolveRelativePath, ToOptions } from './link' +import { AnyRoute, ReactNode, rootRouteId } from './route' +import { RouteById, RouteByPath, RouteIds, RoutePaths } from './routeInfo' +import { RegisteredRouter } from './router' +import { NoInfer, StrictOrFrom } from './utils' + +export function Matches() { + const { routesById, state } = useRouter() + const { matches } = state + + const locationKey = useRouterState().location.state.key + + const route = routesById[rootRouteId] + + const errorComponent = React.useCallback( + (props: any) => { + return React.createElement(ErrorComponent, { + ...props, + useMatch: route.useMatch, + useRouteContext: route.useRouteContext, + useSearch: route.useSearch, + useParams: route.useParams, + }) + }, + [route], + ) + + return ( + + { + warning( + false, + `Error in router! Consider setting an 'errorComponent' in your RootRoute! 👍`, + ) + }} + > + {matches.length ? : null} + + + ) +} + +const defaultPending = () => null +function SafeFragment(props: any) { + return <>{props.children} +} + +export function Match({ matches }: { matches: RouteMatch[] }) { + const { options, routesById } = useRouter() + const match = matches[0]! + const routeId = match?.routeId + const route = routesById[routeId] + const locationKey = useRouterState().location.state?.key + + const PendingComponent = (route.options.pendingComponent ?? + options.defaultPendingComponent ?? + defaultPending) as any + + const routeErrorComponent = + route.options.errorComponent ?? + options.defaultErrorComponent ?? + ErrorComponent + + const ResolvedSuspenseBoundary = + route.options.wrapInSuspense ?? React.Suspense + // const ResolvedSuspenseBoundary = SafeFragment + + const errorComponent = React.useCallback( + (props: any) => { + return React.createElement(routeErrorComponent, { + ...props, + useMatch: route.useMatch, + useRouteContext: route.useRouteContext, + useSearch: route.useSearch, + useParams: route.useParams, + }) + }, + [route], + ) + + return ( + + + { + warning(false, `Error in route match: ${match.id}`) + }} + > + + + + + ) +} +function MatchInner({ match }: { match: RouteMatch }): any { + const { options, routesById } = useRouter() + const route = routesById[match.routeId] + + if (match.status === 'error') { + throw match.error + } + + if (match.status === 'pending') { + throw match.loadPromise + } + + if (match.status === 'success') { + let comp = route.options.component ?? options.defaultComponent + + if (comp) { + return React.createElement(comp, { + useMatch: route.useMatch, + useRouteContext: route.useRouteContext as any, + useSearch: route.useSearch, + useParams: route.useParams as any, + } as any) + } + + return + } + + invariant( + false, + 'Idle routeMatch status encountered during rendering! You should never see this. File an issue!', + ) +} + +export function Outlet() { + const matches = React.useContext(matchesContext).slice(1) + + if (!matches[0]) { + return null + } + + return +} + +export interface MatchRouteOptions { + pending?: boolean + caseSensitive?: boolean + includeSearch?: boolean + fuzzy?: boolean +} + +export type MakeUseMatchRouteOptions< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', +> = ToOptions & MatchRouteOptions + +export function useMatchRoute< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], +>() { + const { matchRoute } = useRouter() + + return React.useCallback( + < + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', + TResolved extends string = ResolveRelativePath>, + >( + opts: MakeUseMatchRouteOptions< + TRouteTree, + TFrom, + TTo, + TMaskFrom, + TMaskTo + >, + ): false | RouteById['types']['allParams'] => { + const { pending, caseSensitive, ...rest } = opts + + return matchRoute(rest as any, { + pending, + caseSensitive, + }) + }, + [], + ) +} + +export type MakeMatchRouteOptions< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', +> = ToOptions & + MatchRouteOptions & { + // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns + children?: + | (( + params?: RouteByPath< + TRouteTree, + ResolveRelativePath> + >['types']['allParams'], + ) => ReactNode) + | React.ReactNode + } + +export function MatchRoute< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', +>( + props: MakeMatchRouteOptions, +): any { + const matchRoute = useMatchRoute() + const params = matchRoute(props as any) + + if (typeof props.children === 'function') { + return (props.children as any)(params) + } + + return !!params ? props.children : null +} + +export function useMatch< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RouteIds = RouteIds, + TStrict extends boolean = true, + TRouteMatchState = RouteMatch, + TSelected = TRouteMatchState, +>( + opts: StrictOrFrom & { + select?: (match: TRouteMatchState) => TSelected + }, +): TStrict extends true ? TRouteMatchState : TRouteMatchState | undefined { + const nearestMatch = React.useContext(matchesContext)[0]! + const nearestMatchRouteId = nearestMatch?.routeId + + const matchRouteId = useRouterState({ + select: (state) => { + const match = opts?.from + ? state.matches.find((d) => d.routeId === opts?.from) + : state.matches.find((d) => d.id === nearestMatch.id) + + return match!.routeId + }, + }) + + if (opts?.strict ?? true) { + invariant( + nearestMatchRouteId == matchRouteId, + `useMatch("${ + matchRouteId as string + }") is being called in a component that is meant to render the '${nearestMatchRouteId}' route. Did you mean to 'useMatch("${ + matchRouteId as string + }", { strict: false })' or 'useRoute("${ + matchRouteId as string + }")' instead?`, + ) + } + + const matchSelection = useRouterState({ + select: (state) => { + const match = opts?.from + ? state.matches.find((d) => d.routeId === opts?.from) + : state.matches.find((d) => d.id === nearestMatch.id) + + invariant( + match, + `Could not find ${ + opts?.from + ? `an active match from "${opts.from}"` + : 'a nearest match!' + }`, + ) + + return opts?.select ? opts.select(match as any) : match + }, + }) + + return matchSelection as any +} + +export const matchesContext = React.createContext(null!) + +export function useMatches(opts?: { + select?: (matches: RouteMatch[]) => T +}): T { + const contextMatches = React.useContext(matchesContext) + + return useRouterState({ + select: (state) => { + const matches = state.matches.slice( + state.matches.findIndex((d) => d.id === contextMatches[0]?.id), + ) + return opts?.select ? opts.select(matches) : (matches as T) + }, + }) +} diff --git a/packages/react-router/src/RouteMatch.ts b/packages/react-router/src/RouteMatch.ts deleted file mode 100644 index 5bb1a46a97..0000000000 --- a/packages/react-router/src/RouteMatch.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { AnyRoute } from './route' -import { ParseRoute, FullSearchSchema, RouteById, RouteIds } from './routeInfo' - -export interface RouteMatch< - TRouteTree extends AnyRoute = AnyRoute, - TRouteId extends RouteIds = ParseRoute['id'], -> { - id: string - routeId: TRouteId - pathname: string - params: RouteById['types']['allParams'] - status: 'pending' | 'success' | 'error' - isFetching: boolean - invalid: boolean - error: unknown - paramsError: unknown - searchError: unknown - updatedAt: number - loadPromise?: Promise - __resolveLoadPromise?: () => void - context: RouteById['types']['allContext'] - routeSearch: RouteById['types']['searchSchema'] - search: FullSearchSchema & - RouteById['types']['fullSearchSchema'] - fetchedAt: number - abortController: AbortController -} -export type AnyRouteMatch = RouteMatch diff --git a/packages/react-router/src/RouterProvider.tsx b/packages/react-router/src/RouterProvider.tsx index 39a587b998..54491e4116 100644 --- a/packages/react-router/src/RouterProvider.tsx +++ b/packages/react-router/src/RouterProvider.tsx @@ -1,27 +1,21 @@ +import { + HistoryLocation, + HistoryState, + RouterHistory, + createBrowserHistory, +} from '@tanstack/history' import * as React from 'react' -import { AnyPathParams, AnySearchSchema, Route } from './route' +import invariant from 'tiny-invariant' +import warning from 'tiny-warning' +import { Matches } from './Matches' import { - RegisteredRouter, - DehydratedRouteMatch, - componentTypes, - BuildNextOptions, - RouterOptions, -} from './router' + LinkInfo, + LinkOptions, + NavigateOptions, + ResolveRelativePath, + ToOptions, +} from './link' import { ParsedLocation } from './location' -import { AnyRouteMatch } from './RouteMatch' -import { RouteMatch } from './RouteMatch' -import { isRedirect } from './redirects' -import { - functionalUpdate, - replaceEqualDeep, - useStableCallback, - last, - pick, - partialDeepEqual, - NoInfer, - PickAsRequired, -} from './utils' -import { RouterProps, Matches } from './react' import { cleanPath, interpolatePath, @@ -32,33 +26,42 @@ import { trimPath, trimPathRight, } from './path' -import invariant from 'tiny-invariant' +import { isRedirect } from './redirects' +import { AnyPathParams, AnyRoute, AnySearchSchema, Route } from './route' import { FullSearchSchema, + ParseRoute, RouteById, + RouteIds, RoutePaths, RoutesById, RoutesByPath, } from './routeInfo' import { - LinkInfo, - LinkOptions, - NavigateOptions, - ResolveRelativePath, - ToOptions, -} from './link' + BuildNextOptions, + DehydratedRouteMatch, + RegisteredRouter, + Router, + RouterOptions, + RouterState, + componentTypes, +} from './router' import { - HistoryLocation, - HistoryState, - RouterHistory, - createBrowserHistory, -} from '.' -import { AnyRoute } from './route' -import { RouterState } from './router' + NoInfer, + PickAsRequired, + functionalUpdate, + last, + partialDeepEqual, + pick, + replaceEqualDeep, + useStableCallback, +} from './utils' +import { MatchRouteOptions } from './Matches' export interface CommitLocationOptions { replace?: boolean resetScroll?: boolean + startTransition?: boolean } export interface MatchLocation { @@ -68,13 +71,6 @@ export interface MatchLocation { from?: string } -export interface MatchRouteOptions { - pending?: boolean - caseSensitive?: boolean - includeSearch?: boolean - fuzzy?: boolean -} - type LinkCurrentTargetElement = { preloadTimeout?: null | ReturnType } @@ -83,7 +79,6 @@ export type BuildLinkFn = < TFrom extends RoutePaths = '/', TTo extends string = '', >( - state: RouterState, dest: LinkOptions, ) => LinkInfo @@ -104,7 +99,6 @@ export type MatchRouteFn = < TTo extends string = '', TResolved = ResolveRelativePath>, >( - state: RouterState, location: ToOptions, opts?: MatchRouteOptions, ) => false | RouteById['types']['allParams'] @@ -115,7 +109,9 @@ export type LoadFn = (opts?: { __dehydratedMatches?: DehydratedRouteMatch[] }) => Promise -const preloadWarning = 'Error preloading route! ☝️' +export type BuildLocationFn = ( + opts: BuildNextOptions, +) => ParsedLocation export type RouterContext< TRouteTree extends AnyRoute, @@ -130,6 +126,7 @@ export type RouterContext< options: RouterOptions history: RouterHistory load: LoadFn + buildLocation: BuildLocationFn } export const routerContext = React.createContext>(null!) @@ -138,13 +135,22 @@ if (typeof document !== 'undefined') { window.__TSR_ROUTER_CONTEXT__ = routerContext as any } +const preloadWarning = 'Error preloading route! ☝️' + +function isCtrlEvent(e: MouseEvent) { + return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) +} + +export class SearchParamError extends Error {} + +export class PathParamError extends Error {} + export function getInitialRouterState( location: ParsedLocation, ): RouterState { return { status: 'idle', - isFetching: false, - resolvedLocation: location!, + resolvedLocation: undefined, location: location!, matches: [], pendingMatches: [], @@ -239,9 +245,27 @@ export function RouterProvider< }, ) - const [state, setState] = React.useState>(() => + const [preState, setState] = React.useState>(() => getInitialRouterState(parseLocation()), ) + const [isTransitioning, startReactTransition] = React.useTransition() + + const state = React.useMemo>( + () => ({ + ...preState, + status: isTransitioning ? 'pending' : 'idle', + }), + [preState, isTransitioning], + ) + + React.useLayoutEffect(() => { + if (!isTransitioning && state.resolvedLocation !== state.location) { + setState((s) => ({ + ...s, + resolvedLocation: s.location, + })) + } + }) const basepath = `/${trimPath(options.basepath ?? '') ?? ''}` @@ -528,10 +552,8 @@ export function RouterProvider< }, ) - const buildLocation = useStableCallback( - ( - opts: BuildNextOptions = {}, - ): ParsedLocation => { + const buildLocation = useStableCallback>( + (opts) => { const build = ( dest: BuildNextOptions & { unmaskOnReload?: boolean @@ -704,7 +726,10 @@ export function RouterProvider< ) const commitLocation = useStableCallback( - async (next: ParsedLocation & CommitLocationOptions) => { + async ({ + startTransition, + ...next + }: ParsedLocation & CommitLocationOptions) => { if (navigateTimeoutRef.current) clearTimeout(navigateTimeoutRef.current) const isSameUrl = latestLocationRef.current.href === next.href @@ -738,10 +763,18 @@ export function RouterProvider< } } - history[next.replace ? 'replace' : 'push']( - nextHistory.href, - nextHistory.state, - ) + const apply = () => { + history[next.replace ? 'replace' : 'push']( + nextHistory.href, + nextHistory.state, + ) + } + + if (startTransition ?? true) { + startReactTransition(apply) + } else { + apply() + } } resetNextScrollRef.current = next.resetScroll ?? true @@ -754,11 +787,13 @@ export function RouterProvider< ({ replace, resetScroll, + startTransition, ...rest }: BuildNextOptions & CommitLocationOptions = {}) => { const location = buildLocation(rest) return commitLocation({ ...location, + startTransition, replace, resetScroll, }) @@ -858,7 +893,9 @@ export function RouterProvider< preload: !!preload, context: parentContext, location: state.location, // TODO: This might need to be latestLocationRef.current...? - navigate: (opts) => navigate({ ...opts, from: match.pathname }), + navigate: (opts) => + navigate({ ...opts, from: match.pathname } as any), + buildLocation, })) ?? ({} as any) const context = { @@ -931,7 +968,10 @@ export function RouterProvider< navigate({ ...opts, from: match.pathname }), }) - await Promise.all([componentsPromise, loaderPromise]) + const [_, loaderContext] = await Promise.all([ + componentsPromise, + loaderPromise, + ]) if ((latestPromise = checkLatest())) return await latestPromise matches[index] = match = { @@ -1000,7 +1040,7 @@ export function RouterProvider< const load = useStableCallback(async () => { const promise = new Promise(async (resolve, reject) => { const next = latestLocationRef.current - const prevLocation = state.resolvedLocation + const prevLocation = state.resolvedLocation || state.location const pathDidChange = !!(next && prevLocation!.href !== next.href) let latestPromise: Promise | undefined | null @@ -1014,12 +1054,6 @@ export function RouterProvider< pathChanged: pathDidChange, }) - // Ingest the new location - setState((s) => ({ - ...s, - location: next, - })) - // Match the routes let matches: RouteMatch[] = matchRoutes( next.pathname, @@ -1029,9 +1063,9 @@ export function RouterProvider< }, ) + // Ingest the new matches setState((s) => ({ ...s, - status: 'pending', matches, })) @@ -1063,11 +1097,11 @@ export function RouterProvider< // state.pendingMatches.includes(id), // ) - setState((s) => ({ - ...s, - status: 'idle', - resolvedLocation: s.location, - })) + // setState((s) => ({ + // ...s, + // status: 'idle', + // resolvedLocation: s.location, + // })) // TODO: // ;( @@ -1123,134 +1157,132 @@ export function RouterProvider< }, ) - const buildLink = useStableCallback>( - (state, dest) => { - // If this link simply reloads the current route, - // make sure it has a new key so it will trigger a data refresh - - // If this `to` is a valid external URL, return - // null for LinkUtils - - const { - to, - preload: userPreload, - preloadDelay: userPreloadDelay, - activeOptions, - disabled, - target, - replace, - resetScroll, - } = dest + const buildLink = useStableCallback>((dest) => { + // If this link simply reloads the current route, + // make sure it has a new key so it will trigger a data refresh - try { - new URL(`${to}`) - return { - type: 'external', - href: to as any, - } - } catch (e) {} + // If this `to` is a valid external URL, return + // null for LinkUtils - const nextOpts = dest + const { + to, + preload: userPreload, + preloadDelay: userPreloadDelay, + activeOptions, + disabled, + target, + replace, + resetScroll, + startTransition, + } = dest - const next = buildLocation(nextOpts as any) + try { + new URL(`${to}`) + return { + type: 'external', + href: to as any, + } + } catch (e) {} + + const nextOpts = dest + const next = buildLocation(nextOpts as any) + + const preload = userPreload ?? options.defaultPreload + const preloadDelay = userPreloadDelay ?? options.defaultPreloadDelay ?? 0 + + // Compare path/hash for matches + const currentPathSplit = latestLocationRef.current.pathname.split('/') + const nextPathSplit = next.pathname.split('/') + const pathIsFuzzyEqual = nextPathSplit.every( + (d, i) => d === currentPathSplit[i], + ) + // Combine the matches based on user options + const pathTest = activeOptions?.exact + ? latestLocationRef.current.pathname === next.pathname + : pathIsFuzzyEqual + const hashTest = activeOptions?.includeHash + ? latestLocationRef.current.hash === next.hash + : true + const searchTest = + activeOptions?.includeSearch ?? true + ? partialDeepEqual(latestLocationRef.current.search, next.search) + : true - const preload = userPreload ?? options.defaultPreload - const preloadDelay = userPreloadDelay ?? options.defaultPreloadDelay ?? 0 + // The final "active" test + const isActive = pathTest && hashTest && searchTest - // Compare path/hash for matches - const currentPathSplit = latestLocationRef.current.pathname.split('/') - const nextPathSplit = next.pathname.split('/') - const pathIsFuzzyEqual = nextPathSplit.every( - (d, i) => d === currentPathSplit[i], - ) - // Combine the matches based on user options - const pathTest = activeOptions?.exact - ? latestLocationRef.current.pathname === next.pathname - : pathIsFuzzyEqual - const hashTest = activeOptions?.includeHash - ? latestLocationRef.current.hash === next.hash - : true - const searchTest = - activeOptions?.includeSearch ?? true - ? partialDeepEqual(latestLocationRef.current.search, next.search) - : true - - // The final "active" test - const isActive = pathTest && hashTest && searchTest - - // The click handler - const handleClick = (e: MouseEvent) => { - if ( - !disabled && - !isCtrlEvent(e) && - !e.defaultPrevented && - (!target || target === '_self') && - e.button === 0 - ) { - e.preventDefault() - - // All is well? Navigate! - commitLocation({ ...next, replace, resetScroll }) - } - } + // The click handler + const handleClick = (e: MouseEvent) => { + if ( + !disabled && + !isCtrlEvent(e) && + !e.defaultPrevented && + (!target || target === '_self') && + e.button === 0 + ) { + e.preventDefault() - // The click handler - const handleFocus = (e: MouseEvent) => { - if (preload) { - preloadRoute(nextOpts as any).catch((err) => { - console.warn(err) - console.warn(preloadWarning) - }) - } + // All is well? Navigate! + commitLocation({ ...next, replace, resetScroll, startTransition }) } + } - const handleTouchStart = (e: TouchEvent) => { + // The click handler + const handleFocus = (e: MouseEvent) => { + if (preload) { preloadRoute(nextOpts as any).catch((err) => { console.warn(err) console.warn(preloadWarning) }) } + } - const handleEnter = (e: MouseEvent) => { - const target = (e.target || {}) as LinkCurrentTargetElement + const handleTouchStart = (e: TouchEvent) => { + preloadRoute(nextOpts as any).catch((err) => { + console.warn(err) + console.warn(preloadWarning) + }) + } - if (preload) { - if (target.preloadTimeout) { - return - } + const handleEnter = (e: MouseEvent) => { + const target = (e.target || {}) as LinkCurrentTargetElement - target.preloadTimeout = setTimeout(() => { - target.preloadTimeout = null - preloadRoute(nextOpts as any).catch((err) => { - console.warn(err) - console.warn(preloadWarning) - }) - }, preloadDelay) + if (preload) { + if (target.preloadTimeout) { + return } - } - const handleLeave = (e: MouseEvent) => { - const target = (e.target || {}) as LinkCurrentTargetElement - - if (target.preloadTimeout) { - clearTimeout(target.preloadTimeout) + target.preloadTimeout = setTimeout(() => { target.preloadTimeout = null - } + preloadRoute(nextOpts as any).catch((err) => { + console.warn(err) + console.warn(preloadWarning) + }) + }, preloadDelay) } + } - return { - type: 'internal', - next, - handleFocus, - handleClick, - handleEnter, - handleLeave, - handleTouchStart, - isActive, - disabled, + const handleLeave = (e: MouseEvent) => { + const target = (e.target || {}) as LinkCurrentTargetElement + + if (target.preloadTimeout) { + clearTimeout(target.preloadTimeout) + target.preloadTimeout = null } - }, - ) + } + + return { + type: 'internal', + next, + handleFocus, + handleClick, + handleEnter, + handleLeave, + handleTouchStart, + isActive, + disabled, + } + }) const latestLocationRef = React.useRef(state.location) @@ -1258,15 +1290,17 @@ export function RouterProvider< const unsub = history.subscribe(() => { latestLocationRef.current = parseLocation(latestLocationRef.current) - React.startTransition(() => { - if (state.location !== latestLocationRef.current) { - try { - load() - } catch (err) { - console.error(err) - } + setState((s) => ({ + ...s, + status: 'pending', + })) + if (state.location !== latestLocationRef.current) { + try { + load() + } catch (err) { + console.error(err) } - }) + } }) const nextLocation = buildLocation({ @@ -1289,54 +1323,58 @@ export function RouterProvider< if (initialLoad.current) { initialLoad.current = false - try { - load() - } catch (err) { - console.error(err) - } + startReactTransition(() => { + try { + load() + } catch (err) { + console.error(err) + } + }) } - const isFetching = React.useMemo( - () => [...state.matches, ...state.pendingMatches].some((d) => d.isFetching), - [state.matches, state.pendingMatches], - ) + const matchRoute = useStableCallback>( + (location, opts) => { + location = { + ...location, + to: location.to + ? resolvePathWithBase((location.from || '') as string, location.to) + : undefined, + } as any - const matchRoute = useStableCallback((state, location, opts) => { - location = { - ...location, - to: location.to - ? resolvePathWithBase((location.from || '') as string, location.to) - : undefined, - } as any - - const next = buildLocation(location as any) - if (opts?.pending && state.status !== 'pending') { - return false - } + const next = buildLocation(location as any) + + if (opts?.pending && state.status !== 'pending') { + return false + } - const baseLocation = opts?.pending - ? latestLocationRef.current - : state.resolvedLocation + const baseLocation = opts?.pending + ? latestLocationRef.current + : state.resolvedLocation - if (!baseLocation) { - return false - } + // const baseLocation = state.resolvedLocation - const match = matchPathname(basepath, baseLocation.pathname, { - ...opts, - to: next.pathname, - }) as any + if (!baseLocation) { + return false + } - if (!match) { - return false - } + const match = matchPathname(basepath, baseLocation.pathname, { + ...opts, + to: next.pathname, + }) as any - if (opts?.includeSearch ?? true) { - return partialDeepEqual(baseLocation.search, next.search) ? match : false - } + if (!match) { + return false + } - return match - }) + if (match && (opts?.includeSearch ?? true)) { + return partialDeepEqual(baseLocation.search, next.search) + ? match + : false + } + + return match + }, + ) const routerContextValue: RouterContext = { routeTree: router.routeTree, @@ -1348,6 +1386,7 @@ export function RouterProvider< options, history, load, + buildLocation, } return ( @@ -1357,35 +1396,62 @@ export function RouterProvider< ) } -function mergeMatches( - prevMatchesById: Record>, - nextMatches: AnyRouteMatch[], -): Record> { - let matchesById = { ...prevMatchesById } - - nextMatches.forEach((match) => { - if (!matchesById[match.id]) { - matchesById[match.id] = match - } - - matchesById[match.id] = { - ...matchesById[match.id], - ...match, - } - }) - - return matchesById -} - -function isCtrlEvent(e: MouseEvent) { - return !!(e.metaKey || e.altKey || e.ctrlKey || e.shiftKey) -} -export class SearchParamError extends Error {} -export class PathParamError extends Error {} - export function getRouteMatch( state: RouterState, id: string, ): undefined | RouteMatch { return [...state.pendingMatches, ...state.matches].find((d) => d.id === id) } + +export function useRouterState< + TSelected = RouterState, +>(opts?: { + select: (state: RouterState) => TSelected +}): TSelected { + const { state } = useRouter() + // return useStore(router.__store, opts?.select as any) + return opts?.select ? opts.select(state) : (state as any) +} + +export type RouterProps< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TDehydrated extends Record = Record, +> = Omit, 'context'> & { + router: Router + context?: Partial['context']> +} + +export function useRouter< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], +>(): RouterContext { + const resolvedContext = window.__TSR_ROUTER_CONTEXT__ || routerContext + const value = React.useContext(resolvedContext) + warning(value, 'useRouter must be used inside a component!') + return value as any +} +export interface RouteMatch< + TRouteTree extends AnyRoute = AnyRoute, + TRouteId extends RouteIds = ParseRoute['id'], +> { + id: string + routeId: TRouteId + pathname: string + params: RouteById['types']['allParams'] + status: 'pending' | 'success' | 'error' + isFetching: boolean + invalid: boolean + error: unknown + paramsError: unknown + searchError: unknown + updatedAt: number + loadPromise?: Promise + __resolveLoadPromise?: () => void + context: RouteById['types']['allContext'] + routeSearch: RouteById['types']['searchSchema'] + search: FullSearchSchema & + RouteById['types']['fullSearchSchema'] + fetchedAt: number + abortController: AbortController +} + +export type AnyRouteMatch = RouteMatch diff --git a/packages/react-router/src/index.tsx b/packages/react-router/src/index.tsx index f2120a2726..d5230e164a 100644 --- a/packages/react-router/src/index.tsx +++ b/packages/react-router/src/index.tsx @@ -2,19 +2,28 @@ export * from '@tanstack/history' export { default as invariant } from 'tiny-invariant' export { default as warning } from 'tiny-warning' +// export * from './awaited +// export * from './defer' +export * from './CatchBoundary' +export * from './fileRoute' +export * from './history' +export * from './index' +// export * from './injectHtml' +export * from './lazyRouteComponent' export * from './link' +export * from './location' +export * from './Matches' export * from './path' export * from './qss' +export * from './redirects' export * from './route' -export * from './fileRoute' export * from './routeInfo' export * from './router' -export * from './searchParams' -export * from './utils' -export * from './react' -export * from './history' -export * from './RouteMatch' -export * from './redirects' -export * from './location' export * from './RouterProvider' // export * from './scroll-restoration' +export * from './searchParams' +export * from './useBlocker' +export * from './useNavigate' +export * from './useParams' +export * from './useSearch' +export * from './utils' diff --git a/packages/react-router/src/injectHtml.ts b/packages/react-router/src/injectHtml.ts new file mode 100644 index 0000000000..e09bb17704 --- /dev/null +++ b/packages/react-router/src/injectHtml.ts @@ -0,0 +1,28 @@ +// export function useInjectHtml() { +// const { } = useRouter() +// return React.useCallback( +// (html: string | (() => Promise | string)) => { +// router.injectHtml(html) +// }, +// [], +// ) +// } +// export function useDehydrate() { +// const { } = useRouter() +// return React.useCallback(function dehydrate( +// key: any, +// data: T | (() => Promise | T), +// ) { +// return router.dehydrateData(key, data) +// }, +// []) +// } +// export function useHydrate() { +// const { } = useRouter() +// return function hydrate(key: any) { +// return router.hydrateData(key) as T +// } +// } +// This is the messiest thing ever... I'm either seriously tired (likely) or +// there has to be a better way to reset error boundaries when the +// router's location key changes. diff --git a/packages/react-router/src/lazyRouteComponent.tsx b/packages/react-router/src/lazyRouteComponent.tsx new file mode 100644 index 0000000000..4c1b0d133c --- /dev/null +++ b/packages/react-router/src/lazyRouteComponent.tsx @@ -0,0 +1,33 @@ +import * as React from 'react' +import { AsyncRouteComponent } from './route' + +export function lazyRouteComponent< + T extends Record, + TKey extends keyof T = 'default', +>( + importer: () => Promise, + exportName?: TKey, +): T[TKey] extends (props: infer TProps) => any + ? AsyncRouteComponent + : never { + let loadPromise: Promise + + const load = () => { + if (!loadPromise) { + loadPromise = importer() + } + + return loadPromise + } + + const lazyComp = React.lazy(async () => { + const moduleExports = await load() + const comp = moduleExports[exportName ?? 'default'] + return { + default: comp, + } + }) + ;(lazyComp as any).preload = load + + return lazyComp as any +} diff --git a/packages/react-router/src/link.ts b/packages/react-router/src/link.tsx similarity index 71% rename from packages/react-router/src/link.ts rename to packages/react-router/src/link.tsx index e1d60d9c36..f3b90bd7ee 100644 --- a/packages/react-router/src/link.ts +++ b/packages/react-router/src/link.tsx @@ -1,5 +1,9 @@ +import * as React from 'react' +import { useMatch } from './Matches' +import { useRouter } from './RouterProvider' import { Trim } from './fileRoute' -import { AnyRoute } from './route' +import { LocationState, ParsedLocation } from './location' +import { AnyRoute, ReactNode } from './route' import { AllParams, FullSearchSchema, @@ -8,8 +12,7 @@ import { RoutePaths, } from './routeInfo' import { RegisteredRouter } from './router' -import { LocationState } from './location' -import { ParsedLocation } from './location' +import { MakeLinkOptions, MakeLinkPropsOptions } from './useNavigate' import { Expand, NoInfer, @@ -17,6 +20,7 @@ import { PickRequired, UnionToIntersection, Updater, + functionalUpdate, } from './utils' export type LinkInfo = @@ -136,6 +140,8 @@ export type NavigateOptions< // `replace` is a boolean that determines whether the navigation should replace the current history entry or push a new one. replace?: boolean resetScroll?: boolean + // If set to `true`, the link's underlying navigate() call will be wrapped in a `React.startTransition` call. Defaults to `true`. + startTransition?: boolean } export type ToOptions< @@ -345,3 +351,157 @@ export type ResolveRelativePath = TFrom extends string : CleanPath, ...Split]>> : never : never + +export function useLinkProps< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', +>( + options: MakeLinkPropsOptions, +): React.AnchorHTMLAttributes { + const { buildLink } = useRouter() + const match = useMatch({ + strict: false, + }) + + const { + // custom props + type, + children, + target, + activeProps = () => ({ className: 'active' }), + inactiveProps = () => ({}), + activeOptions, + disabled, + hash, + search, + params, + to, + state, + mask, + preload, + preloadDelay, + replace, + startTransition, + // element props + style, + className, + onClick, + onFocus, + onMouseEnter, + onMouseLeave, + onTouchStart, + ...rest + } = options + + const linkInfo = buildLink({ + from: options.to ? match.pathname : undefined, + ...options, + } as any) + + if (linkInfo.type === 'external') { + const { href } = linkInfo + return { href } + } + + const { + handleClick, + handleFocus, + handleEnter, + handleLeave, + handleTouchStart, + isActive, + next, + } = linkInfo + + const composeHandlers = + (handlers: (undefined | ((e: any) => void))[]) => + (e: React.SyntheticEvent) => { + if (e.persist) e.persist() + handlers.filter(Boolean).forEach((handler) => { + if (e.defaultPrevented) return + handler!(e) + }) + } + + // Get the active props + const resolvedActiveProps: React.HTMLAttributes = isActive + ? functionalUpdate(activeProps as any, {}) ?? {} + : {} + + // Get the inactive props + const resolvedInactiveProps: React.HTMLAttributes = + isActive ? {} : functionalUpdate(inactiveProps, {}) ?? {} + + return { + ...resolvedActiveProps, + ...resolvedInactiveProps, + ...rest, + href: disabled + ? undefined + : next.maskedLocation + ? next.maskedLocation.href + : next.href, + onClick: composeHandlers([onClick, handleClick]), + onFocus: composeHandlers([onFocus, handleFocus]), + onMouseEnter: composeHandlers([onMouseEnter, handleEnter]), + onMouseLeave: composeHandlers([onMouseLeave, handleLeave]), + onTouchStart: composeHandlers([onTouchStart, handleTouchStart]), + target, + style: { + ...style, + ...resolvedActiveProps.style, + ...resolvedInactiveProps.style, + }, + className: + [ + className, + resolvedActiveProps.className, + resolvedInactiveProps.className, + ] + .filter(Boolean) + .join(' ') || undefined, + ...(disabled + ? { + role: 'link', + 'aria-disabled': true, + } + : undefined), + ['data-status']: isActive ? 'active' : undefined, + } +} + +export interface LinkComponent = {}> { + < + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', + >( + props: MakeLinkOptions & + TProps & + React.RefAttributes, + ): ReactNode +} + +export const Link: LinkComponent = React.forwardRef((props: any, ref) => { + const linkProps = useLinkProps(props) + + return ( + + ) +}) as any diff --git a/packages/react-router/src/location.ts b/packages/react-router/src/location.ts index d5596350aa..4562a5e4db 100644 --- a/packages/react-router/src/location.ts +++ b/packages/react-router/src/location.ts @@ -11,4 +11,5 @@ export interface ParsedLocation { maskedLocation?: ParsedLocation unmaskOnReload?: boolean } + export interface LocationState {} diff --git a/packages/react-router/src/react.tsx b/packages/react-router/src/react.tsx deleted file mode 100644 index d626e82e71..0000000000 --- a/packages/react-router/src/react.tsx +++ /dev/null @@ -1,1013 +0,0 @@ -import * as React from 'react' -import invariant from 'tiny-invariant' -import warning from 'tiny-warning' -import { - LinkOptions, - ToOptions, - ResolveRelativePath, - NavigateOptions, -} from './link' -import { - AnySearchSchema, - AnyPathParams, - AnyContext, - AnyRoute, - rootRouteId, -} from './route' -import { - RoutePaths, - RouteByPath, - RouteIds, - ParseRoute, - RoutesById, - RouteById, - AllParams, -} from './routeInfo' -import { RegisteredRouter, RouterOptions, Router, RouterState } from './router' -import { RouteMatch } from './RouteMatch' -import { NoInfer, functionalUpdate, last } from './utils' -import { MatchRouteOptions, RouterContext } from './RouterProvider' -import { routerContext } from './RouterProvider' - -const useLayoutEffect = - typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect - -export type RouteProps< - TFullSearchSchema extends Record = AnySearchSchema, - TAllParams extends AnyPathParams = AnyPathParams, - TAllContext extends Record = AnyContext, -> = { - useMatch: (opts?: { - select?: (search: TAllContext) => TSelected - }) => TSelected - useRouteContext: (opts?: { - select?: (search: TAllContext) => TSelected - }) => TSelected - useSearch: (opts?: { - select?: (search: TFullSearchSchema) => TSelected - }) => TSelected - useParams: (opts?: { - select?: (search: TAllParams) => TSelected - }) => TSelected -} - -export type ErrorRouteProps< - TFullSearchSchema extends Record = AnySearchSchema, - TAllParams extends AnyPathParams = AnyPathParams, - TAllContext extends Record = AnyContext, -> = { - error: unknown - info: { componentStack: string } -} & RouteProps - -export type PendingRouteProps< - TFullSearchSchema extends Record = AnySearchSchema, - TAllParams extends AnyPathParams = AnyPathParams, - TAllContext extends Record = AnyContext, -> = RouteProps - -// - -type ReactNode = any - -export type SyncRouteComponent = - | ((props: TProps) => ReactNode) - | React.LazyExoticComponent<(props: TProps) => ReactNode> - -export type AsyncRouteComponent = SyncRouteComponent & { - preload?: () => Promise -} - -export type RouteComponent< - TFullSearchSchema extends Record, - TAllParams extends AnyPathParams, - TAllContext extends Record, -> = AsyncRouteComponent> - -export type ErrorRouteComponent< - TFullSearchSchema extends Record, - TAllParams extends AnyPathParams, - TAllContext extends Record, -> = AsyncRouteComponent< - ErrorRouteProps -> - -export type PendingRouteComponent< - TFullSearchSchema extends Record, - TAllParams extends AnyPathParams, - TAllContext extends Record, -> = AsyncRouteComponent< - PendingRouteProps -> - -export type AnyRouteComponent = RouteComponent - -export function lazyRouteComponent< - T extends Record, - TKey extends keyof T = 'default', ->( - importer: () => Promise, - exportName?: TKey, -): T[TKey] extends (props: infer TProps) => any - ? AsyncRouteComponent - : never { - let loadPromise: Promise - - const load = () => { - if (!loadPromise) { - loadPromise = importer() - } - - return loadPromise - } - - const lazyComp = React.lazy(async () => { - const moduleExports = await load() - const comp = moduleExports[exportName ?? 'default'] - return { - default: comp, - } - }) - - ;(lazyComp as any).preload = load - - return lazyComp as any -} - -export type LinkPropsOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', -> = LinkOptions & { - // A function that returns additional props for the `active` state of this link. These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated) - activeProps?: - | React.AnchorHTMLAttributes - | (() => React.AnchorHTMLAttributes) - // A function that returns additional props for the `inactive` state of this link. These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated) - inactiveProps?: - | React.AnchorHTMLAttributes - | (() => React.AnchorHTMLAttributes) - // If set to `true`, the link's underlying navigate() call will be wrapped in a `React.startTransition` call. Defaults to `true`. - startTransition?: boolean -} - -export type MakeUseMatchRouteOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', -> = ToOptions & MatchRouteOptions - -export type MakeMatchRouteOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', -> = ToOptions & - MatchRouteOptions & { - // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns - children?: - | (( - params?: RouteByPath< - TRouteTree, - ResolveRelativePath> - >['types']['allParams'], - ) => ReactNode) - | React.ReactNode - } - -export type MakeLinkPropsOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', -> = LinkPropsOptions & - React.AnchorHTMLAttributes - -export type MakeLinkOptions< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', -> = LinkPropsOptions & - Omit, 'children'> & { - // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns - children?: - | React.ReactNode - | ((state: { isActive: boolean }) => React.ReactNode) - } - -export type PromptProps = { - message: string - condition?: boolean | any - children?: ReactNode -} - -// - -export function useLinkProps< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', ->( - options: MakeLinkPropsOptions, -): React.AnchorHTMLAttributes { - const { buildLink, state: routerState } = useRouter() - const match = useMatch({ - strict: false, - }) - - const { - // custom props - type, - children, - target, - activeProps = () => ({ className: 'active' }), - inactiveProps = () => ({}), - activeOptions, - disabled, - hash, - search, - params, - to, - state, - mask, - preload, - preloadDelay, - replace, - // element props - style, - className, - onClick, - onFocus, - onMouseEnter, - onMouseLeave, - onTouchStart, - ...rest - } = options - - const linkInfo = buildLink(routerState, { - from: options.to ? match.pathname : undefined, - ...options, - } as any) - - if (linkInfo.type === 'external') { - const { href } = linkInfo - return { href } - } - - const { - handleClick, - handleFocus, - handleEnter, - handleLeave, - handleTouchStart, - isActive, - next, - } = linkInfo - - const handleReactClick = (e: Event) => { - if (options.startTransition ?? true) { - ;(React.startTransition || ((d) => d))(() => { - handleClick(e) - }) - } - } - - const composeHandlers = - (handlers: (undefined | ((e: any) => void))[]) => - (e: React.SyntheticEvent) => { - if (e.persist) e.persist() - handlers.filter(Boolean).forEach((handler) => { - if (e.defaultPrevented) return - handler!(e) - }) - } - - // Get the active props - const resolvedActiveProps: React.HTMLAttributes = isActive - ? functionalUpdate(activeProps as any, {}) ?? {} - : {} - - // Get the inactive props - const resolvedInactiveProps: React.HTMLAttributes = - isActive ? {} : functionalUpdate(inactiveProps, {}) ?? {} - - return { - ...resolvedActiveProps, - ...resolvedInactiveProps, - ...rest, - href: disabled - ? undefined - : next.maskedLocation - ? next.maskedLocation.href - : next.href, - onClick: composeHandlers([onClick, handleReactClick]), - onFocus: composeHandlers([onFocus, handleFocus]), - onMouseEnter: composeHandlers([onMouseEnter, handleEnter]), - onMouseLeave: composeHandlers([onMouseLeave, handleLeave]), - onTouchStart: composeHandlers([onTouchStart, handleTouchStart]), - target, - style: { - ...style, - ...resolvedActiveProps.style, - ...resolvedInactiveProps.style, - }, - className: - [ - className, - resolvedActiveProps.className, - resolvedInactiveProps.className, - ] - .filter(Boolean) - .join(' ') || undefined, - ...(disabled - ? { - role: 'link', - 'aria-disabled': true, - } - : undefined), - ['data-status']: isActive ? 'active' : undefined, - } -} - -export interface LinkComponent = {}> { - < - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', - >( - props: MakeLinkOptions & - TProps & - React.RefAttributes, - ): ReactNode -} - -export const Link: LinkComponent = React.forwardRef((props: any, ref) => { - const linkProps = useLinkProps(props) - - return ( - - ) -}) as any - -export function Navigate< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', ->(props: NavigateOptions): null { - const { navigate } = useRouter() - const match = useMatch({ strict: false }) - - useLayoutEffect(() => { - navigate({ - from: props.to ? match.pathname : undefined, - ...props, - } as any) - }, []) - - return null -} - -export const matchesContext = React.createContext(null!) -export type RouterProps< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TDehydrated extends Record = Record, -> = Omit, 'context'> & { - router: Router - context?: Partial['context']> -} - -export function useRouter< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], ->(): RouterContext { - const resolvedContext = window.__TSR_ROUTER_CONTEXT__ || routerContext - const value = React.useContext(resolvedContext) - warning(value, 'useRouter must be used inside a component!') - return value as any -} - -export function useRouterState< - TSelected = RouterState, ->(opts?: { - select: (state: RouterState) => TSelected -}): TSelected { - const { state } = useRouter() - // return useStore(router.__store, opts?.select as any) - return opts?.select ? opts.select(state) : (state as any) -} - -export function useMatches(opts?: { - select?: (matches: RouteMatch[]) => T -}): T { - const contextMatches = React.useContext(matchesContext) - - return useRouterState({ - select: (state) => { - const matches = state.matches.slice( - state.matches.findIndex((d) => d.id === contextMatches[0]?.id), - ) - return opts?.select ? opts.select(matches) : (matches as T) - }, - }) -} - -type StrictOrFrom = - | { - from: TFrom - strict?: true - } - | { - from?: never - strict: false - } - -export function useMatch< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RouteIds = RouteIds, - TStrict extends boolean = true, - TRouteMatchState = RouteMatch, - TSelected = TRouteMatchState, ->( - opts: StrictOrFrom & { - select?: (match: TRouteMatchState) => TSelected - }, -): TStrict extends true ? TRouteMatchState : TRouteMatchState | undefined { - const nearestMatch = React.useContext(matchesContext)[0]! - const nearestMatchRouteId = nearestMatch?.routeId - - const matchRouteId = useRouterState({ - select: (state) => { - const match = opts?.from - ? state.matches.find((d) => d.routeId === opts?.from) - : state.matches.find((d) => d.id === nearestMatch.id) - - return match!.routeId - }, - }) - - if (opts?.strict ?? true) { - invariant( - nearestMatchRouteId == matchRouteId, - `useMatch("${ - matchRouteId as string - }") is being called in a component that is meant to render the '${nearestMatchRouteId}' route. Did you mean to 'useMatch("${ - matchRouteId as string - }", { strict: false })' or 'useRoute("${ - matchRouteId as string - }")' instead?`, - ) - } - - const matchSelection = useRouterState({ - select: (state) => { - const match = opts?.from - ? state.matches.find((d) => d.routeId === opts?.from) - : state.matches.find((d) => d.id === nearestMatch.id) - - invariant( - match, - `Could not find ${ - opts?.from - ? `an active match from "${opts.from}"` - : 'a nearest match!' - }`, - ) - - return opts?.select ? opts.select(match as any) : match - }, - }) - - return matchSelection as any -} - -export type RouteFromIdOrRoute< - T, - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], -> = T extends ParseRoute - ? T - : T extends RouteIds - ? RoutesById[T] - : T extends string - ? RouteIds - : never - -export function useRouteContext< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RouteIds = RouteIds, - TStrict extends boolean = true, - TRouteContext = RouteById['types']['allContext'], - TSelected = TRouteContext, ->( - opts: StrictOrFrom & { - select?: (search: TRouteContext) => TSelected - }, -): TStrict extends true ? TSelected : TSelected | undefined { - return useMatch({ - ...(opts as any), - select: (match: RouteMatch) => - opts?.select - ? opts.select(match.context as TRouteContext) - : match.context, - }) -} - -export function useSearch< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RouteIds = RouteIds, - TStrict extends boolean = true, - TSearch = RouteById['types']['fullSearchSchema'], - TSelected = TSearch, ->( - opts: StrictOrFrom & { - select?: (search: TSearch) => TSelected - }, -): TStrict extends true ? TSelected : TSelected | undefined { - return useMatch({ - ...(opts as any), - select: (match: RouteMatch) => { - return opts?.select ? opts.select(match.search as TSearch) : match.search - }, - }) -} - -export function useParams< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RouteIds = RouteIds, - TDefaultSelected = AllParams & - RouteById['types']['allParams'], - TSelected = TDefaultSelected, ->( - opts: StrictOrFrom & { - select?: (search: TDefaultSelected) => TSelected - }, -): TSelected { - return useRouterState({ - select: (state: any) => { - const params = (last(state.matches) as any)?.params - return opts?.select ? opts.select(params) : params - }, - }) -} - -export function useNavigate< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TDefaultFrom extends RoutePaths = '/', ->(defaultOpts?: { from?: TDefaultFrom }) { - const { navigate } = useRouter() - const match = useMatch({ - strict: false, - }) - return React.useCallback( - < - TFrom extends RoutePaths = TDefaultFrom, - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', - >( - opts?: NavigateOptions, - ) => { - return navigate({ - from: opts?.to ? match.pathname : undefined, - ...defaultOpts, - ...(opts as any), - }) - }, - [], - ) -} - -export function typedNavigate< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TDefaultFrom extends RoutePaths = '/', ->(navigate: (opts: NavigateOptions) => Promise) { - return navigate as < - TFrom extends RoutePaths = TDefaultFrom, - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', - >( - opts?: NavigateOptions, - ) => Promise -} - -export function useMatchRoute< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], ->() { - const { state, matchRoute } = useRouter() - - return React.useCallback( - < - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', - TResolved extends string = ResolveRelativePath>, - >( - opts: MakeUseMatchRouteOptions< - TRouteTree, - TFrom, - TTo, - TMaskFrom, - TMaskTo - >, - ): false | RouteById['types']['allParams'] => { - const { pending, caseSensitive, ...rest } = opts - - return matchRoute(state, rest as any, { - pending, - caseSensitive, - }) - }, - [], - ) -} - -export function Matches() { - const { routesById, state } = useRouter() - - // const matches = useRouterState({ - // select: (state) => { - // return state.matches - // }, - // }) - - const { matches } = state - - const locationKey = useRouterState({ - select: (d) => d.resolvedLocation.state?.key, - }) - - const route = routesById[rootRouteId] - - const errorComponent = React.useCallback( - (props: any) => { - return React.createElement(ErrorComponent, { - ...props, - useMatch: route.useMatch, - useRouteContext: route.useRouteContext, - useSearch: route.useSearch, - useParams: route.useParams, - }) - }, - [route], - ) - - return ( - - { - warning( - false, - `Error in router! Consider setting an 'errorComponent' in your RootRoute! 👍`, - ) - }} - > - {matches.length ? : null} - - - ) -} - -export function MatchRoute< - TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], - TFrom extends RoutePaths = '/', - TTo extends string = '', - TMaskFrom extends RoutePaths = '/', - TMaskTo extends string = '', ->( - props: MakeMatchRouteOptions, -): any { - const matchRoute = useMatchRoute() - const params = matchRoute(props as any) - - if (typeof props.children === 'function') { - return (props.children as any)(params) - } - - return !!params ? props.children : null -} - -export function Outlet() { - const matches = React.useContext(matchesContext).slice(1) - - if (!matches[0]) { - return null - } - - return -} - -const defaultPending = () => null - -function Match({ matches }: { matches: RouteMatch[] }) { - const { options, routesById } = useRouter() - const match = matches[0]! - const routeId = match?.routeId - const route = routesById[routeId] - const locationKey = useRouterState({ - select: (s) => s.resolvedLocation.state?.key, - }) - - const PendingComponent = (route.options.pendingComponent ?? - options.defaultPendingComponent ?? - defaultPending) as any - - const routeErrorComponent = - route.options.errorComponent ?? - options.defaultErrorComponent ?? - ErrorComponent - - const ResolvedSuspenseBoundary = - route.options.wrapInSuspense ?? !route.isRoot - ? React.Suspense - : SafeFragment - - const ResolvedCatchBoundary = !!routeErrorComponent - ? CatchBoundary - : SafeFragment - - const errorComponent = React.useCallback( - (props: any) => { - return React.createElement(routeErrorComponent, { - ...props, - useMatch: route.useMatch, - useRouteContext: route.useRouteContext, - useSearch: route.useSearch, - useParams: route.useParams, - }) - }, - [route], - ) - - return ( - - - { - warning(false, `Error in route match: ${match.id}`) - }} - > - - - - - ) -} - -function MatchInner({ match }: { match: RouteMatch }): any { - const { options, routesById } = useRouter() - const route = routesById[match.routeId] - - if (match.status === 'error') { - throw match.error - } - - if (match.status === 'pending') { - throw match.loadPromise - } - - if (match.status === 'success') { - let comp = route.options.component ?? options.defaultComponent - - if (comp) { - return React.createElement(comp, { - useMatch: route.useMatch, - useRouteContext: route.useRouteContext as any, - useSearch: route.useSearch, - useParams: route.useParams as any, - } as any) - } - - return - } - - invariant( - false, - 'Idle routeMatch status encountered during rendering! You should never see this. File an issue!', - ) -} - -function SafeFragment(props: any) { - return <>{props.children} -} - -// export function useInjectHtml() { -// const { } = useRouter() - -// return React.useCallback( -// (html: string | (() => Promise | string)) => { -// router.injectHtml(html) -// }, -// [], -// ) -// } - -// export function useDehydrate() { -// const { } = useRouter() - -// return React.useCallback(function dehydrate( -// key: any, -// data: T | (() => Promise | T), -// ) { -// return router.dehydrateData(key, data) -// }, -// []) -// } - -// export function useHydrate() { -// const { } = useRouter() - -// return function hydrate(key: any) { -// return router.hydrateData(key) as T -// } -// } - -// This is the messiest thing ever... I'm either seriously tired (likely) or -// there has to be a better way to reset error boundaries when the -// router's location key changes. - -export function CatchBoundary(props: { - resetKey: string - children: any - errorComponent?: any - onCatch: (error: any) => void -}) { - const errorComponent = props.errorComponent ?? ErrorComponent - - return ( - { - if (error) { - return React.createElement(errorComponent, { - error, - }) - } - - return props.children - }} - /> - ) -} - -export class CatchBoundaryImpl extends React.Component<{ - resetKey: string - children: (props: { error: any; reset: () => void }) => any - onCatch?: (error: any) => void -}> { - state = { error: null } as any - static getDerivedStateFromError(error: any) { - return { error } - } - componentDidUpdate( - prevProps: Readonly<{ - resetKey: string - children: (props: { error: any; reset: () => void }) => any - onCatch?: ((error: any, info: any) => void) | undefined - }>, - prevState: any, - ): void { - if (prevState.error && prevProps.resetKey !== this.props.resetKey) { - this.setState({ error: null }) - } - } - componentDidCatch(error: any) { - this.props.onCatch?.(error) - } - render() { - return this.props.children(this.state) - } -} - -export function ErrorComponent({ error }: { error: any }) { - const [show, setShow] = React.useState(process.env.NODE_ENV !== 'production') - - return ( -
-
- Something went wrong! - -
-
- {show ? ( -
-
-            {error.message ? {error.message} : null}
-          
-
- ) : null} -
- ) -} - -export function useBlocker( - message: string, - condition: boolean | any = true, -): void { - const { history } = useRouter() - - React.useEffect(() => { - if (!condition) return - - let unblock = history.block((retry, cancel) => { - if (window.confirm(message)) { - unblock() - retry() - } - }) - - return unblock - }) -} - -export function Block({ message, condition, children }: PromptProps) { - useBlocker(message, condition) - return (children ?? null) as ReactNode -} - -export function shallow(objA: T, objB: T) { - if (Object.is(objA, objB)) { - return true - } - - if ( - typeof objA !== 'object' || - objA === null || - typeof objB !== 'object' || - objB === null - ) { - return false - } - - const keysA = Object.keys(objA) - if (keysA.length !== Object.keys(objB).length) { - return false - } - - for (let i = 0; i < keysA.length; i++) { - if ( - !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || - !Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T]) - ) { - return false - } - } - return true -} diff --git a/packages/react-router/src/route.ts b/packages/react-router/src/route.ts index 91735d88c1..9c9a66632c 100644 --- a/packages/react-router/src/route.ts +++ b/packages/react-router/src/route.ts @@ -1,35 +1,35 @@ +import { HistoryLocation } from '@tanstack/history' +import * as React from 'react' import invariant from 'tiny-invariant' -import { RoutePaths } from './routeInfo' +import { useMatch } from './Matches' +import { AnyRouteMatch } from './RouterProvider' +import { NavigateOptions, ParsePathParams, ToSubOptions } from './link' +import { ParsedLocation } from './location' import { joinPaths, trimPath } from './path' +import { RoutePaths } from './routeInfo' import { AnyRouter } from './router' -import { AnyRouteMatch } from './RouteMatch' +import { useParams } from './useParams' +import { useSearch } from './useSearch' import { + Assign, Expand, IsAny, NoInfer, PickRequired, UnionToIntersection, - Assign, } from './utils' -import { NavigateOptions, ParsePathParams, ToSubOptions } from './link' -import { - ErrorRouteComponent, - PendingRouteComponent, - RouteComponent, - RouteProps, - useMatch, - useParams, - useSearch, -} from './react' -import { HistoryLocation } from '@tanstack/history' -import { ParsedLocation } from './location' +import { BuildLocationFn, NavigateFn } from './RouterProvider' export const rootRouteId = '__root__' as const export type RootRouteId = typeof rootRouteId export type AnyPathParams = {} + export type AnySearchSchema = {} + export type AnyContext = {} + export interface RouteContext {} + export interface RouteMeta {} export type PreloadableObj = { preload?: () => Promise } @@ -155,7 +155,8 @@ type BeforeLoadFn< params: TAllParams context: TParentRoute['types']['allContext'] location: ParsedLocation - navigate: (opts: NavigateOptions) => Promise + navigate: NavigateFn + buildLocation: BuildLocationFn }) => Promise | TRouteContext | void export type UpdatableRouteOptions< @@ -714,3 +715,72 @@ export function createRouteMask< ): RouteMask { return opts as any } + +export type RouteProps< + TFullSearchSchema extends Record = AnySearchSchema, + TAllParams extends AnyPathParams = AnyPathParams, + TAllContext extends Record = AnyContext, +> = { + useMatch: (opts?: { + select?: (search: TAllContext) => TSelected + }) => TSelected + useRouteContext: (opts?: { + select?: (search: TAllContext) => TSelected + }) => TSelected + useSearch: (opts?: { + select?: (search: TFullSearchSchema) => TSelected + }) => TSelected + useParams: (opts?: { + select?: (search: TAllParams) => TSelected + }) => TSelected +} + +export type ErrorRouteProps< + TFullSearchSchema extends Record = AnySearchSchema, + TAllParams extends AnyPathParams = AnyPathParams, + TAllContext extends Record = AnyContext, +> = { + error: unknown + info: { componentStack: string } +} & RouteProps + +export type PendingRouteProps< + TFullSearchSchema extends Record = AnySearchSchema, + TAllParams extends AnyPathParams = AnyPathParams, + TAllContext extends Record = AnyContext, +> = RouteProps +// + +export type ReactNode = any + +export type SyncRouteComponent = + | ((props: TProps) => ReactNode) + | React.LazyExoticComponent<(props: TProps) => ReactNode> + +export type AsyncRouteComponent = SyncRouteComponent & { + preload?: () => Promise +} + +export type RouteComponent< + TFullSearchSchema extends Record, + TAllParams extends AnyPathParams, + TAllContext extends Record, +> = AsyncRouteComponent> + +export type ErrorRouteComponent< + TFullSearchSchema extends Record, + TAllParams extends AnyPathParams, + TAllContext extends Record, +> = AsyncRouteComponent< + ErrorRouteProps +> + +export type PendingRouteComponent< + TFullSearchSchema extends Record, + TAllParams extends AnyPathParams, + TAllContext extends Record, +> = AsyncRouteComponent< + PendingRouteProps +> + +export type AnyRouteComponent = RouteComponent diff --git a/packages/react-router/src/router.ts b/packages/react-router/src/router.ts index ad88a55de9..f147e78405 100644 --- a/packages/react-router/src/router.ts +++ b/packages/react-router/src/router.ts @@ -16,8 +16,8 @@ import { ErrorRouteComponent, PendingRouteComponent, RouteComponent, -} from './react' -import { RouteMatch } from './RouteMatch' +} from './route' +import { RouteMatch } from './RouterProvider' import { ParsedLocation } from './location' import { LocationState } from './location' import { SearchSerializer, SearchParser } from './searchParams' @@ -93,12 +93,11 @@ export interface RouterOptions< } export interface RouterState { - status: 'idle' | 'pending' - isFetching: boolean + status: 'pending' | 'idle' matches: RouteMatch[] pendingMatches: RouteMatch[] location: ParsedLocation> - resolvedLocation: ParsedLocation> + resolvedLocation: undefined | ParsedLocation> lastUpdated: number } @@ -149,13 +148,13 @@ export const componentTypes = [ export type RouterEvents = { onBeforeLoad: { type: 'onBeforeLoad' - from: ParsedLocation + from: undefined | ParsedLocation to: ParsedLocation pathChanged: boolean } onLoad: { type: 'onLoad' - from: ParsedLocation + from: undefined | ParsedLocation to: ParsedLocation pathChanged: boolean } diff --git a/packages/react-router/src/searchParams.ts b/packages/react-router/src/searchParams.ts index 2f42605ab3..db7e337229 100644 --- a/packages/react-router/src/searchParams.ts +++ b/packages/react-router/src/searchParams.ts @@ -74,5 +74,6 @@ export function stringifySearchWith( return searchStr ? `?${searchStr}` : '' } } + export type SearchSerializer = (searchObj: Record) => string export type SearchParser = (searchStr: string) => Record diff --git a/packages/react-router/src/useBlocker.tsx b/packages/react-router/src/useBlocker.tsx new file mode 100644 index 0000000000..4636869582 --- /dev/null +++ b/packages/react-router/src/useBlocker.tsx @@ -0,0 +1,34 @@ +import * as React from 'react' +import { ReactNode } from './route' +import { useRouter } from './RouterProvider' + +export function useBlocker( + message: string, + condition: boolean | any = true, +): void { + const { history } = useRouter() + + React.useEffect(() => { + if (!condition) return + + let unblock = history.block((retry, cancel) => { + if (window.confirm(message)) { + unblock() + retry() + } + }) + + return unblock + }) +} + +export function Block({ message, condition, children }: PromptProps) { + useBlocker(message, condition) + return (children ?? null) as ReactNode +} + +export type PromptProps = { + message: string + condition?: boolean | any + children?: ReactNode +} diff --git a/packages/react-router/src/useNavigate.tsx b/packages/react-router/src/useNavigate.tsx new file mode 100644 index 0000000000..e7479a4f98 --- /dev/null +++ b/packages/react-router/src/useNavigate.tsx @@ -0,0 +1,109 @@ +import * as React from 'react' +import { useMatch } from './Matches' +import { useRouter } from './RouterProvider' +import { LinkOptions, NavigateOptions } from './link' +import { AnyRoute } from './route' +import { RoutePaths } from './routeInfo' +import { RegisteredRouter } from './router' +import { useLayoutEffect } from './utils' + +export function useNavigate< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TDefaultFrom extends RoutePaths = '/', +>(defaultOpts?: { from?: TDefaultFrom }) { + const { navigate } = useRouter() + const match = useMatch({ + strict: false, + }) + return React.useCallback( + < + TFrom extends RoutePaths = TDefaultFrom, + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', + >( + opts?: NavigateOptions, + ) => { + return navigate({ + from: opts?.to ? match.pathname : undefined, + ...defaultOpts, + ...(opts as any), + }) + }, + [], + ) +} + +export function typedNavigate< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TDefaultFrom extends RoutePaths = '/', +>(navigate: (opts: NavigateOptions) => Promise) { + return navigate as < + TFrom extends RoutePaths = TDefaultFrom, + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', + >( + opts?: NavigateOptions, + ) => Promise +} // + +export function Navigate< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', +>(props: NavigateOptions): null { + const { navigate } = useRouter() + const match = useMatch({ strict: false }) + + useLayoutEffect(() => { + navigate({ + from: props.to ? match.pathname : undefined, + ...props, + } as any) + }, []) + + return null +} + +export type MakeLinkPropsOptions< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', +> = LinkPropsOptions & + React.AnchorHTMLAttributes + +export type MakeLinkOptions< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', +> = LinkPropsOptions & + Omit, 'children'> & { + // If a function is passed as a child, it will be given the `isActive` boolean to aid in further styling on the element it returns + children?: + | React.ReactNode + | ((state: { isActive: boolean }) => React.ReactNode) + } + +export type LinkPropsOptions< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RoutePaths = '/', + TTo extends string = '', + TMaskFrom extends RoutePaths = '/', + TMaskTo extends string = '', +> = LinkOptions & { + // A function that returns additional props for the `active` state of this link. These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated) + activeProps?: + | React.AnchorHTMLAttributes + | (() => React.AnchorHTMLAttributes) + // A function that returns additional props for the `inactive` state of this link. These props override other props passed to the link (`style`'s are merged, `className`'s are concatenated) + inactiveProps?: + | React.AnchorHTMLAttributes + | (() => React.AnchorHTMLAttributes) +} diff --git a/packages/react-router/src/useParams.tsx b/packages/react-router/src/useParams.tsx new file mode 100644 index 0000000000..13d8b331ee --- /dev/null +++ b/packages/react-router/src/useParams.tsx @@ -0,0 +1,25 @@ +import { AnyRoute } from './route' +import { RouteIds, RouteById, AllParams } from './routeInfo' +import { RegisteredRouter } from './router' +import { last } from './utils' +import { useRouterState } from './RouterProvider' +import { StrictOrFrom } from './utils' + +export function useParams< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RouteIds = RouteIds, + TDefaultSelected = AllParams & + RouteById['types']['allParams'], + TSelected = TDefaultSelected, +>( + opts: StrictOrFrom & { + select?: (search: TDefaultSelected) => TSelected + }, +): TSelected { + return useRouterState({ + select: (state: any) => { + const params = (last(state.matches) as any)?.params + return opts?.select ? opts.select(params) : params + }, + }) +} diff --git a/packages/react-router/src/useSearch.tsx b/packages/react-router/src/useSearch.tsx new file mode 100644 index 0000000000..5b65f157ad --- /dev/null +++ b/packages/react-router/src/useSearch.tsx @@ -0,0 +1,25 @@ +import { AnyRoute } from './route' +import { RouteIds, RouteById } from './routeInfo' +import { RegisteredRouter } from './router' +import { RouteMatch } from './RouterProvider' +import { useMatch } from './Matches' +import { StrictOrFrom } from './utils' + +export function useSearch< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RouteIds = RouteIds, + TStrict extends boolean = true, + TSearch = RouteById['types']['fullSearchSchema'], + TSelected = TSearch, +>( + opts: StrictOrFrom & { + select?: (search: TSearch) => TSelected + }, +): TStrict extends true ? TSelected : TSelected | undefined { + return useMatch({ + ...(opts as any), + select: (match: RouteMatch) => { + return opts?.select ? opts.select(match.search as TSearch) : match.search + }, + }) +} diff --git a/packages/react-router/src/utils.ts b/packages/react-router/src/utils.ts index 50c200a0a1..6eda08698b 100644 --- a/packages/react-router/src/utils.ts +++ b/packages/react-router/src/utils.ts @@ -1,4 +1,10 @@ import * as React from 'react' +import { useMatch } from './Matches' +import { RouteMatch } from './RouterProvider' +import { AnyRoute } from './route' +import { ParseRoute, RouteIds, RoutesById, RouteById } from './routeInfo' +import { RegisteredRouter } from './router' + export type NoInfer = [T][T extends any ? 0 : never] export type IsAny = 1 extends 0 & T ? Y : N export type IsAnyBoolean = 1 extends 0 & T ? true : false @@ -239,9 +245,9 @@ export function partialDeepEqual(a: any, b: any): boolean { } if (Array.isArray(a) && Array.isArray(b)) { - return ( - a.length === b.length && - a.every((item, index) => partialDeepEqual(item, b[index])) + return !( + a.length !== b.length || + a.some((item, index) => !partialDeepEqual(item, b[index])) ) } @@ -255,3 +261,77 @@ export function useStableCallback any>(fn: T): T { const ref = React.useRef((...args: any[]) => fnRef.current(...args)) return ref.current as T } + +export function shallow(objA: T, objB: T) { + if (Object.is(objA, objB)) { + return true + } + + if ( + typeof objA !== 'object' || + objA === null || + typeof objB !== 'object' || + objB === null + ) { + return false + } + + const keysA = Object.keys(objA) + if (keysA.length !== Object.keys(objB).length) { + return false + } + + for (let i = 0; i < keysA.length; i++) { + if ( + !Object.prototype.hasOwnProperty.call(objB, keysA[i] as string) || + !Object.is(objA[keysA[i] as keyof T], objB[keysA[i] as keyof T]) + ) { + return false + } + } + return true +} + +export type StrictOrFrom = + | { + from: TFrom + strict?: true + } + | { + from?: never + strict: false + } + +export type RouteFromIdOrRoute< + T, + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], +> = T extends ParseRoute + ? T + : T extends RouteIds + ? RoutesById[T] + : T extends string + ? RouteIds + : never + +export function useRouteContext< + TRouteTree extends AnyRoute = RegisteredRouter['routeTree'], + TFrom extends RouteIds = RouteIds, + TStrict extends boolean = true, + TRouteContext = RouteById['types']['allContext'], + TSelected = TRouteContext, +>( + opts: StrictOrFrom & { + select?: (search: TRouteContext) => TSelected + }, +): TStrict extends true ? TSelected : TSelected | undefined { + return useMatch({ + ...(opts as any), + select: (match: RouteMatch) => + opts?.select + ? opts.select(match.context as TRouteContext) + : match.context, + }) +} + +export const useLayoutEffect = + typeof window !== 'undefined' ? React.useLayoutEffect : React.useEffect diff --git a/packages/react-router/tests/createRoute.test.ts b/packages/react-router/tests/createRoute.test.ts index a0572c545f..7f0e0bac54 100644 --- a/packages/react-router/tests/createRoute.test.ts +++ b/packages/react-router/tests/createRoute.test.ts @@ -37,7 +37,7 @@ describe('everything', () => { // const dashboardRoute = new Route({ // getParentRoute: () => rootRoute, // path: 'dashboard', - // loader: async () => { + // load: async () => { // console.log('Fetching all invoices...') // return { // invoices: 'await fetchInvoices()', @@ -63,7 +63,7 @@ describe('everything', () => { // stringifyParams: ({ invoiceId }) => ({ // invoiceId: String(invoiceId), // }), - // loader: async ({ params: { invoiceId } }) => { + // load: async ({ params: { invoiceId } }) => { // console.log('Fetching invoice...') // return { // invoice: 'await fetchInvoiceById(invoiceId!)', @@ -73,7 +73,7 @@ describe('everything', () => { // const usersRoute = new Route({ // getParentRoute: () => dashboardRoute, // path: 'users', - // loader: async () => { + // load: async () => { // return { // users: 'await fetchUsers()', // } @@ -103,7 +103,7 @@ describe('everything', () => { // const userRoute = new Route({ // getParentRoute: () => usersRoute, // path: '$userId', - // loader: async ({ params: { userId }, search }) => { + // load: async ({ params: { userId }, search }) => { // return { // user: 'await fetchUserById(userId!)', // } diff --git a/packages/react-router/tests/index.test.ts b/packages/react-router/tests/index.test.ts index 29245f59b5..82f0af175a 100644 --- a/packages/react-router/tests/index.test.ts +++ b/packages/react-router/tests/index.test.ts @@ -187,11 +187,11 @@ test('it works', () => {}) // }, // { // path: 'a', -// loader: () => sleep(10).then((d) => ({ a: true })), +// load: () => sleep(10).then((d) => ({ a: true })), // children: [ // { // path: 'b', -// loader: () => sleep(10).then((d) => ({ b: true })), +// load: () => sleep(10).then((d) => ({ b: true })), // }, // ], // }, @@ -225,7 +225,7 @@ test('it works', () => {}) // import: async () => { // await sleep(10) // return { -// loader: () => sleep(10).then((d) => ({ a: true })), +// load: () => sleep(10).then((d) => ({ a: true })), // } // }, // children: [ @@ -234,7 +234,7 @@ test('it works', () => {}) // import: async () => { // await sleep(10) // return { -// loader: () => +// load: () => // sleep(10).then((d) => ({ // b: true, // })), @@ -277,7 +277,7 @@ test('it works', () => {}) // await sleep(20) // return 'element' // }, -// loader: () => sleep(30).then((d) => ({ a: true })), +// load: () => sleep(30).then((d) => ({ a: true })), // } // }, // children: [ @@ -290,7 +290,7 @@ test('it works', () => {}) // await sleep(20) // return 'element' // }, -// loader: () => +// load: () => // sleep(30).then((d) => ({ // b: true, // })), @@ -326,12 +326,12 @@ test('it works', () => {}) // { // path: 'a', // pendingMs: 10, -// loader: () => sleep(20), +// load: () => sleep(20), // children: [ // { // path: 'b', // pendingMs: 30, -// loader: () => sleep(40), +// load: () => sleep(40), // }, // ], // }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e63c7f7e4d..49190423d3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + overrides: '@tanstack/history': workspace:* '@tanstack/react-router': workspace:* @@ -441,6 +445,9 @@ importers: '@tanstack/react-query': specifier: ^5.7.0 version: 5.7.0(react-dom@18.2.0)(react@18.2.0) + '@tanstack/react-query-devtools': + specifier: ^5.8.3 + version: 5.8.3(@tanstack/react-query@5.7.0)(react-dom@18.2.0)(react@18.2.0) '@tanstack/react-router': specifier: workspace:* version: link:../../../packages/react-router @@ -1817,6 +1824,12 @@ importers: '@tanstack/history': specifier: workspace:* version: link:../history + react: + specifier: '>=16' + version: 18.2.0 + react-dom: + specifier: '>=16' + version: 18.2.0(react@18.2.0) tiny-invariant: specifier: ^1.3.1 version: 1.3.1 @@ -1898,6 +1911,12 @@ importers: date-fns: specifier: ^2.29.1 version: 2.29.3 + react: + specifier: '>=16' + version: 18.2.0 + react-dom: + specifier: '>=16' + version: 18.2.0(react@18.2.0) packages: @@ -3403,6 +3422,7 @@ packages: /@emotion/memoize@0.7.4: resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + requiresBuild: true dev: false optional: true @@ -4504,6 +4524,10 @@ packages: resolution: {integrity: sha512-n+UFgL4C/OWNpmVR8v/cj4WIMw5xapGLsZS0qHXgGrpXqNTp2IPWzeU848KHpL7BqBQZeuKDj20BuID+1FsmgA==} dev: false + /@tanstack/query-devtools@5.8.3: + resolution: {integrity: sha512-tgVzqWpIg611UXqoIkyOLjEdrV3uNXg86kPrst0/OUP59znma68mOAHw8OEy/xxjN3t94InOfWhmVtkSQ9E8Lg==} + dev: false + /@tanstack/react-actions@0.0.1-beta.203(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-96NtvQAsQb4sYMnJDX1fM4QVo8IL2ggB6ui4o47QEIrfjidZTjaFR+71tZMBxqnsfmaTELmyTL+u31r5dLHeJg==} engines: {node: '>=12'} @@ -4644,6 +4668,19 @@ packages: react-dom: 18.2.0(react@18.2.0) dev: false + /@tanstack/react-query-devtools@5.8.3(@tanstack/react-query@5.7.0)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-C36SQVh3wEjj+wEage2nyPBXQJj+gMqmbyXvUAVwywUjHC8OdpyYFkTiVQVRlPcxh+Td2qhsrn1IHPxd39YkrQ==} + peerDependencies: + '@tanstack/react-query': ^5.8.3 + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@tanstack/query-devtools': 5.8.3 + '@tanstack/react-query': 5.7.0(react-dom@18.2.0)(react@18.2.0) + react: 18.2.0 + react-dom: 18.2.0(react@18.2.0) + dev: false + /@tanstack/react-query@4.18.0(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-s1kdbGMdVcfUIllzsHUqVUdktBT5uuIRgnvrqFNLjl9TSOXEoBSDrhjsGjao0INQZv8cMpQlgOh3YH9YtN6cKw==} peerDependencies: @@ -10848,7 +10885,3 @@ packages: /zod@3.21.4: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} dev: false - -settings: - autoInstallPeers: false - excludeLinksFromLockfile: false