From 0a6f7b3ad0b8358eaef72a42414416b2c7b7da9b Mon Sep 17 00:00:00 2001 From: Jimmy Jia Date: Thu, 27 Aug 2015 17:02:58 -0400 Subject: [PATCH] Refactor and update API * Use createElement instead of root route * Support per-route render callbacks --- .babelrc | 3 +- .eslintignore | 3 - .eslintrc | 15 +---- LICENSE.md | 0 README.md | 72 +++++++++++++++------ package.json | 33 +++++----- src/Container.js | 85 +++++++++++++++++++++++++ src/NestedRenderer.js | 25 -------- src/RootComponent.js | 70 ++++++++++++++++++++ src/RouteAggregator.js | 134 +++++++++++++++++++++++++++++++++++++++ src/createElement.js | 12 ++++ src/generateContainer.js | 90 -------------------------- src/getParamsForRoute.js | 28 ++++++++ src/index.js | 33 +--------- webpack.config.js | 39 ------------ 15 files changed, 404 insertions(+), 238 deletions(-) delete mode 100755 .eslintignore mode change 100755 => 100644 LICENSE.md mode change 100755 => 100644 README.md create mode 100644 src/Container.js delete mode 100644 src/NestedRenderer.js create mode 100644 src/RootComponent.js create mode 100644 src/RouteAggregator.js create mode 100644 src/createElement.js delete mode 100644 src/generateContainer.js create mode 100644 src/getParamsForRoute.js mode change 100755 => 100644 src/index.js delete mode 100755 webpack.config.js diff --git a/.babelrc b/.babelrc index 15d27ad..09a7032 100755 --- a/.babelrc +++ b/.babelrc @@ -1,4 +1,5 @@ { "stage": 0, - "loose": "all" + "loose": "all", + "optional": ["runtime"] } diff --git a/.eslintignore b/.eslintignore deleted file mode 100755 index 8d7c4a7..0000000 --- a/.eslintignore +++ /dev/null @@ -1,3 +0,0 @@ -lib -**/node_modules -**/webpack.config.js \ No newline at end of file diff --git a/.eslintrc b/.eslintrc index 8132e1f..b0c0c8b 100755 --- a/.eslintrc +++ b/.eslintrc @@ -1,16 +1,3 @@ { - "extends": "eslint-config-airbnb", - "env": { - "browser": true, - "mocha": true, - "node": true - }, - "rules": { - "react/jsx-uses-react": 2, - "react/jsx-uses-vars": 2, - "react/react-in-jsx-scope": 2 - }, - "plugins": [ - "react" - ] + "extends": "airbnb" } diff --git a/LICENSE.md b/LICENSE.md old mode 100755 new mode 100644 diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 4a3c53d..6ad2fc3 --- a/README.md +++ b/README.md @@ -1,49 +1,81 @@ -relay-nested-routes +react-router-relay ========================= -Nested react-router views for Relay +Nested react-router routes for Relay - $ npm install --save relay-nested-routes + $ npm install --save react-router-relay -After you've installed it, add it as a root `` to your -react-router@>=1.0.0-beta3 routes like so: +Afterwards, add it as the `createElement` of your react-router@>=1.0.0-beta3 +`` like so: ```js import React from 'react'; import ReactDOM from 'react-dom'; import Relay from 'react-relay'; import { Router, Route } from 'react-router'; - -import RelayNestedRoutes from 'relay-nested-routes'; -var NestedRootContainer = RelayNestedRoutes(React, Relay); +import ReactRouterRelay from 'react-router-relay'; /* ... */ ReactDOM.render(( - - - - - + + + ), document.getElementById('react-root')); ``` Define an object containing your queries that a particular `Relay.Container` -needs and add it as a `queries` prop to any container ``s. +needs and add it as a `queries` prop to any container ``s: + +```js +var AppQueries = { + viewer: (Component) => Relay.QL` + viewer { + ${Component.getFragment('viewer')} + } + ` +}; +``` -`relay-nested-routes` will automatically generate a component that includes all +`react-router-relay` will automatically generate a component that includes all of your fragments, and a route that includes all of your root queries, and dispatch/render everything in one go. -You can also pass props like `renderLoading` by adding them as props to the -`NestedRootContainer` route. +# Render Callbacks + +You can pass in custom `renderLoading`, `renderFetched`, and `renderFailure` +callbacks to your routes: + +```js + } /> +``` + +These have the same signature and behavior as they do on `Relay.RootContainer`, +except that the argument to `renderFetched` also includes the injected props +from React Router. As on `Relay.RootContainer`, the `renderLoading` callback +can simulate the default behavior of rendering the previous view by returning +`undefined`. -# Todo +# Query Parameters -* Named react-router components +You can pass an array to the `queryParams` prop to specify which query +parameters should be passed in from the router and made available as +variables to your root queries and containers: + +```js +` +``` -# Thanks +# Special Thanks [@cpojer](https://github.com/cpojer) diff --git a/package.json b/package.json index 6e5bf34..90d516c 100644 --- a/package.json +++ b/package.json @@ -1,12 +1,17 @@ { - "name": "relay-nested-routes", - "version": "0.3.1", - "description": "Nested react-router views for Relay", + "name": "react-router-relay", + "version": "0.4.0", + "description": "Nested react-router routes for Relay", + "files": [ + "lib", + "LICENSE", + "README.md" + ], "main": "lib/index.js", "scripts": { - "clean": "rimraf lib dist", + "clean": "rimraf lib", "build": "babel src --out-dir lib", - "lint": "eslint src test examples", + "lint": "eslint src", "prepublish": "npm run clean && npm run build" }, "repository": { @@ -27,18 +32,16 @@ "homepage": "https://github.com/devknoll/relay-nested-routes", "devDependencies": { "babel": "^5.5.8", - "babel-core": "^5.6.18", - "babel-eslint": "^3.1.15", - "babel-loader": "^5.1.4", - "eslint": "^0.23", - "eslint-config-airbnb": "0.0.6", - "eslint-plugin-react": "^2.3.0", - "rimraf": "^2.3.4", - "webpack": "^1.9.6", - "webpack-dev-server": "^1.8.2" + "babel-eslint": "^4.0.10", + "eslint": "^1.2", + "eslint-config-airbnb": "0.0.8", + "eslint-plugin-react": "^3.2.3", + "react": "^0.14.0-beta3", + "react-router": "^1.0.0-beta3", + "rimraf": "^2.3.4" }, "dependencies": { "invariant": "^2.1.0", - "warning": "^2.0.0" + "react-static-container": "^1.0.0-alpha.1" } } diff --git a/src/Container.js b/src/Container.js new file mode 100644 index 0000000..c6a0905 --- /dev/null +++ b/src/Container.js @@ -0,0 +1,85 @@ +import React from 'react'; +import StaticContainer from 'react-static-container'; + +import getParamsForRoute from './getParamsForRoute'; +import RootComponent from './RootComponent'; +import RouteAggregator from './RouteAggregator'; + +export default class Container extends React.Component { + static displayName = 'ReactRouterRelay.Container'; + + static propTypes = { + Component: React.PropTypes.func.isRequired, + routerProps: React.PropTypes.object.isRequired, + }; + + static contextTypes = { + routeAggregator: React.PropTypes.instanceOf(RouteAggregator), + }; + + render() { + const {routeAggregator} = this.context; + if (!routeAggregator) { + return ; + } + + const {Component, routerProps} = this.props; + const {route} = routerProps; + + // FIXME: Remove once fix for facebook/react#4218 is released. + const {children} = routerProps; + if (children) { + routerProps.children = React.cloneElement(children, {}); + } + + const {queries} = route; + if (!queries) { + return ; + } + + const params = getParamsForRoute(routerProps); + const {fragmentPointers, failure} = + routeAggregator.getData(queries, params); + + let shouldUpdate = true; + let element; + + // This is largely copied from RelayRootContainer#render. + if (failure) { + const {renderFailure} = route; + if (renderFailure) { + const [error, retry] = failure; + element = renderFailure(error, retry); + } else { + element = null; + } + } else if (fragmentPointers) { + const data = {...routerProps, ...params, ...fragmentPointers}; + + const {renderFetched} = route; + if (renderFetched) { + element = renderFetched(data); + } else { + element = ; + } + } else { + const {renderLoading} = route; + if (renderLoading) { + element = renderLoading(); + } else { + element = undefined; + } + + if (element === undefined) { + element = null; + shouldUpdate = false; + } + } + + return ( + + {element} + + ); + } +} diff --git a/src/NestedRenderer.js b/src/NestedRenderer.js deleted file mode 100644 index c501e2f..0000000 --- a/src/NestedRenderer.js +++ /dev/null @@ -1,25 +0,0 @@ -export default function generateNestedRenderer(React, components, fragmentSpecs) { - const fragmentNames = Object.keys(fragmentSpecs); - - return class NestedRenderer extends React.Component { - static getFragmentNames() { - return fragmentNames; - } - - static getFragment(proxiedFragmentName, ...args) { - const {Component, queryName} = fragmentSpecs[proxiedFragmentName]; - return Component.getFragment(queryName, ...args); - } - - // Hackishly satisfy isRelayContainer. - static getQuery() {} - static getQueryNames() {} - - render() { - return components.reduceRight( - (children, generate) => generate.call(this, {children}), - null - ); - } - }; -} diff --git a/src/RootComponent.js b/src/RootComponent.js new file mode 100644 index 0000000..e6405be --- /dev/null +++ b/src/RootComponent.js @@ -0,0 +1,70 @@ +import React from 'react'; +import Relay from 'react-relay'; + +import Container from './Container'; +import RouteAggregator from './RouteAggregator'; + +export default class RootComponent extends React.Component { + static displayName = 'ReactRouterRelay.RootComponent'; + + static propTypes = { + routerProps: React.PropTypes.object.isRequired, + }; + + static childContextTypes = { + routeAggregator: React.PropTypes.instanceOf(RouteAggregator).isRequired, + }; + + constructor(props, context) { + super(props, context); + + this._routeAggregator = new RouteAggregator(); + this._routeAggregator.updateRoute(props.routerProps); + } + + getChildContext() { + return { + routeAggregator: this._routeAggregator, + }; + } + + componentWillReceiveProps(nextProps) { + const {routerProps} = nextProps; + if (routerProps.isTransitioning) { + return; + } + + this._routeAggregator.updateRoute(routerProps); + } + + renderLoading = () => { + this._routeAggregator.setLoading(); + return this.renderComponent(); + }; + + renderFetched = (data) => { + this._routeAggregator.setFetched(data); + return this.renderComponent(); + }; + + renderFailure = (error, retry) => { + this._routeAggregator.setFailure(error, retry); + return this.renderComponent(); + }; + + renderComponent() { + return ; + } + + render() { + return ( + + ); + } +} diff --git a/src/RouteAggregator.js b/src/RouteAggregator.js new file mode 100644 index 0000000..64eb586 --- /dev/null +++ b/src/RouteAggregator.js @@ -0,0 +1,134 @@ +import invariant from 'invariant'; +import Relay from 'react-relay'; + +import getParamsForRoute from './getParamsForRoute'; + +export default class RouteAggregator { + constructor() { + this._uniqueQueryNames = new WeakMap(); + this._lastQueryIndex = 0; + + this.route = null; + this._fragmentSpecs = null; + + this._data = {}; + this._failure = null; + } + + updateRoute(routerProps) { + const {branch, params, location} = routerProps; + + const relayRoute = { + name: null, + queries: {}, + params: {}, + }; + const fragmentSpecs = {}; + + branch.forEach(route => { + const {queries} = route; + if (!queries) { + return; + } + + const {component} = route; + + // In principle not all container component routes have to specify + // queries, because some of them might somehow receive fragments from + // their parents, but it would definitely be wrong to specify queries + // for a component that isn't a container. + invariant( + Relay.isContainer(component), + 'relay-nested-routes: Route with queries specifies component `%s` ' + + 'that is not a Relay container.', + component.displayName || component.name + ); + + const routeParams = getParamsForRoute({route, branch, params, location}); + Object.assign(relayRoute.params, routeParams); + + Object.keys(queries).forEach(queryName => { + const query = queries[queryName]; + const uniqueQueryName = this._getUniqueQueryName(query, queryName); + + relayRoute.queries[uniqueQueryName] = + () => query(component, routeParams); + fragmentSpecs[uniqueQueryName] = {component, queryName}; + }); + }); + + relayRoute.name = + ['$$_aggregated', ...Object.keys(relayRoute.queries)].join('-'); + + // RootContainer uses referential equality to check for route change, so + // replace the route object entirely. + this.route = relayRoute; + this._fragmentSpecs = fragmentSpecs; + } + + _getUniqueQueryName(query, queryName) { + let uniqueQueryName = this._uniqueQueryNames.get(query); + if (uniqueQueryName === undefined) { + uniqueQueryName = `$$_${queryName}_${++this._lastQueryIndex}`; + this._uniqueQueryNames.set(query, uniqueQueryName); + } + + return uniqueQueryName; + } + + setLoading() { + this._failure = null; + } + + setFetched(data) { + this._failure = null; + this._data = data; + } + + setFailure(error, retry) { + this._failure = [error, retry]; + } + + getData(queries, params) { + // Check that the subset of parameters used for this route match those used + // for the fetched data. + for (const paramName of Object.keys(params)) { + if (this._data[paramName] !== params[paramName]) { + return this._getDataNotFound(); + } + } + + const fragmentPointers = {}; + for (const queryName of Object.keys(queries)) { + const query = queries[queryName]; + const uniqueQueryName = this._getUniqueQueryName(query, queryName); + + const fragmentPointer = this._data[uniqueQueryName]; + if (!fragmentPointer) { + return this._getDataNotFound(); + } + + fragmentPointers[queryName] = fragmentPointer; + } + + return {fragmentPointers}; + } + + _getDataNotFound() { + return {failure: this._failure}; + } + + getFragmentNames() { + return Object.keys(this._fragmentSpecs); + } + + getFragment(fragmentName, variableMapping) { + const {component, queryName} = this._fragmentSpecs[fragmentName]; + return component.getFragment(queryName, variableMapping); + } + + // TODO: Remove when Relay>0.1.1 is released (facebook/relay#103), since + // getFragmentNames and getFragment fulfill the isContainer contract. + getQuery() {} + getQueryNames() {} +} diff --git a/src/createElement.js b/src/createElement.js new file mode 100644 index 0000000..e3c80e3 --- /dev/null +++ b/src/createElement.js @@ -0,0 +1,12 @@ +import React from 'react'; + +import Container from './Container'; + +export default function createElement(Component, props) { + return ( + + ); +} diff --git a/src/generateContainer.js b/src/generateContainer.js deleted file mode 100644 index 6c6ae45..0000000 --- a/src/generateContainer.js +++ /dev/null @@ -1,90 +0,0 @@ -import invariant from 'invariant'; -import warning from 'warning'; - -import generateNestedRenderer from './NestedRenderer'; - -function getRouteName(branch) { - const path = branch - .map(leaf => leaf.path) - .filter(path => path) - .join('/'); - - invariant( - path && path.length > 0, - 'relay-nested-routes: Leaf route with components `%s` is missing ' + - '`path` props required by relay-nested-routes.', - branch.map(leaf => leaf.component).map(component => { - if (!component) { - return '[none]'; - } - - return component.displayName || component.name; - }).join(' -> ') - ); - - return path; -} - -export default function generateContainer(React, Relay, newProps){ - const {branch, components} = newProps; - const name = getRouteName(branch); - - const params = {...newProps.location.query, ...newProps.params}; - const rootQueries = {}; - const fragmentSpecs = {}; - let queryIdx = 0; - - const [, ...elements] = components.map((Component, index) => { - if (!Relay.isContainer(Component)) { - return (props) => ; - } - - const componentName = Component.displayName || Component.name; - const {queries} = branch[index]; - - invariant( - queries, - 'relay-nested-routes: Route with component `%s` is missing a ' + - '`queries` prop: or ', - componentName, - componentName - ); - - const fragmentResolvers = []; - Object.keys(queries).forEach(queryName => { - warning( - !params[queryName], - 'relay-nested-routes: Route with component `%s` has a fragment and a ' + - 'parameter named `%s`. If you meant to override an initialVariable ' + - 'then rename one of them.', - componentName, - queryName - ); - const generatedName = `$$_${name}_${queryName}_${++queryIdx}`; - const resolve = function() { - return this.props[generatedName]; - }; - rootQueries[generatedName] = (_, ...args) => - queries[queryName](Component, ...args); - fragmentSpecs[generatedName] = {Component, queryName}; - fragmentResolvers.push({queryName, resolve}); - }); - - return function ComponentRenderer(props) { - const clonedProps = {...props}; - fragmentResolvers.forEach( - ({queryName, resolve}) => clonedProps[queryName] = resolve.call(this) - ); - return ; - }; - }); - - return { - Component: generateNestedRenderer(React, elements, fragmentSpecs), - route: { - name, - params: params, - queries: rootQueries - } - }; -} diff --git a/src/getParamsForRoute.js b/src/getParamsForRoute.js new file mode 100644 index 0000000..abc5b8d --- /dev/null +++ b/src/getParamsForRoute.js @@ -0,0 +1,28 @@ +import {getRouteParams} from 'react-router/lib/RoutingUtils'; + +export default function getParamsForRoute({route, branch, params, location}) { + const paramsForRoute = {}; + + // Extract route params for current route and all ancestors. + for (const branchRoute of branch) { + Object.assign(paramsForRoute, getRouteParams(branchRoute, params)); + if (branchRoute === route) { + break; + } + } + + // Extract specified routes from query. + if (route.queryParams) { + // Can't use destructuring default value here, because location.query is + // null when no query string is present. + const query = location.query || {}; + + route.queryParams.forEach(queryParam => { + const queryValue = query[queryParam]; + paramsForRoute[queryParam] = + queryValue !== undefined ? queryValue : null; + }); + } + + return paramsForRoute; +} diff --git a/src/index.js b/src/index.js old mode 100755 new mode 100644 index 62f5c7f..c22e843 --- a/src/index.js +++ b/src/index.js @@ -1,31 +1,2 @@ -import generateContainer from './generateContainer'; - -export default function generateRootContainer(React, Relay) { - return class NestedRootContainer extends React.Component { - constructor(props, context) { - super(props, context); - this.state = generateContainer(React, Relay, props); - } - - componentWillReceiveProps(props) { - if (props.isTransitioning) { - return; - } - - this.setState(generateContainer(React, Relay, props)); - } - - render() { - const {Component, route} = this.state; - const {childRoutes, component, ...props} = this.props.route; - - return ( - - ); - } - }; -} +import createElement from './createElement'; +export default {createElement}; diff --git a/webpack.config.js b/webpack.config.js deleted file mode 100755 index d6ec02e..0000000 --- a/webpack.config.js +++ /dev/null @@ -1,39 +0,0 @@ -'use strict'; - -var webpack = require('webpack'); - -var plugins = [ - new webpack.optimize.OccurenceOrderPlugin(), - new webpack.DefinePlugin({ - 'process.env.NODE_ENV': JSON.stringify(process.env.NODE_ENV) - }) -]; - -if (process.env.NODE_ENV === 'production') { - plugins.push( - new webpack.optimize.UglifyJsPlugin({ - compressor: { - screw_ie8: true, - warnings: false - } - }) - ); -} - -module.exports = { - module: { - loaders: [{ - test: /\.js$/, - loaders: ['babel-loader'], - exclude: /node_modules/ - }] - }, - output: { - library: 'relay-nested-routes', - libraryTarget: 'umd' - }, - plugins: plugins, - resolve: { - extensions: ['', '.js'] - } -};