diff --git a/.changeset/404-root-with-path.md b/.changeset/404-root-with-path.md deleted file mode 100644 index 207bb2b5b2..0000000000 --- a/.changeset/404-root-with-path.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@remix-run/router": patch ---- - -Allow 404 detection to leverage root route error boundary if path contains a URL segment diff --git a/.changeset/error-response-type.md b/.changeset/error-response-type.md deleted file mode 100644 index 47f3a99961..0000000000 --- a/.changeset/error-response-type.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@remix-run/router": patch ---- - -Fix `ErrorResponse` type to avoid leaking internal field diff --git a/.changeset/partial-future-config.md b/.changeset/partial-future-config.md deleted file mode 100644 index c9283cbe2c..0000000000 --- a/.changeset/partial-future-config.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router": patch ---- - -Fix `RouterProvider` `future` prop type to be a `Partial` so that not all flags must be specified diff --git a/.changeset/soft-forks-cough.md b/.changeset/soft-forks-cough.md deleted file mode 100644 index 6cd2304adc..0000000000 --- a/.changeset/soft-forks-cough.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"react-router-dom": patch ---- - -Log a warning and fail gracefully in `ScrollRestoration` when `sessionStorage` is unavailable diff --git a/.changeset/start-view-transition.md b/.changeset/start-view-transition.md deleted file mode 100644 index 55a09e5f05..0000000000 --- a/.changeset/start-view-transition.md +++ /dev/null @@ -1,46 +0,0 @@ ---- -"react-router-dom": minor -"react-router": minor -"@remix-run/router": minor ---- - -Add support for the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/ViewTransition) via `document.startViewTransition` to enable CSS animated transitions on SPA navigations in your application. - -The simplest approach to enabling a View Transition in your React Router app is via the new `` prop. This will cause the navigation DOM update to be wrapped in `document.startViewTransition` which will enable transitions for the DOM update. Without any additional CSS styles, you'll get a basic cross-fade animation for your page. - -If you need to apply more fine-grained styles for your animations, you can leverage the `unstable_useViewTransitionState` hook which will tell you when a transition is in progress and you can use that to apply classes or styles: - -```jsx -function ImageLink(to, src, alt) { - let isTransitioning = unstable_useViewTransitionState(to); - return ( - - {alt} - - ); -} -``` - -You can also use the `` shorthand which will manage the hook usage for you and automatically add a `transitioning` class to the `` during the transition: - -```css -a.transitioning img { - view-transition-name: "image-expand"; -} -``` - -```jsx - - {alt} - -``` - -For an example usage of View Transitions with React Router, check out [our fork](https://github.com/brophdawg11/react-router-records) of the [Astro Records](https://github.com/Charca/astro-records) demo. - -For more information on using the View Transitions API, please refer to the [Smooth and simple transitions with the View Transitions API](https://developer.chrome.com/docs/web-platform/view-transitions/) guide from the Google Chrome team. diff --git a/contributors.yml b/contributors.yml index 8f0a2cfa64..6e46af091c 100644 --- a/contributors.yml +++ b/contributors.yml @@ -218,6 +218,7 @@ - tkindy - tlinhart - tom-sherman +- tomasr8 - triangularcube - trungpv1601 - turansky diff --git a/docs/components/form.md b/docs/components/form.md index 2aa940cab3..5d1c6bbc0b 100644 --- a/docs/components/form.md +++ b/docs/components/form.md @@ -273,9 +273,7 @@ See also: [``][link-preventscrollreset] The `unstable_viewTransition` prop enables a [View Transition][view-transitions] for this navigation by wrapping the final state update in `document.startViewTransition()`. If you need to apply specific styles for this view transition, you will also need to leverage the [`unstable_useViewTransitionState()`][use-view-transition-state]. - -Please note that this API is marked unstable and may be subject to breaking changes without a major release. - +Please note that this API is marked unstable and may be subject to breaking changes without a major release # Examples diff --git a/docs/components/link.md b/docs/components/link.md index 948d2310ef..b32e02271c 100644 --- a/docs/components/link.md +++ b/docs/components/link.md @@ -153,13 +153,16 @@ The `unstable_viewTransition` prop enables a [View Transition][view-transitions] ```jsx + Click me + ``` -If you need to apply specific styles for this view transition, you will also need to leverage the [`unstable_useViewTransitionState()`][use-view-transition-state]: +If you need to apply specific styles for this view transition, you will also need to leverage the [`unstable_useViewTransitionState()`][use-view-transition-state] hook (or check out the `transitioning` class and `isTransitioning` render prop in [NavLink][navlink]): ```jsx function ImageLink(to) { - let isTransitioning = unstable_useViewTransitionState(to); + const isTransitioning = + unstable_useViewTransitionState(to); return (

