Skip to content

Commit 31487dd

Browse files
authored
[Fresh] Set up initial scaffolding (#15619)
* Add a minimal failing test for hot reload * Set up scaffolding for React Fresh * Consider type family when comparing elementType Rendering an element with stale type should not cause it to remount. We only do this for FunctionComponent tag since checking is unnecessary for classes or host components. * Add support for forwardRef() Initially I thought I would compare families of inner .render functions. However, there is a corner case where this can create false positives. Such as when you forwardRef(X) the same X twice. Those are supposed to be distinct. But if we compare .render functions, we wouldn't be able to distinguish them after first reload. It seems safer to rely on explicit registration for those. This should be easy when forwardRef() call is in the same file, and usually it would be. For cases like HOCs and style.div`...` factories that return forwardRef(), we could have the __register__ helper itself "dig deeper" and register the inner function. * Show how forwardRef inner identity can be inferred The __register__ implementation can read the inner identity itself. * Add missing __DEV__ to tests * Add support for memo() (without fixing bailouts) This adds rudimentary support for memo components. However, we don't actually skip bailouts yet, so this is not very useful by itself alone. Tests have TODOs that we need to remove after bailout skipping is done. * Refactor type comparison for clarity * Hot update shouldn't re-render ancestor components unnecessarily My code had a bug where it checked for a wrong thing in a wrong set, leading us to always re-render. This fixes the checks so that we only schedule updates for things that were actually edited. * Add test coverage for memo(fn, areEqual) * Explicitly skip bailouts for hot reloading fibers This forces even memo() with shallow comparison to re-render on hot update. * Refactor scheduling update to reduce duplication * Remove unused variable in test * Don't check presence in a set while not hot reloading * Make scheduleHotUpdate() take named arguments * Don't keep unedited component types in the type => family map It's unnecessary because if they haven't been edited, there's no special reconciliation logic. * Add signatures that force remounting Signatures let us force a remount of a type even if from React's point of view, type is the same. A type has one current signature. If that signature changes during next hot update, all Fibers with that type should be deleted and remounted. We do this by mutating elementType scheduling a parent. This will be handy to force remount of mismatching Hooks, as well as failed error boundaries. For this to fully work, we'll need to add a way to skip built-in bailouts for all Fiber types. This will be the most invasive and annoying change. I did it for HostRoot in this PR but there's more. I'll add an automated test case that catches the missing bailout bailouts. * Support forced remounting for all component types This teaches all parent component types to remount their child if necessary. It also adds tests for them. * Remount effects while preserving state for hot reloaded components This makes sure that changes to *code* always propagate. It can break components that aren't resilient to useEffect over-firing, but that seems like a good constraint since you might need to add a dependency later anyway, and this helps avoid coding yourself into the corner. * Add missing __DEV__ blocks to tests * Fix unused variables in tests * Remove outdated TODO * Expose scheduleHotUpdate directly * Inline isCompatibleType * Run one check per component for invalidating deps This also makes the bailouts more targeted--no need to remount useEffect for a parent component of remounted fiber. * Resolve .type early This moves resolving to set up the right .type early instead of doing this before render. A bit more future-proof in case we want to restructure the begin phase later. ForwardRef is special because its type is a wrapper but it can be hot reloaded by itself. So we have a special overload for it that reconstucts the wrapper type if needed. * Add a Suspense todo * Use current.type !== workInProgress.type for ignoring deps This gets rid of one of the sets. * Use workInProgress.type !== current.type check for force re-render We still use a set for forced remount though. * Use wip.type !== current.type check in more places This also disables the remounting tests. They need a separate approach. * Use a dedicated remount mechanism * Add a test for offscreen trees It has a TODO because it seems like offscreen updates are incorrectly applied too soon. * Enable offscreen test now that it is fixed * Fix corner cases in the new remounting mechanism * Remount failed error boundaries on hot reload * Fix test now that act() flushes This test is manual so I don't actually want act here. * Nits * Add comments
1 parent 9c6de71 commit 31487dd

9 files changed

Lines changed: 2472 additions & 11 deletions

packages/react-dom/src/__tests__/ReactFresh-test.internal.js

Lines changed: 2036 additions & 0 deletions
Large diffs are not rendered by default.

packages/react-reconciler/src/ReactChildFiber.js

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ import {
4343
getCurrentFiberStackInDev,
4444
getStackByFiberInDevAndProd,
4545
} from './ReactCurrentFiber';
46+
import {isCompatibleFamilyForHotReloading} from './ReactFiberHotReloading';
4647
import {StrictMode} from './ReactTypeOfMode';
4748

4849
let didWarnAboutMaps;
@@ -378,7 +379,12 @@ function ChildReconciler(shouldTrackSideEffects) {
378379
element: ReactElement,
379380
expirationTime: ExpirationTime,
380381
): Fiber {
381-
if (current !== null && current.elementType === element.type) {
382+
if (
383+
current !== null &&
384+
(current.elementType === element.type ||
385+
// Keep this check inline so it only runs on the false path:
386+
(__DEV__ ? isCompatibleFamilyForHotReloading(current, element) : false))
387+
) {
382388
// Move based on index
383389
const existing = useFiber(current, element.props, expirationTime);
384390
existing.ref = coerceRef(returnFiber, current, element);
@@ -1121,7 +1127,11 @@ function ChildReconciler(shouldTrackSideEffects) {
11211127
if (
11221128
child.tag === Fragment
11231129
? element.type === REACT_FRAGMENT_TYPE
1124-
: child.elementType === element.type
1130+
: child.elementType === element.type ||
1131+
// Keep this check inline so it only runs on the false path:
1132+
(__DEV__
1133+
? isCompatibleFamilyForHotReloading(child, element)
1134+
: false)
11251135
) {
11261136
deleteRemainingChildren(returnFiber, child.sibling);
11271137
const existing = useFiber(

packages/react-reconciler/src/ReactFiber.js

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,13 +45,18 @@ import {
4545
SuspenseComponent,
4646
FunctionComponent,
4747
MemoComponent,
48+
SimpleMemoComponent,
4849
LazyComponent,
4950
EventComponent,
5051
EventTarget,
5152
} from 'shared/ReactWorkTags';
5253
import getComponentName from 'shared/getComponentName';
5354

5455
import {isDevToolsPresent} from './ReactFiberDevToolsHook';
56+
import {
57+
resolveFunctionForHotReloading,
58+
resolveForwardRefForHotReloading,
59+
} from './ReactFiberHotReloading';
5560
import {NoWork} from './ReactFiberExpirationTime';
5661
import {
5762
NoMode,
@@ -218,6 +223,7 @@ export type Fiber = {|
218223
_debugSource?: Source | null,
219224
_debugOwner?: Fiber | null,
220225
_debugIsCurrentlyTiming?: boolean,
226+
_debugNeedsRemount?: boolean,
221227

222228
// Used to verify that the order of hooks does not change between renders.
223229
_debugHookTypes?: Array<HookType> | null,
@@ -302,6 +308,7 @@ function FiberNode(
302308
this._debugSource = null;
303309
this._debugOwner = null;
304310
this._debugIsCurrentlyTiming = false;
311+
this._debugNeedsRemount = false;
305312
this._debugHookTypes = null;
306313
if (!hasBadMapPolyfill && typeof Object.preventExtensions === 'function') {
307314
Object.preventExtensions(this);
@@ -434,6 +441,22 @@ export function createWorkInProgress(
434441
workInProgress.treeBaseDuration = current.treeBaseDuration;
435442
}
436443

444+
if (__DEV__) {
445+
workInProgress._debugNeedsRemount = current._debugNeedsRemount;
446+
switch (workInProgress.tag) {
447+
case IndeterminateComponent:
448+
case FunctionComponent:
449+
case SimpleMemoComponent:
450+
workInProgress.type = resolveFunctionForHotReloading(current.type);
451+
break;
452+
case ForwardRef:
453+
workInProgress.type = resolveForwardRefForHotReloading(current.type);
454+
break;
455+
default:
456+
break;
457+
}
458+
}
459+
437460
return workInProgress;
438461
}
439462

@@ -473,6 +496,10 @@ export function createFiberFromTypeAndProps(
473496
if (typeof type === 'function') {
474497
if (shouldConstruct(type)) {
475498
fiberTag = ClassComponent;
499+
} else {
500+
if (__DEV__) {
501+
resolvedType = resolveFunctionForHotReloading(resolvedType);
502+
}
476503
}
477504
} else if (typeof type === 'string') {
478505
fiberTag = HostComponent;
@@ -509,6 +536,9 @@ export function createFiberFromTypeAndProps(
509536
break getTag;
510537
case REACT_FORWARD_REF_TYPE:
511538
fiberTag = ForwardRef;
539+
if (__DEV__) {
540+
resolvedType = resolveForwardRefForHotReloading(resolvedType);
541+
}
512542
break getTag;
513543
case REACT_MEMO_TYPE:
514544
fiberTag = MemoComponent;
@@ -777,6 +807,7 @@ export function assignFiberPropertiesInDEV(
777807
target._debugSource = source._debugSource;
778808
target._debugOwner = source._debugOwner;
779809
target._debugIsCurrentlyTiming = source._debugIsCurrentlyTiming;
810+
target._debugNeedsRemount = source._debugNeedsRemount;
780811
target._debugHookTypes = source._debugHookTypes;
781812
return target;
782813
}

packages/react-reconciler/src/ReactFiberBeginWork.js

Lines changed: 100 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ import {
7171
getCurrentFiberStackInDev,
7272
} from './ReactCurrentFiber';
7373
import {startWorkTimer, cancelWorkTimer} from './ReactDebugFiberPerf';
74+
import {resolveFunctionForHotReloading} from './ReactFiberHotReloading';
7475

7576
import {
7677
mountChildFibers,
@@ -358,18 +359,22 @@ function updateMemoComponent(
358359
// SimpleMemoComponent codepath doesn't resolve outer props either.
359360
Component.defaultProps === undefined
360361
) {
362+
let resolvedType = type;
363+
if (__DEV__) {
364+
resolvedType = resolveFunctionForHotReloading(type);
365+
}
361366
// If this is a plain function component without default props,
362367
// and with only the default shallow comparison, we upgrade it
363368
// to a SimpleMemoComponent to allow fast path updates.
364369
workInProgress.tag = SimpleMemoComponent;
365-
workInProgress.type = type;
370+
workInProgress.type = resolvedType;
366371
if (__DEV__) {
367372
validateFunctionComponentInDev(workInProgress, type);
368373
}
369374
return updateSimpleMemoComponent(
370375
current,
371376
workInProgress,
372-
type,
377+
resolvedType,
373378
nextProps,
374379
updateExpirationTime,
375380
renderExpirationTime,
@@ -486,7 +491,9 @@ function updateSimpleMemoComponent(
486491
const prevProps = current.memoizedProps;
487492
if (
488493
shallowEqual(prevProps, nextProps) &&
489-
current.ref === workInProgress.ref
494+
current.ref === workInProgress.ref &&
495+
// Prevent bailout if the implementation changed due to hot reload:
496+
(__DEV__ ? workInProgress.type === current.type : true)
490497
) {
491498
didReceiveUpdate = false;
492499
if (updateExpirationTime < renderExpirationTime) {
@@ -1023,6 +1030,9 @@ function mountLazyComponent(
10231030
// Cancel and resume right after we know the tag.
10241031
cancelWorkTimer(workInProgress);
10251032
let Component = readLazyComponentType(elementType);
1033+
if (__DEV__) {
1034+
// TODO: resolve type for hot reloading.
1035+
}
10261036
// Store the unwrapped component in the type.
10271037
workInProgress.type = Component;
10281038
const resolvedTag = (workInProgress.tag = resolveLazyComponentTag(Component));
@@ -2106,18 +2116,103 @@ function bailoutOnAlreadyFinishedWork(
21062116
}
21072117
}
21082118

2119+
function remountFiber(
2120+
current: Fiber,
2121+
oldWorkInProgress: Fiber,
2122+
newWorkInProgress: Fiber,
2123+
): Fiber | null {
2124+
if (__DEV__) {
2125+
const returnFiber = oldWorkInProgress.return;
2126+
if (returnFiber === null) {
2127+
throw new Error('Cannot swap the root fiber.');
2128+
}
2129+
2130+
// Disconnect from the old current.
2131+
// It will get deleted.
2132+
current.alternate = null;
2133+
oldWorkInProgress.alternate = null;
2134+
2135+
// Connect to the new tree.
2136+
newWorkInProgress.index = oldWorkInProgress.index;
2137+
newWorkInProgress.sibling = oldWorkInProgress.sibling;
2138+
newWorkInProgress.return = oldWorkInProgress.return;
2139+
2140+
// Replace the child/sibling pointers above it.
2141+
if (oldWorkInProgress === returnFiber.child) {
2142+
returnFiber.child = newWorkInProgress;
2143+
} else {
2144+
let prevSibling = returnFiber.child;
2145+
if (prevSibling === null) {
2146+
throw new Error('Expected parent to have a child.');
2147+
}
2148+
while (prevSibling.sibling !== oldWorkInProgress) {
2149+
prevSibling = prevSibling.sibling;
2150+
if (prevSibling === null) {
2151+
throw new Error('Expected to find the previous sibling.');
2152+
}
2153+
}
2154+
prevSibling.sibling = newWorkInProgress;
2155+
}
2156+
2157+
// Delete the old fiber and place the new one.
2158+
// Since the old fiber is disconnected, we have to schedule it manually.
2159+
const last = returnFiber.lastEffect;
2160+
if (last !== null) {
2161+
last.nextEffect = current;
2162+
returnFiber.lastEffect = current;
2163+
} else {
2164+
returnFiber.firstEffect = returnFiber.lastEffect = current;
2165+
}
2166+
current.nextEffect = null;
2167+
current.effectTag = Deletion;
2168+
2169+
newWorkInProgress.effectTag |= Placement;
2170+
2171+
// Restart work from the new fiber.
2172+
return newWorkInProgress;
2173+
} else {
2174+
throw new Error(
2175+
'Did not expect this call in production. ' +
2176+
'This is a bug in React. Please file an issue.',
2177+
);
2178+
}
2179+
}
2180+
21092181
function beginWork(
21102182
current: Fiber | null,
21112183
workInProgress: Fiber,
21122184
renderExpirationTime: ExpirationTime,
21132185
): Fiber | null {
21142186
const updateExpirationTime = workInProgress.expirationTime;
21152187

2188+
if (__DEV__) {
2189+
if (workInProgress._debugNeedsRemount && current !== null) {
2190+
// This will restart the begin phase with a new fiber.
2191+
return remountFiber(
2192+
current,
2193+
workInProgress,
2194+
createFiberFromTypeAndProps(
2195+
workInProgress.type,
2196+
workInProgress.key,
2197+
workInProgress.pendingProps,
2198+
workInProgress._debugOwner || null,
2199+
workInProgress.mode,
2200+
workInProgress.expirationTime,
2201+
),
2202+
);
2203+
}
2204+
}
2205+
21162206
if (current !== null) {
21172207
const oldProps = current.memoizedProps;
21182208
const newProps = workInProgress.pendingProps;
21192209

2120-
if (oldProps !== newProps || hasLegacyContextChanged()) {
2210+
if (
2211+
oldProps !== newProps ||
2212+
hasLegacyContextChanged() ||
2213+
// Force a re-render if the implementation changed due to hot reload:
2214+
(__DEV__ ? workInProgress.type !== current.type : false)
2215+
) {
21212216
// If props or context changed, mark the fiber as having performed work.
21222217
// This may be unset if the props are determined to be equal later (memo).
21232218
didReceiveUpdate = true;
@@ -2256,11 +2351,10 @@ function beginWork(
22562351

22572352
switch (workInProgress.tag) {
22582353
case IndeterminateComponent: {
2259-
const elementType = workInProgress.elementType;
22602354
return mountIndeterminateComponent(
22612355
current,
22622356
workInProgress,
2263-
elementType,
2357+
workInProgress.type,
22642358
renderExpirationTime,
22652359
);
22662360
}

packages/react-reconciler/src/ReactFiberHooks.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,11 @@ let currentHookNameInDev: ?HookType = null;
189189
let hookTypesDev: Array<HookType> | null = null;
190190
let hookTypesUpdateIndexDev: number = -1;
191191

192+
// In DEV, this tracks whether currently rendering component needs to ignore
193+
// the dependencies for Hooks that need them (e.g. useEffect or useMemo).
194+
// When true, such Hooks will always be "remounted". Only used during hot reload.
195+
let ignorePreviousDependencies: boolean = false;
196+
192197
function mountHookTypesDev() {
193198
if (__DEV__) {
194199
const hookName = ((currentHookNameInDev: any): HookType);
@@ -296,6 +301,13 @@ function areHookInputsEqual(
296301
nextDeps: Array<mixed>,
297302
prevDeps: Array<mixed> | null,
298303
) {
304+
if (__DEV__) {
305+
if (ignorePreviousDependencies) {
306+
// Only true when this component is being hot reloaded.
307+
return false;
308+
}
309+
}
310+
299311
if (prevDeps === null) {
300312
if (__DEV__) {
301313
warning(
@@ -352,6 +364,9 @@ export function renderWithHooks(
352364
? ((current._debugHookTypes: any): Array<HookType>)
353365
: null;
354366
hookTypesUpdateIndexDev = -1;
367+
// Used for hot reloading:
368+
ignorePreviousDependencies =
369+
current !== null && current.type !== workInProgress.type;
355370
}
356371

357372
// The following should have already been reset

0 commit comments

Comments
 (0)