From 1013aae78c4c82624b144b7f6414b9d3d6ac34c7 Mon Sep 17 00:00:00 2001 From: Jacob Ebey Date: Wed, 5 Nov 2025 15:46:44 -0800 Subject: [PATCH] poc --- packages/react-router/lib/components.tsx | 9 +++- packages/react-router/lib/dom/lib.tsx | 45 +++++++++++++++++++- packages/react-router/lib/hooks.tsx | 47 ++++++++++++++++++++- playground/framework/app/root.tsx | 31 ++++++++++++++ playground/framework/app/routes/_index.tsx | 3 +- playground/framework/app/routes/product.tsx | 31 ++++++++++++-- 6 files changed, 157 insertions(+), 9 deletions(-) diff --git a/packages/react-router/lib/components.tsx b/packages/react-router/lib/components.tsx index 1f20af4099..7b51c1bb34 100644 --- a/packages/react-router/lib/components.tsx +++ b/packages/react-router/lib/components.tsx @@ -521,7 +521,9 @@ export function RouterProvider({ flushSync: reactDomFlushSyncImpl, unstable_onError, }: RouterProviderProps): React.ReactElement { - let [state, setStateImpl] = React.useState(router.state); + let [_state, setStateImpl] = React.useState(router.state); + // @ts-expect-error - Needs React 19 types + let [state, setOptimisticState] = React.useOptimistic(_state); let [pendingState, setPendingState] = React.useState(); let [vtContext, setVtContext] = React.useState({ isTransitioning: false, @@ -591,7 +593,10 @@ export function RouterProvider({ if (reactDomFlushSyncImpl && flushSync) { reactDomFlushSyncImpl(() => logErrorsAndSetState(newState)); } else { - React.startTransition(() => logErrorsAndSetState(newState)); + React.startTransition(() => { + setOptimisticState(newState); + logErrorsAndSetState(newState); + }); } return; } diff --git a/packages/react-router/lib/dom/lib.tsx b/packages/react-router/lib/dom/lib.tsx index cff63d829d..a9c419e6df 100644 --- a/packages/react-router/lib/dom/lib.tsx +++ b/packages/react-router/lib/dom/lib.tsx @@ -2539,7 +2539,7 @@ export function useSubmit(): SubmitFunction { let { basename } = React.useContext(NavigationContext); let currentRouteId = useRouteId(); - return React.useCallback( + const submit = React.useCallback( async (target, options = {}) => { let { action, method, encType, formData, body } = getFormSubmissionInfo( target, @@ -2573,6 +2573,49 @@ export function useSubmit(): SubmitFunction { }, [router, basename, currentRouteId], ); + + return React.useCallback( + (target, options) => { + const deferred = new Deferred(); + // @ts-expect-error - Needs React 19 types + React.startTransition(async () => { + try { + await submit(target, options); + deferred.resolve(); + } catch (error) { + deferred.reject(error); + } + }); + return deferred.promise; + }, + [submit], + ); +} + +// TODO: Move to a shared location +class Deferred { + status: "pending" | "resolved" | "rejected" = "pending"; + promise: Promise; + // @ts-expect-error - no initializer + resolve: (value: T) => void; + // @ts-expect-error - no initializer + reject: (reason?: unknown) => void; + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = (value) => { + if (this.status === "pending") { + this.status = "resolved"; + resolve(value); + } + }; + this.reject = (reason) => { + if (this.status === "pending") { + this.status = "rejected"; + reject(reason); + } + }; + }); + } } // v7: Eventually we should deprecate this entirely in favor of using the diff --git a/packages/react-router/lib/hooks.tsx b/packages/react-router/lib/hooks.tsx index 07035f3a5c..7c8204bccf 100644 --- a/packages/react-router/lib/hooks.tsx +++ b/packages/react-router/lib/hooks.tsx @@ -1825,7 +1825,7 @@ function useNavigateStable(): NavigateFunction { if (!activeRef.current) return; if (typeof to === "number") { - router.navigate(to); + await router.navigate(to); } else { await router.navigate(to, { fromRouteId: id, ...options }); } @@ -1833,7 +1833,50 @@ function useNavigateStable(): NavigateFunction { [router, id], ); - return navigate; + let navigateTransition = React.useCallback( + (to: To | number, options: NavigateOptions = {}) => { + const deferred = new Deferred(); + // @ts-expect-error - Needs React 19 types + React.startTransition(async () => { + try { + await navigate(to as To, options); + deferred.resolve(); + } catch (e) { + deferred.reject(e); + } + }); + return deferred.promise; + }, + [navigate], + ); + + return navigateTransition; +} + +// TODO: Move to a shared location +class Deferred { + status: "pending" | "resolved" | "rejected" = "pending"; + promise: Promise; + // @ts-expect-error - no initializer + resolve: (value: T) => void; + // @ts-expect-error - no initializer + reject: (reason?: unknown) => void; + constructor() { + this.promise = new Promise((resolve, reject) => { + this.resolve = (value) => { + if (this.status === "pending") { + this.status = "resolved"; + resolve(value); + } + }; + this.reject = (reason) => { + if (this.status === "pending") { + this.status = "rejected"; + reject(reason); + } + }; + }); + } } const alreadyWarned: Record = {}; diff --git a/playground/framework/app/root.tsx b/playground/framework/app/root.tsx index 46bce69a48..c0220fbe6d 100644 --- a/playground/framework/app/root.tsx +++ b/playground/framework/app/root.tsx @@ -1,13 +1,20 @@ +import { useTransition } from "react"; import { + useNavigate, Link, Links, Meta, Outlet, Scripts, ScrollRestoration, + useNavigation, } from "react-router"; export function Layout({ children }: { children: React.ReactNode }) { + const [pending, startTransition] = useTransition(); + const navigate = useNavigate(); + const navigation = useNavigation(); + return ( @@ -29,7 +36,31 @@ export function Layout({ children }: { children: React.ReactNode }) { Product +
  • + +
  • +
  • + +
  • +
  • {pending ? "Loading..." : "Idle"}
  • +
    +          

    {JSON.stringify(navigation)}

    +
    {children} diff --git a/playground/framework/app/routes/_index.tsx b/playground/framework/app/routes/_index.tsx index a23689de92..c74e57aca1 100644 --- a/playground/framework/app/routes/_index.tsx +++ b/playground/framework/app/routes/_index.tsx @@ -1,6 +1,7 @@ import type { Route } from "./+types/_index"; -export function loader({ params }: Route.LoaderArgs) { +export async function loader({ params }: Route.LoaderArgs) { + await new Promise((resolve) => setTimeout(resolve, 1000)); return { planet: "world", date: new Date(), fn: () => 1 }; } diff --git a/playground/framework/app/routes/product.tsx b/playground/framework/app/routes/product.tsx index 101343dad0..b0f77573c4 100644 --- a/playground/framework/app/routes/product.tsx +++ b/playground/framework/app/routes/product.tsx @@ -1,9 +1,34 @@ +import { Form, useNavigation } from "react-router"; import type { Route } from "./+types/product"; +import { useTransition } from "react"; -export function loader({ params }: Route.LoaderArgs) { +export async function loader({ params }: Route.LoaderArgs) { + await new Promise((resolve) => setTimeout(resolve, 1000)); return { name: `Super cool product #${params.id}` }; } -export default function Component({ loaderData }: Route.ComponentProps) { - return

    {loaderData.name}

    ; +export async function action() { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return "Action complete!"; +} + +export default function Component({ + actionData, + loaderData, +}: Route.ComponentProps) { + const [pending, setPending] = useTransition(); + return ( + <> +

    {loaderData.name}

    +

    {pending ? "Loading..." : "Idle"}

    +
    { + setPending(() => {}); + }} + > + +
    + {actionData &&

    {actionData}

    } + + ); }