-Please note that this API is marked unstable and may be subject to breaking changes without a major release. - +`unstable_viewTransition` only works when using a data router, see [Picking a Router][picking-a-router] + +Please note that this API is marked unstable and may be subject to breaking changes without a major release [link-native]: ./link-native [scrollrestoration]: ./scroll-restoration @@ -196,3 +199,5 @@ Please note that this API is marked unstable and may be subject to breaking chan [history-state]: https://developer.mozilla.org/en-US/docs/Web/API/History/state [use-view-transition-state]: ../hooks//use-view-transition-state [view-transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API +[picking-a-router]: ../routers/picking-a-router +[navlink]: ./nav-link diff --git a/docs/components/nav-link.md b/docs/components/nav-link.md index 70742b8a1f..e67c0079e5 100644 --- a/docs/components/nav-link.md +++ b/docs/components/nav-link.md @@ -171,10 +171,9 @@ You may also use the `className`/`style` props or the render props passed to `ch ``` - + Please note that this API is marked unstable and may be subject to breaking changes without a major release. - + [aria-current]: https://developer.mozilla.org/en-US/docs/Web/Accessibility/ARIA/Attributes/aria-current [view-transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API -[use-view-transition-state]: ../hooks//use-view-transition-state diff --git a/docs/hooks/use-navigate.md b/docs/hooks/use-navigate.md index b07269409d..3f4334757e 100644 --- a/docs/hooks/use-navigate.md +++ b/docs/hooks/use-navigate.md @@ -89,9 +89,9 @@ function EditContact() { The `unstable_viewTransition` option enables a [View Transition][view-transitions] for this navigation by wrapping the final state update in `document.startViewTransition()`. If you need to apply specific styles for this view transition, you will also need to leverage the [`unstable_useViewTransitionState()`][use-view-transition-state]. - -Please note that this API is marked unstable and may be subject to breaking changes without a major release. - +`unstable_viewTransition` only works when using a data router, see [Picking a Router][picking-a-router] + +Please note that this API is marked unstable and may be subject to breaking changes without a major release [link]: ../components/link [redirect]: ../fetch/redirect @@ -101,3 +101,4 @@ Please note that this API is marked unstable and may be subject to breaking chan [scrollrestoration]: ../components/scroll-restoration [use-view-transition-state]: ../hooks//use-view-transition-state [view-transitions]: https://developer.mozilla.org/en-US/docs/Web/API/View_Transitions_API +[picking-a-router]: ../routers/picking-a-router diff --git a/docs/route/action.md b/docs/route/action.md index 8204dcd2af..7c8f697ff6 100644 --- a/docs/route/action.md +++ b/docs/route/action.md @@ -7,7 +7,7 @@ new: true Route actions are the "writes" to route [loader][loader] "reads". They provide a way for apps to perform data mutations with simple HTML and HTTP semantics while React Router abstracts away the complexity of asynchronous UI and revalidation. This gives you the simple mental model of HTML + HTTP (where the browser handles the asynchrony and revalidation) with the behavior and UX capabilities of modern SPAs. -This feature only works if using a data router like [`createBrowserRouter`][createbrowserrouter] +This feature only works if using a data router, see [Picking a Router][pickingarouter] ```tsx If you do not wish to specify a React element (i.e., `errorElement={}`) you may specify an `ErrorBoundary` component instead (i.e., `ErrorBoundary={MyErrorBoundary}`) and React Router will call `createElement` for you internally. -This feature only works if using a data router like [`createBrowserRouter`][createbrowserrouter] +This feature only works if using a data router, see [Picking a Router][pickingarouter] ```tsx =14.0.0" } @@ -1318,11 +1318,11 @@ } }, "node_modules/react-router": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.16.0.tgz", - "integrity": "sha512-VT4Mmc4jj5YyjpOi5jOf0I+TYzGpvzERy4ckNSvSh2RArv8LLoCxlsZ2D+tc7zgjxcY34oTz2hZaeX5RVprKqA==", + "version": "6.17.0-pre.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.17.0-pre.2.tgz", + "integrity": "sha512-uY2/Fv60f3TRpWero98Ie2sW/XiRPrt627YwuBt4NYQaushRkQS1q6mpDdrJLplObIX08UCEgyfisncNN6YCWg==", "dependencies": { - "@remix-run/router": "1.9.0" + "@remix-run/router": "1.10.0-pre.0" }, "engines": { "node": ">=14.0.0" @@ -1332,12 +1332,12 @@ } }, "node_modules/react-router-dom": { - "version": "6.16.0", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.16.0.tgz", - "integrity": "sha512-aTfBLv3mk/gaKLxgRDUPbPw+s4Y/O+ma3rEN1u8EgEpLpPe6gNjIsWt9rxushMHHMb7mSwxRGdGlGdvmFsyPIg==", + "version": "6.17.0-pre.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.17.0-pre.2.tgz", + "integrity": "sha512-sxC2lH/i+CEvA2A4bkureNGK5oPejrLbmxktZfMSu6t9BgmN0zgptbpwRM33TBjOvvb3ygsaWiAB3gnIUDPAxQ==", "dependencies": { - "@remix-run/router": "1.9.0", - "react-router": "6.16.0" + "@remix-run/router": "1.10.0-pre.0", + "react-router": "6.17.0-pre.2" }, "engines": { "node": ">=14.0.0" diff --git a/examples/view-transitions/package.json b/examples/view-transitions/package.json index 9b1b9aaeb1..6173270bae 100644 --- a/examples/view-transitions/package.json +++ b/examples/view-transitions/package.json @@ -9,7 +9,7 @@ "dependencies": { "react": "18.2.0", "react-dom": "18.2.0", - "react-router-dom": "^6.16.0" + "react-router-dom": "6.17.0-pre.2" }, "devDependencies": { "@rollup/plugin-replace": "5.0.2", diff --git a/package.json b/package.json index 1073d672b6..f1746f97c7 100644 --- a/package.json +++ b/package.json @@ -113,16 +113,16 @@ "none": "48.3 kB" }, "packages/react-router/dist/react-router.production.min.js": { - "none": "15.2 kB" + "none": "13.9 kB" }, "packages/react-router/dist/umd/react-router.production.min.js": { - "none": "17.6 kB" + "none": "16.3 kB" }, "packages/react-router-dom/dist/react-router-dom.production.min.js": { - "none": "13.6 kB" + "none": "15.9 kB" }, "packages/react-router-dom/dist/umd/react-router-dom.production.min.js": { - "none": "19.9 kB" + "none": "22.1 kB" } } } diff --git a/packages/react-router-dom-v5-compat/CHANGELOG.md b/packages/react-router-dom-v5-compat/CHANGELOG.md index 5610fd8a0d..583cc6c043 100644 --- a/packages/react-router-dom-v5-compat/CHANGELOG.md +++ b/packages/react-router-dom-v5-compat/CHANGELOG.md @@ -1,5 +1,13 @@ # `react-router-dom-v5-compat` +## 6.17.0 + +### Patch Changes + +- Updated dependencies: + - `react-router-dom@6.17.0` + - `react-router@6.17.0` + ## 6.16.0 ### Minor Changes diff --git a/packages/react-router-dom-v5-compat/package.json b/packages/react-router-dom-v5-compat/package.json index d70c9e0778..a85918a23e 100644 --- a/packages/react-router-dom-v5-compat/package.json +++ b/packages/react-router-dom-v5-compat/package.json @@ -1,6 +1,6 @@ { "name": "react-router-dom-v5-compat", - "version": "6.16.0", + "version": "6.17.0", "description": "Migration path to React Router v6 from v4/5", "keywords": [ "react", @@ -24,7 +24,7 @@ "types": "./dist/index.d.ts", "dependencies": { "history": "^5.3.0", - "react-router": "6.16.0" + "react-router": "6.17.0" }, "peerDependencies": { "react": ">=16.8", diff --git a/packages/react-router-dom/CHANGELOG.md b/packages/react-router-dom/CHANGELOG.md index 1b63f809ce..10e164c78d 100644 --- a/packages/react-router-dom/CHANGELOG.md +++ b/packages/react-router-dom/CHANGELOG.md @@ -1,5 +1,59 @@ # `react-router-dom` +## 6.17.0 + +### Minor Changes + +- Add experimental support for the [View Transitions API](https://developer.mozilla.org/en-US/docs/Web/API/ViewTransition) via `document.startViewTransition` to enable CSS animated transitions on SPA navigations in your application. ([#10916](https://github.com/remix-run/react-router/pull/10916)) + + The simplest approach to enabling a View Transition in your React Router app is via the new `` prop. This will cause the navigation DOM update to be wrapped in `document.startViewTransition` which will enable transitions for the DOM update. Without any additional CSS styles, you'll get a basic cross-fade animation for your page. + + If you need to apply more fine-grained styles for your animations, you can leverage the `unstable_useViewTransitionState` hook which will tell you when a transition is in progress and you can use that to apply classes or styles: + + ```jsx + function ImageLink(to, src, alt) { + let isTransitioning = unstable_useViewTransitionState(to); + return ( + + {alt} + + ); + } + ``` + + You can also use the `` shorthand which will manage the hook usage for you and automatically add a `transitioning` class to the `` during the transition: + + ```css + a.transitioning img { + view-transition-name: "image-expand"; + } + ``` + + ```jsx + + {alt} + + ``` + + For an example usage of View Transitions with React Router, check out [our fork](https://github.com/brophdawg11/react-router-records) of the [Astro Records](https://github.com/Charca/astro-records) demo. + + For more information on using the View Transitions API, please refer to the [Smooth and simple transitions with the View Transitions API](https://developer.chrome.com/docs/web-platform/view-transitions/) guide from the Google Chrome team. + + Please note, that because the `ViewTransition` API is a DOM API, we now export a specific `RouterProvider` from `react-router-dom` with this functionality. If you are importing `RouterProvider` from `react-router`, then it will not support view transitions. ([#10928](https://github.com/remix-run/react-router/pull/10928) + +### Patch Changes + +- Log a warning and fail gracefully in `ScrollRestoration` when `sessionStorage` is unavailable ([#10848](https://github.com/remix-run/react-router/pull/10848)) +- Updated dependencies: + - `@remix-run/router@1.10.0` + - `react-router@6.17.0` + ## 6.16.0 ### Minor Changes diff --git a/packages/react-router-dom/__tests__/exports-test.tsx b/packages/react-router-dom/__tests__/exports-test.tsx index e3b479d6f1..dcf2e22ba7 100644 --- a/packages/react-router-dom/__tests__/exports-test.tsx +++ b/packages/react-router-dom/__tests__/exports-test.tsx @@ -6,16 +6,23 @@ let nonReExportedKeys = new Set([ "UNSAFE_useRoutesImpl", ]); +let modifiedExports = new Set(["RouterProvider"]); + describe("react-router-dom", () => { for (let key in ReactRouter) { - if (!nonReExportedKeys.has(key)) { - it(`re-exports ${key} from react-router`, () => { - expect(ReactRouterDOM[key]).toBe(ReactRouter[key]); - }); - } else { + if (nonReExportedKeys.has(key)) { it(`does not re-export ${key} from react-router`, () => { expect(ReactRouterDOM[key]).toBe(undefined); }); + } else if (modifiedExports.has(key)) { + it(`re-exports a different version of ${key}`, () => { + expect(ReactRouterDOM[key]).toBeDefined(); + expect(ReactRouterDOM[key]).not.toBe(ReactRouter[key]); + }); + } else { + it(`re-exports ${key} from react-router`, () => { + expect(ReactRouterDOM[key]).toBe(ReactRouter[key]); + }); } } }); diff --git a/packages/react-router-dom/index.tsx b/packages/react-router-dom/index.tsx index 3f76a09afd..8c62c56893 100644 --- a/packages/react-router-dom/index.tsx +++ b/packages/react-router-dom/index.tsx @@ -4,12 +4,15 @@ */ import * as React from "react"; import type { + DataRouteObject, FutureConfig, Location, NavigateOptions, NavigationType, + Navigator, RelativeRoutingType, RouteObject, + RouterProviderProps, To, } from "react-router"; import { @@ -26,9 +29,9 @@ import { UNSAFE_DataRouterStateContext as DataRouterStateContext, UNSAFE_NavigationContext as NavigationContext, UNSAFE_RouteContext as RouteContext, - UNSAFE_ViewTransitionContext as ViewTransitionContext, UNSAFE_mapRouteProperties as mapRouteProperties, UNSAFE_useRouteId as useRouteId, + UNSAFE_useRoutesImpl as useRoutesImpl, } from "react-router"; import type { BrowserHistory, @@ -43,6 +46,8 @@ import type { HydrationState, Router as RemixRouter, V7_FormMethod, + RouterState, + RouterSubscriber, } from "@remix-run/router"; import { createRouter, @@ -143,7 +148,6 @@ export { Outlet, Route, Router, - RouterProvider, Routes, createMemoryRouter, createPath, @@ -203,13 +207,15 @@ export { UNSAFE_NavigationContext, UNSAFE_LocationContext, UNSAFE_RouteContext, - UNSAFE_ViewTransitionContext, UNSAFE_useRouteId, } from "react-router"; //#endregion declare global { var __staticRouterHydrationData: HydrationState | undefined; + interface Document { + startViewTransition(cb: () => Promise | void): ViewTransition; + } } //////////////////////////////////////////////////////////////////////////////// @@ -320,6 +326,31 @@ function deserializeErrors( //#endregion +//////////////////////////////////////////////////////////////////////////////// +//#region Contexts +//////////////////////////////////////////////////////////////////////////////// + +type ViewTransitionContextObject = + | { + isTransitioning: false; + } + | { + isTransitioning: true; + currentLocation: Location; + nextLocation: Location; + }; + +const ViewTransitionContext = React.createContext({ + isTransitioning: false, +}); +if (__DEV__) { + ViewTransitionContext.displayName = "ViewTransition"; +} + +export { ViewTransitionContext as UNSAFE_ViewTransitionContext }; + +//#endregion + //////////////////////////////////////////////////////////////////////////////// //#region Components //////////////////////////////////////////////////////////////////////////////// @@ -348,6 +379,245 @@ function deserializeErrors( const START_TRANSITION = "startTransition"; const startTransitionImpl = React[START_TRANSITION]; +function startTransitionSafe(cb: () => void) { + if (startTransitionImpl) { + startTransitionImpl(cb); + } else { + cb(); + } +} + +interface ViewTransition { + finished: Promise; + ready: Promise; + updateCallbackDone: Promise; + skipTransition(): void; +} + +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); + } + }; + }); + } +} + +/** + * Given a Remix Router instance, render the appropriate UI + */ +export function RouterProvider({ + fallbackElement, + router, + future, +}: RouterProviderProps): React.ReactElement { + let [state, setStateImpl] = React.useState(router.state); + let [pendingState, setPendingState] = React.useState(); + let [vtContext, setVtContext] = React.useState({ + isTransitioning: false, + }); + let [renderDfd, setRenderDfd] = React.useState>(); + let [transition, setTransition] = React.useState(); + let [interruption, setInterruption] = React.useState<{ + state: RouterState; + currentLocation: Location; + nextLocation: Location; + }>(); + let { v7_startTransition } = future || {}; + + let optInStartTransition = React.useCallback( + (cb: () => void) => { + if (v7_startTransition) { + startTransitionSafe(cb); + } else { + cb(); + } + }, + [v7_startTransition] + ); + + let setState = React.useCallback( + ( + newState: RouterState, + { unstable_viewTransitionOpts: viewTransitionOpts } + ) => { + if ( + !viewTransitionOpts || + router.window == null || + typeof router.window.document.startViewTransition !== "function" + ) { + // Mid-navigation state update, or startViewTransition isn't available + optInStartTransition(() => setStateImpl(newState)); + } else if (transition && renderDfd) { + // Interrupting an in-progress transition, cancel and let everything flush + // out, and then kick off a new transition from the interruption state + renderDfd.resolve(); + transition.skipTransition(); + setInterruption({ + state: newState, + currentLocation: viewTransitionOpts.currentLocation, + nextLocation: viewTransitionOpts.nextLocation, + }); + } else { + // Completed navigation update with opted-in view transitions, let 'er rip + setPendingState(newState); + setVtContext({ + isTransitioning: true, + currentLocation: viewTransitionOpts.currentLocation, + nextLocation: viewTransitionOpts.nextLocation, + }); + } + }, + [optInStartTransition, transition, renderDfd, router.window] + ); + + // Need to use a layout effect here so we are subscribed early enough to + // pick up on any render-driven redirects/navigations (useEffect/) + React.useLayoutEffect(() => router.subscribe(setState), [router, setState]); + + // When we start a view transition, create a Deferred we can use for the + // eventual "completed" render + React.useEffect(() => { + if (vtContext.isTransitioning) { + setRenderDfd(new Deferred()); + } + }, [vtContext.isTransitioning]); + + // Once the deferred is created, kick off startViewTransition() to update the + // DOM and then wait on the Deferred to resolve (indicating the DOM update has + // happened) + React.useEffect(() => { + if (renderDfd && pendingState && router.window) { + let newState = pendingState; + let renderPromise = renderDfd.promise; + let transition = router.window.document.startViewTransition(async () => { + optInStartTransition(() => setStateImpl(newState)); + await renderPromise; + }); + transition.finished.finally(() => { + setRenderDfd(undefined); + setTransition(undefined); + setPendingState(undefined); + setVtContext({ isTransitioning: false }); + }); + setTransition(transition); + } + }, [optInStartTransition, pendingState, renderDfd, router.window]); + + // When the new location finally renders and is committed to the DOM, this + // effect will run to resolve the transition + React.useEffect(() => { + if ( + renderDfd && + pendingState && + state.location.key === pendingState.location.key + ) { + renderDfd.resolve(); + } + }, [renderDfd, transition, state.location, pendingState]); + + // If we get interrupted with a new navigation during a transition, we skip + // the active transition, let it cleanup, then kick it off again here + React.useEffect(() => { + if (!vtContext.isTransitioning && interruption) { + setPendingState(interruption.state); + setVtContext({ + isTransitioning: true, + currentLocation: interruption.currentLocation, + nextLocation: interruption.nextLocation, + }); + setInterruption(undefined); + } + }, [vtContext.isTransitioning, interruption]); + + let navigator = React.useMemo((): Navigator => { + return { + createHref: router.createHref, + encodeLocation: router.encodeLocation, + go: (n) => router.navigate(n), + push: (to, state, opts) => + router.navigate(to, { + state, + preventScrollReset: opts?.preventScrollReset, + }), + replace: (to, state, opts) => + router.navigate(to, { + replace: true, + state, + preventScrollReset: opts?.preventScrollReset, + }), + }; + }, [router]); + + let basename = router.basename || "/"; + + let dataRouterContext = React.useMemo( + () => ({ + router, + navigator, + static: false, + basename, + }), + [router, navigator, basename] + ); + + // The fragment and {null} here are important! We need them to keep React 18's + // useId happy when we are server-rendering since we may have a