diff --git a/packages/react-router/modules/Route.js b/packages/react-router/modules/Route.js index 64ac8a947c..a52ac918c4 100644 --- a/packages/react-router/modules/Route.js +++ b/packages/react-router/modules/Route.js @@ -24,10 +24,36 @@ function evalChildrenDev(children, props, path) { return value || null; } +function deepEqual(x, y) { + if (x === y) return true; + + if ( + typeof x === "object" && + x !== null && + typeof y === "object" && + y !== null + ) { + const xKeys = Object.keys(x); + const yKeys = Object.keys(y); + + if (xKeys.length !== yKeys.length) return false; + + for (const key of xKeys) { + if (!(yKeys.includes(key) && deepEqual(x[key], y[key]))) return false; + } + + return true; + } + + return false; +} + /** * The public API for matching a single path and rendering. */ class Route extends React.Component { + _prevMatch = null; + render() { return ( @@ -35,11 +61,14 @@ class Route extends React.Component { invariant(context, "You should not use outside a "); const location = this.props.location || context.location; - const match = this.props.computedMatch + let match = this.props.computedMatch ? this.props.computedMatch // already computed the match for us : this.props.path ? matchPath(location.pathname, this.props) : context.match; + // Reuse the same object if possible so that optimizations like React.PureComponent or React.memo won't be broken + match = deepEqual(match, this._prevMatch) ? this._prevMatch : match; + this._prevMatch = match; const props = { ...context, location, match }; diff --git a/packages/react-router/modules/__tests__/Route-test.js b/packages/react-router/modules/__tests__/Route-test.js index 9825b803eb..ca7daf04be 100644 --- a/packages/react-router/modules/__tests__/Route-test.js +++ b/packages/react-router/modules/__tests__/Route-test.js @@ -1,9 +1,10 @@ -import React from "react"; +import React, { useState, useEffect, memo } from "react"; import ReactDOM from "react-dom"; import { createMemoryHistory as createHistory } from "history"; import { MemoryRouter, Router, Route } from "react-router"; import renderStrict from "./utils/renderStrict.js"; +import { act } from "react-dom/test-utils"; describe("A ", () => { const node = document.createElement("div"); @@ -536,6 +537,44 @@ describe("A ", () => { expect(console.error).not.toHaveBeenCalled(); }); + + it("should not break optimizations like React.PureComponent or React.memo by passing a different object every time", () => { + const SubRoute = jest.fn(function SubRoute(props) { + return ( +
+ some very expensive contents for subpath: + {props.match.params.subPath} +
+ ); + }); + const MemorizedSubRoute = memo(SubRoute); + + function MainRoute() { + const [text, setText] = useState("default text"); + useEffect(() => { + setText("updated text"); + }, []); + + return ( +
+
some common widget with text: {text}
+ +
+ ); + } + + act(() => { + renderStrict( + + + , + node + ); + }); + + // When MainRoute updates, but the URL haven't changed, should not pass a different props.match object and force SubRoute to re-render + expect(SubRoute).toHaveBeenCalledTimes(1); + }); }); describe("the `render` prop", () => {