From f0d3de407f7e83acd8f3d01952e687178f6eab47 Mon Sep 17 00:00:00 2001 From: Brian Pedersen Date: Tue, 9 Oct 2018 08:47:02 -0600 Subject: [PATCH] Match component created for issue #6362 --- packages/react-router-dom/modules/Match.js | 3 + packages/react-router/modules/Match.js | 176 ++++++++ .../modules/__tests__/Match-test.js | 423 ++++++++++++++++++ 3 files changed, 602 insertions(+) create mode 100644 packages/react-router-dom/modules/Match.js create mode 100644 packages/react-router/modules/Match.js create mode 100644 packages/react-router/modules/__tests__/Match-test.js diff --git a/packages/react-router-dom/modules/Match.js b/packages/react-router-dom/modules/Match.js new file mode 100644 index 0000000000..f7d44f6d12 --- /dev/null +++ b/packages/react-router-dom/modules/Match.js @@ -0,0 +1,3 @@ +// Written in this round about way for babel-transform-imports +import { Match } from "react-router"; +export default Match; diff --git a/packages/react-router/modules/Match.js b/packages/react-router/modules/Match.js new file mode 100644 index 0000000000..5912af2f4c --- /dev/null +++ b/packages/react-router/modules/Match.js @@ -0,0 +1,176 @@ +import React from "react"; +import PropTypes from "prop-types"; +import invariant from "invariant"; +import warning from "warning"; + +import RouterContext from "./RouterContext"; +import matchPath from "./matchPath"; +import warnAboutGettingProperty from "./utils/warnAboutGettingProperty"; + +function isEmptyChildren(children) { + return React.Children.count(children) === 0; +} + +function getContext(props, context) { + const location = props.location || context.location; + const match = props.computedMatch + ? props.computedMatch // already computed the match for us + : props.path + ? matchPath(location.pathname, props) + : context.match; + + return { ...context, location, match }; +} + +/** + * The public API for matching a single path and rendering. + */ +class Match extends React.Component { + // TODO: Remove this + static contextTypes = { + router: PropTypes.object.isRequired + }; + + // TODO: Remove this + static childContextTypes = { + router: PropTypes.object.isRequired + }; + + // TODO: Remove this + getChildContext() { + invariant( + this.context.router, + "You should not use outside a " + ); + + let parentContext = this.context.router; + if (__DEV__) { + parentContext = parentContext._withoutWarnings; + } + + const context = getContext(this.props, parentContext); + if (__DEV__) { + const contextWithoutWarnings = { ...context }; + + Object.keys(context).forEach(key => { + warnAboutGettingProperty( + context, + key, + `You should not be using this.context.router.${key} directly. It is private API ` + + "for internal use only and is subject to change at any time. Instead, use " + + "a or withRouter() to access the current location, match, etc." + ); + }); + + context._withoutWarnings = contextWithoutWarnings; + } + + return { + router: context + }; + } + + render() { + return ( + + {context => { + invariant(context, "You should not use outside a "); + + const props = getContext(this.props, context); + + let { children, component, render } = this.props; + // Preact uses an empty array as children by + // default, so use null if that's the case. + if (Array.isArray(children) && children.length === 0) { + children = null; + } + + if (typeof children === "function") { + children = props.match ? children(props) : null; + + if (children === undefined) { + if (__DEV__) { + const { path } = this.props; + + warning( + false, + "You returned `undefined` from the `children` function of " + + `, but you ` + + "should have returned a React element or `null`" + ); + } + + children = null; + } + } + if (props.match === null) children = null; + return ( + + {children && !isEmptyChildren(children) + ? children + : props.match + ? component + ? React.createElement(component, props) + : render + ? render(props) + : null + : null} + + ); + }} + + ); + } +} + +if (__DEV__) { + Match.propTypes = { + children: PropTypes.oneOfType([PropTypes.func, PropTypes.node]), + component: PropTypes.func, + exact: PropTypes.bool, + location: PropTypes.object, + path: PropTypes.string, + render: PropTypes.func, + sensitive: PropTypes.bool, + strict: PropTypes.bool + }; + + Match.prototype.componentDidMount = function() { + warning( + !( + this.props.children && + !isEmptyChildren(this.props.children) && + this.props.component + ), + "You should not use and in the same route; will be ignored" + ); + + warning( + !( + this.props.children && + !isEmptyChildren(this.props.children) && + this.props.render + ), + "You should not use and in the same route; will be ignored" + ); + + warning( + !(this.props.component && this.props.render), + "You should not use and in the same route; will be ignored" + ); + }; + + Match.prototype.componentDidUpdate = function(prevProps) { + warning( + !(this.props.location && !prevProps.location), + ' elements should not change from uncontrolled to controlled (or vice versa). You initially used no "location" prop and then provided one on a subsequent render.' + ); + + warning( + !(!this.props.location && prevProps.location), + ' elements should not change from controlled to uncontrolled (or vice versa). You provided a "location" prop initially but omitted it on a subsequent render.' + ); + }; +} + +export default Match; diff --git a/packages/react-router/modules/__tests__/Match-test.js b/packages/react-router/modules/__tests__/Match-test.js new file mode 100644 index 0000000000..ba3f80ccfc --- /dev/null +++ b/packages/react-router/modules/__tests__/Match-test.js @@ -0,0 +1,423 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import { createMemoryHistory as createHistory } from "history"; + +import MemoryRouter from "../MemoryRouter"; +import Match from "../Match"; +import Router from "../Router"; + +describe("A ", () => { + const node = document.createElement("div"); + + afterEach(() => { + ReactDOM.unmountComponentAtNode(node); + }); + + describe("without a ", () => { + it("throws an error", () => { + spyOn(console, "error"); + + expect(() => { + ReactDOM.render(, node); + }).toThrow(/You should not use outside a /); + }); + }); + + it("renders when it matches", () => { + const text = "cupcakes"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + + it("renders when it matches at the root URL", () => { + const text = "cupcakes"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + + it("does not render when it does not match", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).not.toContain(text); + }); + + it("matches using nextContext when updating", () => { + const history = createHistory({ + initialEntries: ["/sushi/california"] + }); + + ReactDOM.render( + +

