Handle changes at module boundaries (#16002)
Dan Abramov committed
Jun 27, 2019 at 20:53 UTC
f11540926d2b28ec1f227a79ec9e95b75708c2e3
2 files changed
+375
-12
packages/react-refresh/src/ReactFreshRuntime.js
+19
-10
@@ -37,19 +37,18 @@ if (!__DEV__) {
37
38
// In old environments, we'll leak previous types after every edit.
39
const PossiblyWeakMap = typeof WeakMap === 'function' ? WeakMap : Map;
40
-const PossiblyWeakSet = typeof WeakSet === 'function' ? WeakSet : Set;
40
41
// We never remove these associations.
42
// It's OK to reference families, but use WeakMap/Set for types.
43
const allFamiliesByID: Map<string, Family> = new Map();
45
-// $FlowIssue
46
-const allTypes: WeakSet<any> | Set<any> = new PossiblyWeakSet();
44
+const allFamiliesByType: // $FlowIssue
45
+WeakMap<any, Family> | Map<any, Family> = new PossiblyWeakMap();
46
const allSignaturesByType: // $FlowIssue
47
WeakMap<any, Signature> | Map<any, Signature> = new PossiblyWeakMap();
48
// This WeakMap is read by React, so we only put families
49
// that have actually been edited here. This keeps checks fast.
50
// $FlowIssue
52
-const familiesByType: // $FlowIssue
51
+const updatedFamiliesByType: // $FlowIssue
52
WeakMap<any, Family> | Map<any, Family> = new PossiblyWeakMap();
53
54
// This is cleared on every performReactRefresh() call.
@@ -145,7 +144,8 @@ function canPreserveStateBetween(prevType, nextType) {
144
}
145
146
function resolveFamily(type) {
148
- return familiesByType.get(type);
147
+ // Only check updated types to keep lookups fast.
148
+ return updatedFamiliesByType.get(type);
149
}
150
151
export function performReactRefresh(): RefreshUpdate | null {
@@ -163,8 +163,8 @@ export function performReactRefresh(): RefreshUpdate | null {
163
// Now that we got a real edit, we can create associations
164
// that will be read by the React reconciler.
165
const prevType = family.current;
166
- familiesByType.set(prevType, family);
167
- familiesByType.set(nextType, family);
166
+ updatedFamiliesByType.set(prevType, family);
167
+ updatedFamiliesByType.set(nextType, family);
168
family.current = nextType;
169
170
// Determine whether this should be a re-render or a re-mount.
@@ -266,11 +266,9 @@ export function register(type: any, id: string): void {
266
// This can happen in an edge case, e.g. if we register
267
// return value of a HOC but it returns a cached component.
268
// Ignore anything but the first registration for each type.
269
- if (allTypes.has(type)) {
269
+ if (allFamiliesByType.has(type)) {
270
return;
271
}
272
- allTypes.add(type);
273
-
272
// Create family or remember to update it.
273
// None of this bookkeeping affects reconciliation
274
// until the first performReactRefresh() call above.
@@ -281,6 +279,7 @@ export function register(type: any, id: string): void {
279
} else {
280
pendingUpdates.push([family, type]);
281
}
282
+ allFamiliesByType.set(type, family);
283
284
// Visit inner types because we might not have registered them.
285
if (typeof type === 'object' && type !== null) {
@@ -345,6 +344,16 @@ export function getFamilyByID(id: string): Family | void {
344
}
345
}
346
347
+export function getFamilyByType(type: any): Family | void {
348
+ if (__DEV__) {
349
+ return allFamiliesByType.get(type);
350
+ } else {
351
+ throw new Error(
352
+ 'Unexpected call to React Refresh in a production environment.',
353
+ );
354
+ }
355
+}
356
+
357
export function findAffectedHostInstances(
358
families: Array<Family>,
359
): Set<Instance> {
packages/react-refresh/src/__tests__/ReactFreshIntegration-test.js
+356
-2
@@ -21,6 +21,7 @@ let freshPlugin = require('react-refresh/babel');
21
22
describe('ReactFreshIntegration', () => {
23
let container;
24
+ let exportsObj;
25
26
beforeEach(() => {
27
if (__DEV__) {
@@ -32,6 +33,7 @@ describe('ReactFreshIntegration', () => {
33
act = require('react-dom/test-utils').act;
34
container = document.createElement('div');
35
document.body.appendChild(container);
36
+ exportsObj = undefined;
37
}
38
});
39
@@ -63,7 +65,7 @@ describe('ReactFreshIntegration', () => {
65
compileDestructuring && 'transform-es2015-destructuring',
66
].filter(Boolean),
67
}).code;
66
- const exportsObj = {};
68
+ exportsObj = {};
69
// eslint-disable-next-line no-new-func
70
new Function(
71
'global',
@@ -73,6 +75,10 @@ describe('ReactFreshIntegration', () => {
75
'$RefreshSig$',
76
compiled,
77
)(global, React, exportsObj, $RefreshReg$, $RefreshSig$);
78
+ // Module systems will register exports as a fallback.
79
+ // This is useful for cases when e.g. a class is exported,
80
+ // and we don't want to propagate the update beyond this module.
81
+ $RefreshReg$(exportsObj.default, 'exports.default');
82
return exportsObj.default;
83
}
84
@@ -86,9 +92,37 @@ describe('ReactFreshIntegration', () => {
92
}
93
94
function patch(source) {
95
+ const prevExports = exportsObj;
96
execute(source);
97
+ const nextExports = exportsObj;
98
+
99
+ // Check if exported families have changed.
100
+ // (In a real module system we'd do this for *all* exports.)
101
+ // For example, this can happen if you convert a class to a function.
102
+ // Or if you wrap something in a HOC.
103
+ let didExportsChange =
104
+ ReactFreshRuntime.getFamilyByType(prevExports.default) !==
105
+ ReactFreshRuntime.getFamilyByType(nextExports.default);
106
+ if (didExportsChange) {
107
+ // In a real module system, we would propagate such updates upwards,
108
+ // and re-execute modules that imported this one. (Just like if we edited them.)
109
+ // This makes adding/removing/renaming exports re-render references to them.
110
+ // Here, we'll just force a re-render using the newer type to emulate this.
111
+ const NextComponent = nextExports.default;
112
+ act(() => {
113
+ ReactDOM.render(<NextComponent />, container);
114
+ });
115
+ }
116
act(() => {
91
- expect(ReactFreshRuntime.performReactRefresh()).not.toBe(null);
117
+ const result = ReactFreshRuntime.performReactRefresh();
118
+ if (!didExportsChange) {
119
+ // Normally we expect that some components got updated in our tests.
120
+ expect(result).not.toBe(null);
121
+ } else {
122
+ // However, we have tests where we convert functions to classes,
123
+ // and in those cases it's expected nothing would get updated.
124
+ // (Instead, the export change branch above would take care of it.)
125
+ }
126
});
127
expect(ReactFreshRuntime._getMountedRootCount()).toBe(1);
128
}
@@ -945,6 +979,326 @@ describe('ReactFreshIntegration', () => {
979
}
980
});
981
982
+ it('remounts when switching export from function to class', () => {
983
+ if (__DEV__) {
984
+ render(`
985
+ export default function App() {
986
+ return <h1>A1</h1>;
987
+ }
988
+ `);
989
+ let el = container.firstChild;
990
+ expect(el.textContent).toBe('A1');
991
+ patch(`
992
+ export default function App() {
993
+ return <h1>A2</h1>;
994
+ }
995
+ `);
996
+ // Keep state.
997
+ expect(container.firstChild).toBe(el);
998
+ expect(el.textContent).toBe('A2');
999
+
1000
+ patch(`
1001
+ export default class App extends React.Component {
1002
+ render() {
1003
+ return <h1>B1</h1>
1004
+ }
1005
+ }
1006
+ `);
1007
+ // Reset (function -> class).
1008
+ expect(container.firstChild).not.toBe(el);
1009
+ el = container.firstChild;
1010
+ expect(el.textContent).toBe('B1');
1011
+ patch(`
1012
+ export default class App extends React.Component {
1013
+ render() {
1014
+ return <h1>B2</h1>
1015
+ }
1016
+ }
1017
+ `);
1018
+ // Reset (classes always do).
1019
+ expect(container.firstChild).not.toBe(el);
1020
+ el = container.firstChild;
1021
+ expect(el.textContent).toBe('B2');
1022
+
1023
+ patch(`
1024
+ export default function App() {
1025
+ return <h1>C1</h1>;
1026
+ }
1027
+ `);
1028
+ // Reset (class -> function).
1029
+ expect(container.firstChild).not.toBe(el);
1030
+ el = container.firstChild;
1031
+ expect(el.textContent).toBe('C1');
1032
+ patch(`
1033
+ export default function App() {
1034
+ return <h1>C2</h1>;
1035
+ }
1036
+ `);
1037
+ expect(container.firstChild).toBe(el);
1038
+ expect(el.textContent).toBe('C2');
1039
+
1040
+ patch(`
1041
+ export default function App() {
1042
+ return <h1>D1</h1>;
1043
+ }
1044
+ `);
1045
+ el = container.firstChild;
1046
+ expect(el.textContent).toBe('D1');
1047
+ patch(`
1048
+ export default function App() {
1049
+ return <h1>D2</h1>;
1050
+ }
1051
+ `);
1052
+ // Keep state.
1053
+ expect(container.firstChild).toBe(el);
1054
+ expect(el.textContent).toBe('D2');
1055
+ }
1056
+ });
1057
+
1058
+ it('remounts when switching export from class to function', () => {
1059
+ if (__DEV__) {
1060
+ render(`
1061
+ export default class App extends React.Component {
1062
+ render() {
1063
+ return <h1>A1</h1>
1064
+ }
1065
+ }
1066
+ `);
1067
+ let el = container.firstChild;
1068
+ expect(el.textContent).toBe('A1');
1069
+ patch(`
1070
+ export default class App extends React.Component {
1071
+ render() {
1072
+ return <h1>A2</h1>
1073
+ }
1074
+ }
1075
+ `);
1076
+ // Reset (classes always do).
1077
+ expect(container.firstChild).not.toBe(el);
1078
+ el = container.firstChild;
1079
+ expect(el.textContent).toBe('A2');
1080
+
1081
+ patch(`
1082
+ export default function App() {
1083
+ return <h1>B1</h1>;
1084
+ }
1085
+ `);
1086
+ // Reset (class -> function).
1087
+ expect(container.firstChild).not.toBe(el);
1088
+ el = container.firstChild;
1089
+ expect(el.textContent).toBe('B1');
1090
+ patch(`
1091
+ export default function App() {
1092
+ return <h1>B2</h1>;
1093
+ }
1094
+ `);
1095
+ // Keep state.
1096
+ expect(container.firstChild).toBe(el);
1097
+ expect(el.textContent).toBe('B2');
1098
+
1099
+ patch(`
1100
+ export default class App extends React.Component {
1101
+ render() {
1102
+ return <h1>C1</h1>
1103
+ }
1104
+ }
1105
+ `);
1106
+ // Reset (function -> class).
1107
+ expect(container.firstChild).not.toBe(el);
1108
+ el = container.firstChild;
1109
+ expect(el.textContent).toBe('C1');
1110
+ }
1111
+ });
1112
+
1113
+ it('remounts when wrapping export in a HOC', () => {
1114
+ if (__DEV__) {
1115
+ render(`
1116
+ export default function App() {
1117
+ return <h1>A1</h1>;
1118
+ }
1119
+ `);
1120
+ let el = container.firstChild;
1121
+ expect(el.textContent).toBe('A1');
1122
+ patch(`
1123
+ export default function App() {
1124
+ return <h1>A2</h1>;
1125
+ }
1126
+ `);
1127
+ // Keep state.
1128
+ expect(container.firstChild).toBe(el);
1129
+ expect(el.textContent).toBe('A2');
1130
+
1131
+ patch(`
1132
+ function hoc(Inner) {
1133
+ return function Wrapper() {
1134
+ return <Inner />;
1135
+ }
1136
+ }
1137
+
1138
+ function App() {
1139
+ return <h1>B1</h1>;
1140
+ }
1141
+
1142
+ export default hoc(App);
1143
+ `);
1144
+ // Reset (wrapped in HOC).
1145
+ expect(container.firstChild).not.toBe(el);
1146
+ el = container.firstChild;
1147
+ expect(el.textContent).toBe('B1');
1148
+ patch(`
1149
+ function hoc(Inner) {
1150
+ return function Wrapper() {
1151
+ return <Inner />;
1152
+ }
1153
+ }
1154
+
1155
+ function App() {
1156
+ return <h1>B2</h1>;
1157
+ }
1158
+
1159
+ export default hoc(App);
1160
+ `);
1161
+ // Keep state.
1162
+ expect(container.firstChild).toBe(el);
1163
+ expect(el.textContent).toBe('B2');
1164
+
1165
+ patch(`
1166
+ export default function App() {
1167
+ return <h1>C1</h1>;
1168
+ }
1169
+ `);
1170
+ // Reset (unwrapped).
1171
+ expect(container.firstChild).not.toBe(el);
1172
+ el = container.firstChild;
1173
+ expect(el.textContent).toBe('C1');
1174
+ patch(`
1175
+ export default function App() {
1176
+ return <h1>C2</h1>;
1177
+ }
1178
+ `);
1179
+ expect(container.firstChild).toBe(el);
1180
+ expect(el.textContent).toBe('C2');
1181
+ }
1182
+ });
1183
+
1184
+ it('remounts when wrapping export in memo()', () => {
1185
+ if (__DEV__) {
1186
+ render(`
1187
+ export default function App() {
1188
+ return <h1>A1</h1>;
1189
+ }
1190
+ `);
1191
+ let el = container.firstChild;
1192
+ expect(el.textContent).toBe('A1');
1193
+ patch(`
1194
+ export default function App() {
1195
+ return <h1>A2</h1>;
1196
+ }
1197
+ `);
1198
+ // Keep state.
1199
+ expect(container.firstChild).toBe(el);
1200
+ expect(el.textContent).toBe('A2');
1201
+
1202
+ patch(`
1203
+ function App() {
1204
+ return <h1>B1</h1>;
1205
+ }
1206
+
1207
+ export default React.memo(App);
1208
+ `);
1209
+ // Reset (wrapped in HOC).
1210
+ expect(container.firstChild).not.toBe(el);
1211
+ el = container.firstChild;
1212
+ expect(el.textContent).toBe('B1');
1213
+ patch(`
1214
+ function App() {
1215
+ return <h1>B2</h1>;
1216
+ }
1217
+
1218
+ export default React.memo(App);
1219
+ `);
1220
+ // Keep state.
1221
+ expect(container.firstChild).toBe(el);
1222
+ expect(el.textContent).toBe('B2');
1223
+
1224
+ patch(`
1225
+ export default function App() {
1226
+ return <h1>C1</h1>;
1227
+ }
1228
+ `);
1229
+ // Reset (unwrapped).
1230
+ expect(container.firstChild).not.toBe(el);
1231
+ el = container.firstChild;
1232
+ expect(el.textContent).toBe('C1');
1233
+ patch(`
1234
+ export default function App() {
1235
+ return <h1>C2</h1>;
1236
+ }
1237
+ `);
1238
+ expect(container.firstChild).toBe(el);
1239
+ expect(el.textContent).toBe('C2');
1240
+ }
1241
+ });
1242
+
1243
+ it('remounts when wrapping export in forwardRef()', () => {
1244
+ if (__DEV__) {
1245
+ render(`
1246
+ export default function App() {
1247
+ return <h1>A1</h1>;
1248
+ }
1249
+ `);
1250
+ let el = container.firstChild;
1251
+ expect(el.textContent).toBe('A1');
1252
+ patch(`
1253
+ export default function App() {
1254
+ return <h1>A2</h1>;
1255
+ }
1256
+ `);
1257
+ // Keep state.
1258
+ expect(container.firstChild).toBe(el);
1259
+ expect(el.textContent).toBe('A2');
1260
+
1261
+ patch(`
1262
+ function App() {
1263
+ return <h1>B1</h1>;
1264
+ }
1265
+
1266
+ export default React.forwardRef(App);
1267
+ `);
1268
+ // Reset (wrapped in HOC).
1269
+ expect(container.firstChild).not.toBe(el);
1270
+ el = container.firstChild;
1271
+ expect(el.textContent).toBe('B1');
1272
+ patch(`
1273
+ function App() {
1274
+ return <h1>B2</h1>;
1275
+ }
1276
+
1277
+ export default React.forwardRef(App);
1278
+ `);
1279
+ // Keep state.
1280
+ expect(container.firstChild).toBe(el);
1281
+ expect(el.textContent).toBe('B2');
1282
+
1283
+ patch(`
1284
+ export default function App() {
1285
+ return <h1>C1</h1>;
1286
+ }
1287
+ `);
1288
+ // Reset (unwrapped).
1289
+ expect(container.firstChild).not.toBe(el);
1290
+ el = container.firstChild;
1291
+ expect(el.textContent).toBe('C1');
1292
+ patch(`
1293
+ export default function App() {
1294
+ return <h1>C2</h1>;
1295
+ }
1296
+ `);
1297
+ expect(container.firstChild).toBe(el);
1298
+ expect(el.textContent).toBe('C2');
1299
+ }
1300
+ });
1301
+
1302
describe('with inline requires', () => {
1303
beforeEach(() => {
1304
global.FakeModuleSystem = {};