From ec3f8e5815d2108b6ab5123a8813db465babff5d Mon Sep 17 00:00:00 2001 From: Joe Haddad Date: Sat, 18 Apr 2020 13:55:10 -0400 Subject: [PATCH] Create React Refresh Utils (#12006) * Create React Refresh Utils * Fix Linting * Update Prettier Ignore * fix rules --- .eslintignore | 3 +- .prettierignore | 4 +- packages/react-refresh-utils/.gitignore | 2 + packages/react-refresh-utils/README.md | 17 ++ .../ReactRefreshWebpackPlugin.ts | 69 ++++++++ .../internal/ReactRefreshModule.runtime.ts | 72 +++++++++ .../react-refresh-utils/internal/helpers.ts | 151 ++++++++++++++++++ packages/react-refresh-utils/loader.ts | 17 ++ packages/react-refresh-utils/package.json | 30 ++++ packages/react-refresh-utils/runtime.ts | 39 +++++ packages/react-refresh-utils/tsconfig.json | 11 ++ yarn.lock | 5 + 12 files changed, 418 insertions(+), 2 deletions(-) create mode 100644 packages/react-refresh-utils/.gitignore create mode 100644 packages/react-refresh-utils/README.md create mode 100644 packages/react-refresh-utils/ReactRefreshWebpackPlugin.ts create mode 100644 packages/react-refresh-utils/internal/ReactRefreshModule.runtime.ts create mode 100644 packages/react-refresh-utils/internal/helpers.ts create mode 100644 packages/react-refresh-utils/loader.ts create mode 100644 packages/react-refresh-utils/package.json create mode 100644 packages/react-refresh-utils/runtime.ts create mode 100644 packages/react-refresh-utils/tsconfig.json diff --git a/.eslintignore b/.eslintignore index 1e7644e7ef801..8f8f73d1f5dbd 100644 --- a/.eslintignore +++ b/.eslintignore @@ -4,4 +4,5 @@ node_modules **/dist/** examples/with-ioc/** examples/with-kea/** -packages/next/compiled/**/* \ No newline at end of file +packages/next/compiled/**/* +packages/react-refresh-utils/**/*.js diff --git a/.prettierignore b/.prettierignore index 47d0e812cbdb7..6cddf4e79be5b 100644 --- a/.prettierignore +++ b/.prettierignore @@ -2,4 +2,6 @@ node_modules **/.next/** **/_next/** **/dist/** -packages/next/compiled/** \ No newline at end of file +packages/next/compiled/** +packages/react-refresh-utils/**/*.js +packages/react-refresh-utils/**/*.d.ts diff --git a/packages/react-refresh-utils/.gitignore b/packages/react-refresh-utils/.gitignore new file mode 100644 index 0000000000000..fc6b86e7dbf26 --- /dev/null +++ b/packages/react-refresh-utils/.gitignore @@ -0,0 +1,2 @@ +*.d.ts +*.js diff --git a/packages/react-refresh-utils/README.md b/packages/react-refresh-utils/README.md new file mode 100644 index 0000000000000..e6bf24759fbca --- /dev/null +++ b/packages/react-refresh-utils/README.md @@ -0,0 +1,17 @@ +# `@next/react-refresh-utils` + +This is an **experimental** package that provides utilities for React Refresh. + +Its API is not stable as that of Next.js, nor does it follow semver rules. + +**Use it at your own risk**. + +## Usage + +All entrypoints below must wired into your build tooling for this to work. + +### `@next/react-refresh-utils/loader` + +### `@next/react-refresh-utils/ReactRefreshWebpackPlugin` + +### `@next/react-refresh-utils/runtime` diff --git a/packages/react-refresh-utils/ReactRefreshWebpackPlugin.ts b/packages/react-refresh-utils/ReactRefreshWebpackPlugin.ts new file mode 100644 index 0000000000000..af40eca52e602 --- /dev/null +++ b/packages/react-refresh-utils/ReactRefreshWebpackPlugin.ts @@ -0,0 +1,69 @@ +import { Compiler, Template, version } from 'webpack' + +function webpack4(compiler: Compiler) { + // Webpack 4 does not have a method to handle interception of module + // execution. + // The closest thing we have to emulating this is mimicking the behavior of + // `strictModuleExceptionHandling` in `MainTemplate`: + // https://github.com/webpack/webpack/blob/4c644bf1f7cb067c748a52614500e0e2182b2700/lib/MainTemplate.js#L200 + + compiler.hooks.compilation.tap('ReactFreshWebpackPlugin', compilation => { + const hookRequire: typeof compilation['mainTemplate']['hooks']['requireExtensions'] = (compilation + .mainTemplate.hooks as any).require + + hookRequire.tap('ReactFreshWebpackPlugin', (source, chunk, hash) => { + // Webpack 4 evaluates module code on the following line: + // ``` + // modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId)); + // ``` + // https://github.com/webpack/webpack/blob/4c644bf1f7cb067c748a52614500e0e2182b2700/lib/MainTemplate.js#L200 + + const lines = source.split('\n') + const evalIndex = lines.findIndex(l => + l.includes('modules[moduleId].call(') + ) + // Unable to find the module execution, that's OK: + if (evalIndex === -1) { + return source + } + + return Template.asString([ + ...lines.slice(0, evalIndex), + ` + var hasRefresh = !!self.$RefreshInterceptModuleExecution$; + var cleanup = hasRefresh + ? self.$RefreshInterceptModuleExecution$(moduleId) + : function() {}; + try { + `, + lines[evalIndex], + ` + } finally { + cleanup(); + } + `, + ...lines.slice(evalIndex + 1), + ]) + }) + }) +} + +class ReactFreshWebpackPlugin { + apply(compiler: Compiler) { + const webpackMajorVersion = parseInt(version ?? '', 10) + + switch (webpackMajorVersion) { + case 4: { + webpack4(compiler) + break + } + default: { + throw new Error( + `ReactFreshWebpackPlugin does not support webpack v${webpackMajorVersion}.` + ) + } + } + } +} + +export default ReactFreshWebpackPlugin diff --git a/packages/react-refresh-utils/internal/ReactRefreshModule.runtime.ts b/packages/react-refresh-utils/internal/ReactRefreshModule.runtime.ts new file mode 100644 index 0000000000000..27774f910f035 --- /dev/null +++ b/packages/react-refresh-utils/internal/ReactRefreshModule.runtime.ts @@ -0,0 +1,72 @@ +import { RefreshRuntimeGlobals } from '../runtime' + +declare const self: Window & RefreshRuntimeGlobals + +type Dictionary = { [key: string]: unknown } +declare const module: { + id: string + __proto__: { exports: unknown } + hot: { + accept: () => void + dispose: (onDispose: (data: Dictionary) => void) => void + invalidate: () => void + data?: Dictionary + } +} + +export default function() { + const currentExports = module.__proto__.exports + const prevExports = module.hot.data?.prevExports ?? null + + // This cannot happen in MainTemplate because the exports mismatch between + // templating and execution. + self.$RefreshHelpers$.registerExportsForReactRefresh( + currentExports, + module.id + ) + + // A module can be accepted automatically based on its exports, e.g. when it + // is a Refresh Boundary. + if (self.$RefreshHelpers$.isReactRefreshBoundary(currentExports)) { + // Save the previous exports on update so we can compare the boundary + // signatures. + module.hot.dispose(data => { + data.prevExports = currentExports + }) + // Unconditionally accept an update to this module, we'll check if it's + // still a Refresh Boundary later. + module.hot.accept() + + // This field is set when the previous version of this module was a Refresh + // Boundary, letting us know we need to check for invalidation or enqueue + // an update. + if (prevExports !== null) { + // A boundary can become ineligible if its exports are incompatible with + // the previous exports. + // + // For example, if you add/remove/change exports, we'll want to + // re-execute the importing modules, and force those components to + // re-render. Similarly, if you convert a class component to a function, + // we want to invalidate the boundary. + if ( + self.$RefreshHelpers$.shouldInvalidateReactRefreshBoundary( + prevExports, + currentExports + ) + ) { + module.hot.invalidate() + } else { + self.$RefreshHelpers$.scheduleUpdate() + } + } + } else { + // Since we just executed the code for the module, it's possible that the + // new exports made it ineligible for being a boundary. + // We only care about the case when we were _previously_ a boundary, + // because we already accepted this update (accidental side effect). + const isNoLongerABoundary = prevExports !== null + if (isNoLongerABoundary) { + module.hot.invalidate() + } + } +} diff --git a/packages/react-refresh-utils/internal/helpers.ts b/packages/react-refresh-utils/internal/helpers.ts new file mode 100644 index 0000000000000..485160611e20a --- /dev/null +++ b/packages/react-refresh-utils/internal/helpers.ts @@ -0,0 +1,151 @@ +/** + * MIT License + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ + +// This file is copied from the Metro JavaScript bundler, with minor tweaks for +// webpack 4 compatibility. +// +// https://github.com/facebook/metro/blob/d6b9685c730d0d63577db40f41369157f28dfa3a/packages/metro/src/lib/polyfills/require.js + +import RefreshRuntime from 'react-refresh/runtime' + +declare const module: { + hot: { + status: () => + | 'idle' + | 'check' + | 'prepare' + | 'ready' + | 'dispose' + | 'apply' + | 'abort' + | 'fail' + } +} + +function registerExportsForReactRefresh( + moduleExports: unknown, + moduleID: string +) { + RefreshRuntime.register(moduleExports, moduleID + ' %exports%') + if (moduleExports == null || typeof moduleExports !== 'object') { + // Exit if we can't iterate over exports. + // (This is important for legacy environments.) + return + } + for (const key in moduleExports) { + const exportValue = moduleExports[key] + const typeID = moduleID + ' %exports% ' + key + RefreshRuntime.register(exportValue, typeID) + } +} + +function isReactRefreshBoundary(moduleExports: unknown): boolean { + if (RefreshRuntime.isLikelyComponentType(moduleExports)) { + return true + } + if (moduleExports == null || typeof moduleExports !== 'object') { + // Exit if we can't iterate over exports. + return false + } + let hasExports = false + let areAllExportsComponents = true + for (const key in moduleExports) { + hasExports = true + if (key === '__esModule') { + continue + } + const exportValue = moduleExports[key] + if (!RefreshRuntime.isLikelyComponentType(exportValue)) { + areAllExportsComponents = false + } + } + return hasExports && areAllExportsComponents +} + +function shouldInvalidateReactRefreshBoundary( + prevExports: unknown, + nextExports: unknown +): boolean { + const prevSignature = getRefreshBoundarySignature(prevExports) + const nextSignature = getRefreshBoundarySignature(nextExports) + if (prevSignature.length !== nextSignature.length) { + return true + } + for (let i = 0; i < nextSignature.length; i++) { + if (prevSignature[i] !== nextSignature[i]) { + return true + } + } + return false +} + +function getRefreshBoundarySignature(moduleExports: unknown): Array { + const signature = [] + signature.push(RefreshRuntime.getFamilyByType(moduleExports)) + if (moduleExports == null || typeof moduleExports !== 'object') { + // Exit if we can't iterate over exports. + // (This is important for legacy environments.) + return signature + } + for (const key in moduleExports) { + if (key === '__esModule') { + continue + } + const exportValue = moduleExports[key] + signature.push(key) + signature.push(RefreshRuntime.getFamilyByType(exportValue)) + } + return signature +} + +let isUpdateScheduled: boolean = false +function scheduleUpdate() { + if (isUpdateScheduled) { + return + } + + function canApplyUpdate() { + return module.hot.status() === 'idle' + } + + isUpdateScheduled = true + setTimeout(() => { + isUpdateScheduled = false + + // Only trigger refresh if the webpack HMR state is idle + if (canApplyUpdate()) { + return RefreshRuntime.performReactRefresh() + } + + return scheduleUpdate() + }, 30) +} + +export default { + registerExportsForReactRefresh, + isReactRefreshBoundary, + shouldInvalidateReactRefreshBoundary, + getRefreshBoundarySignature, + scheduleUpdate, +} diff --git a/packages/react-refresh-utils/loader.ts b/packages/react-refresh-utils/loader.ts new file mode 100644 index 0000000000000..e4215093d77b8 --- /dev/null +++ b/packages/react-refresh-utils/loader.ts @@ -0,0 +1,17 @@ +import { loader } from 'webpack' +import RefreshModuleRuntime from './internal/ReactRefreshModule.runtime' + +let refreshModuleRuntime = RefreshModuleRuntime.toString() +refreshModuleRuntime = refreshModuleRuntime.slice( + refreshModuleRuntime.indexOf('{') + 1, + refreshModuleRuntime.lastIndexOf('}') +) + +const ReactRefreshLoader: loader.Loader = function ReactRefreshLoader( + source, + inputSourceMap +) { + this.callback(null, `${source}\n\n;${refreshModuleRuntime}`, inputSourceMap) +} + +export default ReactRefreshLoader diff --git a/packages/react-refresh-utils/package.json b/packages/react-refresh-utils/package.json new file mode 100644 index 0000000000000..de63e35734516 --- /dev/null +++ b/packages/react-refresh-utils/package.json @@ -0,0 +1,30 @@ +{ + "name": "@next/react-refresh-utils", + "version": "9.3.6-canary.3", + "description": "An experimental package providing utilities for React Refresh.", + "repository": { + "url": "zeit/next.js", + "directory": "packages/react-refresh-utils" + }, + "files": [ + "internal/*.d.ts", + "internal/*.js", + "*.d.ts", + "*.js" + ], + "author": "Joe Haddad ", + "license": "MIT", + "scripts": { + "prepublish": "tsc -d -p tsconfig.json", + "build": "tsc -d -w -p tsconfig.json" + }, + "dependencies": {}, + "peerDependencies": { + "react-refresh": "0.8.1", + "webpack": "^4" + }, + "devDependencies": { + "react-refresh": "0.8.1", + "webpack": "4.42.1" + } +} diff --git a/packages/react-refresh-utils/runtime.ts b/packages/react-refresh-utils/runtime.ts new file mode 100644 index 0000000000000..247298543049c --- /dev/null +++ b/packages/react-refresh-utils/runtime.ts @@ -0,0 +1,39 @@ +import RefreshRuntime from 'react-refresh/runtime' +import RefreshHelpers from './internal/helpers' + +export type RefreshRuntimeGlobals = { + $RefreshReg$: (type: unknown, id: string) => void + $RefreshSig$: () => (type: unknown) => unknown + $RefreshInterceptModuleExecution$: (moduleId: string) => () => void + $RefreshHelpers$: typeof RefreshHelpers +} + +declare const self: Window & RefreshRuntimeGlobals + +// Hook into ReactDOM initialization +RefreshRuntime.injectIntoGlobalHook(self) + +// noop fns to prevent runtime errors during initialization +self.$RefreshReg$ = () => {} +self.$RefreshSig$ = () => type => type + +// Register global helpers +self.$RefreshHelpers$ = RefreshHelpers + +// Register a helper for module execution interception +self.$RefreshInterceptModuleExecution$ = function(webpackModuleId) { + const prevRefreshReg = self.$RefreshReg$ + const prevRefreshSig = self.$RefreshSig$ + + self.$RefreshReg$ = (type, id) => { + RefreshRuntime.register(type, webpackModuleId + ' ' + id) + } + self.$RefreshSig$ = RefreshRuntime.createSignatureFunctionForTransform + + // Modeled after `useEffect` cleanup pattern: + // https://reactjs.org/docs/hooks-effect.html#effects-with-cleanup + return () => { + self.$RefreshReg$ = prevRefreshReg + self.$RefreshSig$ = prevRefreshSig + } +} diff --git a/packages/react-refresh-utils/tsconfig.json b/packages/react-refresh-utils/tsconfig.json new file mode 100644 index 0000000000000..a3863e09c8d72 --- /dev/null +++ b/packages/react-refresh-utils/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "esModuleInterop": true, + "target": "es3", + "lib": ["dom"], + "downlevelIteration": true, + "preserveWatchOutput": true + }, + "include": ["**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/yarn.lock b/yarn.lock index ea62bcf79bd7b..c72565bc3ab18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -13976,6 +13976,11 @@ react-is@^16.8.1, react-is@^16.8.4: resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.12.0.tgz#2cc0fe0fba742d97fd527c42a13bec4eeb06241c" integrity sha512-rPCkf/mWBtKc97aLL9/txD8DZdemK0vkA3JMLShjlJB3Pj3s+lpf1KaBzMfQrAmhMQB0n1cU/SUGgKKBCe837Q== +react-refresh@0.8.1: + version "0.8.1" + resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.8.1.tgz#5500506ad6fc891fdd057d0bf3581f9310abc6a2" + integrity sha512-xZIKi49RtLUUSAZ4a4ut2xr+zr4+glOD5v0L413B55MPvlg4EQ6Ctx8PD4CmjlPGoAWmSCTmmkY59TErizNsow== + react-ssr-prepass@1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/react-ssr-prepass/-/react-ssr-prepass-1.0.8.tgz#036abffe541975b20213cf7b261c05ac2843480d"