Skip to content

Commit 5be8ef9

Browse files
committed
Repro for mismatch of changed hook and used hook
1 parent 9ffe910 commit 5be8ef9

File tree

1 file changed

+248
-0
lines changed

1 file changed

+248
-0
lines changed

packages/react-devtools-shared/src/__tests__/profilingCache-test.js

Lines changed: 248 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -844,6 +844,254 @@ describe('ProfilingCache', () => {
844844
}
845845
});
846846

847+
// @reactVersion >= 18.0
848+
it('should properly detect changed hooks #2', () => {
849+
// inlined `use-sync-external-store/with-selector`
850+
function useSyncExternalStoreWithSelector<Snapshot, Selection>(
851+
subscribe: (() => void) => () => void,
852+
getSnapshot: () => Snapshot,
853+
getServerSnapshot: void | null | (() => Snapshot),
854+
selector: (snapshot: Snapshot) => Selection,
855+
isEqual?: (a: Selection, b: Selection) => boolean,
856+
): Selection {
857+
// Use this to track the rendered snapshot.
858+
const instRef = React.useRef<
859+
| {
860+
hasValue: true,
861+
value: Selection,
862+
}
863+
| {
864+
hasValue: false,
865+
value: null,
866+
}
867+
| null,
868+
>(null);
869+
let inst;
870+
if (instRef.current === null) {
871+
inst = {
872+
hasValue: false,
873+
value: null,
874+
};
875+
instRef.current = inst;
876+
} else {
877+
inst = instRef.current;
878+
}
879+
880+
const [getSelection, getServerSelection] = React.useMemo(() => {
881+
// Track the memoized state using closure variables that are local to this
882+
// memoized instance of a getSnapshot function. Intentionally not using a
883+
// useRef hook, because that state would be shared across all concurrent
884+
// copies of the hook/component.
885+
let hasMemo = false;
886+
let memoizedSnapshot;
887+
let memoizedSelection: Selection;
888+
const memoizedSelector = (nextSnapshot: Snapshot) => {
889+
if (!hasMemo) {
890+
// The first time the hook is called, there is no memoized result.
891+
hasMemo = true;
892+
memoizedSnapshot = nextSnapshot;
893+
const nextSelection = selector(nextSnapshot);
894+
if (isEqual !== undefined) {
895+
// Even if the selector has changed, the currently rendered selection
896+
// may be equal to the new selection. We should attempt to reuse the
897+
// current value if possible, to preserve downstream memoizations.
898+
if (inst.hasValue) {
899+
const currentSelection = inst.value;
900+
if (isEqual(currentSelection, nextSelection)) {
901+
memoizedSelection = currentSelection;
902+
return currentSelection;
903+
}
904+
}
905+
}
906+
memoizedSelection = nextSelection;
907+
return nextSelection;
908+
}
909+
910+
// We may be able to reuse the previous invocation's result.
911+
const prevSnapshot: Snapshot = (memoizedSnapshot: any);
912+
const prevSelection: Selection = (memoizedSelection: any);
913+
914+
if (Object.is(prevSnapshot, nextSnapshot)) {
915+
// The snapshot is the same as last time. Reuse the previous selection.
916+
return prevSelection;
917+
}
918+
919+
// The snapshot has changed, so we need to compute a new selection.
920+
const nextSelection = selector(nextSnapshot);
921+
922+
// If a custom isEqual function is provided, use that to check if the data
923+
// has changed. If it hasn't, return the previous selection. That signals
924+
// to React that the selections are conceptually equal, and we can bail
925+
// out of rendering.
926+
if (isEqual !== undefined && isEqual(prevSelection, nextSelection)) {
927+
return prevSelection;
928+
}
929+
930+
memoizedSnapshot = nextSnapshot;
931+
memoizedSelection = nextSelection;
932+
return nextSelection;
933+
};
934+
// Assigning this to a constant so that Flow knows it can't change.
935+
const maybeGetServerSnapshot =
936+
getServerSnapshot === undefined ? null : getServerSnapshot;
937+
const getSnapshotWithSelector = () => memoizedSelector(getSnapshot());
938+
const getServerSnapshotWithSelector =
939+
maybeGetServerSnapshot === null
940+
? undefined
941+
: () => memoizedSelector(maybeGetServerSnapshot());
942+
return [getSnapshotWithSelector, getServerSnapshotWithSelector];
943+
}, [getSnapshot, getServerSnapshot, selector, isEqual]);
944+
945+
const value = React.useSyncExternalStore(
946+
subscribe,
947+
getSelection,
948+
getServerSelection,
949+
);
950+
951+
React.useEffect(() => {
952+
// $FlowFixMe[incompatible-type] changing the variant using mutation isn't supported
953+
inst.hasValue = true;
954+
// $FlowFixMe[incompatible-type]
955+
inst.value = value;
956+
}, [value]);
957+
958+
React.useDebugValue(value);
959+
return value;
960+
}
961+
962+
let reduxStore = {a: 0, b: 0};
963+
function getState() {
964+
return reduxStore;
965+
}
966+
967+
let syncExternalStoreCallback;
968+
function subscribe(callback) {
969+
syncExternalStoreCallback = callback;
970+
}
971+
972+
const ReactReduxContext = React.createContext(0);
973+
const DropTargetContext = React.createContext(0);
974+
const EditorContext = React.createContext(0);
975+
976+
const ResizableComponent = React.memo(function ResizableComponent(props) {
977+
// 0
978+
React.useRef(null);
979+
React.useContext(EditorContext);
980+
React.useContext(DropTargetContext);
981+
// useShowPropertyPane
982+
// useDispatch
983+
React.useContext(ReactReduxContext);
984+
// 1
985+
React.useCallback(() => {}, []);
986+
// useWidgetSelection
987+
// useDispatch
988+
React.useContext(ReactReduxContext);
989+
// 2
990+
React.useCallback(() => {}, []);
991+
// 3
992+
React.useCallback(() => {}, []);
993+
// useWidgetDragResize
994+
// useDispatch
995+
React.useContext(ReactReduxContext);
996+
// 4
997+
React.useCallback(() => {}, []);
998+
// 5
999+
React.useCallback(() => {}, []);
1000+
// useSelector
1001+
React.useContext(ReactReduxContext);
1002+
// 6
1003+
React.useRef();
1004+
// 7
1005+
React.useCallback();
1006+
// 8,9,10,11
1007+
const selectedState1 = useSyncExternalStoreWithSelector(
1008+
subscribe,
1009+
getState,
1010+
getState,
1011+
state => state.a,
1012+
);
1013+
React.useDebugValue(selectedState1);
1014+
// useSelector
1015+
React.useContext(ReactReduxContext);
1016+
// 12
1017+
React.useRef();
1018+
// 13
1019+
React.useCallback();
1020+
// 14,15,16,17
1021+
const selectedState2 = useSyncExternalStoreWithSelector(
1022+
subscribe,
1023+
getState,
1024+
getState,
1025+
state => state.a,
1026+
);
1027+
React.useDebugValue(selectedState2);
1028+
// useSelector
1029+
React.useContext(ReactReduxContext);
1030+
// 18
1031+
React.useRef();
1032+
// 19
1033+
React.useCallback();
1034+
// 20,21,22,23
1035+
const selectedState3 = useSyncExternalStoreWithSelector(
1036+
subscribe,
1037+
getState,
1038+
getState,
1039+
state => state.a,
1040+
);
1041+
React.useDebugValue(selectedState3);
1042+
// useSelector
1043+
React.useContext(ReactReduxContext);
1044+
// 24
1045+
React.useRef();
1046+
// 25
1047+
React.useCallback();
1048+
// 26,27,28,29
1049+
const valueThatChanges = useSyncExternalStoreWithSelector(
1050+
subscribe,
1051+
getState,
1052+
getState,
1053+
state => state.b,
1054+
);
1055+
React.useDebugValue(valueThatChanges);
1056+
1057+
return valueThatChanges;
1058+
});
1059+
1060+
utils.act(() => render(<ResizableComponent />));
1061+
1062+
utils.act(() => store.profilerStore.startProfiling());
1063+
1064+
utils.act(() => {
1065+
reduxStore = {...reduxStore, b: reduxStore.b + 1};
1066+
syncExternalStoreCallback();
1067+
});
1068+
1069+
utils.act(() => store.profilerStore.stopProfiling());
1070+
1071+
const rootID = store.roots[0];
1072+
1073+
const changeDescriptions = store.profilerStore
1074+
.getDataForRoot(rootID)
1075+
.commitData.map(commitData => commitData.changeDescriptions);
1076+
1077+
expect(changeDescriptions).toMatchInlineSnapshot(`
1078+
[
1079+
Map {
1080+
2 => {
1081+
"context": false,
1082+
"didHooksChange": true,
1083+
"hooks": [
1084+
31,
1085+
],
1086+
"isFirstMount": false,
1087+
"props": [],
1088+
"state": null,
1089+
},
1090+
},
1091+
]
1092+
`);
1093+
});
1094+
8471095
// @reactVersion >= 18.0
8481096
it('should calculate durations based on actual children (not filtered children)', () => {
8491097
store.componentFilters = [utils.createDisplayNameFilter('^Parent$')];

0 commit comments

Comments
 (0)