diff --git a/docs/guide/data-loading.md b/docs/guide/data-loading.md index ac5209cc80..ea7707bb6f 100644 --- a/docs/guide/data-loading.md +++ b/docs/guide/data-loading.md @@ -194,16 +194,17 @@ const postsLoader = new Loader({ }) const loaderClient = new LoaderClient({ - getLoaders: () => ({ postsLoader }), + loader: [postsLoader], }) -// Use RootRoute's special `withRouterContext` method to require a specific type -// of router context to be both available in every route and to be passed to -// the router for implementation. +// Create a new routerContext using new RouterContext<{...}>() class and pass it whatever types you would like to be available in your router context. -const rootRoute = RootRoute.withRouterContext<{ +const routerContext = new RouterContext<{ loaderClient: typeof loaderClient -}>()() +}>() + +// Then use the same routerContext to create your root route +const rootRoute = routerContext.createRootRoute() // Notice how our postsRoute references context to get the loader client // This can be a powerful tool for dependency injection across your router @@ -211,8 +212,8 @@ const rootRoute = RootRoute.withRouterContext<{ const postsRoute = new Route({ getParentPath: () => rootRoute, path: 'posts', - async loader({ context }) { - const { postsLoader } = context.loaderClient + async loader({ context: { loaderClient } }) { + const { postsLoader } = loaderClient await postsLoader.load() return () => useLoader({ loader: postsLoader }) }, @@ -225,10 +226,12 @@ const postsRoute = new Route({ const routeTree = rootRoute.addChildren([postsRoute]) +// Use your routerContext to create a new router +// This will require that you fullfil the type requirements of the routerContext const router = new Router({ routeTree, context: { - // Supply our loaderClient to the whole router + // Supply our loaderClient to the router (and all routes) loaderClient, }, }) diff --git a/docs/guide/router-context.md b/docs/guide/router-context.md index eae7584e35..36ff1b0ded 100644 --- a/docs/guide/router-context.md +++ b/docs/guide/router-context.md @@ -15,7 +15,7 @@ These are just suggested uses of the router context. You can use it for whatever ## Typed Router Context -Like everything else, the router context (at least the one you inject at `new Router()` is strictly typed. This type can be augemented via routes' `getContext` option. If that's the case, the type at the edge of the route is a merged interface-like type of the base context type and every route's `getContext` return type. To constrain the type of the router context, you must use the `RootRoute.withRouterContext()` factory instead of the `new RootRoute()` constructor. Here's an example: +Like everything else, the router context (at least the one you inject at `new Router()` is strictly typed. This type can be augmented via any route's `getContext` option. If that's the case, the type at the edge of the route is a merged interface-like type of the base context type and every route's `getContext` return type. To constrain the type of the root router context, you must use the `new RouteContext()` class to create a new `routerContext` and then use the `routerContext.createRootRoute()` method instead of the `new RootRoute()` class to create your root route. Here's an example: ```tsx import { RootRoute } from '@tanstack/router' @@ -24,12 +24,22 @@ interface MyRouterContext { user: User } -const rootRoute = RootRoute.withRouterContext()({ +const routerContext = new RouterContext() + +// Use the routerContext to create your root route +const rootRoute = routerContext.createRootRoute({ component: App, }) -``` -> ⚠️ Did you notice the curried call above? Make sure you first call `RootRoute.withRouterContext()` and then call the returned function with the route options. This is a requirement of the `RootRoute.withRouterContext` factory. +const routeTree = rootRoute.addChildren([ + // ... +]) + +// Use the routerContext to create your router +const router = new Router({ + routeTree, +}) +``` ## Passing the initial Router Context @@ -40,6 +50,7 @@ The router context is passed to the router at instantiation time. You can pass t ```tsx import { Router } from '@tanstack/router' +// Use the routerContext you created to create your router const router = new Router({ routeTree, context: { @@ -58,7 +69,7 @@ Once you have defined the router context type, you can use it in your route defi ```tsx import { Route } from '@tanstack/router' -const userRoute = Route({ +const userRoute = new Route({ getRootRoute: () => rootRoute, path: 'todos', component: Todos, @@ -68,7 +79,7 @@ const userRoute = Route({ }) ``` -You can even inject your data fetching client itself! +You can even inject your data fetching client itself... in fact, this is highly recommended! ```tsx import { RootRoute } from '@tanstack/router' @@ -77,9 +88,7 @@ interface MyRouterContext { queryClient: QueryClient } -const rootRoute = RootRoute.withRouterContext()({ - component: App, -}) +const routerContext = new RouterContext() const queryClient = new QueryClient() @@ -96,7 +105,7 @@ Then, in your route: ```tsx import { Route } from '@tanstack/router' -const userRoute = Route({ +const userRoute = new Route({ getRootRoute: () => rootRoute, path: 'todos', component: Todos, @@ -120,7 +129,9 @@ interface MyRouterContext { foo: boolean } -const rootRoute = RootRoute.withRouterContext()({ +const routerContext = new RouterContext() + +const rootRoute = routerContext.createRootRoute({ component: App, }) @@ -131,7 +142,7 @@ const router = new Router({ }, }) -const userRoute = Route({ +const userRoute = new Route({ getRootRoute: () => rootRoute, path: 'admin', component: Todos, diff --git a/docs/guide/ssr-and-streaming.md b/docs/guide/ssr-and-streaming.md index 52747c2769..50af3b7640 100644 --- a/docs/guide/ssr-and-streaming.md +++ b/docs/guide/ssr-and-streaming.md @@ -216,7 +216,7 @@ export function createRouter() { // Optionally, we can use `Wrap` to wrap our router in the loader client provider Wrap: ({ children }) => { return ( - + {children} ) diff --git a/docs/guide/type-safety.md b/docs/guide/type-safety.md index 63aba22fec..10f8c89d1e 100644 --- a/docs/guide/type-safety.md +++ b/docs/guide/type-safety.md @@ -62,15 +62,13 @@ The `from` property is optional, which means if you don't pass it, you'll get th Router context is so extremely useful as it's the ultimate hierarchical dependency injection. You can supply context to the router and to each and every route it renders. As you build up this context, TanStack Router will merge it down with the hierarchy of routes, so that each route has access to the context of all of its parents. -If you want to use context, it's highly recommended that you use the `RootRoute.withRouterContext()(rootRouteOptions)` utility. - -This utility will create a requirement for you to pass a context type to your router, and will also ensure that your context is properly typed throughout the entire route tree. +The `new RouteContext()` utility creates a new router context that when instantiated with a type, creates a requirement for you to fullfil the same type contract to your router, and will also ensure that your context is properly typed throughout the entire route tree. ```tsx -const rootRoute = new RootRoute.withRouterContext<{ whateverYouWant: true }>()({ - component: () => { - // ... - }, +const routeContext = new RouteContext<{ whateverYouWant: true }>() + +const rootRoute = routeContext.createRootRoute({ + component: App, }) const routeTree = rootRoute.addChildren([ diff --git a/examples/react/basic-ssr-streaming/src/entry-server.tsx b/examples/react/basic-ssr-streaming/src/entry-server.tsx index df7bae9c54..88698ac09d 100644 --- a/examples/react/basic-ssr-streaming/src/entry-server.tsx +++ b/examples/react/basic-ssr-streaming/src/entry-server.tsx @@ -12,7 +12,6 @@ import express from 'express' // index.js import './fetch-polyfill' import { createRouter } from './router' -import { Transform } from 'stream' type ReactReadableStream = ReadableStream & { allReady?: Promise | undefined @@ -34,7 +33,6 @@ export async function render(opts: { router.update({ history: memoryHistory, context: { - ...router.context, head: opts.head, }, }) diff --git a/examples/react/basic-ssr-streaming/src/index.tsx b/examples/react/basic-ssr-streaming/src/index.tsx index c01fb07cdd..cfb3c10a3e 100644 --- a/examples/react/basic-ssr-streaming/src/index.tsx +++ b/examples/react/basic-ssr-streaming/src/index.tsx @@ -30,7 +30,7 @@ export function App({ /> - + diff --git a/examples/react/basic-ssr-streaming/src/loaderClient.tsx b/examples/react/basic-ssr-streaming/src/loaderClient.tsx index 37723b5fb4..87c2b88234 100644 --- a/examples/react/basic-ssr-streaming/src/loaderClient.tsx +++ b/examples/react/basic-ssr-streaming/src/loaderClient.tsx @@ -4,7 +4,7 @@ import { postLoader } from './routes/posts/$postId' export const createLoaderClient = () => { return new LoaderClient({ - getLoaders: () => ({ postsLoader, postLoader, testLoader }), + loaders: [postsLoader, postLoader, testLoader], }) } diff --git a/examples/react/basic-ssr-streaming/src/router.tsx b/examples/react/basic-ssr-streaming/src/router.tsx index 3b84eb5576..9827ef6229 100644 --- a/examples/react/basic-ssr-streaming/src/router.tsx +++ b/examples/react/basic-ssr-streaming/src/router.tsx @@ -1,4 +1,4 @@ -import { Router } from '@tanstack/router' +import { Router, RouterContext } from '@tanstack/router' import { LoaderClientProvider } from '@tanstack/react-loaders' import { rootRoute } from './routes/root' @@ -10,10 +10,10 @@ import { postIdRoute } from './routes/posts/$postId' import { createLoaderClient } from './loaderClient' import React from 'react' -export type RouterContext = { +export const routerContext = new RouterContext<{ loaderClient: ReturnType head: string -} +}>() export const routeTree = rootRoute.addChildren([ indexRoute, @@ -42,7 +42,7 @@ export function createRouter() { // Wrap our router in the loader client provider Wrap: ({ children }) => { return ( - + {children} ) @@ -55,7 +55,7 @@ export function createRouter() { hydrateLoaderInstanceFn: (instance) => router.hydrateData(instance.hashedKey) as any, dehydrateLoaderInstanceFn: (instance) => - router.dehydrateData(instance.hashedKey, () => instance.state), + router.dehydrateData(instance.hashedKey, () => instance), } return router diff --git a/examples/react/basic-ssr-streaming/src/routes/posts.tsx b/examples/react/basic-ssr-streaming/src/routes/posts.tsx index fd2289c5cb..18c024098a 100644 --- a/examples/react/basic-ssr-streaming/src/routes/posts.tsx +++ b/examples/react/basic-ssr-streaming/src/routes/posts.tsx @@ -1,23 +1,9 @@ import * as React from 'react' -import { - Link, - Outlet, - Route, - StreamedPromise, - useDehydrate, - useHydrate, - useInjectHtml, - useRouter, -} from '@tanstack/router' +import { Link, Outlet, Route } from '@tanstack/router' import { rootRoute } from './root' -// import { loaderClient } from '../entry-client' -import { Loader } from '@tanstack/react-loaders' +import { Loader, useLoaderInstance } from '@tanstack/react-loaders' import { postIdRoute } from './posts/$postId' -declare module 'react' { - function use(promise: Promise): T -} - export type PostType = { id: string title: string @@ -25,6 +11,7 @@ export type PostType = { } export const postsLoader = new Loader({ + key: 'posts', fn: async () => { console.log('Fetching posts...') await new Promise((r) => @@ -38,6 +25,7 @@ export const postsLoader = new Loader({ }) export const testLoader = new Loader({ + key: 'test', fn: async (wait: number) => { await new Promise((r) => setTimeout(r, wait)) return { @@ -49,27 +37,20 @@ export const testLoader = new Loader({ export const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: async ({ context, preload }) => { - const { postsLoader } = context.loaderClient.loaders - await postsLoader.load({ preload }) - return { - usePosts: () => postsLoader.useLoader(), - } + loader: async ({ context: { loaderClient }, preload }) => { + await loaderClient.load({ key: 'posts', preload }) + return () => useLoaderInstance({ key: 'posts' }) }, component: function Posts({ useLoader }) { - const { usePosts } = useLoader() - - const { - state: { data: posts }, - } = usePosts() + const { data: posts } = useLoader()() return (
- - + +
    {posts?.map((post) => { return ( @@ -104,9 +85,10 @@ function Test({ wait }: { wait: number }) { } function TestInner({ wait }: { wait: number }) { - const instance = testLoader.useLoader({ + const instance = useLoaderInstance({ + key: 'test', variables: wait, }) - return
    Test: {instance.state.data.test}
    + return
    Test: {instance.data.test}
    } diff --git a/examples/react/basic-ssr-streaming/src/routes/posts/$postId.tsx b/examples/react/basic-ssr-streaming/src/routes/posts/$postId.tsx index 25075957ca..fb42d44448 100644 --- a/examples/react/basic-ssr-streaming/src/routes/posts/$postId.tsx +++ b/examples/react/basic-ssr-streaming/src/routes/posts/$postId.tsx @@ -1,10 +1,16 @@ -import { Loader } from '@tanstack/react-loaders' +import { + createLoaderOptions, + Loader, + typedClient, + useLoaderInstance, +} from '@tanstack/react-loaders' import { Route } from '@tanstack/router' import * as React from 'react' // import { loaderClient } from '../../entry-client' import { postsLoader, postsRoute, PostType } from '../posts' export const postLoader = new Loader({ + key: 'post', fn: async (postId: string) => { console.log(`Fetching post with id ${postId}...`) @@ -16,31 +22,30 @@ export const postLoader = new Loader({ (r) => r.json() as Promise, ) }, - onInvalidate: async () => { - await postsLoader.invalidate() + onInvalidate: async ({ client }) => { + await typedClient(client).invalidateLoader({ key: 'posts' }) }, }) export const postIdRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', - loader: async ({ context, params: { postId }, preload }) => { - const { postLoader } = context.loaderClient.loaders - - const instance = postLoader.getInstance({ - variables: postId, - }) - - await instance.load({ + loader: async ({ + context: { loaderClient }, + params: { postId }, + preload, + }) => { + const loaderOpts = createLoaderOptions({ key: 'post', variables: postId }) + + await loaderClient.load({ + ...loaderOpts, preload, }) - return () => instance.useInstance() + return () => useLoaderInstance(loaderOpts) }, component: function Post({ useLoader }) { - const { - state: { data: post }, - } = useLoader()() + const { data: post } = useLoader()() return (
    diff --git a/examples/react/basic-ssr-streaming/src/routes/root.tsx b/examples/react/basic-ssr-streaming/src/routes/root.tsx index 0c808edb08..c8c8431970 100644 --- a/examples/react/basic-ssr-streaming/src/routes/root.tsx +++ b/examples/react/basic-ssr-streaming/src/routes/root.tsx @@ -1,10 +1,10 @@ import { TanStackRouterDevtools } from '@tanstack/router-devtools' import * as React from 'react' -import { Link, Outlet, RootRoute } from '@tanstack/router' -import { RouterContext } from '../router' +import { Link, Outlet } from '@tanstack/router' +import { routerContext } from '../router' import { DehydrateRouter } from '@tanstack/react-start/client' -export const rootRoute = RootRoute.withRouterContext()({ +export const rootRoute = routerContext.createRootRoute({ component: Root, }) diff --git a/examples/react/basic-ssr/src/loaderClient.tsx b/examples/react/basic-ssr/src/loaderClient.tsx index 0b0635e4f5..e1b9048ec1 100644 --- a/examples/react/basic-ssr/src/loaderClient.tsx +++ b/examples/react/basic-ssr/src/loaderClient.tsx @@ -4,7 +4,7 @@ import { postLoader } from './routes/posts/$postId' export const createLoaderClient = () => { return new LoaderClient({ - getLoaders: () => ({ postsLoader, postLoader }), + loaders: [postsLoader, postLoader], }) } diff --git a/examples/react/basic-ssr/src/router.tsx b/examples/react/basic-ssr/src/router.tsx index 5dd5e42baf..5bd8303c02 100644 --- a/examples/react/basic-ssr/src/router.tsx +++ b/examples/react/basic-ssr/src/router.tsx @@ -1,4 +1,4 @@ -import { Router } from '@tanstack/router' +import { Router, RouterContext } from '@tanstack/router' import { LoaderClientProvider } from '@tanstack/react-loaders' import { rootRoute } from './routes/root' @@ -10,10 +10,10 @@ import { postIdRoute } from './routes/posts/$postId' import { createLoaderClient } from './loaderClient' import React from 'react' -export type RouterContext = { +export const routerContext = new RouterContext<{ loaderClient: ReturnType head: string -} +}>() export const routeTree = rootRoute.addChildren([ indexRoute, @@ -42,7 +42,7 @@ export function createRouter() { // Wrap our router in the loader client provider Wrap: ({ children }) => { return ( - + {children} ) diff --git a/examples/react/basic-ssr/src/routes/posts.tsx b/examples/react/basic-ssr/src/routes/posts.tsx index 1c6442fb0e..66177a5d1d 100644 --- a/examples/react/basic-ssr/src/routes/posts.tsx +++ b/examples/react/basic-ssr/src/routes/posts.tsx @@ -1,8 +1,7 @@ import * as React from 'react' import { Link, Outlet, Route } from '@tanstack/router' import { rootRoute } from './root' -// import { loaderClient } from '../entry-client' -import { Loader } from '@tanstack/react-loaders' +import { Loader, useLoaderInstance } from '@tanstack/react-loaders' import { postIdRoute } from './posts/$postId' export type PostType = { @@ -12,6 +11,7 @@ export type PostType = { } export const postsLoader = new Loader({ + key: 'posts', fn: async () => { console.log('Fetching posts...') await new Promise((r) => @@ -26,15 +26,12 @@ export const postsLoader = new Loader({ export const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: async ({ context, preload }) => { - const { postsLoader } = context.loaderClient.loaders - await postsLoader.load({ preload }) - return () => postsLoader.useLoader() + loader: async ({ context: { loaderClient }, preload }) => { + await loaderClient.load({ key: 'posts', preload }) + return () => useLoaderInstance({ key: 'posts' }) }, component: function Posts({ useLoader }) { - const { - state: { data: posts }, - } = useLoader()() + const { data: posts } = useLoader()() return (
    diff --git a/examples/react/basic-ssr/src/routes/posts/$postId.tsx b/examples/react/basic-ssr/src/routes/posts/$postId.tsx index 2bb537692e..7dee35737a 100644 --- a/examples/react/basic-ssr/src/routes/posts/$postId.tsx +++ b/examples/react/basic-ssr/src/routes/posts/$postId.tsx @@ -1,10 +1,16 @@ -import { Loader } from '@tanstack/react-loaders' +import { + createLoaderOptions, + Loader, + typedClient, + useLoaderInstance, +} from '@tanstack/react-loaders' import { Route } from '@tanstack/router' import * as React from 'react' // import { loaderClient } from '../../entry-client' -import { postsLoader, postsRoute, PostType } from '../posts' +import { postsRoute, PostType } from '../posts' export const postLoader = new Loader({ + key: 'post', fn: async (postId: string) => { console.log(`Fetching post with id ${postId}...`) @@ -14,31 +20,33 @@ export const postLoader = new Loader({ (r) => r.json() as Promise, ) }, - onInvalidate: async () => { - await postsLoader.invalidate() + onInvalidate: async ({ client }) => { + await typedClient(client).invalidateLoader({ key: 'posts' }) }, }) export const postIdRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', - loader: async ({ context, params: { postId }, preload }) => { - const { postLoader } = context.loaderClient.loaders - - const instance = postLoader.getInstance({ + loader: async ({ + context: { loaderClient }, + params: { postId }, + preload, + }) => { + const loaderOptions = createLoaderOptions({ + key: 'post', variables: postId, }) - await instance.load({ + await loaderClient.load({ + ...loaderOptions, preload, }) - return () => instance.useInstance() + return () => useLoaderInstance(loaderOptions) }, component: function Post({ useLoader }) { - const { - state: { data: post }, - } = useLoader()() + const { data: post } = useLoader()() return (
    diff --git a/examples/react/basic-ssr/src/routes/root.tsx b/examples/react/basic-ssr/src/routes/root.tsx index 408d6779d9..fa39549e58 100644 --- a/examples/react/basic-ssr/src/routes/root.tsx +++ b/examples/react/basic-ssr/src/routes/root.tsx @@ -1,10 +1,10 @@ import { TanStackRouterDevtools } from '@tanstack/router-devtools' import * as React from 'react' import { Link, Outlet, RootRoute } from '@tanstack/router' -import { RouterContext } from '../router' import { DehydrateRouter } from '@tanstack/react-start/client' +import { routerContext } from '../router' -export const rootRoute = RootRoute.withRouterContext()({ +export const rootRoute = routerContext.createRootRoute({ component: Root, }) diff --git a/examples/react/basic/src/main.tsx b/examples/react/basic/src/main.tsx index bec121b059..72eebcd47b 100644 --- a/examples/react/basic/src/main.tsx +++ b/examples/react/basic/src/main.tsx @@ -3,11 +3,11 @@ import ReactDOM from 'react-dom/client' import { Outlet, RouterProvider, - Router, Link, - RootRoute, Route, ErrorComponent, + RouterContext, + Router, } from '@tanstack/router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' import axios from 'axios' @@ -15,7 +15,9 @@ import { LoaderClient, Loader, LoaderClientProvider, - useLoader, + useLoaderInstance, + createLoaderOptions, + typedClient, } from '@tanstack/react-loaders' type PostType = { @@ -47,21 +49,20 @@ const fetchPost = async (postId: string) => { } const postsLoader = new Loader({ + key: 'posts', fn: fetchPosts, }) const postLoader = new Loader({ + key: 'post', fn: fetchPost, - onInvalidate: () => { - postsLoader.invalidate() + onInvalidate: ({ client }) => { + typedClient(client).invalidateLoader({ key: 'posts' }) }, }) const loaderClient = new LoaderClient({ - getLoaders: () => ({ - posts: postsLoader, - post: postLoader, - }), + loaders: [postsLoader, postLoader], }) declare module '@tanstack/react-loaders' { @@ -70,11 +71,11 @@ declare module '@tanstack/react-loaders' { } } -type RouterContext = { +const routerContext = new RouterContext<{ loaderClient: typeof loaderClient -} +}>() -const rootRoute = RootRoute.withRouterContext()({ +const rootRoute = routerContext.createRootRoute({ component: () => { return ( <> @@ -121,27 +122,18 @@ const indexRoute = new Route({ const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: async ({ context }) => { - const postsLoader = context.loaderClient.loaders.posts - - await postsLoader.load() - - return { - promise: new Promise((r) => setTimeout(r, 500)), - useLoader: () => - useLoader({ - loader: postsLoader, - }), - } + loader: async ({ context: { loaderClient } }) => { + await loaderClient.load({ key: 'posts' }) + return () => useLoaderInstance({ key: 'posts' }) }, component: ({ useLoader }) => { - const postsLoader = useLoader().useLoader() + const postsLoader = useLoader()() return (
      {[ - ...postsLoader.state.data, + ...postsLoader.data, { id: 'i-do-not-exist', title: 'Non-existent Post' }, ]?.map((post) => { return ( @@ -179,17 +171,15 @@ const postRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', loader: async ({ context: { loaderClient }, params: { postId } }) => { - const postLoader = loaderClient.loaders.post - await postLoader.load({ + const loaderOptions = createLoaderOptions({ + key: 'post', variables: postId, }) + await loaderClient.load(loaderOptions) + // Return a curried hook! - return () => - useLoader({ - loader: postLoader, - variables: postId, - }) + return () => useLoaderInstance(loaderOptions) }, errorComponent: ({ error }) => { if (error instanceof NotFoundError) { @@ -199,9 +189,7 @@ const postRoute = new Route({ return }, component: () => { - const { - state: { data: post }, - } = postRoute.useLoader()() + const { data: post } = postRoute.useLoader()() return (
      @@ -240,7 +228,7 @@ if (!rootElement.innerHTML) { root.render( // - + , // , diff --git a/examples/react/kitchen-sink-multi-file/src/actionClient.tsx b/examples/react/kitchen-sink-multi-file/src/actionClient.tsx index 73f8dc4b21..ec537abf59 100644 --- a/examples/react/kitchen-sink-multi-file/src/actionClient.tsx +++ b/examples/react/kitchen-sink-multi-file/src/actionClient.tsx @@ -1,12 +1,13 @@ -import { ActionClient } from '@tanstack/react-actions' import { updateInvoiceAction } from './routes/dashboard/invoices/invoice' import { createInvoiceAction } from './routes/dashboard/invoices/invoices' +import { actionContext } from './actionContext' +import { loaderClient } from './loaderClient' -export const actionClient = new ActionClient({ - getActions: () => ({ - createInvoiceAction, - updateInvoiceAction, - }), +export const actionClient = actionContext.createClient({ + actions: [createInvoiceAction, updateInvoiceAction], + context: { + loaderClient, + }, }) declare module '@tanstack/react-actions' { diff --git a/examples/react/kitchen-sink-multi-file/src/actionContext.tsx b/examples/react/kitchen-sink-multi-file/src/actionContext.tsx new file mode 100644 index 0000000000..615118c6d0 --- /dev/null +++ b/examples/react/kitchen-sink-multi-file/src/actionContext.tsx @@ -0,0 +1,6 @@ +import { ActionContext } from '@tanstack/react-actions' +import { loaderClient } from './loaderClient' + +export const actionContext = new ActionContext<{ + loaderClient: typeof loaderClient +}>() diff --git a/examples/react/kitchen-sink-multi-file/src/loaderClient.tsx b/examples/react/kitchen-sink-multi-file/src/loaderClient.tsx index 0e64a01e25..f31c1c393d 100644 --- a/examples/react/kitchen-sink-multi-file/src/loaderClient.tsx +++ b/examples/react/kitchen-sink-multi-file/src/loaderClient.tsx @@ -6,13 +6,13 @@ import { userLoader } from './routes/dashboard/users/user' import { randomIdLoader } from './routes/layout/' export const loaderClient = new LoaderClient({ - getLoaders: () => ({ + loaders: [ invoicesLoader, invoiceLoader, usersLoader, userLoader, randomIdLoader, - }), + ], }) declare module '@tanstack/react-loaders' { diff --git a/examples/react/kitchen-sink-multi-file/src/main.tsx b/examples/react/kitchen-sink-multi-file/src/main.tsx index 7ae46036f5..f2b9a15b0c 100644 --- a/examples/react/kitchen-sink-multi-file/src/main.tsx +++ b/examples/react/kitchen-sink-multi-file/src/main.tsx @@ -66,10 +66,10 @@ function App() {
      - + diff --git a/examples/react/kitchen-sink-multi-file/src/router.tsx b/examples/react/kitchen-sink-multi-file/src/router.tsx index 39937ec02d..cf83b873aa 100644 --- a/examples/react/kitchen-sink-multi-file/src/router.tsx +++ b/examples/react/kitchen-sink-multi-file/src/router.tsx @@ -1,5 +1,5 @@ import * as React from 'react' -import { Router } from '@tanstack/router' +import { Router, RouterContext } from '@tanstack/router' import { rootRoute } from './routes/root' import { indexRoute } from './routes' @@ -12,14 +12,8 @@ import { import { layoutRoute } from './routes/layout' import { dashboardIndexRoute } from './routes/dashboard/dashboard' import { invoicesRoute } from './routes/dashboard/invoices' -import { - createInvoiceAction, - invoicesIndexRoute, -} from './routes/dashboard/invoices/invoices' -import { - invoiceRoute, - updateInvoiceAction, -} from './routes/dashboard/invoices/invoice' +import { invoicesIndexRoute } from './routes/dashboard/invoices/invoices' +import { invoiceRoute } from './routes/dashboard/invoices/invoice' import { usersRoute } from './routes/dashboard/users' import { usersIndexRoute } from './routes/dashboard/users/users' import { userRoute } from './routes/dashboard/users/user' @@ -27,6 +21,12 @@ import { layoutRouteA } from './routes/layout/layout-a' import { layoutRouteB } from './routes/layout/layout-b' import { Spinner } from './components/Spinner' import { loaderClient } from './loaderClient' +import { actionClient } from './actionClient' + +export const routerContext = new RouterContext<{ + loaderClient: typeof loaderClient + actionClient: typeof actionClient +}>() const routeTree = rootRoute.addChildren([ indexRoute, @@ -40,14 +40,11 @@ const routeTree = rootRoute.addChildren([ layoutRoute.addChildren([layoutRouteA, layoutRouteB]), ]) -export type RouterContext = { - loaderClient: typeof loaderClient -} - export const router = new Router({ routeTree, context: { loaderClient, + actionClient, }, defaultPendingComponent: () => (
      @@ -55,8 +52,7 @@ export const router = new Router({
      ), onRouteChange: () => { - createInvoiceAction.clear() - updateInvoiceAction.clear() + actionClient.clearAll() }, }) diff --git a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/dashboard.tsx b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/dashboard.tsx index e387217fc0..7e9de19b87 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/dashboard.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/dashboard.tsx @@ -1,4 +1,4 @@ -import { useLoader } from '@tanstack/react-loaders' +import { useLoaderInstance } from '@tanstack/react-loaders' import { Route } from '@tanstack/router' import * as React from 'react' import { dashboardRoute } from '.' @@ -11,8 +11,7 @@ export const dashboardIndexRoute = new Route({ }) function DashboardHome() { - const invoicesLoaderInstance = useLoader({ loader: invoicesLoader }) - const invoices = invoicesLoaderInstance.state.data + const { data: invoices } = useLoaderInstance({ key: 'invoices' }) return (
      diff --git a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/index.tsx b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/index.tsx index 6e08497fc1..28e0ef91c2 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/index.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/index.tsx @@ -5,8 +5,8 @@ import { rootRoute } from '../root' export const dashboardRoute = new Route({ getParentRoute: () => rootRoute, path: 'dashboard', - loader: ({ context, preload }) => - context.loaderClient.loaders.invoicesLoader.load({ preload }), + loader: ({ context: { loaderClient }, preload }) => + loaderClient.load({ key: 'invoices', preload }), component: function Dashboard() { return ( <> diff --git a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/index.tsx b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/index.tsx index 20b022e9a3..331245d27a 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/index.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/index.tsx @@ -2,13 +2,13 @@ import { Link, Outlet, MatchRoute, Route } from '@tanstack/router' import * as React from 'react' import { Spinner } from '../../../components/Spinner' import { dashboardRoute } from '..' -import { invoiceRoute, updateInvoiceAction } from './invoice' -import { Loader } from '@tanstack/react-loaders' +import { invoiceRoute } from './invoice' +import { Loader, useLoaderInstance } from '@tanstack/react-loaders' import { useAction } from '@tanstack/react-actions' -import { createInvoiceAction } from './invoices' import { fetchInvoices } from '../../../mockTodos' export const invoicesLoader = new Loader({ + key: 'invoices', fn: async () => { console.log('Fetching invoices...') return fetchInvoices() @@ -18,29 +18,27 @@ export const invoicesLoader = new Loader({ export const invoicesRoute = new Route({ getParentRoute: () => dashboardRoute, path: 'invoices', - loader: ({ context }) => { - const { invoicesLoader } = context.loaderClient.loaders - return () => invoicesLoader.useLoader() + loader: async ({ context: { loaderClient } }) => { + await loaderClient.load({ key: 'invoices' }) + return () => useLoaderInstance({ key: 'invoices' }) }, component: function Invoices({ useLoader }) { - const { - state: { data: invoices }, - } = useLoader()() + const { data: invoices } = useLoader()() // Get the action for a child route - const createInvoice = useAction({ action: createInvoiceAction }) - const updateInvoice = useAction({ action: updateInvoiceAction }) + const [createInvoice] = useAction({ key: 'createInvoice' }) + const [updateInvoice] = useAction({ key: 'updateInvoice' }) return (
      {invoices?.map((invoice) => { - const foundPending = updateInvoice.state.pendingSubmissions.find( - (d) => d.payload?.id === invoice.id, + const foundPending = updateInvoice.pendingSubmissions.find( + (d) => d.variables?.id === invoice.id, ) if (foundPending) { - invoice = { ...invoice, ...foundPending.payload } + invoice = { ...invoice, ...foundPending.variables } } return ( @@ -74,11 +72,11 @@ export const invoicesRoute = new Route({
      ) })} - {createInvoice.state.pendingSubmissions.map((action) => ( -
      + {createInvoice.pendingSubmissions.map((submission) => ( + diff --git a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/invoice.tsx b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/invoice.tsx index 803ff8e25e..f281ae3905 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/invoice.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/invoice.tsx @@ -9,13 +9,20 @@ import { Route, RegisteredRoutesInfo, } from '@tanstack/router' -import { Loader } from '@tanstack/react-loaders' +import { + createLoaderOptions, + Loader, + typedClient, + useLoaderInstance, +} from '@tanstack/react-loaders' import { Action, useAction } from '@tanstack/react-actions' import { invoicesIndexRoute } from './invoices' +import { actionContext } from '../../../actionContext' class InvoiceNotFoundError extends Error {} export const invoiceLoader = new Loader({ + key: 'invoice', fn: async (invoiceId: number) => { console.log('Fetching invoice...') const invoice = await fetchInvoiceById(invoiceId) @@ -26,16 +33,18 @@ export const invoiceLoader = new Loader({ return invoice }, - onInvalidate: async () => { - await invoicesLoader.invalidate() + onInvalidate: async ({ client }) => { + typedClient(client).invalidateLoader({ key: 'invoices' }) }, }) -export const updateInvoiceAction = new Action({ +export const updateInvoiceAction = actionContext.createAction({ + key: 'updateInvoice', fn: patchInvoice, - onEachSuccess: async ({ payload }) => { - await invoiceLoader.invalidateInstance({ - variables: payload.id, + onEachSuccess: async ({ submission, context: { loaderClient } }) => { + await loaderClient.invalidateInstance({ + key: 'invoice', + variables: submission.variables.id, }) }, }) @@ -51,25 +60,27 @@ export const invoiceRoute = new Route({ showNotes: z.boolean().optional(), notes: z.string().optional(), }), - loader: async ({ context, params: { invoiceId }, preload }) => { - const { invoiceLoader } = context.loaderClient.loaders - - const invoiceLoaderInstance = invoiceLoader.getInstance({ + loader: async ({ + context: { loaderClient }, + params: { invoiceId }, + preload, + }) => { + const loaderOptions = createLoaderOptions({ + key: 'invoice', variables: invoiceId, }) - await invoiceLoaderInstance.load({ + await loaderClient.load({ + ...loaderOptions, preload, }) - return () => invoiceLoaderInstance.useInstance() + return () => useLoaderInstance(loaderOptions) }, component: function InvoiceView({ useLoader, useSearch }) { - const { - state: { data: invoice }, - } = useLoader()() + const { data: invoice } = useLoader()() const search = useSearch() - const action = useAction({ action: updateInvoiceAction }) + const [action, actionClient] = useAction({ key: 'updateInvoice' }) const navigate = useNavigate() const [notes, setNotes] = React.useState(search.notes ?? ``) @@ -88,17 +99,20 @@ export const invoiceRoute = new Route({ event.preventDefault() event.stopPropagation() const formData = new FormData(event.target as HTMLFormElement) - action.submit({ - id: invoice.id, - title: formData.get('title') as string, - body: formData.get('body') as string, + actionClient.submitAction({ + key: 'updateInvoice', + variables: { + id: invoice.id, + title: formData.get('title') as string, + body: formData.get('body') as string, + }, }) }} className="p-2 space-y-2" >
      - {action.state.latestSubmission?.payload?.id === invoice.id ? ( -
      - {action.state.latestSubmission?.status === 'success' ? ( + {action.latestSubmission?.variables?.id === invoice.id ? ( +
      + {action.latestSubmission?.status === 'success' ? (
      Saved!
      - ) : action.state.latestSubmission?.status === 'error' ? ( + ) : action.latestSubmission?.status === 'error' ? (
      Failed to save.
      diff --git a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/invoices.tsx b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/invoices.tsx index 0dbc4096d5..252f5e9558 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/invoices.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/invoices/invoices.tsx @@ -1,14 +1,16 @@ import * as React from 'react' import { Invoice, postInvoice } from '../../../mockTodos' import { InvoiceFields } from '../../../components/InvoiceFields' -import { invoicesLoader, invoicesRoute } from '.' -import { Action, useAction } from '@tanstack/react-actions' +import { invoicesRoute } from '.' +import { useAction } from '@tanstack/react-actions' import { Route } from '@tanstack/router' +import { actionContext } from '../../../actionContext' -export const createInvoiceAction = new Action({ +export const createInvoiceAction = actionContext.createAction({ + key: 'createInvoice', fn: postInvoice, - onEachSuccess: async () => { - await invoicesLoader.invalidate() + onEachSuccess: async ({ context: { loaderClient } }) => { + await loaderClient.invalidateLoader({ key: 'invoices' }) }, }) @@ -16,7 +18,7 @@ export const invoicesIndexRoute = new Route({ getParentRoute: () => invoicesRoute, path: '/', component: function InvoicesHome() { - const action = useAction({ action: createInvoiceAction }) + const [action, actionClient] = useAction({ key: 'createInvoice' }) return ( <> @@ -26,9 +28,12 @@ export const invoicesIndexRoute = new Route({ event.preventDefault() event.stopPropagation() const formData = new FormData(event.target as HTMLFormElement) - action.submit({ - title: formData.get('title') as string, - body: formData.get('body') as string, + actionClient.submitAction({ + key: 'createInvoice', + variables: { + title: formData.get('title') as string, + body: formData.get('body') as string, + }, }) }} className="space-y-2" @@ -43,11 +48,11 @@ export const invoicesIndexRoute = new Route({ Create
      - {action.state.latestSubmission?.status === 'success' ? ( + {action.latestSubmission?.status === 'success' ? (
      Created!
      - ) : action.state.latestSubmission?.status === 'error' ? ( + ) : action.latestSubmission?.status === 'error' ? (
      Failed to create.
      diff --git a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/users/index.tsx b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/users/index.tsx index 23ce22066f..de045cd8a7 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/users/index.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/users/index.tsx @@ -12,12 +12,13 @@ import { z } from 'zod' import { Spinner } from '../../../components/Spinner' import { fetchUsers } from '../../../mockTodos' import { dashboardRoute } from '..' -import { Loader, useLoader } from '@tanstack/react-loaders' +import { Loader, useLoaderInstance } from '@tanstack/react-loaders' const usersViewSortBy = z.enum(['name', 'id', 'email']) export type UsersViewSortBy = z.infer export const usersLoader = new Loader({ + key: 'users', fn: async () => { console.log('Fetching users...') return fetchUsers() @@ -45,19 +46,14 @@ export const usersRoute = new Route({ }, }), ], - loader: async ({ context, preload }) => { - const { usersLoader } = context.loaderClient.loaders - - await usersLoader.load({ preload }) - - return () => usersLoader.useLoader() + loader: async ({ context: { loaderClient }, preload }) => { + await loaderClient.load({ key: 'users', preload }) + return () => useLoaderInstance({ key: 'users' }) }, component: function Users({ useLoader, useSearch }) { const navigate = useNavigate() const { usersView } = useSearch() - const { - state: { data: users }, - } = useLoader()() + const { data: users } = useLoader()() const sortBy = usersView?.sortBy ?? 'name' const filterBy = usersView?.filterBy diff --git a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/users/user.tsx b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/users/user.tsx index aa3959df62..15f9dd590e 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/dashboard/users/user.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/dashboard/users/user.tsx @@ -1,16 +1,22 @@ import * as React from 'react' import { fetchUserById } from '../../../mockTodos' import { usersLoader, usersRoute } from '.' -import { Loader } from '@tanstack/react-loaders' +import { + createLoaderOptions, + Loader, + typedClient, + useLoaderInstance, +} from '@tanstack/react-loaders' import { Route } from '@tanstack/router' export const userLoader = new Loader({ + key: 'user', fn: async (userId: number) => { console.log(`Fetching user with id ${userId}...`) return fetchUserById(userId) }, - onInvalidate: async () => { - await usersLoader.invalidate() + onInvalidate: async ({ client }) => { + typedClient(client).invalidateLoader({ key: 'users' }) }, }) @@ -19,23 +25,25 @@ export const userRoute = new Route({ path: '$userId', parseParams: ({ userId }) => ({ userId: Number(userId) }), stringifyParams: ({ userId }) => ({ userId: `${userId}` }), - loader: async ({ context, params: { userId }, preload }) => { - const { userLoader } = context.loaderClient.loaders - - const userLoaderInstance = userLoader.getInstance({ + loader: async ({ + context: { loaderClient }, + params: { userId }, + preload, + }) => { + const loaderOptions = createLoaderOptions({ + key: 'user', variables: userId, }) - await userLoaderInstance.load({ + await loaderClient.load({ + ...loaderOptions, preload, }) - return () => userLoaderInstance.useInstance() + return () => useLoaderInstance(loaderOptions) }, component: function User({ useLoader }) { - const { - state: { data: user }, - } = useLoader()() + const { data: user } = useLoader()() return ( <> diff --git a/examples/react/kitchen-sink-multi-file/src/routes/layout/index.tsx b/examples/react/kitchen-sink-multi-file/src/routes/layout/index.tsx index af3ee28d13..6b1b34b1b5 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/layout/index.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/layout/index.tsx @@ -1,4 +1,4 @@ -import { Loader } from '@tanstack/react-loaders' +import { Loader, useLoaderInstance } from '@tanstack/react-loaders' import { Outlet, Route } from '@tanstack/router' import * as React from 'react' import { fetchRandomNumber } from '../../mockTodos' @@ -6,21 +6,19 @@ import { fetchRandomNumber } from '../../mockTodos' import { rootRoute } from '../root' export const randomIdLoader = new Loader({ + key: 'random', fn: fetchRandomNumber, }) export const layoutRoute = new Route({ getParentRoute: () => rootRoute, id: 'layout', - loader: async ({ context }) => { - const { randomIdLoader } = context.loaderClient.loaders - await randomIdLoader.load() - return () => randomIdLoader.useLoader() + loader: async ({ context: { loaderClient } }) => { + await loaderClient.load({ key: 'random' }) + return () => useLoaderInstance({ key: 'random' }) }, component: function LayoutWrapper({ useLoader }) { - const { - state: { data: randomId }, - } = useLoader()() + const { data: randomId } = useLoader()() return (
      diff --git a/examples/react/kitchen-sink-multi-file/src/routes/root.tsx b/examples/react/kitchen-sink-multi-file/src/routes/root.tsx index b414d07650..49fca83a21 100644 --- a/examples/react/kitchen-sink-multi-file/src/routes/root.tsx +++ b/examples/react/kitchen-sink-multi-file/src/routes/root.tsx @@ -1,11 +1,11 @@ import * as React from 'react' -import { Link, Outlet, RootRoute, useRouter } from '@tanstack/router' +import { Link, Outlet, useRouter } from '@tanstack/router' import { Spinner } from '../components/Spinner' import { TanStackRouterDevtools } from '@tanstack/router-devtools' import { useLoaderClient } from '@tanstack/react-loaders' -import { RouterContext } from '../router' +import { routerContext } from '../router' -export const rootRoute = RootRoute.withRouterContext()({ +export const rootRoute = routerContext.createRootRoute({ component: () => { const router = useRouter() const loaderClient = useLoaderClient() diff --git a/examples/react/kitchen-sink-single-file/src/main.tsx b/examples/react/kitchen-sink-single-file/src/main.tsx index 35cda74e3f..fdb91e0eaf 100644 --- a/examples/react/kitchen-sink-single-file/src/main.tsx +++ b/examples/react/kitchen-sink-single-file/src/main.tsx @@ -12,11 +12,14 @@ import { RootRoute, Route, redirect, + RouterContext, } from '@tanstack/router' import { - Action, - ActionClient, ActionClientProvider, + ActionContext, + RegisteredActionClient, + RegisteredActions, + RegisteredActionsByKey, useAction, } from '@tanstack/react-actions' import { @@ -24,6 +27,9 @@ import { LoaderClient, useLoaderClient, LoaderClientProvider, + typedClient, + useLoaderInstance, + createLoaderOptions, } from '@tanstack/react-loaders' import { TanStackRouterDevtools } from '@tanstack/router-devtools' @@ -47,6 +53,7 @@ type UsersViewSortBy = 'name' | 'id' | 'email' // Loaders const invoicesLoader = new Loader({ + key: 'invoices', fn: async () => { console.log('Fetching invoices...') return fetchInvoices() @@ -54,16 +61,18 @@ const invoicesLoader = new Loader({ }) const invoiceLoader = new Loader({ + key: 'invoice', fn: async (invoiceId: number) => { console.log(`Fetching invoice with id ${invoiceId}...`) return fetchInvoiceById(invoiceId) }, - onInvalidate: async () => { - await invoicesLoader.invalidate() + onInvalidate: async ({ client }) => { + await typedClient(client).invalidateLoader({ key: 'invoices' }) }, }) const usersLoader = new Loader({ + key: 'users', fn: async () => { console.log('Fetching users...') return fetchUsers() @@ -71,29 +80,31 @@ const usersLoader = new Loader({ }) const userLoader = new Loader({ + key: 'user', fn: async (userId: number) => { console.log(`Fetching user with id ${userId}...`) return fetchUserById(userId) }, - onInvalidate: async () => { - await usersLoader.invalidate() + onInvalidate: async ({ client }) => { + await typedClient(client).invalidateLoader({ key: 'users' }) }, }) const randomIdLoader = new Loader({ + key: 'random', fn: () => { return fetchRandomNumber() }, }) const loaderClient = new LoaderClient({ - getLoaders: () => ({ + loaders: [ invoicesLoader, invoiceLoader, usersLoader, userLoader, randomIdLoader, - }), + ], }) // Register things for typesafety @@ -105,24 +116,35 @@ declare module '@tanstack/react-loaders' { // Actions -const createInvoiceAction = new Action({ +const actionContext = new ActionContext<{ + loaderClient: typeof loaderClient +}>() + +const createInvoiceAction = actionContext.createAction({ + key: 'createInvoice', fn: postInvoice, - onEachSuccess: async () => { - await invoicesLoader.invalidate() + onEachSuccess: async ({ context: { loaderClient } }) => { + await loaderClient.invalidateLoader({ key: 'invoices' }) }, }) -const updateInvoiceAction = new Action({ +const updateInvoiceAction = actionContext.createAction({ + key: 'updateInvoice', fn: patchInvoice, - onEachSuccess: async ({ payload }) => { - await invoiceLoader.invalidateInstance({ - variables: payload.id, + onEachSuccess: async ({ submission, context: { loaderClient } }) => { + await loaderClient.invalidateLoader({ key: 'invoices' }) + await loaderClient.invalidateInstance({ + key: 'invoice', + variables: submission.variables.id, }) }, }) -const actionClient = new ActionClient({ - getActions: () => ({ createInvoiceAction, updateInvoiceAction }), +const actionClient = actionContext.createClient({ + actions: [createInvoiceAction, updateInvoiceAction], + context: { + loaderClient, + }, }) // Register things for typesafety @@ -134,8 +156,14 @@ declare module '@tanstack/react-actions' { // Routes +export const routerContext = new RouterContext<{ + auth: AuthContext + loaderClient: typeof loaderClient + actionClient: typeof actionClient +}>() + // Build our routes. We could do this in our component, too. -const rootRoute = RootRoute.withRouterContext<{ auth: AuthContext }>()({ +const rootRoute = routerContext.createRootRoute({ component: () => { const loaderClient = useLoaderClient() @@ -145,14 +173,8 @@ const rootRoute = RootRoute.withRouterContext<{ auth: AuthContext }>()({

      Kitchen Sink

      {/* Show a global spinner when the router is transitioning */} -
      - +
      +
      @@ -241,7 +263,8 @@ const indexRoute = new Route({ const dashboardRoute = new Route({ getParentRoute: () => rootRoute, path: 'dashboard', - loader: ({ preload }) => invoicesLoader.load({ preload }), + loader: ({ preload, context: { loaderClient } }) => + loaderClient.load({ key: 'invoices', preload }), component: () => { return ( <> @@ -290,9 +313,7 @@ const dashboardIndexRoute = new Route({ getParentRoute: () => dashboardRoute, path: '/', component: () => { - const { - state: { data: invoices }, - } = invoicesLoader.useLoader() + const { data: invoices } = useLoaderInstance({ key: 'invoices' }) return (
      @@ -309,20 +330,14 @@ const invoicesRoute = new Route({ getParentRoute: () => dashboardRoute, path: 'invoices', component: () => { - const { - state: { data: invoices }, - } = invoicesLoader.useLoader() - - const { - state: { pendingSubmissions: updateSubmissions }, - } = useAction({ - action: updateInvoiceAction, + const { data: invoices } = useLoaderInstance({ key: 'invoices' }) + + const [{ pendingSubmissions: updateSubmissions }] = useAction({ + key: 'updateInvoice', }) - const { - state: { pendingSubmissions: createSubmissions }, - } = useAction({ - action: createInvoiceAction, + const [{ pendingSubmissions: createSubmissions }] = useAction({ + key: 'createInvoice', }) return ( @@ -330,13 +345,13 @@ const invoicesRoute = new Route({
      {invoices?.map((invoice) => { const updateSubmission = updateSubmissions.find( - (d) => d.payload.id === invoice.id, + (d) => d.variables?.id === invoice.id, ) if (updateSubmission) { invoice = { ...invoice, - ...updateSubmission.payload, + ...updateSubmission.variables, } } @@ -375,7 +390,7 @@ const invoicesRoute = new Route({ @@ -393,9 +408,9 @@ const invoicesIndexRoute = new Route({ getParentRoute: () => invoicesRoute, path: '/', component: () => { - const { - state: { latestSubmission }, - } = useAction({ action: createInvoiceAction }) + const [{ latestSubmission }, actionClient] = useAction({ + key: 'createInvoice', + }) return ( <> @@ -405,9 +420,12 @@ const invoicesIndexRoute = new Route({ event.preventDefault() event.stopPropagation() const formData = new FormData(event.target as HTMLFormElement) - createInvoiceAction.submit({ - title: formData.get('title') as string, - body: formData.get('body') as string, + actionClient.submitAction({ + key: 'createInvoice', + variables: { + title: formData.get('title') as string, + body: formData.get('body') as string, + }, }) }} className="space-y-2" @@ -452,29 +470,30 @@ const invoiceRoute = new Route({ notes: z.string().optional(), }) .parse(search), - loader: async ({ params: { invoiceId }, preload }) => { - const invoicesLoaderInstance = invoiceLoader.getInstance({ + loader: async ({ + params: { invoiceId }, + preload, + context: { loaderClient }, + }) => { + const loaderOptions = createLoaderOptions({ + key: 'invoice', variables: invoiceId, }) - await invoicesLoaderInstance.load({ + await loaderClient.load({ + ...loaderOptions, preload, }) - return () => invoicesLoaderInstance.useInstance() + return () => useLoaderInstance(loaderOptions) }, component: ({ useLoader, useSearch }) => { const search = useSearch() const navigate = useNavigate() - - const { - state: { data: invoice }, - } = useLoader()() - - const { - state: { latestSubmission }, - } = useAction({ action: updateInvoiceAction }) - + const { data: invoice } = useLoader()() + const [{ latestSubmission }, actionClient] = useAction({ + key: 'updateInvoice', + }) const [notes, setNotes] = React.useState(search.notes ?? '') React.useEffect(() => { @@ -494,10 +513,13 @@ const invoiceRoute = new Route({ event.preventDefault() event.stopPropagation() const formData = new FormData(event.target as HTMLFormElement) - updateInvoiceAction.submit({ - id: invoice.id, - title: formData.get('title') as string, - body: formData.get('body') as string, + actionClient.submitAction({ + key: 'updateInvoice', + variables: { + id: invoice.id, + title: formData.get('title') as string, + body: formData.get('body') as string, + }, }) }} className="p-2 space-y-2" @@ -545,7 +567,7 @@ const invoiceRoute = new Route({ Save
      - {latestSubmission?.payload.id === invoice.id ? ( + {latestSubmission?.variables.id === invoice.id ? (
      {latestSubmission?.status === 'success' ? (
      @@ -584,15 +606,14 @@ const usersRoute = new Route({ }, }), ], - loader: ({ preload }) => usersLoader.load({ preload }), - component: ({ useSearch }) => { + loader: async ({ preload, context: { loaderClient } }) => { + await loaderClient.load({ key: 'users', preload }) + return () => useLoaderInstance({ key: 'users' }) + }, + component: ({ useSearch, useLoader }) => { const navigate = useNavigate() const { usersView } = useSearch() - - const { - state: { data: users }, - } = usersLoader.useLoader() - + const { data: users } = useLoader()() const sortBy = usersView?.sortBy ?? 'name' const filterBy = usersView?.filterBy @@ -745,17 +766,21 @@ const userRoute = new Route({ // Since our userId isn't part of our pathname, make sure we // augment the userId as the key for this route getKey: ({ search: { userId } }) => userId, - loader: async ({ search: { userId }, preload }) => { - const userLoaderInstance = userLoader.getInstance({ variables: userId }) - - await userLoaderInstance.load({ preload }) + loader: async ({ + search: { userId }, + preload, + context: { loaderClient }, + }) => { + const loaderOptions = createLoaderOptions({ + key: 'user', + variables: userId, + }) - return () => userLoaderInstance.useInstance() + await loaderClient.load({ ...loaderOptions, preload }) + return () => useLoaderInstance(loaderOptions) }, component: ({ useLoader }) => { - const { - state: { data: user }, - } = useLoader()() + const { data: user } = useLoader()() return ( <> @@ -879,14 +904,17 @@ const loginRoute = new Route({ const layoutRoute = new Route({ getParentRoute: () => rootRoute, id: 'layout', - loader: async ({ preload }) => randomIdLoader.load({ preload }), - component: () => { - const randomIdLoaderInstance = randomIdLoader.useLoader() + loader: async ({ preload, context: { loaderClient } }) => { + await loaderClient.load({ key: 'random', preload }) + return () => useLoaderInstance({ key: 'random' }) + }, + component: ({ useLoader }) => { + const { data } = useLoader()() return (
      Layout
      -
      Random #: {randomIdLoaderInstance.state.data}
      +
      Random #: {data}

      @@ -942,7 +970,9 @@ const router = new Router({ actionClient.clearAll() }, context: { - auth: undefined!, + loaderClient, + actionClient, + auth: undefined!, // We'll inject this when we render }, }) @@ -1019,10 +1049,10 @@ function SubApp() {
      - +
      +function Spinner({ show }: { show?: boolean }) { + return ( +
      + ⍥ +
      + ) } function useSessionStorage(key: string, initialValue: T) { diff --git a/examples/react/quickstart/src/main.tsx b/examples/react/quickstart/src/main.tsx index ef09d8387c..e4ec4a8a1c 100644 --- a/examples/react/quickstart/src/main.tsx +++ b/examples/react/quickstart/src/main.tsx @@ -8,6 +8,7 @@ import { Route, RootRoute, } from '@tanstack/router' +import { TanStackRouterDevtools } from '@tanstack/router-devtools' const rootRoute = new RootRoute({ component: () => ( @@ -17,6 +18,7 @@ const rootRoute = new RootRoute({

      + ), }) diff --git a/examples/react/start-basic/.gitignore b/examples/react/wip-start-basic/.gitignore similarity index 100% rename from examples/react/start-basic/.gitignore rename to examples/react/wip-start-basic/.gitignore diff --git a/examples/react/start-basic/.vscode/extensions.json b/examples/react/wip-start-basic/.vscode/extensions.json similarity index 100% rename from examples/react/start-basic/.vscode/extensions.json rename to examples/react/wip-start-basic/.vscode/extensions.json diff --git a/examples/react/start-basic/.vscode/launch.json b/examples/react/wip-start-basic/.vscode/launch.json similarity index 100% rename from examples/react/start-basic/.vscode/launch.json rename to examples/react/wip-start-basic/.vscode/launch.json diff --git a/examples/react/start-basic/README.md b/examples/react/wip-start-basic/README.md similarity index 100% rename from examples/react/start-basic/README.md rename to examples/react/wip-start-basic/README.md diff --git a/examples/react/start-basic/astro.config.ts b/examples/react/wip-start-basic/astro.config.ts similarity index 100% rename from examples/react/start-basic/astro.config.ts rename to examples/react/wip-start-basic/astro.config.ts diff --git a/examples/react/start-basic/package.json b/examples/react/wip-start-basic/package.json similarity index 100% rename from examples/react/start-basic/package.json rename to examples/react/wip-start-basic/package.json diff --git a/examples/react/start-basic/public/favicon.svg b/examples/react/wip-start-basic/public/favicon.svg similarity index 100% rename from examples/react/start-basic/public/favicon.svg rename to examples/react/wip-start-basic/public/favicon.svg diff --git a/examples/react/start-basic/src/app/entry-client.tsx b/examples/react/wip-start-basic/src/app/entry-client.tsx similarity index 100% rename from examples/react/start-basic/src/app/entry-client.tsx rename to examples/react/wip-start-basic/src/app/entry-client.tsx diff --git a/examples/react/start-basic/src/app/entry-server.tsx b/examples/react/wip-start-basic/src/app/entry-server.tsx similarity index 100% rename from examples/react/start-basic/src/app/entry-server.tsx rename to examples/react/wip-start-basic/src/app/entry-server.tsx diff --git a/examples/react/start-basic/src/app/loaderClient.tsx b/examples/react/wip-start-basic/src/app/loaderClient.tsx similarity index 88% rename from examples/react/start-basic/src/app/loaderClient.tsx rename to examples/react/wip-start-basic/src/app/loaderClient.tsx index 0b0635e4f5..e1b9048ec1 100644 --- a/examples/react/start-basic/src/app/loaderClient.tsx +++ b/examples/react/wip-start-basic/src/app/loaderClient.tsx @@ -4,7 +4,7 @@ import { postLoader } from './routes/posts/$postId' export const createLoaderClient = () => { return new LoaderClient({ - getLoaders: () => ({ postsLoader, postLoader }), + loaders: [postsLoader, postLoader], }) } diff --git a/examples/react/start-basic/src/app/router.tsx b/examples/react/wip-start-basic/src/app/router.tsx similarity index 76% rename from examples/react/start-basic/src/app/router.tsx rename to examples/react/wip-start-basic/src/app/router.tsx index 29a620dd30..fd01a49f64 100644 --- a/examples/react/start-basic/src/app/router.tsx +++ b/examples/react/wip-start-basic/src/app/router.tsx @@ -1,4 +1,4 @@ -import { Router } from '@tanstack/router' +import { Router, RouterContext } from '@tanstack/router' import { rootRoute } from './routes/root' import { indexRoute } from './routes/index' @@ -7,7 +7,14 @@ import { postsIndexRoute } from './routes/posts/index' import { postIdRoute } from './routes/posts/$postId' import { createLoaderClient } from './loaderClient' -import { LoaderClientProvider } from '@tanstack/react-loaders' +import { + LoaderClientProvider, + RegisteredLoaderClient, +} from '@tanstack/react-loaders' + +export const routerContext = new RouterContext<{ + loaderClient: RegisteredLoaderClient +}>() export const routeTree = rootRoute.addChildren([ indexRoute, @@ -32,7 +39,7 @@ export function createRouter() { }, Provider: ({ children }) => { return ( - + {children} ) diff --git a/examples/react/start-basic/src/app/routes/index.tsx b/examples/react/wip-start-basic/src/app/routes/index.tsx similarity index 100% rename from examples/react/start-basic/src/app/routes/index.tsx rename to examples/react/wip-start-basic/src/app/routes/index.tsx diff --git a/examples/react/start-basic/src/app/routes/posts.tsx b/examples/react/wip-start-basic/src/app/routes/posts.tsx similarity index 100% rename from examples/react/start-basic/src/app/routes/posts.tsx rename to examples/react/wip-start-basic/src/app/routes/posts.tsx diff --git a/examples/react/start-basic/src/app/routes/posts/$postId.tsx b/examples/react/wip-start-basic/src/app/routes/posts/$postId.tsx similarity index 100% rename from examples/react/start-basic/src/app/routes/posts/$postId.tsx rename to examples/react/wip-start-basic/src/app/routes/posts/$postId.tsx diff --git a/examples/react/start-basic/src/app/routes/posts/index.tsx b/examples/react/wip-start-basic/src/app/routes/posts/index.tsx similarity index 100% rename from examples/react/start-basic/src/app/routes/posts/index.tsx rename to examples/react/wip-start-basic/src/app/routes/posts/index.tsx diff --git a/examples/react/start-basic/src/app/routes/root.tsx b/examples/react/wip-start-basic/src/app/routes/root.tsx similarity index 83% rename from examples/react/start-basic/src/app/routes/root.tsx rename to examples/react/wip-start-basic/src/app/routes/root.tsx index cc20b102e6..02f9fe5fb7 100644 --- a/examples/react/start-basic/src/app/routes/root.tsx +++ b/examples/react/wip-start-basic/src/app/routes/root.tsx @@ -1,21 +1,11 @@ import { TanStackRouterDevtools } from '@tanstack/router-devtools' -import { - ErrorComponent, - Link, - Outlet, - RootRoute, - useRouter, -} from '@tanstack/router' +import { ErrorComponent, Link, Outlet, useRouter } from '@tanstack/router' import { Scripts } from '@tanstack/react-start/client' -import type { RegisteredLoaderClient } from '@tanstack/react-loaders' import { secret } from '../secrets.server$' +import { routerContext } from '../router' -export interface RouterContext { - loaderClient: RegisteredLoaderClient -} - -export const rootRoute = RootRoute.withRouterContext()({ +export const rootRoute = routerContext.createRootRoute({ wrapInSuspense: false, errorComponent: ({ error }) => , component: function Root() { diff --git a/examples/react/start-basic/src/app/secrets.server$.ts b/examples/react/wip-start-basic/src/app/secrets.server$.ts similarity index 100% rename from examples/react/start-basic/src/app/secrets.server$.ts rename to examples/react/wip-start-basic/src/app/secrets.server$.ts diff --git a/examples/react/start-basic/src/env.d.ts b/examples/react/wip-start-basic/src/env.d.ts similarity index 100% rename from examples/react/start-basic/src/env.d.ts rename to examples/react/wip-start-basic/src/env.d.ts diff --git a/examples/react/start-basic/src/pages/[...app].ts b/examples/react/wip-start-basic/src/pages/[...app].ts similarity index 100% rename from examples/react/start-basic/src/pages/[...app].ts rename to examples/react/wip-start-basic/src/pages/[...app].ts diff --git a/examples/react/start-basic/src/pages/hello.astro b/examples/react/wip-start-basic/src/pages/hello.astro similarity index 100% rename from examples/react/start-basic/src/pages/hello.astro rename to examples/react/wip-start-basic/src/pages/hello.astro diff --git a/examples/react/start-basic/tsconfig.json b/examples/react/wip-start-basic/tsconfig.json similarity index 100% rename from examples/react/start-basic/tsconfig.json rename to examples/react/wip-start-basic/tsconfig.json diff --git a/examples/react/start-kitchen-sink-single-file/.gitignore b/examples/react/wip-start-kitchen-sink-single-file/.gitignore similarity index 100% rename from examples/react/start-kitchen-sink-single-file/.gitignore rename to examples/react/wip-start-kitchen-sink-single-file/.gitignore diff --git a/examples/react/start-kitchen-sink-single-file/README.md b/examples/react/wip-start-kitchen-sink-single-file/README.md similarity index 100% rename from examples/react/start-kitchen-sink-single-file/README.md rename to examples/react/wip-start-kitchen-sink-single-file/README.md diff --git a/examples/react/start-kitchen-sink-single-file/index.html b/examples/react/wip-start-kitchen-sink-single-file/index.html similarity index 100% rename from examples/react/start-kitchen-sink-single-file/index.html rename to examples/react/wip-start-kitchen-sink-single-file/index.html diff --git a/examples/react/start-kitchen-sink-single-file/package.json b/examples/react/wip-start-kitchen-sink-single-file/package.json similarity index 100% rename from examples/react/start-kitchen-sink-single-file/package.json rename to examples/react/wip-start-kitchen-sink-single-file/package.json diff --git a/examples/react/start-kitchen-sink-single-file/src/Expensive.tsx b/examples/react/wip-start-kitchen-sink-single-file/src/Expensive.tsx similarity index 100% rename from examples/react/start-kitchen-sink-single-file/src/Expensive.tsx rename to examples/react/wip-start-kitchen-sink-single-file/src/Expensive.tsx diff --git a/examples/react/start-kitchen-sink-single-file/src/main.tsx b/examples/react/wip-start-kitchen-sink-single-file/src/main.tsx similarity index 99% rename from examples/react/start-kitchen-sink-single-file/src/main.tsx rename to examples/react/wip-start-kitchen-sink-single-file/src/main.tsx index b4ed83a11c..7ad17462a2 100644 --- a/examples/react/start-kitchen-sink-single-file/src/main.tsx +++ b/examples/react/wip-start-kitchen-sink-single-file/src/main.tsx @@ -12,6 +12,7 @@ import { useParams, RootRoute, Route, + RouterContext, } from '@tanstack/router' import { Action, @@ -88,13 +89,13 @@ const randomIdLoader = new Loader({ }) const loaderClient = new LoaderClient({ - getLoaders: () => ({ + loaders: [ invoicesLoader, invoiceLoader, usersLoader, userLoader, randomIdLoader, - }), + ], }) // Register things for typesafety @@ -123,7 +124,7 @@ const updateInvoiceAction = new Action({ }) const actionClient = new ActionClient({ - getActions: () => ({ createInvoiceAction, updateInvoiceAction }), + actions: [createInvoiceAction, updateInvoiceAction], }) // Register things for typesafety @@ -135,8 +136,12 @@ declare module '@tanstack/react-actions' { // Routes +const routerContext = new RouterContext<{ + auth: AuthContext +}>() + // Build our routes. We could do this in our component, too. -const rootRoute = RootRoute.withRouterContext<{ auth: AuthContext }>()({ +const rootRoute = routerContext.createRootRoute({ component: () => { const loaderClient = useLoaderClient() @@ -1012,10 +1017,10 @@ function SubApp() {
      - + ({}) + +const trpcClient = trpc.createClient({ + links: [ + httpBatchLink({ + url: 'http://localhost:4000', + // optional + headers() { + return { + // authorization: getAuthCookie(), + } + }, + }), + ], +}) + export function Spinner() { return (
      @@ -26,7 +43,12 @@ export function Spinner() { ) } -const rootRoute = new RootRoute({ +const routerContext = new RouterContext<{ + trpc: typeof trpc + queryClient: typeof queryClient +}>() + +const rootRoute = routerContext.createRootRoute({ component: () => { return ( <> @@ -60,8 +82,12 @@ const rootRoute = new RootRoute({ const indexRoute = new Route({ getParentRoute: () => rootRoute, path: '/', - component: () => { - const helloQuery = trpc.hello.useQuery() + loader: () => { + // TODO: Prefetch hello using TRPC + return () => trpc.hello.useQuery() + }, + component: ({ useLoader }) => { + const helloQuery = useLoader()() if (!helloQuery.data) return return
      {helloQuery.data}
      }, @@ -71,9 +97,12 @@ const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', errorComponent: () => 'Oh crap!', - // loader: async () => {} - component: () => { - const postsQuery = trpc.posts.useQuery() + loader: async () => { + // TODO: Prefetch posts using TRPC + return () => trpc.posts.useQuery() + }, + component: ({ useLoader }) => { + const postsQuery = useLoader()() if (postsQuery.isLoading) { return @@ -123,11 +152,10 @@ const postRoute = new Route({ path: '$postId', loader: async ({ params: { postId } }) => { // TODO: Prefetch post using TRPC - return {} + return () => trpc.post.useQuery(postId) }, - component: () => { - const { postId } = useParams({ from: postRoute.id }) - const postQuery = trpc.post.useQuery(postId) + component: ({ useLoader }) => { + const postQuery = useLoader()() if (postQuery.isLoading) { return @@ -150,6 +178,10 @@ const routeTree = rootRoute.addChildren([ const router = new Router({ routeTree, defaultPreload: 'intent', + context: { + trpc, + queryClient, + }, }) declare module '@tanstack/router' { @@ -158,22 +190,6 @@ declare module '@tanstack/router' { } } -export const trpc = createTRPCReact({}) - -const trpcClient = trpc.createClient({ - links: [ - httpBatchLink({ - url: 'http://localhost:4000', - // optional - headers() { - return { - // authorization: getAuthCookie(), - } - }, - }), - ], -}) - function App() { return ( // Build our routes and render our router diff --git a/examples/react/with-trpc-react-query/index.html b/examples/react/wip-trpc-react-query/index.html similarity index 100% rename from examples/react/with-trpc-react-query/index.html rename to examples/react/wip-trpc-react-query/index.html diff --git a/examples/react/with-trpc-react-query/package.json b/examples/react/wip-trpc-react-query/package.json similarity index 100% rename from examples/react/with-trpc-react-query/package.json rename to examples/react/wip-trpc-react-query/package.json diff --git a/examples/react/with-trpc-react-query/server/server.ts b/examples/react/wip-trpc-react-query/server/server.ts similarity index 100% rename from examples/react/with-trpc-react-query/server/server.ts rename to examples/react/wip-trpc-react-query/server/server.ts diff --git a/examples/react/with-trpc-react-query/tsconfig.dev.json b/examples/react/wip-trpc-react-query/tsconfig.dev.json similarity index 100% rename from examples/react/with-trpc-react-query/tsconfig.dev.json rename to examples/react/wip-trpc-react-query/tsconfig.dev.json diff --git a/examples/react/with-trpc-react-query/tsconfig.json b/examples/react/wip-trpc-react-query/tsconfig.json similarity index 100% rename from examples/react/with-trpc-react-query/tsconfig.json rename to examples/react/wip-trpc-react-query/tsconfig.json diff --git a/examples/react/with-trpc-react-query/vite.config.js b/examples/react/wip-trpc-react-query/vite.config.js similarity index 100% rename from examples/react/with-trpc-react-query/vite.config.js rename to examples/react/wip-trpc-react-query/vite.config.js diff --git a/examples/react/wip-with-framer-motion/package.json b/examples/react/wip-with-framer-motion/package.json index 3c88671a85..9f05d2d46e 100644 --- a/examples/react/wip-with-framer-motion/package.json +++ b/examples/react/wip-with-framer-motion/package.json @@ -1,5 +1,5 @@ { - "name": "tanstack-router-react-example-basic", + "name": "tanstack-router-react-example-with-framer-motion", "version": "0.0.0", "private": true, "scripts": { diff --git a/examples/react/wip-with-framer-motion/src/main.tsx b/examples/react/wip-with-framer-motion/src/main.tsx index 6303de7e6d..a144e084c6 100644 --- a/examples/react/wip-with-framer-motion/src/main.tsx +++ b/examples/react/wip-with-framer-motion/src/main.tsx @@ -6,12 +6,13 @@ import { RouterProvider, Router, Link, - RootRoute, Route, ErrorComponent, - useRouter, - useMatches, RouterContext, + useMatch, + MatchesProvider, + useRouterState, + useMatches, } from '@tanstack/router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' import axios from 'axios' @@ -19,7 +20,9 @@ import { LoaderClient, Loader, LoaderClientProvider, - useLoader, + typedClient, + useLoaderInstance, + createLoaderOptions, } from '@tanstack/react-loaders' type PostType = { @@ -51,21 +54,20 @@ const fetchPost = async (postId: string) => { } const postsLoader = new Loader({ + key: 'posts', fn: fetchPosts, }) const postLoader = new Loader({ + key: 'post', fn: fetchPost, - onInvalidate: () => { - postsLoader.invalidate() + onInvalidate: async ({ client }) => { + await typedClient(client).invalidateLoader({ key: 'posts' }) }, }) const loaderClient = new LoaderClient({ - getLoaders: () => ({ - posts: postsLoader, - post: postLoader, - }), + loaders: [postsLoader, postLoader], }) declare module '@tanstack/react-loaders' { @@ -74,11 +76,7 @@ declare module '@tanstack/react-loaders' { } } -type RouterContext = { - loaderClient: typeof loaderClient -} - -export const transitionProps = { +export const mainTransitionProps = { initial: { y: -20, opacity: 0, position: 'absolute' }, animate: { y: 0, opacity: 1, damping: 5 }, exit: { y: 60, opacity: 0 }, @@ -89,10 +87,31 @@ export const transitionProps = { }, } as const -const rootRoute = RootRoute.withRouterContext()({ +export const postTransitionProps = { + initial: { y: -20, opacity: 0 }, + animate: { y: 0, opacity: 1, damping: 5 }, + exit: { y: 60, opacity: 0 }, + transition: { + type: 'spring', + stiffness: 150, + damping: 10, + }, +} as const + +const routerContext = new RouterContext<{ + loaderClient: typeof loaderClient +}>() + +const rootRoute = routerContext.createRootRoute({ component: () => { - const router = useRouter() const matches = useMatches() + const match = useMatch() + const nextMatchIndex = matches.findIndex((d) => d.id === match.id) + 1 + const nextMatch = matches[nextMatchIndex] + // const routerState = useRouterState() + + console.log(nextMatch.id) + return ( <>
      @@ -116,9 +135,7 @@ const rootRoute = RootRoute.withRouterContext()({

      - - - + {/* Start rendering router matches */} @@ -132,7 +149,7 @@ const indexRoute = new Route({ path: '/', component: () => { return ( - +

      Welcome Home!

      ) @@ -142,27 +159,23 @@ const indexRoute = new Route({ const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: async ({ context }) => { - const postsLoader = context.loaderClient.loaders.posts + loader: async ({ context: { loaderClient } }) => { + await loaderClient.load({ key: 'posts' }) - await postsLoader.load() - - return { - promise: new Promise((r) => setTimeout(r, 500)), - useLoader: () => - useLoader({ - loader: postsLoader, - }), - } + return () => + useLoaderInstance({ + key: 'posts', + }) }, component: ({ useLoader }) => { - const postsLoader = useLoader().useLoader() + const { data: posts } = useLoader()() + const match = useMatch() return ( - +
        {[ - ...postsLoader.state.data, + ...posts, { id: 'i-do-not-exist', title: 'Non-existent Post' }, ]?.map((post) => { return ( @@ -182,7 +195,19 @@ const postsRoute = new Route({ })}

      - + + { + return ( + + + {children} + + + ) + }} + /> +
      ) }, @@ -199,18 +224,20 @@ class NotFoundError extends Error {} const postRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', - loader: async ({ context: { loaderClient }, params: { postId } }) => { - const postLoader = loaderClient.loaders.post - await postLoader.load({ + loader: async ({ + context: { loaderClient }, + params: { postId }, + preload, + }) => { + const loaderOptions = createLoaderOptions({ + key: 'post', variables: postId, }) + await loaderClient.load({ ...loaderOptions, preload }) + // Return a curried hook! - return () => - useLoader({ - loader: postLoader, - variables: postId, - }) + return () => useLoaderInstance(loaderOptions) }, errorComponent: ({ error }) => { if (error instanceof NotFoundError) { @@ -220,15 +247,13 @@ const postRoute = new Route({ return }, component: () => { - const { - state: { data: post }, - } = postRoute.useLoader()() + const { data: post } = postRoute.useLoader()() return ( -
      +

      {post.title}

      {post.body}
      -
      +
      ) }, }) @@ -261,7 +286,7 @@ if (!rootElement.innerHTML) { root.render( // - + , // , diff --git a/examples/react/with-react-query/src/main.tsx b/examples/react/with-react-query/src/main.tsx index 47db32b5ed..d309b37fc4 100644 --- a/examples/react/with-react-query/src/main.tsx +++ b/examples/react/with-react-query/src/main.tsx @@ -8,6 +8,7 @@ import { Link, useParams, RootRoute, + RouterContext, } from '@tanstack/router' import { TanStackRouterDevtools } from '@tanstack/router-devtools' import { @@ -26,7 +27,13 @@ type PostType = { body: string } -const rootRoute = new RootRoute({ +const queryClient = new QueryClient() + +const routerContext = new RouterContext<{ + queryClient: typeof queryClient +}>() + +const rootRoute = routerContext.createRootRoute({ component: () => { return ( <> @@ -72,10 +79,15 @@ const indexRoute = new Route({ const postsRoute = new Route({ getParentRoute: () => rootRoute, path: 'posts', - loader: () => - queryClient.ensureQueryData({ queryKey: ['posts'], queryFn: fetchPosts }), - component: () => { - const postsQuery = useQuery(['posts'], fetchPosts) + loader: async ({ context: { queryClient } }) => { + await queryClient.ensureQueryData({ + queryKey: ['posts'], + queryFn: fetchPosts, + }) + return () => useQuery(['posts'], fetchPosts) + }, + component: ({ useLoader }) => { + const postsQuery = useLoader()() return (
      @@ -120,13 +132,18 @@ const postsIndexRoute = new Route({ const postRoute = new Route({ getParentRoute: () => postsRoute, path: '$postId', - loader: async ({ params: { postId } }) => - queryClient.ensureQueryData(['posts', postId], () => fetchPostById(postId)), - component: () => { - const { postId } = useParams({ from: postRoute.id }) - const postQuery = useQuery(['posts', postId], () => fetchPostById(postId), { - enabled: !!postId, - }) + loader: async ({ params: { postId }, context: { queryClient } }) => { + await queryClient.ensureQueryData(['posts', postId], () => + fetchPostById(postId), + ) + + return () => + useQuery(['posts', postId], () => fetchPostById(postId), { + enabled: !!postId, + }) + }, + component: ({ useLoader }) => { + const postQuery = useLoader()() return (
      @@ -146,6 +163,9 @@ const routeTree = rootRoute.addChildren([ const router = new Router({ routeTree, defaultPreload: 'intent', + context: { + queryClient, + }, }) declare module '@tanstack/router' { @@ -154,8 +174,6 @@ declare module '@tanstack/router' { } } -const queryClient = new QueryClient() - function App() { return ( // Build our routes and render our router diff --git a/examples/react/with-trpc/client/main.tsx b/examples/react/with-trpc/client/main.tsx index b627d9a6dd..4a5f15d73c 100644 --- a/examples/react/with-trpc/client/main.tsx +++ b/examples/react/with-trpc/client/main.tsx @@ -12,13 +12,16 @@ import { Route, useParams, useRouter, + RouterContext, } from '@tanstack/router' import { + createLoaderOptions, Loader, LoaderClient, LoaderClientProvider, + typedClient, useLoaderClient, - useLoader, + useLoaderInstance, } from '@tanstack/react-loaders' import { AppRouter } from '../server/server' import { TanStackRouterDevtools } from '@tanstack/router-devtools' @@ -35,19 +38,21 @@ export const trpc = createTRPCProxyClient({ }) const postsLoader = new Loader({ + key: 'posts', fn: () => trpc.posts.query(), }) const postLoader = new Loader({ + key: 'post', fn: (postId: number) => trpc.post.query(postId), - onInvalidate: () => { + onInvalidate: ({ client }) => { // Invalidate the posts loader when a post is invalidated - postsLoader.invalidate() + typedClient(client).invalidateLoader({ key: 'posts' }) }, }) const loaderClient = new LoaderClient({ - getLoaders: () => ({ postsLoader, postLoader }), + loaders: [postsLoader, postLoader], }) declare module '@tanstack/react-loaders' { @@ -60,11 +65,16 @@ function Spinner() { return
      } -const rootRoute = new RootRoute({ +const routerContext = new RouterContext<{ + loaderClient: typeof loaderClient +}>() + +const rootRoute = routerContext.createRootRoute({ component: () => { const { state: { status }, } = useRouter() + const { state: { isLoading }, } = useLoaderClient() @@ -166,7 +176,9 @@ const indexRoute = new Route({ const dashboardRoute = new Route({ getParentRoute: () => rootRoute, path: 'dashboard', - loader: ({ preload }) => postsLoader.load({ preload }), + loader: async ({ preload, context: { loaderClient } }) => { + await loaderClient.load({ key: 'posts', preload }) + }, component: () => { return ( <> @@ -213,9 +225,7 @@ const dashboardIndexRoute = new Route({ getParentRoute: () => dashboardRoute, path: '/', component: () => { - const { - state: { data: posts }, - } = useLoader({ loader: postsLoader }) + const { data: posts } = useLoaderInstance({ key: 'posts' }) return (
      @@ -232,9 +242,7 @@ const postsRoute = new Route({ getParentRoute: () => dashboardRoute, path: 'posts', component: () => { - const { - state: { data: posts }, - } = useLoader({ loader: postsLoader }) + const { data: posts } = useLoaderInstance({ key: 'posts' }) return (
      @@ -299,14 +307,20 @@ const postRoute = new Route({ showNotes: z.boolean().optional(), notes: z.string().optional(), }), - loader: async ({ params: { postId }, preload }) => { - await postLoader.load({ variables: postId, preload }) - return () => useLoader({ loader: postLoader, variables: postId }) + loader: async ({ + params: { postId }, + preload, + context: { loaderClient }, + }) => { + const loaderOptions = createLoaderOptions({ + key: 'post', + variables: postId, + }) + await loaderClient.load({ ...loaderOptions, preload }) + return () => useLoaderInstance(loaderOptions) }, component: ({ useLoader }) => { - const { - state: { data: post }, - } = useLoader()() + const { data: post } = useLoader()() const search = useSearch({ from: postRoute.id }) const navigate = useNavigate({ from: postRoute.id }) @@ -387,6 +401,9 @@ const router = new Router({
      ), + context: { + loaderClient, + }, }) declare module '@tanstack/router' { @@ -399,7 +416,7 @@ const rootElement = document.getElementById('app')! if (!rootElement.innerHTML) { const root = ReactDOM.createRoot(rootElement) root.render( - + , ) diff --git a/packages/actions/src/index.ts b/packages/actions/src/index.ts index 1903e544f8..ab5c33581f 100644 --- a/packages/actions/src/index.ts +++ b/packages/actions/src/index.ts @@ -8,240 +8,275 @@ export interface Register { } export type RegisteredActionClient = Register extends { - actionClient: ActionClient + actionClient: ActionClient } - ? ActionClient + ? ActionClient : ActionClient export type RegisteredActions = Register extends { - actionClient: ActionClient + actionClient: ActionClient } ? TActions + : Action + +export type RegisteredActionsByKey = Register extends { + actionClient: ActionClient +} + ? ActionsToRecord : Record -export type AnyAction = Action +export type AnyAction = Action -export interface ActionClientOptions< - TActions extends Record, -> { - getActions: () => TActions +export type ActionClientOptions< + TAction extends AnyAction, + TContext = unknown, +> = { + actions: TAction[] defaultMaxAge?: number defaultGcMaxAge?: number +} & (undefined extends TContext + ? { context?: TContext } + : { context: TContext }) + +export type ActionClientStore = Store + +export type ActionClientState = { + isSubmitting?: SubmissionState[] + actions: Record +} + +export interface ActionState< + TKey extends string = string, + TVariables = unknown, + TResponse = unknown, + TError = Error, +> { + key: TKey + submissions: SubmissionState[] + latestSubmission?: SubmissionState + pendingSubmissions: SubmissionState[] } -export type ActionClientStore = Store<{ - isSubmitting?: ActionSubmission[] -}> +export type ActionFn = ( + submission: TActionVariables, +) => TActionResponse | Promise + +export interface SubmissionState< + TVariables = unknown, + TResponse = unknown, + TError = Error, +> { + submittedAt: number + status: 'pending' | 'success' | 'error' + variables: undefined extends TVariables ? undefined : TVariables + response?: TResponse + error?: TError +} -type ResolveActions> = { - [TKey in keyof TAction]: TAction[TKey] extends Action< - infer _, - infer TVariables, - infer TData, - infer TError +export type ActionsToRecord = { + [TKey in TActions['__types']['key']]: Extract< + TActions, + { options: { key: TKey } } > - ? Action<_, TVariables, TData, TError> - : Action +} + +export class ActionContext { + constructor() {} + + createAction = ( + options: ActionOptions, + ) => { + return new Action(options) + } + + createClient = ( + options: ActionClientOptions, + ) => { + return new ActionClient(options) + } } // A action client that tracks instances of actions by unique key like react query export class ActionClient< - _TActions extends Record = Record, - TActions extends ResolveActions<_TActions> = ResolveActions<_TActions>, + _TActions extends AnyAction = Action, + TContext = unknown, + TActions extends ActionsToRecord<_TActions> = ActionsToRecord<_TActions>, > { - options: ActionClientOptions<_TActions> + options: ActionClientOptions<_TActions, TContext> actions: TActions __store: ActionClientStore state: ActionClientStore['state'] - initialized = false - - constructor(options: ActionClientOptions<_TActions>) { + constructor(options: ActionClientOptions<_TActions, TContext>) { this.options = options this.__store = new Store( - {}, + { + actions: options.actions.reduce((acc, action) => { + return { + ...acc, + [action.options.key]: { + submissions: [], + pendingSubmissions: [], + }, + } + }, {}), + }, { onUpdate: () => { + this.__store.state = this.#resolveState(this.__store.state) this.state = this.__store.state }, }, ) as ActionClientStore this.state = this.__store.state this.actions = {} as any - this.init() - } - init = () => { - if (this.initialized) return - Object.entries(this.options.getActions()).forEach( - ([key, action]: [string, Action]) => { - ;(this.actions as any)[key] = action.init(key, this) - }, - ) - this.initialized = true - } - - clearAll = () => { - Object.keys(this.actions).forEach((key) => { - this.actions[key]!.clear() + Object.values(this.options.actions).forEach((action: Action) => { + ;(this.actions as any)[action.options.key] = action }) } -} - -export type ActionByKey< - TActions extends Record, - TKey extends keyof TActions, -> = TActions[TKey] -export type SubmitFn< - TPayload = unknown, - TResponse = unknown, -> = undefined extends TPayload - ? () => Promise - : (payload: TPayload) => Promise + #resolveState = (state: ActionClientState): ActionClientState => { + Object.keys(state.actions).forEach((key) => { + let action = state.actions[key]! + const latestSubmission = action.submissions[action.submissions.length - 1] + const pendingSubmissions = action.submissions.filter( + (d) => d.status === 'pending', + ) + + // Only update if things have changed + if (latestSubmission !== action.latestSubmission) { + action = { + ...action, + latestSubmission, + } + } -export interface ActionOptions< - TKey extends string = string, - TPayload = unknown, - TResponse = unknown, - TError = Error, -> { - fn: (payload: TPayload) => TResponse | Promise - onLatestSuccess?: ActionCallback - onEachSuccess?: ActionCallback - onLatestError?: ActionCallback - onEachError?: ActionCallback - onLatestSettled?: ActionCallback - onEachSettled?: ActionCallback - maxSubmissions?: number - debug?: boolean -} + // Only update if things have changed + if ( + pendingSubmissions.map((d) => d.submittedAt).join('-') !== + action.pendingSubmissions.map((d) => d.submittedAt).join('-') + ) { + action = { + ...action, + pendingSubmissions, + } + } -export type ActionCallback = ( - submission: ActionSubmission, -) => void | Promise + state.actions[key] = action + }) -export class Action< - TKey extends string = string, - TPayload = unknown, - TResponse = unknown, - TError = Error, -> { - __types!: { - key: TKey - payload: TPayload - response: TResponse - error: TError + return state } - key!: TKey - client?: ActionClient - options: ActionOptions - __store: Store> - state: ActionStore - - constructor(options: ActionOptions) { - this.__store = new Store>( - { - submissions: [], - pendingSubmissions: [], - latestSubmission: undefined, - }, - { - onUpdate: () => { - this.__store.state = this.#resolveState(this.__store.state) - this.state = this.__store.state - }, - }, - ) - this.state = this.#resolveState(this.__store.state) - this.options = options + + clearAll = () => { + this.__store.batch(() => { + Object.keys(this.actions).forEach((key) => { + this.clearAction({ key }) + }) + }) } - init = (key: TKey, client: ActionClient) => { - this.client = client - this.key = key as TKey - return this as Action + clearAction = (opts: { key: TKey }) => { + this.#setAction(opts, (s) => { + return { + ...s, + submissions: s.submissions.filter((d) => d.status == 'pending'), + } + }) } - #resolveState = ( - state: ActionStore, - ): ActionStore => { - const latestSubmission = state.submissions[state.submissions.length - 1] - const pendingSubmissions = state.submissions.filter( - (d) => d.status === 'pending', - ) - - return { - ...state, - latestSubmission, - pendingSubmissions, - } + submitAction = async < + TKey extends keyof TActions, + TAction extends TActions[TKey], + >( + opts: { + key: TKey + } & (undefined extends TAction['__types']['variables'] + ? { variables?: TAction['__types']['variables'] } + : { variables: TAction['__types']['variables'] }), + ): Promise => { + return this.#submitAction(opts as any) } - clear = async () => { - // await Promise.all(this.#promises) - this.__store.setState((s) => ({ - ...s, - submissions: s.submissions.filter((d) => d.status === 'pending'), - })) + #setAction = async ( + opts: { + key: TKey + }, + updater: (action: ActionState) => ActionState, + ) => { + this.__store.setState((s) => { + const action = s.actions[opts.key as any] ?? createAction(opts) + + return { + ...s, + actions: { + ...s.actions, + [opts.key]: updater(action), + }, + } + }) } - #promises: Promise[] = [] + #setSubmission = async ( + opts: { + key: TKey + submittedAt: number + }, + updater: (submission: SubmissionState) => SubmissionState, + ) => { + this.#setAction(opts, (s) => { + const submission = s.submissions.find( + (d) => d.submittedAt === opts.submittedAt, + ) - submit: SubmitFn = async (payload?: TPayload) => { - const promise = this.#submit(payload as TPayload) - this.#promises.push(promise) + invariant(submission, 'Could not find submission in action store') - const res = await promise - this.#promises = this.#promises.filter((d) => d !== promise) - return res + return { + ...s, + submissions: s.submissions.map((d) => + d.submittedAt === opts.submittedAt ? updater(d) : d, + ), + } + }) } - #submit: SubmitFn = async (payload?: TPayload) => { - const submission: ActionSubmission = { - submittedAt: Date.now(), - status: 'pending', - payload: payload as ActionSubmission< - TPayload, - TResponse, - TError - >['payload'], - invalidate: () => { - setSubmission((s) => ({ - ...s, - isInvalid: true, - })) - }, - getIsLatest: () => - this.state.submissions[this.state.submissions.length - 1] - ?.submittedAt === submission.submittedAt, - } + #getIsLatestSubmission = (opts: { + key: TKey + submittedAt: number + }) => { + const action = this.state.actions[opts.key as any] + return action?.latestSubmission?.submittedAt === opts.submittedAt + } - const setSubmission = ( - updater: ( - submission: ActionSubmission, - ) => ActionSubmission, - ) => { - this.__store.setState((s) => { - const a = s.submissions.find( - (d) => d.submittedAt === submission.submittedAt, - ) - - invariant(a, 'Could not find submission in submission store') - - return { - ...s, - submissions: s.submissions.map((d) => - d.submittedAt === submission.submittedAt ? updater(d) : d, - ), - } - }) + #submitAction = async < + TKey extends keyof TActions, + TAction extends TActions[TKey], + >( + opts: { + key: TKey + } & (undefined extends TAction['__types']['variables'] + ? { variables?: TAction['__types']['variables'] } + : { variables: TAction['__types']['variables'] }), + ) => { + const action = this.actions[opts.key] + + const submittedAt = Date.now() + + const submission: SubmissionState< + TAction['__types']['variables'], + TAction['__types']['response'], + TAction['__types']['error'] + > = { + submittedAt, + status: 'pending', + variables: opts.variables as any, } - this.__store.setState((s) => { + this.#setAction(opts, (s) => { let submissions = [...s.submissions, submission] submissions.reverse() - submissions = submissions.slice(0, this.options.maxSubmissions ?? 10) + submissions = submissions.slice(0, action.options.maxSubmissions ?? 10) submissions.reverse() return { @@ -251,37 +286,57 @@ export class Action< }) const after = async () => { - this.options.onEachSettled?.(submission) - if (submission.getIsLatest()) - await this.options.onLatestSettled?.(submission) + action.options.onEachSettled?.({ + submission, + context: this.options.context, + }) + if (this.#getIsLatestSubmission({ ...opts, submittedAt })) + await action.options.onLatestSettled?.({ + submission, + context: this.options.context, + }) } try { - const res = await this.options.fn?.(submission.payload) - setSubmission((s) => ({ + const res = await action.options.fn?.(submission.variables, { + context: this.options.context, + }) + this.#setSubmission({ ...opts, submittedAt }, (s) => ({ ...s, response: res, })) - await this.options.onEachSuccess?.(submission) - if (submission.getIsLatest()) - await this.options.onLatestSuccess?.(submission) + await action.options.onEachSuccess?.({ + submission, + context: this.options.context, + }) + if (this.#getIsLatestSubmission({ ...opts, submittedAt })) + await action.options.onLatestSuccess?.({ + submission, + context: this.options.context, + }) await after() - setSubmission((s) => ({ + this.#setSubmission({ ...opts, submittedAt }, (s) => ({ ...s, status: 'success', })) return res } catch (err: any) { console.error(err) - setSubmission((s) => ({ + this.#setSubmission({ ...opts, submittedAt }, (s) => ({ ...s, error: err, })) - await this.options.onEachError?.(submission) - if (submission.getIsLatest()) - await this.options.onLatestError?.(submission) + await action.options.onEachError?.({ + submission, + context: this.options.context, + }) + if (this.#getIsLatestSubmission({ ...opts, submittedAt })) + await action.options.onLatestError?.({ + submission, + context: this.options.context, + }) await after() - setSubmission((s) => ({ + this.#setSubmission({ ...opts, submittedAt }, (s) => ({ ...s, status: 'error', })) @@ -290,31 +345,68 @@ export class Action< } } -export interface ActionStore< - TPayload = unknown, +export interface ActionOptions< + TKey extends string = string, + TVariables = unknown, TResponse = unknown, TError = Error, + TContext = unknown, > { - submissions: ActionSubmission[] - latestSubmission?: ActionSubmission - pendingSubmissions: ActionSubmission[] + key: TKey + fn: ( + variables: TVariables, + opts: { context: TContext }, + ) => TResponse | Promise + onLatestSuccess?: ActionCallback + onEachSuccess?: ActionCallback + onLatestError?: ActionCallback + onEachError?: ActionCallback + onLatestSettled?: ActionCallback + onEachSettled?: ActionCallback + maxSubmissions?: number + debug?: boolean } -export type ActionFn = ( - submission: TActionPayload, -) => TActionResponse | Promise +export type ActionCallback = (opts: { + submission: SubmissionState + context: TContext +}) => void | Promise -export interface ActionSubmission< - TPayload = unknown, +export class Action< + TKey extends string = string, + TVariables = unknown, TResponse = unknown, TError = Error, + TContext = unknown, > { - submittedAt: number - status: 'pending' | 'success' | 'error' - payload: undefined extends TPayload ? undefined : TPayload - response?: TResponse - error?: TError - isInvalid?: boolean - invalidate: () => void - getIsLatest: () => boolean + __types!: { + key: TKey + variables: TVariables + response: TResponse + error: TError + context: TContext + } + + constructor( + public options: ActionOptions< + TKey, + TVariables, + TResponse, + TError, + TContext + >, + ) {} +} + +export function createAction< + TKey extends string = string, + TVariables = unknown, + TResponse = unknown, + TError = Error, +>(opts: { key: any }): ActionState { + return { + key: opts.key, + submissions: [], + pendingSubmissions: [], + } } diff --git a/packages/loaders/src/index.ts b/packages/loaders/src/index.ts index 7de16921fb..9bdb4a457e 100644 --- a/packages/loaders/src/index.ts +++ b/packages/loaders/src/index.ts @@ -13,15 +13,19 @@ export type RegisteredLoaderClient = Register extends { : LoaderClient export type RegisteredLoaders = Register extends { - loaderClient: LoaderClient + loaderClient: LoaderClient } - ? TLoader - : Record + ? TLoaders + : Loader -export interface LoaderClientOptions< - TLoader extends Record, -> { - getLoaders: () => TLoader +export type RegisteredLoadersByKey = Register extends { + loaderClient: LoaderClient +} + ? LoadersToRecord + : Loader + +export interface LoaderClientOptions { + loaders: TLoader[] defaultMaxAge?: number defaultPreloadMaxAge?: number defaultGcMaxAge?: number @@ -30,10 +34,22 @@ export interface LoaderClientOptions< dehydrateLoaderInstanceFn?: (loader: LoaderInstance) => void } -export type LoaderClientStore = Store<{ +export type LoaderClientStore = Store> + +export type LoaderClientState = { isLoading: boolean isPreloading: boolean -}> + loaders: Record +} + +export type LoaderState = { + instances: Record +} + +export type LoaderInstanceMeta = { + store: Store + subscriptionCount: number +} export interface DehydratedLoaderClient { loaders: Record< @@ -49,260 +65,86 @@ export interface DehydratedLoaderClient { > } -type ResolveLoaders> = { - [TKey in keyof TLoader]: TLoader[TKey] extends Loader< - infer _, - infer TVariables, - infer TData, - infer TError +export type LoadersToRecord = { + [TKey in TLoaders['__types']['key']]: Extract< + TLoaders, + { options: { key: TKey } } > - ? Loader<_, TVariables, TData, TError> - : Loader } - // A loader client that tracks instances of loaders by unique key like react query export class LoaderClient< - _TLoaders extends Record = Record, - TLoaders extends ResolveLoaders<_TLoaders> = ResolveLoaders<_TLoaders>, + _TLoader extends AnyLoader = Loader, + TLoaders extends LoadersToRecord<_TLoader> = LoadersToRecord<_TLoader>, > { - options: LoaderClientOptions<_TLoaders> + options: LoaderClientOptions<_TLoader> loaders: TLoaders + loaderInstanceMeta: Record = {} loaderInstances: Record = {} - __store: LoaderClientStore - state: LoaderClientStore['state'] + __store: LoaderClientStore + state: LoaderClientStore['state'] - initialized = false - - constructor(options: LoaderClientOptions<_TLoaders>) { + constructor(options: LoaderClientOptions<_TLoader>) { this.options = options this.__store = new Store( { isLoading: false, isPreloading: false, + loaders: Object.values(this.options.loaders).reduce((acc, loader) => { + return { + ...acc, + [loader.options.key]: { + instances: {}, + }, + } + }, {} as any), }, { onUpdate: () => { this.state = this.__store.state + + // const isLoading = Object.values(client.loaders).some((loader) => { + // return Object.values(loader.instances).some( + // (instance) => + // instance.state.isFetching && !instance.state.preload, + // ) + // }) + + // const isPreloading = Object.values(client.loaders).some((loader) => { + // return Object.values(loader.instances).some( + // (instance) => instance.state.isFetching && instance.state.preload, + // ) + // }) + + // if ( + // client.state.isLoading === isLoading && + // client.state.isPreloading === isPreloading + // ) { + // return + // } + + // client.__store.setState((s) => { + // return { + // isLoading, + // isPreloading, + // } + // }) }, }, - ) as LoaderClientStore + ) as LoaderClientStore this.state = this.__store.state this.loaders = {} as any - this.init() - } + this.loaderInstanceMeta = {} as any - init = () => { - if (this.initialized) return - Object.entries(this.options.getLoaders()).forEach( + Object.entries(this.options.loaders).forEach( ([key, loader]: [string, Loader]) => { - ;(this.loaders as any)[key] = loader.init(key, this) + ;(this.loaders as any)[loader.options.key] = loader }, ) - this.initialized = true } - dehydrate = (): DehydratedLoaderClient => { - return { - loaders: Object.values(this.loaders).reduce( - (acc, loader: AnyLoader) => ({ - ...acc, - [loader.key]: Object.values(loader.instances).reduce( - (acc, instance) => ({ - ...acc, - [instance.hashedKey]: { - hashedKey: instance.hashedKey, - variables: instance.variables, - state: instance.state, - }, - }), - {}, - ), - }), - {}, - ), - } - } - - hydrate = (data: DehydratedLoaderClient) => { - Object.entries(data.loaders).forEach(([loaderKey, instances]) => { - const loader = this.loaders[loaderKey] as Loader - - Object.values(instances).forEach((dehydratedInstance) => { - let instance = loader.instances[dehydratedInstance.hashedKey] - - if (!instance) { - instance = loader.instances[dehydratedInstance.hashedKey] = - loader.getInstance({ - variables: dehydratedInstance.variables, - }) - } - - instance.__store.setState(() => dehydratedInstance.state) - }) - }) - } -} - -export type LoaderByKey< - TLoaders extends Record, - TKey extends keyof TLoaders, -> = TLoaders[TKey] - -export type LoaderInstanceByKey< - TLoaders extends Record, - TKey extends keyof TLoaders, -> = TLoaders[TKey] extends Loader< - infer _, - infer TVariables, - infer TData, - infer TError -> - ? LoaderInstance<_, TVariables, TData, TError> - : never - -export type LoaderCallback = ( - loader: Loader, -) => void | Promise - -export type LoaderInstanceCallback< - TKey extends string, - TVariables, - TData, - TError, -> = ( - loader: LoaderInstance, -) => void | Promise - -export interface NullableLoaderStore - extends Omit, 'data'> { - data?: TData -} - -export interface LoaderStore { - status: 'idle' | 'pending' | 'success' | 'error' - isFetching: boolean - invalidAt: number - preloadInvalidAt: number - invalid: boolean - updatedAt?: number - data: TData - error?: TError - preload: boolean -} - -export type LoaderFn = ( - variables: TVariables, -) => TData | Promise - -export type LoaderOptions< - TKey extends string = string, - TVariables = unknown, - TData = unknown, - TError = Error, -> = ( - | { - fn: LoaderFn - getFn?: never - } - | { - fn?: never - getFn: (ctx: { - loaderInstance: LoaderInstance - signal: AbortSignal | null - }) => LoaderFn - } -) & { - // The max age to consider loader data fresh (not-stale) in milliseconds from the time of fetch - // Defaults to 1000. Only stale loader data is refetched. - maxAge?: number - preloadMaxAge?: number - // The max age to client the loader data in milliseconds from the time of route inactivity - // before it is garbage collected. - gcMaxAge?: number - onInvalidate?: LoaderCallback - onEachInvalidate?: LoaderInstanceCallback - onLatestSuccess?: LoaderInstanceCallback - onEachSuccess?: LoaderInstanceCallback - onLatestError?: LoaderInstanceCallback - onEachError?: LoaderInstanceCallback - onLatestSettled?: LoaderInstanceCallback - onEachSettled?: LoaderInstanceCallback - onEachOutdated?: LoaderInstanceCallback - refetchOnWindowFocus?: boolean - debug?: boolean -} - -export type HydrateUpdater = - | LoaderStore - | (( - ctx: LoaderInstance, - ) => LoaderStore) - -export function getInitialLoaderState(): LoaderStore { - return { - status: 'idle', - invalid: false, - invalidAt: Infinity, - preloadInvalidAt: Infinity, - isFetching: false, - updatedAt: 0, - data: undefined!, - preload: false, - } as const -} - -export type VariablesOptions = undefined extends TVariables - ? { - variables?: TVariables - } - : { - variables: TVariables - } - -export type VariablesFn< - TVariables, - TReturn, - TOptions = {}, -> = undefined extends TVariables - ? keyof PickRequired extends never - ? { - (opts?: VariablesOptions & TOptions): TReturn - } - : { - (opts: VariablesOptions & TOptions): TReturn - } - : { - (opts: VariablesOptions & TOptions): TReturn - } - -const visibilityChangeEvent = 'visibilitychange' - -export type AnyLoader = Loader - -let uid = 0 - -export class Loader< - TKey extends string = string, - TVariables = unknown, - TData = unknown, - TError = Error, -> { - __types!: { - key: TKey - variables: TVariables - data: TData - error: TError - } - options: LoaderOptions - key!: TKey - client?: LoaderClient - instances: Record> - - constructor(options: LoaderOptions) { - this.options = options - this.instances = {} - this.key = `loader-${uid++}` as TKey + mount = () => { + const visibilityChangeEvent = 'visibilitychange' // addEventListener does not exist in React Native, but window does // In the future, we might need to invert control here for more adapters @@ -311,360 +153,415 @@ export class Loader< window.addEventListener(visibilityChangeEvent, this.#reloadAll, false) } - Loader.onCreateFns.forEach((cb) => cb(this)) - } - - static onCreateFns: ((loader: AnyLoader) => void)[] = [] - - init = (key: TKey, client: LoaderClient) => { - this.client = client - this.key = key - this.instances = {} - return this as Loader - } - - dispose = () => { - if (typeof window !== 'undefined' && window.removeEventListener) { - window.removeEventListener(visibilityChangeEvent, this.#reloadAll) + return () => { + if (typeof window !== 'undefined' && window.removeEventListener) { + window.removeEventListener(visibilityChangeEvent, this.#reloadAll) + } } } #reloadAll = () => { - Object.values(this.instances).forEach((instance) => { - instance.loadIfActive({ - isFocusReload: true, - }) - }) + return Promise.all( + (Object.values(this.state.loaders) as LoaderState[]).map((loader) => { + return Promise.all( + Object.values(loader.instances).map((instance) => { + return this.loadIfActive({ + ...(instance as any), + isFocusReload: true, + }) + }), + ) + }), + ) } - getInstance: VariablesFn< - TVariables, - LoaderInstance - > = (opts: any = {}) => { - const hashedKey = hashKey([this.key, opts.variables]) - if (this.instances[hashedKey]) { - return this.instances[hashedKey] as any - } - - const loader = new LoaderInstance({ - hashedKey, - loader: this, - variables: opts.variables as any, - }) - - return (this.instances[hashedKey] = loader) + dehydrate = (): Record => { + return this.state.loaders } - load: VariablesFn< - TVariables, - Promise, - { - maxAge?: number - preload?: boolean - signal?: AbortSignal - } - > = async (opts: any = {}) => { - return this.getInstance(opts).load(opts as any) + hydrate = (data: Record) => { + this.__store.setState((s) => ({ + ...s, + loaders: data, + })) } - fetch: VariablesFn< - TVariables, - Promise, - { - maxAge?: number - preload?: boolean + subscribeToInstance = < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions, + callback: () => void, + ) => { + const { key, variables } = opts + const hashedKey = hashKey([key, variables]) + + let meta = this.loaderInstanceMeta[hashedKey] + + if (!meta) { + meta = { + subscriptionCount: 0, + store: new Store(undefined, { + onSubscribe: () => { + if (!meta!.subscriptionCount) { + this.#stopInstanceGc(opts) + } + meta!.subscriptionCount++ + return () => { + meta!.subscriptionCount-- + if (!meta!.subscriptionCount) { + this.#startInstanceGc(opts) + } + } + }, + }), + } + this.loaderInstanceMeta[hashedKey] = meta } - > = async (opts: any = {}) => { - return this.getInstance(opts).fetch(opts as any) - } - invalidateInstance: VariablesFn< - TVariables, - Promise, - { - maxAge?: number + const unsub = meta?.store.subscribe(callback) + + if (meta.store.listeners.size) { + this.#stopInstanceGc(opts) + } else { + this.#startInstanceGc(opts) } - > = async (opts: any = {}) => { - await this.getInstance(opts).invalidate() - await this.options.onInvalidate?.(this) - } - invalidate = async () => { - await Promise.all( - Object.values(this.instances).map((loader) => loader.invalidate()), - ) + return unsub } -} - -export interface LoaderInstanceOptions< - TKey extends string = string, - TVariables = unknown, - TData = unknown, - TError = Error, -> { - hashedKey: string - loader: Loader - variables: TVariables -} - -export interface NullableLoaderInstance< - TKey extends string = string, - TVariables = unknown, - TData = unknown, - TError = Error, -> extends Omit< - LoaderInstance, - '__store' | 'state' - > { - __store: Store> - state: NullableLoaderStore -} -export type AnyLoaderInstance = LoaderInstance - -export class LoaderInstance< - TKey extends string = string, - TVariables = unknown, - TData = unknown, - TError = Error, -> { - __types!: { - key: TKey - variables: TVariables - data: TData - error: TError - } - hashedKey: string - options: LoaderInstanceOptions - loader: Loader - __store: Store> - state: LoaderStore - variables: TVariables - promise?: Promise - __loadPromise?: Promise - #subscriptionCount = 0 + setInstance = < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions, + updater: ( + prev: LoaderInstance< + TResolvedLoader['__types']['data'], + TResolvedLoader['__types']['error'] + >, + ) => LoaderInstance< + TResolvedLoader['__types']['data'], + TResolvedLoader['__types']['error'] + >, + ) => { + const { key, variables } = opts + const hashedKey = hashKey([key, variables]) - constructor(options: LoaderInstanceOptions) { - this.options = options - this.loader = options.loader - this.hashedKey = options.hashedKey - this.variables = options.variables - this.__store = new Store>( - getInitialLoaderState(), - { - onSubscribe: () => { - if (!this.#subscriptionCount) { - this.#stopGc() - } - this.#subscriptionCount++ - return () => { - this.#subscriptionCount-- - if (!this.#subscriptionCount) { - this.#startGc() - } - } - }, - onUpdate: () => { - this.state = this.__store.state - this.#notifyClient() + this.__store.setState((s) => ({ + ...s, + loaders: { + ...s.loaders, + [key]: { + ...s.loaders[key], + instances: { + ...s.loaders[key].instances, + [hashedKey]: updater( + s.loaders[key].instances[hashedKey]! || + createLoaderInstance({ ...opts, hashedKey } as any), + ), + }, }, }, - ) - - this.state = this.__store.state - - if (this.__store.listeners.size) { - this.#stopGc() - } else { - this.#startGc() - } - - LoaderInstance.onCreateFns.forEach((cb) => cb(this)) + })) } - static onCreateFns: ((loader: AnyLoaderInstance) => void)[] = [] + getInstance = < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions, + ) => { + const { key, variables } = opts + const hashedKey = hashKey([key, variables]) - #notifyClient = () => { - const client = this.loader.client + let instance = this.state.loaders[key]?.instances[hashedKey] - if (!client) return + if (!instance) { + instance = createLoaderInstance({ + key, + hashedKey, + variables: opts.variables as any, + }) - const isLoading = Object.values(client.loaders).some((loader) => { - return Object.values(loader.instances).some( - (instance) => instance.state.isFetching && !instance.state.preload, - ) - }) + setTimeout(() => { + this.setInstance(opts, () => instance!) + }) + } - const isPreloading = Object.values(client.loaders).some((loader) => { - return Object.values(loader.instances).some( - (instance) => instance.state.isFetching && instance.state.preload, - ) - }) + return instance + } - if ( - client.state.isLoading === isLoading && - client.state.isPreloading === isPreloading - ) { - return - } + #startInstanceGc = < + TKey extends keyof TLoaders, + TResolvedLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions, + ) => { + const loader = this.loaders[opts.key] - client.__store.setState((s) => { + const gcTimeout = setTimeout(() => { + this.setInstance(opts, (s) => { + return { + ...s, + gcTimeout: undefined, + } + }) + this.#gc(opts) + }, loader.options.gcMaxAge ?? this?.options.defaultGcMaxAge ?? 5 * 60 * 1000) + + this.setInstance(opts, (s) => { return { - isLoading, - isPreloading, + ...s, + gcTimeout, } }) } - #gcTimeout?: ReturnType - - #startGc = () => { - this.#gcTimeout = setTimeout(() => { - this.#gcTimeout = undefined - this.#gc() - }, this.loader.options.gcMaxAge ?? this.loader.client?.options.defaultGcMaxAge ?? 5 * 60 * 1000) - } + #stopInstanceGc = < + TKey extends keyof TLoaders, + TResolvedLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions, + ) => { + const instance = this.getInstance(opts) - #stopGc = () => { - if (this.#gcTimeout) { - clearTimeout(this.#gcTimeout) - this.#gcTimeout = undefined + if (instance.gcTimeout) { + clearTimeout(instance.gcTimeout) + this.setInstance(opts, (s) => { + return { + ...s, + gcTimeout: undefined, + } + }) } } - #gc = () => { - this.#destroy() + #gc = ( + opts: GetInstanceOptions, + ) => { + this.clearInstance(opts) } - #destroy = () => { - delete this.loader.instances[this.hashedKey] + clearInstance = < + TKey extends keyof TLoaders, + TResolvedLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions, + ) => { + const { key, variables } = opts + const hashedKey = hashKey([key, variables]) + + this.__store.setState((s) => { + return { + ...s, + loaders: { + ...s.loaders, + [key]: { + instances: { + ...s.loaders[key].instances, + [hashedKey]: undefined, + }, + }, + }, + } + }) } - getIsInvalid = (opts?: { preload?: boolean }) => { + getIsInvalid = < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions & { + preload?: boolean + }, + ) => { + const instance = this.getInstance(opts as any) const now = Date.now() return ( - this.state.status === 'success' && - (this.state.invalid || - (opts?.preload ? this.state.preloadInvalidAt : this.state.invalidAt) < - now) + instance.status === 'success' && + (instance.invalid || + (opts?.preload ? instance.preloadInvalidAt : instance.invalidAt) < now) ) } - invalidate = async () => { - this.__store.setState((s) => ({ - ...s, - invalid: true, - })) + invalidateLoader = async < + TKey extends keyof TLoaders, + TResolvedLoader extends TLoaders[TKey] = TLoaders[TKey], + >(opts: { + key: TKey + variables?: TResolvedLoader['__types']['variables'] + }) => { + const loader = this.loaders[opts.key] - await this.loadIfActive() + await Promise.all( + Object.values(this.state.loaders[opts.key].instances).map((instance) => + this.invalidateInstance(instance as any), + ), + ) + await loader.options.onInvalidate?.({ + loader: this.state.loaders[opts.key], + client: this as unknown as any, + }) + } + + invalidateInstance = async < + TKey extends keyof TLoaders, + TResolvedLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions, + ) => { + const loader = this.loaders[opts.key] - await this.loader.options.onEachInvalidate?.(this) + this.setInstance(opts, (s) => { + return { + ...s, + invalid: true, + } + }) + + await this.loadIfActive(opts as any) + await loader.options.onEachInvalidate?.({ + instance: this.getInstance(opts as any), + client: this as unknown as any, + }) } - loadIfActive = async (opts?: { isFocusReload?: boolean }) => { - if (this.__store.listeners.size) { - this.load(opts) + loadIfActive = async < + TKey extends keyof TLoaders, + TResolvedLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions & { + isFocusReload?: boolean + }, + ) => { + const { key, variables } = opts + const hashedKey = hashKey([key, variables]) + + if (this.loaderInstanceMeta[hashedKey]?.store.listeners.size) { + this.load(opts as any) try { - await this.promise + await this.getInstance(opts as any).loadPromise } catch (err) { // Ignore } } } - load = async (opts?: { - maxAge?: number - preload?: boolean - isFocusReload?: boolean - signal?: AbortSignal - }): Promise => { + load = async < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions & { + maxAge?: number + preload?: boolean + isFocusReload?: boolean + signal?: AbortSignal + }, + ): Promise => { + const { key } = opts + const loader = this.loaders[key] + + const getInstance = () => this.getInstance(opts as any) + if (opts?.isFocusReload) { if ( !( - this.loader.options.refetchOnWindowFocus ?? - this.loader.client?.options.defaultRefetchOnWindowFocus ?? + loader.options.refetchOnWindowFocus ?? + this.options.defaultRefetchOnWindowFocus ?? true ) ) { - return this.state.data! + return getInstance().data } } if ( - this.state.status === 'error' || - this.state.status === 'idle' || - this.getIsInvalid(opts) + getInstance().status === 'error' || + getInstance().status === 'idle' || + this.getIsInvalid(opts as any) ) { - // Fetch if we need to - if (!this.__loadPromise) { - this.fetch(opts).catch(() => { + // Start a fetch if we need to + if (getInstance().status !== 'pending') { + this.fetch(opts as any).catch(() => { // Ignore }) } } - // If we already have data, return it - if (typeof this.state.data !== 'undefined') { - return this.state.data! + // If we already have data, always return it + if (typeof getInstance().data !== 'undefined') { + return getInstance().data! } // Otherwise wait for the data to be fetched - return this.promise! + return getInstance().loadPromise } - #latestId = '' - - fetch = async (opts?: { - maxAge?: number - preload?: boolean - signal?: AbortSignal - }): Promise => { - // this.store.batch(() => { - // If the match was in an error state, set it - // to a loading state again. Otherwise, keep it - // as loading or resolved - if (this.state.status === 'idle') { - this.__store.setState((s) => ({ + fetch = async < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions & { + maxAge?: number + preload?: boolean + signal?: AbortSignal + isFocusReload?: boolean + }, + ): Promise => { + const loader = this.loaders[opts.key] + const instance = this.getInstance(opts as any) + const fetchedAt = Date.now() + + this.__store.batch(() => { + // If the match was in an error state, set it + // to a loading state again. Otherwise, keep it + // as loading or resolved + if (instance.status === 'idle') { + this.setInstance(opts as any, (s) => ({ + ...s, + status: 'pending', + fetchedAt, + })) + } + + // We started loading the route, so it's no longer invalid + this.setInstance(opts as any, (s) => ({ ...s, - status: 'pending', + preload: !!opts?.preload, + invalid: false, + isFetching: true, })) - } - - // We started loading the route, so it's no longer invalid - this.__store.setState((s) => ({ - ...s, - preload: !!opts?.preload, - invalid: false, - isFetching: true, - })) - // }) - - const loadId = '' + Date.now() + Math.random() - this.#latestId = loadId + }) const hasNewer = () => { - if (loadId !== this.#latestId) { - this.promise = this.__loadPromise - return this.promise - } - return undefined + const latest = this.getInstance(opts as any) + return latest && latest.fetchedAt !== fetchedAt + ? latest.loadPromise + : undefined } let newer: ReturnType - this.promise = this.__loadPromise = Promise.resolve().then(async () => { + const loadPromise = Promise.resolve().then(async () => { const after = async () => { - this.__store.setState((s) => ({ + this.setInstance(opts as any, (s) => ({ ...s, isFetching: false, })) if ((newer = hasNewer())) { - await this.loader.options.onLatestSettled?.(this) + await loader.options.onLatestSettled?.({ + instance: this.getInstance(opts as any), + client: this as unknown as any, + }) return newer } else { - await this.loader.options.onEachSettled?.(this) + await loader.options.onEachSettled?.({ + instance: this.getInstance(opts as any), + client: this as unknown as any, + }) } return @@ -672,51 +569,61 @@ export class LoaderInstance< try { const loaderFn = - this.loader.options.getFn?.({ - loaderInstance: this, - signal: opts?.signal ?? null, - }) ?? this.loader.options.fn! + loader.options.getFn?.(this.getInstance(opts as any)) ?? + loader.options.fn! - const data = await loaderFn(this.variables as any) + const data = await loaderFn(this.getInstance(opts as any).variables) if ((newer = hasNewer())) return newer - this.setData(data, opts) + this.setInstanceData(opts as any, data) if ((newer = hasNewer())) { - await this.loader.options.onLatestSuccess?.(this) + await loader.options.onLatestSuccess?.({ + instance: this.getInstance(opts as any), + client: this as unknown as any, + }) return newer } else { - await this.loader.options.onEachSuccess?.(this) + await loader.options.onEachSuccess?.({ + instance: this.getInstance(opts as any), + client: this as unknown as any, + }) } - this.__store.setState((s) => ({ + this.setInstance(opts as any, (s) => ({ ...s, status: 'success', })) await after() - return this.state.data! + return this.getInstance(opts as any).data } catch (err) { if (process.env.NODE_ENV !== 'production') { console.error(err) } - this.__store.setState((s) => ({ + this.setInstance(opts as any, (s) => ({ ...s, - error: err as TError, + error: err as any, updatedAt: Date.now(), })) if ((newer = hasNewer())) { - await this.loader.options.onLatestError?.(this) + await loader.options.onLatestError?.({ + instance: this.getInstance(opts as any), + client: this as unknown as any, + }) return newer } else { - await this.loader.options.onEachError?.(this) + await loader.options.onEachError?.({ + instance: this.getInstance(opts as any), + client: this as unknown as any, + }) } - this.__store.setState((s) => ({ + this.setInstance(opts as any, (s) => ({ ...s, status: 'error', })) @@ -727,22 +634,34 @@ export class LoaderInstance< } }) - this.__loadPromise - .then(() => { - delete this.__loadPromise - }) - .catch(() => {}) + this.setInstance(opts as any, (s) => ({ + ...s, + loadPromise: loadPromise as any, + fetchedAt, + })) - return this.__loadPromise + return loadPromise } - setData = ( - updater: TData | ((prev: TData | undefined) => TData), - opts?: { maxAge?: number; updatedAt?: number }, - ) => { + setInstanceData = < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions & { + maxAge?: number + updatedAt?: number + }, + updater: + | TResolvedLoader['__types']['data'] + | (( + prev: TResolvedLoader['__types']['data'] | undefined, + ) => TResolvedLoader['__types']['data']), + ): TResolvedLoader['__types']['data'] => { + const loader = this.loaders[opts.key] + const data = typeof updater === 'function' - ? (updater as any)(this.state.data) + ? (updater as any)(this.getInstance(opts as any).data) : updater invariant( @@ -755,18 +674,18 @@ export class LoaderInstance< const preloadInvalidAt = updatedAt + (opts?.maxAge ?? - this.loader.options.preloadMaxAge ?? - this.loader.client?.options.defaultPreloadMaxAge ?? + loader.options.preloadMaxAge ?? + this.options.defaultPreloadMaxAge ?? 10000) const invalidAt = updatedAt + (opts?.maxAge ?? - this.loader.options.maxAge ?? - this.loader.client?.options.defaultMaxAge ?? + loader.options.maxAge ?? + this.options.defaultMaxAge ?? 1000) - this.__store.setState((s) => ({ + this.setInstance(opts as any, (s) => ({ ...s, error: undefined, updatedAt, @@ -774,34 +693,221 @@ export class LoaderInstance< preloadInvalidAt: preloadInvalidAt, invalidAt: invalidAt, })) + + return this.getInstance(opts as any).data } - __hydrate = (opts?: { - hydrate: HydrateUpdater - }) => { - const hydrateFn = - opts?.hydrate ?? this.loader.client?.options.hydrateLoaderInstanceFn + __hydrateLoaderInstance = < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions & { + hydrate: HydrateUpdater< + TResolvedLoader['__types']['variables'], + TResolvedLoader['__types']['data'], + TResolvedLoader['__types']['error'] + > + }, + ) => { + if (typeof document !== 'undefined') { + const hydrateFn = opts?.hydrate ?? this.options.hydrateLoaderInstanceFn + + const instance = this.getInstance(opts as any) - if (hydrateFn && this.state.status === 'idle') { - // If we have a hydrate option, we need to do that first - const hydratedData = - typeof hydrateFn === 'function' ? hydrateFn(this as any) : hydrateFn + if (hydrateFn && instance.status === 'idle') { + // If we have a hydrate option, we need to do that first + const hydratedData = + typeof hydrateFn === 'function' ? hydrateFn(instance) : hydrateFn - if (hydratedData) { - this.__store.setState(() => hydratedData) + if (hydratedData) { + this.state.loaders[opts.key].instances[instance.hashedKey] = { + ...hydratedData, + loadPromise: Promise.resolve(), + } + } } } } - __dehydrate = (opts?: { - dehydrate: ( - instance: LoaderInstance, - ) => void - }) => { + __dehydrateLoaderInstance = < + TKey extends keyof TLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], + >( + opts: GetInstanceOptions & { + dehydrate: ( + instance: LoaderInstance< + TResolvedLoader['__types']['variables'], + TResolvedLoader['__types']['data'], + TResolvedLoader['__types']['error'] + >, + ) => void + }, + ) => { const dehydrateFn = - opts?.dehydrate ?? this.loader.client?.options.dehydrateLoaderInstanceFn + opts?.dehydrate ?? this.options.dehydrateLoaderInstanceFn + + dehydrateFn?.(this.getInstance(opts as any)) + } +} + +export type GetInstanceOptions = TLoader extends Loader< + infer _TKey, + infer TVariables, + infer TData, + infer TError +> + ? undefined extends TVariables + ? { + key: TKey + variables?: TVariables + } + : { key: TKey; variables: TVariables } + : never + +export type LoaderByKey< + TLoaders extends Record, + TKey extends keyof TLoaders, +> = TLoaders[TKey] + +export type LoaderInstanceByKey< + TLoaders extends Record, + TKey extends keyof TLoaders, +> = TLoaders[TKey] extends Loader< + infer _, + infer TVariables, + infer TData, + infer TError +> + ? LoaderInstance + : never + +export type LoaderStateCallback = (ctx: { + loader: LoaderState + client: LoaderClient +}) => void | Promise + +export type LoaderInstanceCallback = (ctx: { + instance: LoaderInstance + client: LoaderClient +}) => void | Promise + +export interface NullableLoaderInstance< + TVariables = unknown, + TData = unknown, + TError = Error, +> extends Omit, 'data'> { + data?: TData +} - dehydrateFn?.(this as any) +export interface LoaderInstance< + TVariables = unknown, + TData = unknown, + TError = Error, +> { + key: string + hashedKey: string + variables: TVariables + status: 'idle' | 'pending' | 'success' | 'error' + isFetching: boolean + invalidAt: number + preloadInvalidAt: number + invalid: boolean + updatedAt?: number + data: TData + error?: TError + preload: boolean + gcTimeout?: ReturnType + loadPromise?: Promise + fetchedAt: number +} + +export type LoaderFn = ( + variables: TVariables, +) => TData | Promise + +export type LoaderOptions< + TKey = string, + TVariables = unknown, + TData = unknown, + TError = Error, +> = ( + | { + fn: LoaderFn + getFn?: never + } + | { + fn?: never + getFn: ( + state: LoaderInstance, + ) => LoaderFn + } +) & { + key: TKey + // The max age to consider loader data fresh (not-stale) in milliseconds from the time of fetch + // Defaults to 1000. Only stale loader data is refetched. + maxAge?: number + preloadMaxAge?: number + // The max age to client the loader data in milliseconds from the time of route inactivity + // before it is garbage collected. + gcMaxAge?: number + onInvalidate?: LoaderStateCallback + onEachInvalidate?: LoaderInstanceCallback + onLatestSuccess?: LoaderInstanceCallback + onEachSuccess?: LoaderInstanceCallback + onLatestError?: LoaderInstanceCallback + onEachError?: LoaderInstanceCallback + onLatestSettled?: LoaderInstanceCallback + onEachSettled?: LoaderInstanceCallback + onEachOutdated?: LoaderInstanceCallback + refetchOnWindowFocus?: boolean + debug?: boolean +} + +export type HydrateUpdater = + | LoaderInstance + | (( + ctx: LoaderInstance, + ) => LoaderInstance) + +export type AnyLoader = Loader + +export class Loader< + TKey extends string = string, + TVariables = unknown, + TData = unknown, + TError = Error, +> { + __types!: { + key: TKey + variables: TVariables + data: TData + error: TError + } + constructor(public options: LoaderOptions) {} +} + +export function createLoaderInstance< + TVariables, + TData = unknown, + TError = unknown, +>(opts: { + key: any + hashedKey: string + variables: any +}): LoaderInstance { + return { + key: opts.key, + hashedKey: opts.hashedKey, + variables: opts.variables, + status: 'idle', + invalid: false, + invalidAt: Infinity, + preloadInvalidAt: Infinity, + isFetching: false, + updatedAt: 0, + data: undefined!, + preload: false, + fetchedAt: 0, } } @@ -818,6 +924,22 @@ export function hashKey(queryKey: any): string { ) } -type PickRequired = { - [K in keyof T as undefined extends T[K] ? never : K]: T[K] +export function typedClient(client: LoaderClient): RegisteredLoaderClient { + return client +} + +export function createLoaderOptions< + TLoader extends AnyLoader = RegisteredLoaders, + TLoaders extends LoadersToRecord = LoadersToRecord, + TKey extends keyof TLoaders = keyof RegisteredLoaders, + TResolvedLoader extends AnyLoader = TLoaders[TKey], +>( + opts: GetInstanceOptions & { + client?: LoaderClient + }, +): { + key: TKey + variables: TResolvedLoader['__types']['variables'] +} { + return opts as any } diff --git a/packages/react-actions/src/index.tsx b/packages/react-actions/src/index.tsx index 8e0e942aa0..d750cd2df5 100644 --- a/packages/react-actions/src/index.tsx +++ b/packages/react-actions/src/index.tsx @@ -1,73 +1,85 @@ import * as React from 'react' import { - Action, - ActionByKey, ActionClient, + ActionClientState, ActionClientStore, - ActionStore, - ActionSubmission, + ActionState, RegisteredActions, + RegisteredActionsByKey, } from '@tanstack/actions' import { useStore } from '@tanstack/react-store' import invariant from 'tiny-invariant' export * from '@tanstack/actions' -const actionClientContext = React.createContext>(null as any) +const actionsContext = React.createContext<{ + client: ActionClient + state: ActionClientState +}>(null as any) + +const useLayoutEffect = + typeof document !== 'undefined' ? React.useLayoutEffect : React.useEffect export function ActionClientProvider(props: { - actionClient: ActionClient + client: ActionClient children: any }) { + const [state, _setState] = React.useState(() => props.client.state) + + useLayoutEffect(() => { + return props.client.__store.subscribe(() => { + ;(React.startTransition || ((d) => d()))(() => + _setState(props.client.state), + ) + }) + }, []) + return ( - + {props.children} - + ) } export function useAction< - TKey extends keyof RegisteredActions, - TAction, - TActionFromKey extends ActionByKey, - TResolvedAction extends unknown extends TAction - ? TActionFromKey - : TAction extends Action - ? TAction - : never, - TPayload extends TResolvedAction['__types']['payload'], - TResponse extends TResolvedAction['__types']['response'], - TError extends TResolvedAction['__types']['error'], ->( - opts: ( - | { - key: TKey - } - | { action: TAction } - ) & { - track?: (actionStore: ActionStore) => any - }, -): TResolvedAction { - const actionClient = React.useContext(actionClientContext) + TKey extends keyof RegisteredActionsByKey, + TAction extends RegisteredActionsByKey[TKey] = RegisteredActionsByKey[TKey], +>(opts: { + key: TKey +}): [ + state: ActionState< + TAction['__types']['key'], + TAction['__types']['variables'], + TAction['__types']['response'], + TAction['__types']['error'] + >, + client: ActionClient, +] { + const ctx = React.useContext(actionsContext) + + invariant( + ctx, + 'useAction must be used inside a component!', + ) + + const { client, state } = ctx - const optsKey = (opts as { key: string }).key - const optsAction = (opts as { action: any }).action - const action = optsAction ?? actionClient.actions[optsKey] - useStore(action.__store, (d) => opts?.track?.(d as any) ?? d) - return action as any + const action = state.actions[opts.key] + + return [action as any, client] } export function useActionClient(opts?: { - track?: (actionClientStore: ActionClientStore) => any + track?: (clientStore: ActionClientStore) => any }): ActionClient { - const actionClient = React.useContext(actionClientContext) + const ctx = React.useContext(actionsContext) - if (!actionClient) - invariant( - 'useActionClient must be used inside a component!', - ) + invariant( + ctx, + 'useAction must be used inside a component!', + ) - useStore(actionClient.__store, (d) => opts?.track?.(d as any) ?? d) + useStore(ctx.client.__store, (d) => opts?.track?.(d as any) ?? d) - return actionClient + return ctx.client } diff --git a/packages/react-loaders/src/index.tsx b/packages/react-loaders/src/index.tsx index 7ad6e334c6..5387cae9a9 100644 --- a/packages/react-loaders/src/index.tsx +++ b/packages/react-loaders/src/index.tsx @@ -4,220 +4,174 @@ import { LoaderClient, RegisteredLoaders, LoaderInstanceByKey, - LoaderStore, - LoaderClientStore, LoaderClientOptions, - VariablesOptions, - Loader, LoaderInstance, - NullableLoaderInstance, - LoaderInstanceOptions, HydrateUpdater, + AnyLoader, + GetInstanceOptions, + NullableLoaderInstance, + LoadersToRecord, + LoaderByKey, + LoaderClientState, + hashKey, + createLoaderInstance, } from '@tanstack/loaders' -import { useStore } from '@tanstack/react-store' - export * from '@tanstack/loaders' // export type NoInfer = [T][T extends any ? 0 : never] -declare module '@tanstack/loaders' { - interface Loader< - TKey extends string = string, - TVariables = unknown, - TData = unknown, - TError = Error, - > { - useLoader: UseLoaderFn - } +const loadersContext = React.createContext<{ + client: LoaderClient + state: LoaderClientState +}>(null as any) - interface LoaderInstance< - TKey extends string = string, - TVariables = unknown, - TData = unknown, - TError = Error, - > { - useInstance: (opts?: { - strict?: TStrict - track?: (loaderStore: LoaderStore) => any - throwOnError?: boolean - hydrate?: - | LoaderStore - | (( - ctx: LoaderInstance, - ) => LoaderStore) - }) => UseLoaderReturn - } -} - -type UseLoaderFn = - | unknown - | undefined extends TVariables - ? ( - opts?: UseLoaderOpts, - ) => UseLoaderReturn - : ( - opts: UseLoaderOpts, - ) => UseLoaderReturn - -type UseLoaderOpts = { - strict?: TStrict - track?: (loaderStore: LoaderStore) => any - throwOnError?: boolean - hydrate?: - | LoaderStore - | (( - ctx: LoaderInstance, - ) => LoaderStore) -} & VariablesOptions - -type UseLoaderReturn< - TKey extends string, - TVariables, - TData, - TError, - TStrict, -> = TStrict extends false - ? NullableLoaderInstance_> - : LoaderInstance - -Loader.onCreateFns.push((loader) => { - loader.useLoader = (opts: any) => { - const loaderInstance = loader.getInstance(opts) - return loaderInstance.useInstance(opts) - } -}) - -LoaderInstance.onCreateFns.push((loaderInstance) => { - loaderInstance.useInstance = (opts?: any) => { - // Before anything runs, attempt hydration - loaderInstance.__hydrate(opts) - - if ( - loaderInstance.state.status === 'error' && - (opts?.throwOnError ?? true) - ) { - throw loaderInstance.state.error - } - - if (opts?.strict ?? true) { - if (loaderInstance.state.status === 'pending') { - throw loaderInstance.promise - } - } - - // If we're still in an idle state, we need to suspend via load - if (loaderInstance.state.status === 'idle') { - throw loaderInstance.load() - } - - React.useEffect(() => { - loaderInstance.load() - }, [loaderInstance]) - - useStore(loaderInstance.__store, (d) => opts?.track?.(d as any) ?? d) - - // If we didn't suspend, dehydrate the loader instance - loaderInstance.__dehydrate(opts) - - return loaderInstance as any - } -}) - -const loaderClientContext = React.createContext>(null as any) +const useLayoutEffect = + typeof document !== 'undefined' ? React.useLayoutEffect : React.useEffect export function LoaderClientProvider({ - loaderClient, + client, children, ...rest }: { - loaderClient: LoaderClient + client: LoaderClient children: any -} & Pick, 'defaultGcMaxAge' | 'defaultMaxAge'>) { - loaderClient.options = { - ...loaderClient.options, +} & Omit, 'loaders'>) { + client.options = { + ...client.options, ...rest, } + const [state, _setState] = React.useState(() => client.state) + + useLayoutEffect(() => { + return client.__store.subscribe(() => { + ;(React.startTransition || ((d) => d()))(() => _setState(client.state)) + }) + }) + + React.useEffect(client.mount, [client]) + return ( - + {children} - + ) } type NullableLoaderInstance_ = TLoaderInstance extends LoaderInstance< - infer TKey, infer TVariables, infer TData, infer TError > - ? NullableLoaderInstance + ? NullableLoaderInstance : never -export function useLoader< - TKey extends keyof RegisteredLoaders, - TLoader, - TStrict, - TLoaderInstanceFromKey extends LoaderInstanceByKey, - TResolvedLoaderInstance extends unknown extends TLoader - ? TLoaderInstanceFromKey - : TLoader extends Loader< - infer TTKey, - infer TTVariables, - infer TTData, - infer TTError - > - ? LoaderInstance - : never, - TVariables extends TResolvedLoaderInstance['__types']['variables'], - TData extends TResolvedLoaderInstance['__types']['data'], - TError extends TResolvedLoaderInstance['__types']['error'], +export function useLoaderInstance< + TLoader_ extends AnyLoader = RegisteredLoaders, + TLoaders extends LoadersToRecord = LoadersToRecord, + TKey extends keyof TLoaders = keyof TLoaders, + TLoader extends LoaderByKey = LoaderByKey, + TLoaderInstance extends LoaderInstanceByKey< + TLoaders, + TKey + > = LoaderInstanceByKey, + TVariables extends TLoaderInstance['variables'] = TLoaderInstance['variables'], + TData extends TLoaderInstance['data'] = TLoaderInstance['data'], + TError extends TLoaderInstance['error'] = TLoaderInstance['error'], + TStrict extends unknown = true, >( - opts: ( - | { - key: TKey - } - | { - loader: TLoader - } - ) & { + opts: GetInstanceOptions & { strict?: TStrict - track?: (loaderStore: LoaderStore) => any throwOnError?: boolean hydrate?: HydrateUpdater - } & VariablesOptions, + // track?: (loaderStore: LoaderInstance) => any + }, ): TStrict extends false - ? NullableLoaderInstance_ - : TResolvedLoaderInstance { - const loaderClient = React.useContext(loaderClientContext) - - const optsKey = (opts as { key: string }).key - const optsLoader = (opts as { loader: any }).loader + ? NullableLoaderInstance_ + : TLoaderInstance { + const ctx = React.useContext(loadersContext) invariant( - loaderClient || optsLoader, - 'useLoader must be used inside a component!', + ctx, + `useLoaderInstance must be used inside a component or be provided one via the 'client' option!`, ) - const loader = optsLoader ?? loaderClient.loaders[optsKey] - return loader.useLoader(opts) + const { client, state } = ctx + + const { key, variables } = opts + const hashedKey = hashKey([key, variables]) + + // Before anything runs, attempt hydration + client.__hydrateLoaderInstance(opts as any) + + const defaultInstance = React.useMemo( + () => + createLoaderInstance({ + ...(opts as any), + hashedKey, + }), + [hashedKey], + ) + + const stateLoaderInstance = state.loaders[opts.key].instances[hashedKey] + + const optimisticLoaderInstance = + client.state.loaders[opts.key].instances[hashedKey]! + + const loaderInstance = + (typeof document !== 'undefined' + ? stateLoaderInstance + : optimisticLoaderInstance) || defaultInstance + + if (loaderInstance.status === 'error' && (opts?.throwOnError ?? true)) { + throw loaderInstance.error + } + + if (opts?.strict ?? true) { + if (loaderInstance.status === 'pending') { + console.log('pending throw') + throw loaderInstance.loadPromise + } + } + + // If we're still in an idle state, we need to suspend via load + if (loaderInstance.status === 'idle') { + console.log('idle throw') + throw client.load(opts) + } + + React.useEffect(() => { + client.load(opts) + }, [client]) + + useLayoutEffect(() => { + const unsub = client.subscribeToInstance(opts, () => {}) + return unsub + }, [hashedKey]) + + // useStore(loaderInstance.__store, (d) => opts?.track?.(d as any) ?? d) + + // If we didn't suspend, dehydrate the loader instance + client.__dehydrateLoaderInstance(opts as any) + + return loaderInstance as any } export function useLoaderClient(opts?: { - track?: (loaderClientStore: LoaderClientStore) => any + // track?: (loaderClientStore: LoaderClientStore) => any }): LoaderClient { - const loaderClient = React.useContext(loaderClientContext) + const ctx = React.useContext(loadersContext) - if (!loaderClient) + if (!ctx) invariant( 'useLoaderClient must be used inside a component!', ) - useStore(loaderClient.__store, (d) => opts?.track?.(d as any) ?? d) + // useStore(ctx.__store, (d) => opts?.track?.(d as any) ?? d) - return loaderClient + return ctx.client } diff --git a/packages/react-start/src/client.tsx b/packages/react-start/src/client.tsx index 014e9a30dc..92ead84d37 100644 --- a/packages/react-start/src/client.tsx +++ b/packages/react-start/src/client.tsx @@ -20,6 +20,7 @@ export function DehydrateRouter() { return ( ` + )}"] = ${JSON.stringify(data)} + ;(() => { + var el = document.getElementById('${id}') + el.parentElement.removeChild(el) + })() + ` }) return () => this.hydrateData(key) @@ -1035,7 +1156,7 @@ export class Router< if (typeof document !== 'undefined') { const strKey = typeof key === 'string' ? key : JSON.stringify(key) - return window[`__TSR__DEHYRATED__${strKey}` as any] as T + return window[`__TSR_DEHYDRATED__${strKey}` as any] as T } return undefined @@ -1194,7 +1315,7 @@ export class Router< if (nextParams) { dest.__matches - ?.map((d) => d.route.options.stringifyParams) + ?.map((d) => this.getRoute(d.routeId).options.stringifyParams) .filter(Boolean) .forEach((fn) => { nextParams = { ...nextParams!, ...fn!(nextParams!) } @@ -1205,13 +1326,19 @@ export class Router< const preSearchFilters = dest.__matches - ?.map((match) => match.route.options.preSearchFilters ?? []) + ?.map( + (match) => + this.getRoute(match.routeId).options.preSearchFilters ?? [], + ) .flat() .filter(Boolean) ?? [] const postSearchFilters = dest.__matches - ?.map((match) => match.route.options.postSearchFilters ?? []) + ?.map( + (match) => + this.getRoute(match.routeId).options.postSearchFilters ?? [], + ) .flat() .filter(Boolean) ?? [] @@ -1312,6 +1439,62 @@ export class Router< return this.navigationPromise } + + getRouteMatch = ( + id: string, + ): undefined | RouteMatch => { + return ( + this.state.matches.find((d) => d.id === id) || + this.state.preloadMatches[id] + ) + } + + setRouteMatch = ( + id: string, + updater: ( + prev: RouteMatch, + ) => RouteMatch, + ) => { + this.__store.setState((prev) => ({ + ...prev, + matches: prev.matches.map((d) => { + if (d.id === id) { + return updater(d as any) + } + return d + }), + })) + } + + setPreloadRouteMatch = ( + id: string, + updater: ( + prev: RouteMatch, + ) => RouteMatch, + ) => { + invariant(this.state.preloadMatches[id], 'Match not found') + + this.__store.setState((prev) => ({ + ...prev, + preloadMatches: { + ...prev.preloadMatches, + [id]: updater(prev.preloadMatches[id] as any), + }, + })) + } + + #setEitherRouteMatch = ( + id: string, + updater: ( + prev: RouteMatch, + ) => RouteMatch, + ) => { + if (this.state.matches.find((d) => d.id === id)) { + return this.setRouteMatch(id, updater) + } + + return this.setPreloadRouteMatch(id, updater) + } } // Detect if we're in the DOM @@ -1323,6 +1506,7 @@ function getInitialRouterState(): RouterState { resolvedLocation: null!, location: null!, matches: [], + preloadMatches: {}, lastUpdated: Date.now(), } } @@ -1360,3 +1544,40 @@ function escapeJSON(jsonString: string) { .replace(/'/g, "\\'") // Escape single quotes .replace(/"/g, '\\"') // Escape double quotes } + +export function createRouteMatch({ + route, + id, + params, + pathname, +}: { + route: AnyRoute + id: string + params: AnyPathParams + pathname: string +}) { + const hasLoaders = !!( + route.options.loader || + componentTypes.some((d) => route.options[d]?.preload) + ) + + const state: RouteMatch = { + id: id, + routeId: route.id, + params: params, + pathname: pathname, + updatedAt: 0, + routeSearch: {}, + search: {} as any, + status: hasLoaders ? 'pending' : 'success', + error: undefined, + loader: undefined, + loadPromise: Promise.resolve(), + routeContext: undefined!, + context: undefined!, + abortController: new AbortController(), + fetchedAt: 0, + } + + return state +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ec0f1412dc..6251a1d1c7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -182,6 +182,9 @@ importers: '@tanstack/react-loaders': specifier: workspace:* version: link:../../../packages/react-loaders + '@tanstack/react-store': + specifier: workspace:0.0.1-beta.118 + version: link:../../../packages/react-store '@tanstack/router': specifier: workspace:* version: link:../../../packages/router @@ -648,6 +651,52 @@ importers: specifier: ^4.4.5 version: 4.4.5(@types/node@20.4.4) + examples/react/wip-with-framer-motion: + dependencies: + '@tanstack/react-actions': + specifier: workspace:* + version: link:../../../packages/react-actions + '@tanstack/react-loaders': + specifier: workspace:* + version: link:../../../packages/react-loaders + '@tanstack/router': + specifier: workspace:* + version: link:../../../packages/router + '@tanstack/router-devtools': + specifier: workspace:* + version: link:../../../packages/router-devtools + '@vitejs/plugin-react': + specifier: ^1.1.3 + version: 1.3.2 + axios: + specifier: ^1.1.3 + version: 1.1.3 + framer-motion: + specifier: ^10.13.0 + version: 10.13.0(react-dom@18.2.0)(react@18.2.0) + immer: + specifier: ^9.0.15 + version: 9.0.16 + react: + specifier: ^18.2.0 + version: 18.2.0 + react-dom: + specifier: ^18.2.0 + version: 18.2.0(react@18.2.0) + vite: + specifier: ^2.8.6 + version: 2.9.15 + zod: + specifier: ^3.19.1 + version: 3.20.2 + devDependencies: + '@types/react': + specifier: ^18.0.25 + version: 18.0.26 + '@types/react-dom': + specifier: ^18.0.8 + version: 18.0.9 + examples/react/with-framer-motion: dependencies: '@tanstack/react-actions': @@ -1495,13 +1544,6 @@ packages: '@babel/template': 7.22.5 '@babel/types': 7.22.5 - /@babel/helper-function-name@7.21.0: - resolution: {integrity: sha512-HfK1aMRanKHpxemaY2gqBmL04iAPOPRj7DxtNbiDOrJK+gdwkiNRVpCpUJYbUT+aZyemKN8brqTOxzCaG6ExRg==} - engines: {node: '>=6.9.0'} - dependencies: - '@babel/template': 7.22.5 - '@babel/types': 7.22.5 - /@babel/helper-function-name@7.22.5: resolution: {integrity: sha512-wtHSq6jMRE3uF2otvfuD3DIvVhOsSNshQl0Qrd7qC9oQJzHvOL4qQXlQn2916+CXGywIjpGuIkoyZRRxHPiNQQ==} engines: {node: '>=6.9.0'} @@ -1531,7 +1573,7 @@ packages: resolution: {integrity: sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA==} engines: {node: '>=6.9.0'} dependencies: - '@babel/types': 7.21.0 + '@babel/types': 7.22.5 /@babel/helper-module-imports@7.22.5: resolution: {integrity: sha512-8Dl6+HD/cKifutF5qGd/8ZJi84QeAKh+CEe1sBzz8UayBBGg1dAIJrdHOcOM5b2MpzWL2yuotJTtGjETq0qjXg==} @@ -1791,7 +1833,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.20.5 - '@babel/helper-environment-visitor': 7.18.9 + '@babel/helper-environment-visitor': 7.22.5 '@babel/helper-plugin-utils': 7.22.5 '@babel/helper-remap-async-to-generator': 7.18.9(@babel/core@7.20.5) '@babel/plugin-syntax-async-generators': 7.8.4(@babel/core@7.20.5) @@ -1889,7 +1931,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/compat-data': 7.20.5 + '@babel/compat-data': 7.22.9 '@babel/core': 7.20.5 '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.20.5) '@babel/helper-plugin-utils': 7.22.5 @@ -2139,7 +2181,7 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.20.5 - '@babel/helper-module-imports': 7.18.6 + '@babel/helper-module-imports': 7.22.5 '@babel/helper-plugin-utils': 7.22.5 '@babel/helper-remap-async-to-generator': 7.18.9(@babel/core@7.20.5) transitivePeerDependencies: @@ -2172,12 +2214,12 @@ packages: '@babel/core': 7.20.5 '@babel/helper-annotate-as-pure': 7.18.6 '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.20.5) - '@babel/helper-environment-visitor': 7.18.9 - '@babel/helper-function-name': 7.19.0 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-function-name': 7.22.5 '@babel/helper-optimise-call-expression': 7.18.6 '@babel/helper-plugin-utils': 7.22.5 '@babel/helper-replace-supers': 7.19.1 - '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-split-export-declaration': 7.22.6 globals: 11.12.0 transitivePeerDependencies: - supports-color @@ -2246,7 +2288,7 @@ packages: dependencies: '@babel/core': 7.20.5 '@babel/helper-compilation-targets': 7.22.9(@babel/core@7.20.5) - '@babel/helper-function-name': 7.19.0 + '@babel/helper-function-name': 7.22.5 '@babel/helper-plugin-utils': 7.22.5 /@babel/plugin-transform-literals@7.18.9(@babel/core@7.20.5): @@ -2286,7 +2328,7 @@ packages: '@babel/core': 7.20.5 '@babel/helper-module-transforms': 7.22.9(@babel/core@7.20.5) '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-simple-access': 7.20.2 + '@babel/helper-simple-access': 7.22.5 /@babel/plugin-transform-modules-systemjs@7.19.6(@babel/core@7.20.5): resolution: {integrity: sha512-fqGLBepcc3kErfR9R3DnVpURmckXP7gj7bAlrTQyBxrigFqszZCkFkcoxzCp2v32XmwXLvbw+8Yq9/b+QqksjQ==} @@ -2295,10 +2337,10 @@ packages: '@babel/core': ^7.0.0-0 dependencies: '@babel/core': 7.20.5 - '@babel/helper-hoist-variables': 7.18.6 + '@babel/helper-hoist-variables': 7.22.5 '@babel/helper-module-transforms': 7.22.9(@babel/core@7.20.5) '@babel/helper-plugin-utils': 7.22.5 - '@babel/helper-validator-identifier': 7.19.1 + '@babel/helper-validator-identifier': 7.22.5 /@babel/plugin-transform-modules-umd@7.18.6(@babel/core@7.20.5): resolution: {integrity: sha512-dcegErExVeXcRqNtkRU/z8WlBLnvD4MRnHgNs3MytRO1Mn1sHRyhbcpYbVMGclAqOjdW+9cfkdZno9dFdfKLfQ==} @@ -2435,10 +2477,10 @@ packages: dependencies: '@babel/core': 7.20.5 '@babel/helper-annotate-as-pure': 7.18.6 - '@babel/helper-module-imports': 7.18.6 - '@babel/helper-plugin-utils': 7.20.2 + '@babel/helper-module-imports': 7.22.5 + '@babel/helper-plugin-utils': 7.22.5 '@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.20.5) - '@babel/types': 7.20.5 + '@babel/types': 7.22.5 dev: true /@babel/plugin-transform-react-jsx@7.21.0(@babel/core@7.20.5): @@ -2449,7 +2491,7 @@ packages: dependencies: '@babel/core': 7.20.5 '@babel/helper-annotate-as-pure': 7.18.6 - '@babel/helper-module-imports': 7.18.6 + '@babel/helper-module-imports': 7.22.5 '@babel/helper-plugin-utils': 7.22.5 '@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.20.5) '@babel/types': 7.22.5 @@ -2463,7 +2505,7 @@ packages: dependencies: '@babel/core': 7.21.0 '@babel/helper-annotate-as-pure': 7.18.6 - '@babel/helper-module-imports': 7.18.6 + '@babel/helper-module-imports': 7.22.5 '@babel/helper-plugin-utils': 7.22.5 '@babel/plugin-syntax-jsx': 7.18.6(@babel/core@7.21.0) '@babel/types': 7.22.5 @@ -2769,10 +2811,10 @@ packages: dependencies: '@babel/code-frame': 7.22.5 '@babel/generator': 7.22.9 - '@babel/helper-environment-visitor': 7.18.9 - '@babel/helper-function-name': 7.21.0 - '@babel/helper-hoist-variables': 7.18.6 - '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-function-name': 7.22.5 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.22.7 '@babel/types': 7.22.5 debug: 4.3.4 @@ -2787,10 +2829,10 @@ packages: dependencies: '@babel/code-frame': 7.22.5 '@babel/generator': 7.22.9 - '@babel/helper-environment-visitor': 7.18.9 - '@babel/helper-function-name': 7.21.0 - '@babel/helper-hoist-variables': 7.18.6 - '@babel/helper-split-export-declaration': 7.18.6 + '@babel/helper-environment-visitor': 7.22.5 + '@babel/helper-function-name': 7.22.5 + '@babel/helper-hoist-variables': 7.22.5 + '@babel/helper-split-export-declaration': 7.22.6 '@babel/parser': 7.22.7 '@babel/types': 7.22.5 debug: 4.3.4 @@ -2828,8 +2870,8 @@ packages: resolution: {integrity: sha512-69OnhBxSSgK0OzTJai4kyPDiKTIe3j+ctaHdIGVbRahTLAT7L3R9oeXHC2aVSuGYt3cVnoAMDmOCgJ2yaiLMvg==} engines: {node: '>=6.9.0'} dependencies: - '@babel/helper-string-parser': 7.19.4 - '@babel/helper-validator-identifier': 7.19.1 + '@babel/helper-string-parser': 7.22.5 + '@babel/helper-validator-identifier': 7.22.5 to-fast-properties: 2.0.0 dev: false @@ -2840,6 +2882,7 @@ packages: '@babel/helper-string-parser': 7.22.5 '@babel/helper-validator-identifier': 7.22.5 to-fast-properties: 2.0.0 + dev: true /@babel/types@7.21.2: resolution: {integrity: sha512-3wRZSs7jiFaB8AjxiiD+VqN5DTG2iRvJGQ+qYFrs/654lg6kGTQWIOFjlBo5RaXuAZjBmP3+OQH4dmhqiiyYxw==} @@ -3589,7 +3632,7 @@ packages: engines: {node: '>=6.0.0'} dependencies: '@jridgewell/set-array': 1.1.2 - '@jridgewell/sourcemap-codec': 1.4.14 + '@jridgewell/sourcemap-codec': 1.4.15 '@jridgewell/trace-mapping': 0.3.17 /@jridgewell/resolve-uri@3.1.0: @@ -4432,8 +4475,8 @@ packages: resolution: {integrity: sha512-hCR3PJQsAIXyxhTNSiDFY//LhnMZWpNNr5etoCqx/iUfGc5gXWtQR2Phl908jVR6uPXacojQWTg4qRpkxTuGug==} dependencies: '@types/prop-types': 15.7.5 - '@types/scheduler': 0.16.2 - csstype: 3.1.1 + '@types/scheduler': 0.16.3 + csstype: 3.1.2 dev: true /@types/react@18.2.15: @@ -5054,7 +5097,6 @@ packages: proxy-from-env: 1.1.0 transitivePeerDependencies: - debug - dev: true /axios@1.2.0: resolution: {integrity: sha512-zT7wZyNYu3N5Bu0wuZ6QccIf93Qk1eV8LOewxgjOZFd2DenOs98cJ7+Y6703d0wkaXGY6/nZd4EweJaHz9uzQw==} @@ -5087,7 +5129,7 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 dependencies: - '@babel/compat-data': 7.20.5 + '@babel/compat-data': 7.22.9 '@babel/core': 7.20.5 '@babel/helper-define-polyfill-provider': 0.3.3(@babel/core@7.20.5) semver: 6.3.1 @@ -5223,10 +5265,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001434 - electron-to-chromium: 1.4.284 - node-releases: 2.0.6 - update-browserslist-db: 1.0.10(browserslist@4.21.4) + caniuse-lite: 1.0.30001517 + electron-to-chromium: 1.4.468 + node-releases: 2.0.13 + update-browserslist-db: 1.0.11(browserslist@4.21.4) /browserslist@4.21.9: resolution: {integrity: sha512-M0MFoZzbUrRU4KNfCrDLnvyE7gub+peetoTid3TBIqtunaDJyXlwhakT+/VkvSXcfIzFfK/nkCs4nmyTmxdNSg==} @@ -5305,9 +5347,6 @@ packages: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} - /caniuse-lite@1.0.30001434: - resolution: {integrity: sha512-aOBHrLmTQw//WFa2rcF1If9fa3ypkC1wzqqiKHgfdrXTWcU8C4gKVZT77eQAPWN1APys3+uQ0Df07rKauXGEYA==} - /caniuse-lite@1.0.30001517: resolution: {integrity: sha512-Vdhm5S11DaFVLlyiKu4hiUTkpZu+y1KA/rZZqVQfOD5YdDT/eQKlkt7NaE0WGOFgX32diqt9MiP9CAiFeRklaA==} @@ -5581,7 +5620,7 @@ packages: /core-js-compat@3.26.1: resolution: {integrity: sha512-622/KzTudvXCDLRw70iHW4KKs1aGpcRcowGWyYJr2DEBfRrd6hNJybxSWJFuZYD4ma86xhrwDDHxmDaIq4EA8A==} dependencies: - browserslist: 4.21.4 + browserslist: 4.21.9 /core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} @@ -5855,9 +5894,6 @@ packages: /ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - /electron-to-chromium@1.4.284: - resolution: {integrity: sha512-M8WEXFuKXMYMVr45fo8mq0wUrrJHheiKZf6BArTKk9ZBYCKJEOU5H8cdWgDT+qCVZf7Na4lVUaZsA+h6uA9+PA==} - /electron-to-chromium@1.4.468: resolution: {integrity: sha512-6M1qyhaJOt7rQtNti1lBA0GwclPH+oKCmsra/hkcWs5INLxfXXD/dtdnaKUYQu/pjOBP/8Osoe4mAcNvvzoFag==} @@ -8530,9 +8566,6 @@ packages: /node-releases@2.0.13: resolution: {integrity: sha512-uYr7J37ae/ORWdZeQ1xxMJe3NtdmqMC/JZK+geofDrkLUApKRHPd18/TxtBOJ4A0/+uUIliorNrfYV6s1b02eQ==} - /node-releases@2.0.6: - resolution: {integrity: sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg==} - /nodemon@2.0.20: resolution: {integrity: sha512-Km2mWHKKY5GzRg6i1j5OxOHQtuvVsgskLfigG25yTtbyfRGn/GNvIbRyOf1PSCKJ2aT/58TiuUsuOU5UToVViw==} engines: {node: '>=8.10.0'} @@ -10479,8 +10512,8 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} - /update-browserslist-db@1.0.10(browserslist@4.21.4): - resolution: {integrity: sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ==} + /update-browserslist-db@1.0.11(browserslist@4.21.4): + resolution: {integrity: sha512-dCwEFf0/oT85M1fHBg4F0jtLwJrutGoHSQXCh7u4o2t1drG+c0a9Flnqww6XUKSfQMPpJBRjU8d4RXB09qtvaA==} hasBin: true peerDependencies: browserslist: '>= 4.21.0'