diff --git a/README.md b/README.md index 425d77c55..7ec16cd0f 100644 --- a/README.md +++ b/README.md @@ -683,7 +683,38 @@ LibV8 itself is already [beyond version 7](https://github.com/cowboyd/libv8/rele ### HMR Hot Module Replacement is [possible with this gem](https://stackoverflow.com/a/54846330/193785) as it does just pass through to Webpacker. Please open an issue to let us know tips and tricks for it to add to the wiki. -Sample repo that shows HMR working with `react-rails`: [https://github.com/edelgado/react-rails-hmr](https://github.com/edelgado/react-rails-hmr) +Similar to `useContext`, you can pass the webpack context into `useHotReload` to enable hot reload: +1. install [react-hot-loader](https://github.com/gaearon/react-hot-loader) and [@hot-loader/react-dom](https://github.com/hot-loader/react-dom) +2. add the following to your webpack config in dev: +```js +{ + module: { + rules: [ + { + test: /\.(jsx|tsx)?$/, + use: ["react-hot-loader/webpack"], + }, + ], + }, + resolve: { + alias: { + "react-dom": "@hot-loader/react-dom", + }, + }, +} +``` +3. in your entry file, usually where you call `ReactRailsUJS.useContext` already, call `useHotReload`: +```js +var ReactRailsUJS = require("react_ujs") +var myCustomContext = require.context("custom_components", true) +ReactRailsUJS.useHotReload(myCustomContext) +``` +4. optionally, for CSS to hot reload, update the following for dev in `webpacker.yml`: +```yml +development: + <<: *default + extract_css: false +``` One caveat is that currently you [cannot Server-Side Render along with HMR](https://github.com/reactjs/react-rails/issues/925#issuecomment-415469572). diff --git a/react_ujs/index.js b/react_ujs/index.js index 9f56d2b17..980e144bf 100644 --- a/react_ujs/index.js +++ b/react_ujs/index.js @@ -6,6 +6,9 @@ var detectEvents = require("./src/events/detect") var constructorFromGlobal = require("./src/getConstructor/fromGlobal") var constructorFromRequireContextWithGlobalFallback = require("./src/getConstructor/fromRequireContextWithGlobalFallback") +var renderWithReactDOM = require("./src/renderComponent/withReactDOM") +var renderWithHotReload = require("./src/renderComponent/withHotReload") + var ReactRailsUJS = { // This attribute holds the name of component which should be mounted // example: `data-react-class="MyApp.Items.EditForm"` @@ -71,6 +74,11 @@ var ReactRailsUJS = { useContext: function(requireContext) { this.getConstructor = constructorFromRequireContextWithGlobalFallback(requireContext) }, + + // Called after React unmounts component at `node` + // Override this function to perform any cleanup + // the default function does nothing + onComponentUnmountAtNode: function (node) {}, // Render `componentName` with `props` to a string, // using the specified `renderFunction` from `react-dom/server`. @@ -80,6 +88,18 @@ var ReactRailsUJS = { return ReactDOMServer[renderFunction](element) }, + // Render `component` using the specified `renderFunction` from `react-dom`. + // Override this function to render components in a custom way. + // function signature: ("hydrate" | "render", component, node, props) + renderComponent: renderWithReactDOM, + + // Enables hot reload for component rendering. + // + // See the HMR section in README to ensure required steps are completed. + useHotReload: function(requireContext) { + this.renderComponent = renderWithHotReload(requireContext) + }, + // Within `searchSelector`, find nodes which should have React components // inside them, and mount them with their props. mountComponents: function(searchSelector) { @@ -112,9 +132,9 @@ var ReactRailsUJS = { } if (hydrate && typeof ReactDOM.hydrate === "function") { - component = ReactDOM.hydrate(component, node); + renderComponent("hydrate", component, node, props); } else { - component = ReactDOM.render(component, node); + renderComponent("render", component, node, props); } } } @@ -128,6 +148,7 @@ var ReactRailsUJS = { for (var i = 0; i < nodes.length; ++i) { var node = nodes[i]; ReactDOM.unmountComponentAtNode(node); + ReactRailsUJS.onComponentUnmountAtNode(node); } }, diff --git a/react_ujs/src/renderComponent/withHotReload.js b/react_ujs/src/renderComponent/withHotReload.js new file mode 100644 index 000000000..ed38edaaf --- /dev/null +++ b/react_ujs/src/renderComponent/withHotReload.js @@ -0,0 +1,64 @@ +var ReactDOM = require("react-dom") +var reactHotLoader = require("react-hot-loader") +var AppContainer = reactHotLoader.AppContainer; + +var IS_MOUNTED_ATTR = "data-react-is-mounted"; + +// Render React component with hot reload. +// +// See the HMR section in README to ensure required steps are completed. +module.exports = function(webpackRequireContext) { + ReactRailsUJS.onComponentUnmountAtNode = function(node) { + node.setAttribute(IS_MOUNTED_ATTR, "false"); + } + + return function(renderFunctionName, component, node, props) { + var className = node.getAttribute(ReactRailsUJS.CLASS_NAME_ATTR); + var filename = getFileNameFromClassName(className); + var path = webpackRequireContext.resolve("./" + filename); + var cache = require.cache; + var module = cache[path]; + var moduleParent = module && cache[module.parents[0]]; + if (!moduleParent || !moduleParent.hot) { + console.warn(`Cannot hot reload for ${path}. Ensure webpack-dev-server is started with --hot and WEBPACKER_DEV_SERVER_HMR=true`); + return; + } + moduleParent.hot.accept(path, () => reRenderAllNodes(className, renderFunctionName)); + + ReactDOM[renderFunctionName](React.createElement(AppContainer, null, component), node); + node.setAttribute(IS_MOUNTED_ATTR, "true"); + }; +} + +function getFileNameFromClassName(className) { + var parts = className.split("."); + var filename = parts.shift(); + + return filename; +} + +function reRenderAllNodes(className, renderFunctionName) { + var nodes = findAllReactNodes(className); + for (var i = 0; i < nodes.length; ++i) { + var node = nodes[i]; + if (!isReactMountedAtNode(node)) continue; + + var propsJson = node.getAttribute(ujs.PROPS_ATTR); + var props = propsJson && JSON.parse(propsJson); + var FreshComponent = React.createElement(FreshConstructor, props); + ReactDOM[renderFunctionName](React.createElement(AppContainer, null, FreshComponent), node); + } +} + +function findAllReactNodes(className) { + var selector = '[' + ReactRailsUJS.CLASS_NAME_ATTR + '="' + className + '"]'; + if (ReactRailsUJS.jQuery) { + return ReactRailsUJS.jQuery(selector, document); + } else { + return parent.querySelectorAll(selector); + } +} + +function isReactMountedAtNode(node) { + return node.matches('[' + IS_MOUNTED_ATTR + '="true"]'); +} diff --git a/react_ujs/src/renderComponent/withReactDOM.js b/react_ujs/src/renderComponent/withReactDOM.js new file mode 100644 index 000000000..a6785a2ed --- /dev/null +++ b/react_ujs/src/renderComponent/withReactDOM.js @@ -0,0 +1,10 @@ +var ReactDOM = require("react-dom") + +// Render React component via ReactDOM, for example: +// +// - `renderComponent("hydrate", component, node, props)` -> `ReactDOM.hydrate(component, node);` +// - `renderComponent("render", component, node, props)` -> `ReactDOM.render(component, node);` +// +module.exports = function(renderFunctionName, component, node) { + ReactDOM[renderFunctionName](component, node); +};