{match.url}

} + /> +
, + node + ); + + history.push("/sushi/spicy-tuna"); + + expect(node.innerHTML).toContain("/sushi/spicy-tuna"); + }); + + describe("with dynamic segments in the path", () => { + it("decodes them", () => { + ReactDOM.render( + +

{match.params.id}

} + /> +
, + node + ); + + expect(node.innerHTML).toContain("a dynamic segment"); + }); + }); + + describe("with a unicode path", () => { + it("is able to match", () => { + ReactDOM.render( + +

{match.url}

} /> +
, + node + ); + + expect(node.innerHTML).toContain("/パス名"); + }); + }); + + describe("with escaped special characters in the path", () => { + it("is able to match", () => { + ReactDOM.render( + +

{match.url}

} + /> +
, + node + ); + + expect(node.innerHTML).toContain("/pizza (1)"); + }); + }); + + describe("with `exact=true`", () => { + it("renders when the URL does not have a trailing slash", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + + it("renders when the URL has trailing slash", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + + describe("and `strict=true`", () => { + it("does not render when the URL has a trailing slash", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} + /> +
, + node + ); + + expect(node.innerHTML).not.toContain(text); + }); + + it("does not render when the URL does not have a trailing slash", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} + /> +
, + node + ); + + expect(node.innerHTML).not.toContain(text); + }); + }); + }); + + describe("the `location` prop", () => { + it("overrides `context.location`", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} + /> +
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + }); + + describe("the `children` prop", () => { + describe("that is an element", () => { + it("renders", () => { + const text = "bubblegum"; + + ReactDOM.render( + + +

{text}

+
+
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + }); + + describe("that is a function", () => { + it("receives { history, location, match } props", () => { + const history = createHistory(); + + let props = null; + ReactDOM.render( + + { + props = p; + return null; + }} + /> + , + node + ); + + expect(props).not.toBe(null); + expect(props.history).toBe(history); + expect(typeof props.location).toBe("object"); + expect(typeof props.match).toBe("object"); + }); + + it("renders", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + + describe("that returns `undefined`", () => { + it("logs a warning to the console and renders nothing", () => { + spyOn(console, "error"); + + ReactDOM.render( + + undefined} /> + , + node + ); + + expect(node.innerHTML).toEqual(""); + + expect(console.error).toHaveBeenCalledWith( + expect.stringContaining( + "You returned `undefined` from the `children` function" + ) + ); + }); + }); + }); + + describe("that is an empty array (as in Preact)", () => { + it("ignores the children", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

}>{[]}
+
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + }); + + describe("that doesn't match path", () => { + it("doesn't render `children` function", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).not.toContain(text); + }); + it("doesn't render `children` node", () => { + const text = "bubblegum"; + + ReactDOM.render( + + + , + node + ); + + expect(node.innerHTML).not.toContain(text); + }); + it("doesn't render `render` prop", () => { + const text = "bubblegum"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).not.toContain(text); + }); + }); + }); + + describe("the `component` prop", () => { + it("renders the component", () => { + const text = "bubblegum"; + + const Home = () =>

{text}

; + + ReactDOM.render( + + + , + node + ); + + expect(node.innerHTML).toContain(text); + }); + + it("receives { history, location, match } props", () => { + const history = createHistory(); + + let props = null; + const Component = p => { + props = p; + return null; + }; + + ReactDOM.render( + + + , + node + ); + + expect(props).not.toBe(null); + expect(props.history).toBe(history); + expect(typeof props.location).toBe("object"); + expect(typeof props.match).toBe("object"); + }); + }); + + describe("the `render` prop", () => { + it("renders its return value", () => { + const text = "Mrs. Kato"; + + ReactDOM.render( + +

{text}

} /> +
, + node + ); + + expect(node.innerHTML).toContain(text); + }); + + it("receives { history, location, match } props", () => { + const history = createHistory(); + + let props = null; + ReactDOM.render( + + { + props = p; + return null; + }} + /> + , + node + ); + + expect(props).not.toBe(null); + expect(props.history).toBe(history); + expect(typeof props.location).toBe("object"); + expect(typeof props.match).toBe("object"); + }); + }); +});