@samitouri / QOS-React-2 / commits / 556cc6fe19

[Fresh] Generate signatures for Hooks (#15733)

* Generate signatures for Hooks This currently only works one level deep. For custom Hooks, we'll need to add some way to compose signatures. * Be more resilient to plugin conflicts This prevents a class of problems where other plugins cause our visitor to re-run. It's a standard Babel practice, e.g.: https://github.com/babel/babel/blob/8c7d4b55c99ff34cb9d493d452472e59b5ed1e70/packages/babel-plugin-transform-react-constant-elements/src/index.js#L85-L86 * Remove unnecessary stuff from debugging * Include Foo.useHookName() calls into the signature * Add an integration test for adding/removing an effect * Add integration test for changing custom Hook order * Include custom Hooks into the signatures * Fix inferred names for function expressions * Support export default hoc(Foo) when Foo is defined separately * Add more built-in Hooks

Dan Abramov committed May 29, 2019 at 14:15 UTC 556cc6fe1925b8d78f9089769acdf20afaedb281
6 files changed +1057 -69
packages/react-fresh/src/ReactFreshBabelPlugin.js
+336 -44
@@ -8,7 +8,7 @@
8 'use strict';
9
10 export default function(babel) {
11 - const {types: t, template} = babel;
11 + const {types: t} = babel;
12
13 const registrationsByProgramPath = new Map();
14 function createRegistration(programPath, persistentID) {
@@ -24,10 +24,6 @@ export default function(babel) {
24 return handle;
25 }
26
27 - const buildRegistrationCall = template(`
28 - __register__(HANDLE, PERSISTENT_ID);
29 - `);
30 -
27 function isComponentishName(name) {
28 return typeof name === 'string' && name[0] >= 'A' && name[0] <= 'Z';
29 }
@@ -35,6 +31,15 @@ export default function(babel) {
31 function findInnerComponents(inferredName, path, callback) {
32 const node = path.node;
33 switch (node.type) {
34 + case 'Identifier': {
35 + if (!isComponentishName(node.name)) {
36 + return false;
37 + }
38 + // export default hoc(Foo)
39 + // const X = hoc(Foo)
40 + callback(inferredName, node, null);
41 + return true;
42 + }
43 case 'FunctionDeclaration': {
44 // function Foo() {}
45 // export function Foo() {}
@@ -158,6 +163,80 @@ export default function(babel) {
163 return false;
164 }
165
166 + let hookCalls = new WeakMap();
167 +
168 + function recordHookCall(functionNode, hookCallPath, hookName) {
169 + if (!hookCalls.has(functionNode)) {
170 + hookCalls.set(functionNode, []);
171 + }
172 + let hookCallsForFn = hookCalls.get(functionNode);
173 + let key = '';
174 + if (hookCallPath.parent.type === 'VariableDeclarator') {
175 + // TODO: if there is no LHS, consider some other heuristic.
176 + key = hookCallPath.parentPath.get('id').getSource();
177 + }
178 + hookCallsForFn.push({
179 + name: hookName,
180 + callee: hookCallPath.node.callee,
181 + key,
182 + });
183 + }
184 +
185 + function isBuiltinHook(hookName) {
186 + switch (hookName) {
187 + case 'useState':
188 + case 'React.useState':
189 + case 'useReducer':
190 + case 'React.useReducer':
191 + case 'useEffect':
192 + case 'React.useEffect':
193 + case 'useLayoutEffect':
194 + case 'React.useLayoutEffect':
195 + case 'useMemo':
196 + case 'React.useMemo':
197 + case 'useCallback':
198 + case 'React.useCallback':
199 + case 'useRef':
200 + case 'React.useRef':
201 + case 'useContext':
202 + case 'React.useContext':
203 + case 'useImperativeMethods':
204 + case 'React.useImperativeMethods':
205 + case 'useDebugValue':
206 + case 'React.useDebugValue':
207 + return true;
208 + default:
209 + return false;
210 + }
211 + }
212 +
213 + function getHookCallsSignature(functionNode) {
214 + const fnHookCalls = hookCalls.get(functionNode);
215 + if (fnHookCalls === undefined) {
216 + return null;
217 + }
218 + return {
219 + key: fnHookCalls.map(call => call.name + '{' + call.key + '}').join('\n'),
220 + customHooks: fnHookCalls
221 + .filter(call => !isBuiltinHook(call.name))
222 + .map(call => call.callee),
223 + };
224 + }
225 +
226 + function createArgumentsForSignature(node, signature) {
227 + const {key, customHooks} = signature;
228 + const args = [node, t.stringLiteral(key)];
229 + if (customHooks.length > 0) {
230 + args.push(t.arrowFunctionExpression([], t.arrayExpression(customHooks)));
231 + }
232 + return args;
233 + }
234 +
235 + let seenForRegistration = new WeakSet();
236 + let seenForSignature = new WeakSet();
237 + let seenForHookCalls = new WeakSet();
238 + let seenForOutro = new WeakSet();
239 +
240 return {
241 visitor: {
242 ExportDefaultDeclaration(path) {
@@ -171,6 +250,15 @@ export default function(babel) {
250 // are currently ignored.
251 return;
252 }
253 +
254 + // Make sure we're not mutating the same tree twice.
255 + // This can happen if another Babel plugin replaces parents.
256 + if (seenForRegistration.has(node)) {
257 + return;
258 + }
259 + seenForRegistration.add(node);
260 + // Don't mutate the tree above this point.
261 +
262 // This code path handles nested cases like:
263 // export default memo(() => {})
264 // In those cases it is more plausible people will omit names
@@ -183,6 +271,13 @@ export default function(babel) {
271 inferredName,
272 declPath,
273 (persistentID, targetExpr, targetPath) => {
274 + if (targetPath === null) {
275 + // For case like:
276 + // export default hoc(Foo)
277 + // we don't want to wrap Foo inside the call.
278 + // Instead we assume it's registered at definition.
279 + return;
280 + }
281 const handle = createRegistration(programPath, persistentID);
282 targetPath.replaceWith(
283 t.assignmentExpression('=', handle, targetExpr),
@@ -190,7 +285,158 @@ export default function(babel) {
285 },
286 );
287 },
193 - FunctionDeclaration(path) {
288 + FunctionDeclaration: {
289 + enter(path) {
290 + const node = path.node;
291 + let programPath;
292 + let insertAfterPath;
293 + switch (path.parent.type) {
294 + case 'Program':
295 + insertAfterPath = path;
296 + programPath = path.parentPath;
297 + break;
298 + case 'ExportNamedDeclaration':
299 + insertAfterPath = path.parentPath;
300 + programPath = insertAfterPath.parentPath;
301 + break;
302 + case 'ExportDefaultDeclaration':
303 + insertAfterPath = path.parentPath;
304 + programPath = insertAfterPath.parentPath;
305 + break;
306 + default:
307 + return;
308 + }
309 + const id = node.id;
310 + if (id === null) {
311 + // We don't currently handle anonymous default exports.
312 + return;
313 + }
314 + const inferredName = id.name;
315 + if (!isComponentishName(inferredName)) {
316 + return;
317 + }
318 +
319 + // Make sure we're not mutating the same tree twice.
320 + // This can happen if another Babel plugin replaces parents.
321 + if (seenForRegistration.has(node)) {
322 + return;
323 + }
324 + seenForRegistration.add(node);
325 + // Don't mutate the tree above this point.
326 +
327 + // export function Named() {}
328 + // function Named() {}
329 + findInnerComponents(
330 + inferredName,
331 + path,
332 + (persistentID, targetExpr) => {
333 + const handle = createRegistration(programPath, persistentID);
334 + insertAfterPath.insertAfter(
335 + t.expressionStatement(
336 + t.assignmentExpression('=', handle, targetExpr),
337 + ),
338 + );
339 + },
340 + );
341 + },
342 + exit(path) {
343 + const node = path.node;
344 + const id = node.id;
345 + if (id === null) {
346 + return;
347 + }
348 + const signature = getHookCallsSignature(node);
349 + if (signature === null) {
350 + return;
351 + }
352 +
353 + // Make sure we're not mutating the same tree twice.
354 + // This can happen if another Babel plugin replaces parents.
355 + if (seenForSignature.has(node)) {
356 + return;
357 + }
358 + seenForSignature.add(node);
359 + // Don't muatte the tree above this point.
360 +
361 + // Unlike with __register__, this needs to work for nested
362 + // declarations too. So we need to search for a path where
363 + // we can insert a statement rather than hardcoding it.
364 + let insertAfterPath = null;
365 + path.find(p => {
366 + if (p.parentPath.isBlock()) {
367 + insertAfterPath = p;
368 + return true;
369 + }
370 + });
371 + if (insertAfterPath === null) {
372 + return;
373 + }
374 +
375 + insertAfterPath.insertAfter(
376 + t.expressionStatement(
377 + t.callExpression(
378 + t.identifier('__signature__'),
379 + createArgumentsForSignature(id, signature),
380 + ),
381 + ),
382 + );
383 + },
384 + },
385 + 'ArrowFunctionExpression|FunctionExpression': {
386 + exit(path) {
387 + const node = path.node;
388 + const signature = getHookCallsSignature(node);
389 + if (signature === null) {
390 + return;
391 + }
392 +
393 + // Make sure we're not mutating the same tree twice.
394 + // This can happen if another Babel plugin replaces parents.
395 + if (seenForSignature.has(node)) {
396 + return;
397 + }
398 + seenForSignature.add(node);
399 + // Don't mutate the tree above this point.
400 +
401 + if (path.parent.type === 'VariableDeclarator') {
402 + let insertAfterPath = null;
403 + path.find(p => {
404 + if (p.parentPath.isBlock()) {
405 + insertAfterPath = p;
406 + return true;
407 + }
408 + });
409 + if (insertAfterPath === null) {
410 + return;
411 + }
412 + // Special case when a function would get an inferred name:
413 + // let Foo = () => {}
414 + // let Foo = function() {}
415 + // We'll add signature it on next line so that
416 + // we don't mess up the inferred 'Foo' function name.
417 + insertAfterPath.insertAfter(
418 + t.expressionStatement(
419 + t.callExpression(
420 + t.identifier('__signature__'),
421 + createArgumentsForSignature(path.parent.id, signature),
422 + ),
423 + ),
424 + );
425 + // Result: let Foo = () => {}; __signature(Foo, ...);
426 + } else {
427 + // let Foo = hoc(() => {})
428 + path.replaceWith(
429 + t.callExpression(
430 + t.identifier('__signature__'),
431 + createArgumentsForSignature(node, signature),
432 + ),
433 + );
434 + // Result: let Foo = hoc(__signature(() => {}, ...))
435 + }
436 + },
437 + },
438 + VariableDeclaration(path) {
439 + const node = path.node;
440 let programPath;
441 let insertAfterPath;
442 switch (path.parent.type) {
@@ -209,39 +455,15 @@ export default function(babel) {
455 default:
456 return;
457 }
212 - const id = path.node.id;
213 - if (id === null) {
214 - // We don't currently handle anonymous default exports.
215 - return;
216 - }
217 - const inferredName = id.name;
218 - if (!isComponentishName(inferredName)) {
458 +
459 + // Make sure we're not mutating the same tree twice.
460 + // This can happen if another Babel plugin replaces parents.
461 + if (seenForRegistration.has(node)) {
462 return;
463 }
221 - // export function Named() {}
222 - // function Named() {}
223 - findInnerComponents(inferredName, path, (persistentID, targetExpr) => {
224 - const handle = createRegistration(programPath, persistentID);
225 - insertAfterPath.insertAfter(
226 - t.expressionStatement(
227 - t.assignmentExpression('=', handle, targetExpr),
228 - ),
229 - );
230 - });
231 - },
232 - VariableDeclaration(path) {
233 - let programPath;
234 - switch (path.parent.type) {
235 - case 'Program':
236 - programPath = path.parentPath;
237 - break;
238 - case 'ExportNamedDeclaration':
239 - case 'ExportDefaultDeclaration':
240 - programPath = path.parentPath.parentPath;
241 - break;
242 - default:
243 - return;
244 - }
464 + seenForRegistration.add(node);
465 + // Don't mutate the tree above this point.
466 +
467 const declPaths = path.get('declarations');
468 if (declPaths.length !== 1) {
469 return;
@@ -252,29 +474,99 @@ export default function(babel) {
474 inferredName,
475 declPath,
476 (persistentID, targetExpr, targetPath) => {
477 + if (targetPath === null) {
478 + // For case like:
479 + // export const Something = hoc(Foo)
480 + // we don't want to wrap Foo inside the call.
481 + // Instead we assume it's registered at definition.
482 + return;
483 + }
484 const handle = createRegistration(programPath, persistentID);
256 - targetPath.replaceWith(
257 - t.assignmentExpression('=', handle, targetExpr),
258 - );
485 + if (
486 + (targetExpr.type === 'ArrowFunctionExpression' ||
487 + targetExpr.type === 'FunctionExpression') &&
488 + targetPath.parent.type === 'VariableDeclarator'
489 + ) {
490 + // Special case when a function would get an inferred name:
491 + // let Foo = () => {}
492 + // let Foo = function() {}
493 + // We'll register it on next line so that
494 + // we don't mess up the inferred 'Foo' function name.
495 + insertAfterPath.insertAfter(
496 + t.expressionStatement(
497 + t.assignmentExpression('=', handle, declPath.node.id),
498 + ),
499 + );
500 + // Result: let Foo = () => {}; _c1 = Foo;
501 + } else {
502 + // let Foo = hoc(() => {})
503 + targetPath.replaceWith(
504 + t.assignmentExpression('=', handle, targetExpr),
505 + );
506 + // Result: let Foo = _c1 = hoc(() => {})
507 + }
508 },
509 );
510 },
511 + CallExpression(path) {
512 + const node = path.node;
513 + const callee = node.callee;
514 +
515 + let name = null;
516 + switch (callee.type) {
517 + case 'Identifier':
518 + name = callee.name;
519 + break;
520 + case 'MemberExpression':
521 + name = callee.property.name;
522 + break;
523 + }
524 + if (name === null || !/^use[A-Z]/.test(name)) {
525 + return;
526 + }
527 +
528 + // Make sure we're not recording the same calls twice.
529 + // This can happen if another Babel plugin replaces parents.
530 + if (seenForHookCalls.has(node)) {
531 + return;
532 + }
533 + seenForHookCalls.add(node);
534 + // Don't mutate the tree above this point.
535 +
536 + const fn = path.scope.getFunctionParent();
537 + if (fn === null) {
538 + return;
539 + }
540 + recordHookCall(fn.block, path, name);
541 + },
542 Program: {
543 exit(path) {
544 const registrations = registrationsByProgramPath.get(path);
545 if (registrations === undefined) {
546 return;
547 }
548 +
549 + // Make sure we're not mutating the same tree twice.
550 + // This can happen if another Babel plugin replaces parents.
551 + const node = path.node;
552 + if (seenForOutro.has(node)) {
553 + return;
554 + }
555 + seenForOutro.add(node);
556 + // Don't mutate the tree above this point.
557 +
558 registrationsByProgramPath.delete(path);
559 const declarators = [];
560 path.pushContainer('body', t.variableDeclaration('var', declarators));
561 registrations.forEach(({handle, persistentID}) => {
562 path.pushContainer(
563 'body',
274 - buildRegistrationCall({
275 - HANDLE: handle,
276 - PERSISTENT_ID: t.stringLiteral(persistentID),
277 - }),
564 + t.expressionStatement(
565 + t.callExpression(t.identifier('__register__'), [
566 + handle,
567 + t.stringLiteral(persistentID),
568 + ]),
569 + ),
570 );
571 declarators.push(t.variableDeclarator(handle));
572 });
packages/react-fresh/src/ReactFreshRuntime.js
+49 -8
@@ -14,11 +14,16 @@ import type {
14
15 import {REACT_MEMO_TYPE, REACT_FORWARD_REF_TYPE} from 'shared/ReactSymbols';
16
17 +type Signature = {|
18 + key: string,
19 + getCustomHooks: () => Array<Function>,
20 +|};
21 +
22 // We never remove these associations.
23 // It's OK to reference families, but use WeakMap/Set for types.
24 const allFamiliesByID: Map<string, Family> = new Map();
25 const allTypes: WeakSet<any> = new WeakSet();
21 -const allSignaturesByType: WeakMap<any, string> = new WeakMap();
26 +const allSignaturesByType: WeakMap<any, Signature> = new WeakMap();
27 // This WeakMap is read by React, so we only put families
28 // that have actually been edited here. This keeps checks fast.
29 const familiesByType: WeakMap<any, Family> = new WeakMap();
@@ -27,6 +32,37 @@ const familiesByType: WeakMap<any, Family> = new WeakMap();
32 // It is an array of [Family, NextType] tuples.
33 let pendingUpdates: Array<[Family, any]> = [];
34
35 +function haveEqualSignatures(prevType, nextType) {
36 + const prevSignature = allSignaturesByType.get(prevType);
37 + const nextSignature = allSignaturesByType.get(nextType);
38 +
39 + if (prevSignature === undefined && nextSignature === undefined) {
40 + return true;
41 + }
42 + if (prevSignature === undefined || nextSignature === undefined) {
43 + return false;
44 + }
45 + if (prevSignature.key !== nextSignature.key) {
46 + return false;
47 + }
48 +
49 + // TODO: we might need to calculate previous signature earlier in practice,
50 + // such as during the first time a component is resolved. We'll revisit this.
51 + const prevCustomHooks = prevSignature.getCustomHooks();
52 + const nextCustomHooks = nextSignature.getCustomHooks();
53 + if (prevCustomHooks.length !== nextCustomHooks.length) {
54 + return false;
55 + }
56 +
57 + for (let i = 0; i < nextCustomHooks.length; i++) {
58 + if (!haveEqualSignatures(prevCustomHooks[i], nextCustomHooks[i])) {
59 + return false;
60 + }
61 + }
62 +
63 + return true;
64 +}
65 +
66 export function prepareUpdate(): HotUpdate {
67 const staleFamilies = new Set();
68 const updatedFamilies = new Set();
@@ -42,12 +78,10 @@ export function prepareUpdate(): HotUpdate {
78 family.current = nextType;
79
80 // Determine whether this should be a re-render or a re-mount.
45 - const prevSignature = allSignaturesByType.get(prevType);
46 - const nextSignature = allSignaturesByType.get(nextType);
47 - if (prevSignature !== nextSignature) {
48 - staleFamilies.add(family);
49 - } else {
81 + if (haveEqualSignatures(prevType, nextType)) {
82 updatedFamilies.add(family);
83 + } else {
84 + staleFamilies.add(family);
85 }
86 });
87
@@ -98,6 +132,13 @@ export function register(type: any, id: string): void {
132 }
133 }
134
101 -export function setSignature(type: any, signature: string): void {
102 - allSignaturesByType.set(type, signature);
135 +export function setSignature(
136 + type: any,
137 + key: string,
138 + getCustomHooks?: () => Array<Function>,
139 +): void {
140 + allSignaturesByType.set(type, {
141 + key,
142 + getCustomHooks: getCustomHooks || (() => []),
143 + });
144 }
packages/react-fresh/src/__tests__/ReactFresh-test.js
+3 -2
@@ -72,8 +72,9 @@ describe('ReactFresh', () => {
72 ReactFreshRuntime.register(type, id);
73 }
74
75 - function __signature__(type, id) {
76 - ReactFreshRuntime.setSignature(type, id);
75 + function __signature__(type, key, getCustomHooks) {
76 + ReactFreshRuntime.setSignature(type, key, getCustomHooks);
77 + return type;
78 }
79
80 it('can preserve state for compatible types', () => {
packages/react-fresh/src/__tests__/ReactFreshBabelPlugin-test.js
+84
@@ -257,7 +257,9 @@ describe('ReactFreshBabelPlugin', () => {
257 }
258
259 const B = hoc(A);
260 + // This is currently registered as a false positive:
261 const NotAComponent = wow(A);
262 + // We could avoid it but it also doesn't hurt.
263 `),
264 ).toMatchSnapshot();
265 });
@@ -295,7 +297,89 @@ describe('ReactFreshBabelPlugin', () => {
297 React.createContext(Store);
298
299 const B = hoc(A);
300 + // This is currently registered as a false positive:
301 const NotAComponent = wow(A);
302 + // We could avoid it but it also doesn't hurt.
303 + `),
304 + ).toMatchSnapshot();
305 + });
306 +
307 + it('registers capitalized identifiers in HOC calls', () => {
308 + expect(
309 + transform(`
310 + function Foo() {
311 + return <h1>Hi</h1>;
312 + }
313 +
314 + export default hoc(Foo);
315 + export const A = hoc(Foo);
316 + const B = hoc(Foo);
317 + `),
318 + ).toMatchSnapshot();
319 + });
320 +
321 + it('generates signatures for function declarations calling hooks', () => {
322 + expect(
323 + transform(`
324 + export default function App() {
325 + const [foo, setFoo] = useState(0);
326 + React.useEffect(() => {});
327 + return <h1>{foo}</h1>;
328 + }
329 + `),
330 + ).toMatchSnapshot();
331 + });
332 +
333 + it('generates signatures for function expressions calling hooks', () => {
334 + // Unlike __register__, we want to sign all functions -- not just top level.
335 + // This lets us support editing HOCs better.
336 + // For function declarations, __signature__ is called on next line.
337 + // For function expressions, it wraps the expression.
338 + // In order for this to work, __signature__ returns its first argument.
339 + expect(
340 + transform(`
341 + export const A = React.memo(React.forwardRef((props, ref) => {
342 + const [foo, setFoo] = useState(0);
343 + React.useEffect(() => {});
344 + return <h1 ref={ref}>{foo}</h1>;
345 + }));
346 +
347 + export const B = React.memo(React.forwardRef(function(props, ref) {
348 + const [foo, setFoo] = useState(0);
349 + React.useEffect(() => {});
350 + return <h1 ref={ref}>{foo}</h1>;
351 + }));
352 +
353 + function hoc() {
354 + return function Inner() {
355 + const [foo, setFoo] = useState(0);
356 + React.useEffect(() => {});
357 + return <h1 ref={ref}>{foo}</h1>;
358 + };
359 + }
360 +
361 + export let C = hoc();
362 + `),
363 + ).toMatchSnapshot();
364 + });
365 +
366 + it('includes custom hooks into the signatures', () => {
367 + expect(
368 + transform(`
369 + function useFancyState() {
370 + const [foo, setFoo] = React.useState(0);
371 + useFancyEffect();
372 + return foo;
373 + }
374 +
375 + const useFancyEffect = () => {
376 + React.useEffect(() => {});
377 + };
378 +
379 + export default function App() {
380 + const bar = useFancyState();
381 + return <h1>{bar}</h1>;
382 + }
383 `),
384 ).toMatchSnapshot();
385 });
packages/react-fresh/src/__tests__/ReactFreshIntegration-test.js
+449 -2
@@ -57,10 +57,11 @@ describe('ReactFreshIntegration', () => {
57 }).code;
58 const exportsObj = {};
59 // eslint-disable-next-line no-new-func
60 - new Function('React', 'exports', '__register__', compiled)(
60 + new Function('React', 'exports', '__register__', '__signature__', compiled)(
61 React,
62 exportsObj,
63 __register__,
64 + __signature__,
65 );
66 return exportsObj.default;
67 }
@@ -75,13 +76,20 @@ describe('ReactFreshIntegration', () => {
76 function patch(source) {
77 execute(source);
78 const hotUpdate = ReactFreshRuntime.prepareUpdate();
78 - scheduleHotUpdate(lastRoot, hotUpdate);
79 + act(() => {
80 + scheduleHotUpdate(lastRoot, hotUpdate);
81 + });
82 }
83
84 function __register__(type, id) {
85 ReactFreshRuntime.register(type, id);
86 }
87
88 + function __signature__(type, key, getCustomHooks) {
89 + ReactFreshRuntime.setSignature(type, key, getCustomHooks);
90 + return type;
91 + }
92 +
93 it('reloads function declarations', () => {
94 if (__DEV__) {
95 render(`
@@ -244,4 +252,443 @@ describe('ReactFreshIntegration', () => {
252 expect(el.textContent).toBe('B2');
253 }
254 });
255 +
256 + it('resets state when renaming a state variable', () => {
257 + if (__DEV__) {
258 + render(`
259 + const {useState} = React;
260 +
261 + export default function App() {
262 + const [foo, setFoo] = useState(1);
263 + return <h1>A{foo}</h1>;
264 + }
265 + `);
266 + const el = container.firstChild;
267 + expect(el.textContent).toBe('A1');
268 +
269 + patch(`
270 + const {useState} = React;
271 +
272 + export default function App() {
273 + const [foo, setFoo] = useState('ignored');
274 + return <h1>B{foo}</h1>;
275 + }
276 + `);
277 + // Same state variable name, so state is preserved.
278 + expect(container.firstChild).toBe(el);
279 + expect(el.textContent).toBe('B1');
280 +
281 + patch(`
282 + const {useState} = React;
283 +
284 + export default function App() {
285 + const [bar, setBar] = useState(2);
286 + return <h1>C{bar}</h1>;
287 + }
288 + `);
289 + // Different state variable name, so state is reset.
290 + expect(container.firstChild).not.toBe(el);
291 + const newEl = container.firstChild;
292 + expect(newEl.textContent).toBe('C2');
293 + }
294 + });
295 +
296 + it('resets state when renaming a state variable in a HOC', () => {
297 + if (__DEV__) {
298 + render(`
299 + const {useState} = React;
300 +
301 + function hoc(Wrapped) {
302 + return function Generated() {
303 + const [foo, setFoo] = useState(1);
304 + return <Wrapped value={foo} />;
305 + };
306 + }
307 +
308 + export default hoc(({ value }) => {
309 + return <h1>A{value}</h1>;
310 + });
311 + `);
312 + const el = container.firstChild;
313 + expect(el.textContent).toBe('A1');
314 +
315 + patch(`
316 + const {useState} = React;
317 +
318 + function hoc(Wrapped) {
319 + return function Generated() {
320 + const [foo, setFoo] = useState('ignored');
321 + return <Wrapped value={foo} />;
322 + };
323 + }
324 +
325 + export default hoc(({ value }) => {
326 + return <h1>B{value}</h1>;
327 + });
328 + `);
329 + // Same state variable name, so state is preserved.
330 + expect(container.firstChild).toBe(el);
331 + expect(el.textContent).toBe('B1');
332 +
333 + patch(`
334 + const {useState} = React;
335 +
336 + function hoc(Wrapped) {
337 + return function Generated() {
338 + const [bar, setBar] = useState(2);
339 + return <Wrapped value={bar} />;
340 + };
341 + }
342 +
343 + export default hoc(({ value }) => {
344 + return <h1>C{value}</h1>;
345 + });
346 + `);
347 + // Different state variable name, so state is reset.
348 + expect(container.firstChild).not.toBe(el);
349 + const newEl = container.firstChild;
350 + expect(newEl.textContent).toBe('C2');
351 + }
352 + });
353 +
354 + it('resets state when renaming a state variable in a HOC with indirection', () => {
355 + if (__DEV__) {
356 + render(`
357 + const {useState} = React;
358 +
359 + function hoc(Wrapped) {
360 + return function Generated() {
361 + const [foo, setFoo] = useState(1);
362 + return <Wrapped value={foo} />;
363 + };
364 + }
365 +
366 + function Indirection({ value }) {
367 + return <h1>A{value}</h1>;
368 + }
369 +
370 + export default hoc(Indirection);
371 + `);
372 + const el = container.firstChild;
373 + expect(el.textContent).toBe('A1');
374 +
375 + patch(`
376 + const {useState} = React;
377 +
378 + function hoc(Wrapped) {
379 + return function Generated() {
380 + const [foo, setFoo] = useState('ignored');
381 + return <Wrapped value={foo} />;
382 + };
383 + }
384 +
385 + function Indirection({ value }) {
386 + return <h1>B{value}</h1>;
387 + }
388 +
389 + export default hoc(Indirection);
390 + `);
391 + // Same state variable name, so state is preserved.
392 + expect(container.firstChild).toBe(el);
393 + expect(el.textContent).toBe('B1');
394 +
395 + patch(`
396 + const {useState} = React;
397 +
398 + function hoc(Wrapped) {
399 + return function Generated() {
400 + const [bar, setBar] = useState(2);
401 + return <Wrapped value={bar} />;
402 + };
403 + }
404 +
405 + function Indirection({ value }) {
406 + return <h1>C{value}</h1>;
407 + }
408 +
409 + export default hoc(Indirection);
410 + `);
411 + // Different state variable name, so state is reset.
412 + expect(container.firstChild).not.toBe(el);
413 + const newEl = container.firstChild;
414 + expect(newEl.textContent).toBe('C2');
415 + }
416 + });
417 +
418 + it('resets effects while preserving state', () => {
419 + if (__DEV__) {
420 + render(`
421 + const {useState} = React;
422 +
423 + export default function App() {
424 + const [value, setValue] = useState(0);
425 + return <h1>A{value}</h1>;
426 + }
427 + `);
428 + let el = container.firstChild;
429 + expect(el.textContent).toBe('A0');
430 +
431 + // Add an effect.
432 + patch(`
433 + const {useState} = React;
434 +
435 + export default function App() {
436 + const [value, setValue] = useState(0);
437 + React.useEffect(() => {
438 + const id = setInterval(() => {
439 + setValue(v => v + 1);
440 + }, 1000);
441 + return () => clearInterval(id);
442 + }, []);
443 + return <h1>B{value}</h1>;
444 + }
445 + `);
446 + // We added an effect, thereby changing Hook order.
447 + // This causes a remount.
448 + expect(container.firstChild).not.toBe(el);
449 + el = container.firstChild;
450 + expect(el.textContent).toBe('B0');
451 +
452 + act(() => {
453 + jest.advanceTimersByTime(1000);
454 + });
455 + expect(el.textContent).toBe('B1');
456 +
457 + patch(`
458 + const {useState} = React;
459 +
460 + export default function App() {
461 + const [value, setValue] = useState(0);
462 + React.useEffect(() => {
463 + const id = setInterval(() => {
464 + setValue(v => v + 10);
465 + }, 1000);
466 + return () => clearInterval(id);
467 + }, []);
468 + return <h1>C{value}</h1>;
469 + }
470 + `);
471 + // Same Hooks are called, so state is preserved.
472 + expect(container.firstChild).toBe(el);
473 + expect(el.textContent).toBe('C1');
474 +
475 + // Effects are always reset, so timer was reinstalled.
476 + // The new version increments by 10 rather than 1.
477 + act(() => {
478 + jest.advanceTimersByTime(1000);
479 + });
480 + expect(el.textContent).toBe('C11');
481 +
482 + patch(`
483 + const {useState} = React;
484 +
485 + export default function App() {
486 + const [value, setValue] = useState(0);
487 + return <h1>D{value}</h1>;
488 + }
489 + `);
490 + // Removing the effect changes the signature
491 + // and causes the component to remount.
492 + expect(container.firstChild).not.toBe(el);
493 + el = container.firstChild;
494 + expect(el.textContent).toBe('D0');
495 + }
496 + });
497 +
498 + it('does not get confused when custom hooks are reordered', () => {
499 + if (__DEV__) {
500 + render(`
501 + function useFancyState(initialState) {
502 + return React.useState(initialState);
503 + }
504 +
505 + const App = () => {
506 + const [x, setX] = useFancyState('X');
507 + const [y, setY] = useFancyState('Y');
508 + return <h1>A{x}{y}</h1>;
509 + };
510 +
511 + export default App;
512 + `);
513 + let el = container.firstChild;
514 + expect(el.textContent).toBe('AXY');
515 +
516 + patch(`
517 + function useFancyState(initialState) {
518 + return React.useState(initialState);
519 + }
520 +
521 + const App = () => {
522 + const [x, setX] = useFancyState('X');
523 + const [y, setY] = useFancyState('Y');
524 + return <h1>B{x}{y}</h1>;
525 + };
526 +
527 + export default App;
528 + `);
529 + // Same state variables, so no remount.
530 + expect(container.firstChild).toBe(el);
531 + expect(el.textContent).toBe('BXY');
532 +
533 + patch(`
534 + function useFancyState(initialState) {
535 + return React.useState(initialState);
536 + }
537 +
538 + const App = () => {
539 + const [y, setY] = useFancyState('Y');
540 + const [x, setX] = useFancyState('X');
541 + return <h1>B{x}{y}</h1>;
542 + };
543 +
544 + export default App;
545 + `);
546 + // Hooks were re-ordered. This causes a remount.
547 + // Therefore, Hook calls don't accidentally share state.
548 + expect(container.firstChild).not.toBe(el);
549 + el = container.firstChild;
550 + expect(el.textContent).toBe('BXY');
551 + }
552 + });
553 +
554 + it('remounts component if custom hook it uses changes order', () => {
555 + if (__DEV__) {
556 + render(`
557 + const App = () => {
558 + const [x, setX] = useFancyState('X');
559 + const [y, setY] = useFancyState('Y');
560 + return <h1>A{x}{y}</h1>;
561 + };
562 +
563 + const useFancyState = (initialState) => {
564 + const result = useIndirection(initialState);
565 + return result;
566 + };
567 +
568 + function useIndirection(initialState) {
569 + return React.useState(initialState);
570 + }
571 +
572 + export default App;
573 + `);
574 + let el = container.firstChild;
575 + expect(el.textContent).toBe('AXY');
576 +
577 + patch(`
578 + const App = () => {
579 + const [x, setX] = useFancyState('X');
580 + const [y, setY] = useFancyState('Y');
581 + return <h1>B{x}{y}</h1>;
582 + };
583 +
584 + const useFancyState = (initialState) => {
585 + const result = useIndirection();
586 + return result;
587 + };
588 +
589 + function useIndirection(initialState) {
590 + return React.useState(initialState);
591 + }
592 +
593 + export default App;
594 + `);
595 + // We didn't change anything except the header text.
596 + // So we don't expect a remount.
597 + expect(container.firstChild).toBe(el);
598 + expect(el.textContent).toBe('BXY');
599 +
600 + patch(`
601 + const App = () => {
602 + const [x, setX] = useFancyState('X');
603 + const [y, setY] = useFancyState('Y');
604 + return <h1>C{x}{y}</h1>;
605 + };
606 +
607 + const useFancyState = (initialState) => {
608 + const result = useIndirection(initialState);
609 + return result;
610 + };
611 +
612 + function useIndirection(initialState) {
613 + React.useEffect(() => {});
614 + return React.useState(initialState);
615 + }
616 +
617 + export default App;
618 + `);
619 + // The useIndirection Hook added an affect,
620 + // so we had to remount the component.
621 + expect(container.firstChild).not.toBe(el);
622 + el = container.firstChild;
623 + expect(el.textContent).toBe('CXY');
624 +
625 + patch(`
626 + const App = () => {
627 + const [x, setX] = useFancyState('X');
628 + const [y, setY] = useFancyState('Y');
629 + return <h1>D{x}{y}</h1>;
630 + };
631 +
632 + const useFancyState = (initialState) => {
633 + const result = useIndirection();
634 + return result;
635 + };
636 +
637 + function useIndirection(initialState) {
638 + React.useEffect(() => {});
639 + return React.useState(initialState);
640 + }
641 +
642 + export default App;
643 + `);
644 + // We didn't change anything except the header text.
645 + // So we don't expect a remount.
646 + expect(container.firstChild).toBe(el);
647 + expect(el.textContent).toBe('DXY');
648 + }
649 + });
650 +
651 + it('does not lose the inferred arrow names', () => {
652 + if (__DEV__) {
653 + render(`
654 + const Parent = () => {
655 + return <Child/>;
656 + };
657 +
658 + const Child = () => {
659 + useMyThing();
660 + return <h1>{Parent.name} {Child.name} {useMyThing.name}</h1>;
661 + };
662 +
663 + const useMyThing = () => {
664 + React.useState();
665 + };
666 +
667 + export default Parent;
668 + `);
669 + expect(container.textContent).toBe('Parent Child useMyThing');
670 + }
671 + });
672 +
673 + it('does not lose the inferred function names', () => {
674 + if (__DEV__) {
675 + render(`
676 + var Parent = function() {
677 + return <Child/>;
678 + };
679 +
680 + var Child = function() {
681 + useMyThing();
682 + return <h1>{Parent.name} {Child.name} {useMyThing.name}</h1>;
683 + };
684 +
685 + var useMyThing = function() {
686 + React.useState();
687 + };
688 +
689 + export default Parent;
690 + `);
691 + expect(container.textContent).toBe('Parent Child useMyThing');
692 + }
693 + });
694 });
packages/react-fresh/src/__tests__/__snapshots__/ReactFreshBabelPlugin-test.js.snap
+136 -13
@@ -1,5 +1,61 @@
1 // Jest Snapshot v1, https://goo.gl/fbAQLP
2
3 +exports[`ReactFreshBabelPlugin generates signatures for function declarations calling hooks 1`] = `
4 +"
5 +export default function App() {
6 + const [foo, setFoo] = useState(0);
7 + React.useEffect(() => {});
8 + return <h1>{foo}</h1>;
9 +}
10 +
11 +__signature__(App, \\"useState{[foo, setFoo]}\\\\nuseEffect{}\\");
12 +
13 +_c = App;
14 +
15 +var _c;
16 +
17 +__register__(_c, \\"App\\");"
18 +`;
19 +
20 +exports[`ReactFreshBabelPlugin generates signatures for function expressions calling hooks 1`] = `
21 +"
22 +export const A = _c3 = React.memo(_c2 = React.forwardRef(_c = __signature__((props, ref) => {
23 + const [foo, setFoo] = useState(0);
24 + React.useEffect(() => {});
25 + return <h1 ref={ref}>{foo}</h1>;
26 +}, \\"useState{[foo, setFoo]}\\\\nuseEffect{}\\")));
27 +
28 +export const B = _c6 = React.memo(_c5 = React.forwardRef(_c4 = __signature__(function (props, ref) {
29 + const [foo, setFoo] = useState(0);
30 + React.useEffect(() => {});
31 + return <h1 ref={ref}>{foo}</h1>;
32 +}, \\"useState{[foo, setFoo]}\\\\nuseEffect{}\\")));
33 +
34 +function hoc() {
35 + return __signature__(function Inner() {
36 + const [foo, setFoo] = useState(0);
37 + React.useEffect(() => {});
38 + return <h1 ref={ref}>{foo}</h1>;
39 + }, \\"useState{[foo, setFoo]}\\\\nuseEffect{}\\");
40 +}
41 +
42 +export let C = hoc();
43 +
44 +var _c, _c2, _c3, _c4, _c5, _c6;
45 +
46 +__register__(_c, \\"A$React.memo$React.forwardRef\\");
47 +
48 +__register__(_c2, \\"A$React.memo\\");
49 +
50 +__register__(_c3, \\"A\\");
51 +
52 +__register__(_c4, \\"B$React.memo$React.forwardRef\\");
53 +
54 +__register__(_c5, \\"B$React.memo\\");
55 +
56 +__register__(_c6, \\"B\\");"
57 +`;
58 +
59 exports[`ReactFreshBabelPlugin ignores HOC definitions 1`] = `
60 "
61 let connect = () => {
@@ -51,6 +107,36 @@ exports[`ReactFreshBabelPlugin ignores unnamed function declarations 1`] = `
107 export default function () {}"
108 `;
109
110 +exports[`ReactFreshBabelPlugin includes custom hooks into the signatures 1`] = `
111 +"
112 +function useFancyState() {
113 + const [foo, setFoo] = React.useState(0);
114 + useFancyEffect();
115 + return foo;
116 +}
117 +
118 +__signature__(useFancyState, \\"useState{[foo, setFoo]}\\\\nuseFancyEffect{}\\", () => [useFancyEffect]);
119 +
120 +const useFancyEffect = () => {
121 + React.useEffect(() => {});
122 +};
123 +
124 +__signature__(useFancyEffect, \\"useEffect{}\\");
125 +
126 +export default function App() {
127 + const bar = useFancyState();
128 + return <h1>{bar}</h1>;
129 +}
130 +
131 +__signature__(App, \\"useFancyState{bar}\\", () => [useFancyState]);
132 +
133 +_c = App;
134 +
135 +var _c;
136 +
137 +__register__(_c, \\"App\\");"
138 +`;
139 +
140 exports[`ReactFreshBabelPlugin only registers pascal case functions 1`] = `
141 "
142 function hello() {
@@ -58,6 +144,28 @@ function hello() {
144 }"
145 `;
146
147 +exports[`ReactFreshBabelPlugin registers capitalized identifiers in HOC calls 1`] = `
148 +"
149 +function Foo() {
150 + return <h1>Hi</h1>;
151 +}
152 +
153 +_c = Foo;
154 +export default _c2 = hoc(Foo);
155 +export const A = _c3 = hoc(Foo);
156 +const B = _c4 = hoc(Foo);
157 +
158 +var _c, _c2, _c3, _c4;
159 +
160 +__register__(_c, \\"Foo\\");
161 +
162 +__register__(_c2, \\"%default%\\");
163 +
164 +__register__(_c3, \\"A\\");
165 +
166 +__register__(_c4, \\"B\\");"
167 +`;
168 +
169 exports[`ReactFreshBabelPlugin registers identifiers used in JSX at definition site 1`] = `
170 "
171 import A from './A';
@@ -78,15 +186,19 @@ function Foo() {
186
187 _c2 = Foo;
188 const B = _c3 = hoc(A);
81 -const NotAComponent = wow(A);
189 +// This is currently registered as a false positive:
190 +const NotAComponent = _c4 = wow(A);
191 +// We could avoid it but it also doesn't hurt.
192
83 -var _c, _c2, _c3;
193 +var _c, _c2, _c3, _c4;
194
195 __register__(_c, 'Header');
196
197 __register__(_c2, 'Foo');
198
89 -__register__(_c3, 'B');"
199 +__register__(_c3, 'B');
200 +
201 +__register__(_c4, 'NotAComponent');"
202 `;
203
204 exports[`ReactFreshBabelPlugin registers identifiers used in React.createElement at definition site 1`] = `
@@ -111,15 +223,19 @@ _c2 = Foo;
223 React.createContext(Store);
224
225 const B = _c3 = hoc(A);
114 -const NotAComponent = wow(A);
226 +// This is currently registered as a false positive:
227 +const NotAComponent = _c4 = wow(A);
228 +// We could avoid it but it also doesn't hurt.
229
116 -var _c, _c2, _c3;
230 +var _c, _c2, _c3, _c4;
231
232 __register__(_c, 'Header');
233
234 __register__(_c2, 'Foo');
235
122 -__register__(_c3, 'B');"
236 +__register__(_c3, 'B');
237 +
238 +__register__(_c4, 'NotAComponent');"
239 `;
240
241 exports[`ReactFreshBabelPlugin registers likely HOCs with inline functions 1`] = `
@@ -218,13 +334,15 @@ __register__(_c3, 'Baz');"
334
335 exports[`ReactFreshBabelPlugin registers top-level exported named arrow functions 1`] = `
336 "
221 -export const Hello = _c = () => {
337 +export const Hello = () => {
338 function handleClick() {}
339 return <h1 onClick={handleClick}>Hi</h1>;
340 };
341
226 -export let Bar = _c2 = props => <Hello />;
342 +_c = Hello;
343 +export let Bar = props => <Hello />;
344
345 +_c2 = Bar;
346 export default (() => {
347 // This one should be ignored.
348 // You should name your components.
@@ -260,14 +378,17 @@ __register__(_c2, \\"Bar\\");"
378
379 exports[`ReactFreshBabelPlugin registers top-level variable declarations with arrow functions 1`] = `
380 "
263 -let Hello = _c = () => {
381 +let Hello = () => {
382 const handleClick = () => {};
383 return <h1 onClick={handleClick}>Hi</h1>;
384 };
267 -const Bar = _c2 = () => {
385 +_c = Hello;
386 +const Bar = () => {
387 return <Hello />;
388 };
270 -var Baz = _c3 = () => <div />;
389 +_c2 = Bar;
390 +var Baz = () => <div />;
391 +_c3 = Baz;
392 var sum = () => {};
393
394 var _c, _c2, _c3;
@@ -281,13 +402,15 @@ __register__(_c3, \\"Baz\\");"
402
403 exports[`ReactFreshBabelPlugin registers top-level variable declarations with function expressions 1`] = `
404 "
284 -let Hello = _c = function () {
405 +let Hello = function () {
406 function handleClick() {}
407 return <h1 onClick={handleClick}>Hi</h1>;
408 };
288 -const Bar = _c2 = function Baz() {
409 +_c = Hello;
410 +const Bar = function Baz() {
411 return <Hello />;
412 };
413 +_c2 = Bar;
414 function sum() {}
415 let Baz = 10;
416 var Qux;