From 39a3f2f1609d27e825458bb87e10e36f928456d2 Mon Sep 17 00:00:00 2001 From: Dan Abramov Date: Tue, 21 May 2019 21:54:10 +0100 Subject: [PATCH] [Fresh] Set up infra for runtime and Babel plugin (#15698) * Add a stub for React Fresh Babel plugin package * Move ReactFresh-test into ReactFresh top level directory * Add a stub for React Fresh Runtime entry point * Extract Fresh runtime from tests into its entry point --- packages/react-fresh/README.md | 7 ++ packages/react-fresh/babel.js | 13 +++ packages/react-fresh/npm/babel.js | 7 ++ packages/react-fresh/npm/runtime.js | 7 ++ packages/react-fresh/package.json | 30 +++++ packages/react-fresh/runtime.js | 13 +++ .../react-fresh/src/ReactFreshBabelPlugin.js | 15 +++ packages/react-fresh/src/ReactFreshRuntime.js | 103 +++++++++++++++++ .../src/__tests__/ReactFresh-test.js} | 108 ++---------------- .../__tests__/ReactFreshBabelPlugin-test.js | 23 ++++ .../ReactFreshBabelPlugin-test.js.snap | 3 + .../src/ReactFiberHotReloading.js | 16 ++- scripts/rollup/bundles.js | 17 +++ 13 files changed, 255 insertions(+), 107 deletions(-) create mode 100644 packages/react-fresh/README.md create mode 100644 packages/react-fresh/babel.js create mode 100644 packages/react-fresh/npm/babel.js create mode 100644 packages/react-fresh/npm/runtime.js create mode 100644 packages/react-fresh/package.json create mode 100644 packages/react-fresh/runtime.js create mode 100644 packages/react-fresh/src/ReactFreshBabelPlugin.js create mode 100644 packages/react-fresh/src/ReactFreshRuntime.js rename packages/{react-dom/src/__tests__/ReactFresh-test.internal.js => react-fresh/src/__tests__/ReactFresh-test.js} (96%) create mode 100644 packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js create mode 100644 packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap diff --git a/packages/react-fresh/README.md b/packages/react-fresh/README.md new file mode 100644 index 000000000000..d8ff49aca67e --- /dev/null +++ b/packages/react-fresh/README.md @@ -0,0 +1,7 @@ +# react-fresh + +This is an experimental package for hot reloading. + +**Its API is not as stable as that of React, React Native, or React DOM, and does not follow the common versioning scheme.** + +**Use it at your own risk.** diff --git a/packages/react-fresh/babel.js b/packages/react-fresh/babel.js new file mode 100644 index 000000000000..22ba11b55c23 --- /dev/null +++ b/packages/react-fresh/babel.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const ReactFreshBabelPlugin = require('./src/ReactFreshBabelPlugin'); + +// This is hacky but makes it work with both Rollup and Jest. +module.exports = ReactFreshBabelPlugin.default || ReactFreshBabelPlugin; diff --git a/packages/react-fresh/npm/babel.js b/packages/react-fresh/npm/babel.js new file mode 100644 index 000000000000..164511887cd1 --- /dev/null +++ b/packages/react-fresh/npm/babel.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-fresh-babel.production.min.js'); +} else { + module.exports = require('./cjs/react-fresh-babel.development.js'); +} diff --git a/packages/react-fresh/npm/runtime.js b/packages/react-fresh/npm/runtime.js new file mode 100644 index 000000000000..835eea42e345 --- /dev/null +++ b/packages/react-fresh/npm/runtime.js @@ -0,0 +1,7 @@ +'use strict'; + +if (process.env.NODE_ENV === 'production') { + module.exports = require('./cjs/react-fresh-runtime.production.min.js'); +} else { + module.exports = require('./cjs/react-fresh-runtime.development.js'); +} diff --git a/packages/react-fresh/package.json b/packages/react-fresh/package.json new file mode 100644 index 000000000000..f027c8046515 --- /dev/null +++ b/packages/react-fresh/package.json @@ -0,0 +1,30 @@ +{ + "name": "react-fresh", + "private": true, + "description": "React is a JavaScript library for building user interfaces.", + "keywords": [ + "react" + ], + "version": "0.1.0", + "homepage": "https://reactjs.org/", + "bugs": "https://github.com/facebook/react/issues", + "license": "MIT", + "files": [ + "LICENSE", + "README.md", + "babel.js", + "runtime.js", + "build-info.json", + "cjs/", + "umd/" + ], + "main": "index.js", + "repository": { + "type": "git", + "url": "https://github.com/facebook/react.git", + "directory": "packages/react" + }, + "engines": { + "node": ">=0.10.0" + } +} diff --git a/packages/react-fresh/runtime.js b/packages/react-fresh/runtime.js new file mode 100644 index 000000000000..c7a67cd7e668 --- /dev/null +++ b/packages/react-fresh/runtime.js @@ -0,0 +1,13 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +const ReactFreshRuntime = require('./src/ReactFreshRuntime'); + +// This is hacky but makes it work with both Rollup and Jest. +module.exports = ReactFreshRuntime.default || ReactFreshRuntime; diff --git a/packages/react-fresh/src/ReactFreshBabelPlugin.js b/packages/react-fresh/src/ReactFreshBabelPlugin.js new file mode 100644 index 000000000000..109deb149a1c --- /dev/null +++ b/packages/react-fresh/src/ReactFreshBabelPlugin.js @@ -0,0 +1,15 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +// TODO +export default function(babel) { + return { + visitor: {}, + }; +} diff --git a/packages/react-fresh/src/ReactFreshRuntime.js b/packages/react-fresh/src/ReactFreshRuntime.js new file mode 100644 index 000000000000..0f509788ed7b --- /dev/null +++ b/packages/react-fresh/src/ReactFreshRuntime.js @@ -0,0 +1,103 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @flow + */ + +import type { + Family, + HotUpdate, +} from 'react-reconciler/src/ReactFiberHotReloading'; + +import {REACT_MEMO_TYPE, REACT_FORWARD_REF_TYPE} from 'shared/ReactSymbols'; + +// We never remove these associations. +// It's OK to reference families, but use WeakMap/Set for types. +const allFamiliesByID: Map = new Map(); +const allTypes: WeakSet = new WeakSet(); +const allSignaturesByType: WeakMap = new WeakMap(); +// This WeakMap is read by React, so we only put families +// that have actually been edited here. This keeps checks fast. +const familiesByType: WeakMap = new WeakMap(); + +// This is cleared on every prepareUpdate() call. +// It is an array of [Family, NextType] tuples. +let pendingUpdates: Array<[Family, any]> = []; + +export function prepareUpdate(): HotUpdate { + const staleFamilies = new Set(); + const updatedFamilies = new Set(); + + const updates = pendingUpdates; + pendingUpdates = []; + updates.forEach(([family, nextType]) => { + // Now that we got a real edit, we can create associations + // that will be read by the React reconciler. + const prevType = family.current; + familiesByType.set(prevType, family); + familiesByType.set(nextType, family); + family.current = nextType; + + // Determine whether this should be a re-render or a re-mount. + const prevSignature = allSignaturesByType.get(prevType); + const nextSignature = allSignaturesByType.get(nextType); + if (prevSignature !== nextSignature) { + staleFamilies.add(family); + } else { + updatedFamilies.add(family); + } + }); + + return { + familiesByType, + updatedFamilies, + staleFamilies, + }; +} + +export function register(type: any, id: string): void { + if (type === null) { + return; + } + if (typeof type !== 'function' && typeof type !== 'object') { + return; + } + + // This can happen in an edge case, e.g. if we register + // return value of a HOC but it returns a cached component. + // Ignore anything but the first registration for each type. + if (allTypes.has(type)) { + return; + } + allTypes.add(type); + + // Create family or remember to update it. + // None of this bookkeeping affects reconciliation + // until the first prepareUpdate() call above. + let family = allFamiliesByID.get(id); + if (family === undefined) { + family = {current: type}; + allFamiliesByID.set(id, family); + } else { + pendingUpdates.push([family, type]); + } + + // Visit inner types because we might not have registered them. + if (typeof type === 'object' && type !== null) { + switch (type.$$typeof) { + case REACT_FORWARD_REF_TYPE: + register(type.render, id + '$render'); + break; + case REACT_MEMO_TYPE: + register(type.type, id + '$type'); + break; + } + } +} + +export function setSignature(type: any, signature: string): void { + allSignaturesByType.set(type, signature); +} diff --git a/packages/react-dom/src/__tests__/ReactFresh-test.internal.js b/packages/react-fresh/src/__tests__/ReactFresh-test.js similarity index 96% rename from packages/react-dom/src/__tests__/ReactFresh-test.internal.js rename to packages/react-fresh/src/__tests__/ReactFresh-test.js index b92c2d6c4485..faaf1ae1adbc 100644 --- a/packages/react-dom/src/__tests__/ReactFresh-test.internal.js +++ b/packages/react-fresh/src/__tests__/ReactFresh-test.js @@ -13,21 +13,16 @@ let React; let ReactDOM; +let ReactFreshRuntime; let Scheduler; let act; +let lastRoot; describe('ReactFresh', () => { let container; - let familiesByID; - let familiesByType; - let newFamilies; - let updatedFamilies; - let performHotReload; - let signaturesByType; + let scheduleHotUpdate; beforeEach(() => { - let scheduleHotUpdate; - let lastRoot; global.__REACT_DEVTOOLS_GLOBAL_HOOK__ = { supportsFiber: true, inject: injected => { @@ -42,24 +37,11 @@ describe('ReactFresh', () => { jest.resetModules(); React = require('react'); ReactDOM = require('react-dom'); + ReactFreshRuntime = require('react-fresh/runtime'); Scheduler = require('scheduler'); act = require('react-dom/test-utils').act; container = document.createElement('div'); document.body.appendChild(container); - - familiesByID = new Map(); - familiesByType = new WeakMap(); - - if (__DEV__) { - performHotReload = function(staleFamilies) { - scheduleHotUpdate({ - root: lastRoot, - familiesByType, - updatedFamilies, - staleFamilies, - }); - }; - } }); afterEach(() => { @@ -67,26 +49,12 @@ describe('ReactFresh', () => { }); function prepare(version) { - newFamilies = new Set(); - updatedFamilies = new Set(); - signaturesByType = new Map(); const Component = version(); - - // Fill in the signatures. - for (let family of newFamilies) { - const latestSignature = signaturesByType.get(family.currentType) || null; - family.currentSignature = latestSignature; - } - - newFamilies = null; - updatedFamilies = null; - signaturesByType = null; - return Component; } function render(version, props) { - const Component = prepare(version); + const Component = version(); act(() => { ReactDOM.render(, container); }); @@ -94,74 +62,18 @@ describe('ReactFresh', () => { } function patch(version) { - // Will be filled in by __register__ calls in user code. - newFamilies = new Set(); - updatedFamilies = new Set(); - signaturesByType = new Map(); const Component = version(); - - // Fill in the signatures. - for (let family of newFamilies) { - const latestSignature = signaturesByType.get(family.currentType) || null; - family.currentSignature = latestSignature; - } - // Now that all registration and signatures are collected, - // find which registrations changed their signatures since last time. - const staleFamilies = new Set(); - for (let family of updatedFamilies) { - const latestSignature = signaturesByType.get(family.currentType) || null; - if (family.currentSignature !== latestSignature) { - family.currentSignature = latestSignature; - staleFamilies.add(family); - } - } - - performHotReload(staleFamilies); - newFamilies = null; - updatedFamilies = null; - signaturesByType = null; + const hotUpdate = ReactFreshRuntime.prepareUpdate(); + scheduleHotUpdate(lastRoot, hotUpdate); return Component; } function __register__(type, id) { - if (familiesByType.has(type)) { - return; - } - let family = familiesByID.get(id); - let isNew = false; - if (family === undefined) { - isNew = true; - family = {currentType: type, currentSignature: null}; - familiesByID.set(id, family); - } - const prevType = family.currentType; - if (isNew) { - // The first time a type is registered, we don't need - // any special reconciliation logic. So we won't add it to the map. - // Instead, this will happen the firt time it is edited. - newFamilies.add(family); - } else { - family.currentType = type; - // Point both previous and next types to this family. - familiesByType.set(prevType, family); - familiesByType.set(type, family); - updatedFamilies.add(family); - } - - if (typeof type === 'object' && type !== null) { - switch (type.$$typeof) { - case Symbol.for('react.forward_ref'): - __register__(type.render, id + '$render'); - break; - case Symbol.for('react.memo'): - __register__(type.type, id + '$type'); - break; - } - } + ReactFreshRuntime.register(type, id); } - function __signature__(type, signature) { - signaturesByType.set(type, signature); + function __signature__(type, id) { + ReactFreshRuntime.setSignature(type, id); } it('can preserve state for compatible types', () => { diff --git a/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js b/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js new file mode 100644 index 000000000000..c44cd08af99e --- /dev/null +++ b/packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js @@ -0,0 +1,23 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +'use strict'; + +let babel = require('babel-core'); +let freshPlugin = require('react-fresh/babel'); + +function transform(input, options = {}) { + return babel.transform(input, { + plugins: [[freshPlugin]], + }).code; +} + +describe('ReactFreshBabelPlugin', () => { + it('hello world', () => { + expect(transform(`hello()`)).toMatchSnapshot(); + }); +}); diff --git a/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap b/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap new file mode 100644 index 000000000000..665cb8949ae4 --- /dev/null +++ b/packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ReactFreshBabelPlugin hello world 1`] = `"hello();"`; diff --git a/packages/react-reconciler/src/ReactFiberHotReloading.js b/packages/react-reconciler/src/ReactFiberHotReloading.js index 886d63d4a0ac..c325ae2836f9 100644 --- a/packages/react-reconciler/src/ReactFiberHotReloading.js +++ b/packages/react-reconciler/src/ReactFiberHotReloading.js @@ -29,14 +29,12 @@ import { REACT_LAZY_TYPE, } from 'shared/ReactSymbols'; -type Family = {| - currentType: any, - currentSignature: null | string, +export type Family = {| + current: any, |}; -type HotUpdate = {| +export type HotUpdate = {| familiesByType: WeakMap, - root: FiberRoot, staleFamilies: Set, updatedFamilies: Set, |}; @@ -56,7 +54,7 @@ export function resolveFunctionForHotReloading(type: any): any { return type; } // Use the latest known implementation. - return family.currentType; + return family.current; } else { return type; } @@ -94,7 +92,7 @@ export function resolveForwardRefForHotReloading(type: any): any { return type; } // Use the latest known implementation. - return family.currentType; + return family.current; } else { return type; } @@ -194,12 +192,12 @@ export function markFailedErrorBoundaryForHotReloading(fiber: Fiber) { } } -export function scheduleHotUpdate(hotUpdate: HotUpdate): void { +export function scheduleHotUpdate(root: FiberRoot, hotUpdate: HotUpdate): void { if (__DEV__) { // TODO: warn if its identity changes over time? familiesByType = hotUpdate.familiesByType; - const {root, staleFamilies, updatedFamilies} = hotUpdate; + const {staleFamilies, updatedFamilies} = hotUpdate; flushPassiveEffects(); flushSync(() => { scheduleFibersWithFamiliesRecursively( diff --git a/scripts/rollup/bundles.js b/scripts/rollup/bundles.js index 0a2796304af1..8d50247a64db 100644 --- a/scripts/rollup/bundles.js +++ b/scripts/rollup/bundles.js @@ -398,6 +398,23 @@ const bundles = [ global: 'ESLintPluginReactHooks', externals: [], }, + + /******* React Fresh *******/ + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: ISOMORPHIC, + entry: 'react-fresh/babel', + global: 'ReactFreshBabelPlugin', + externals: [], + }, + { + bundleTypes: [NODE_DEV, NODE_PROD], + moduleType: ISOMORPHIC, + entry: 'react-fresh/runtime', + global: 'ReactFreshRuntime', + externals: [], + }, + { bundleTypes: [ FB_WWW_DEV,