Skip to content

Commit

Permalink
Fix race condition in useReactiveVar (#10072)
Browse files Browse the repository at this point in the history
Co-authored-by: Ben Newman <[email protected]>
Co-authored-by: Jerel Miller <[email protected]>
  • Loading branch information
3 people authored Aug 31, 2023
1 parent d414de7 commit 51045c3
Show file tree
Hide file tree
Showing 3 changed files with 78 additions and 21 deletions.
5 changes: 5 additions & 0 deletions .changeset/angry-llamas-walk.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@apollo/client": patch
---

Fixes race conditions in useReactiveVar that may prevent updates to the reactive variable from propagating through the hook.
54 changes: 54 additions & 0 deletions src/react/hooks/__tests__/useReactiveVar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -323,5 +323,59 @@ describe("useReactiveVar Hook", () => {
resolve();
}
);

itAsync(
"should survive many rerenderings despite racing asynchronous updates",
(resolve, reject) => {
const rv = makeVar(0);

function App() {
const value = useReactiveVar(rv);
return (
<div className="App">
<h1>{value}</h1>
</div>
);
}

const goalCount = 1000;
let updateCount = 0;
let stopped = false;

function spam() {
if (stopped) return;
try {
if (++updateCount <= goalCount) {
act(() => {
rv(updateCount);
setTimeout(spam, Math.random() * 10);
});
} else {
stopped = true;
expect(rv()).toBe(goalCount);
screen
.findByText(String(goalCount))
.then((element) => {
expect(element.nodeName.toLowerCase()).toBe("h1");
})
.then(resolve, reject);
}
} catch (e) {
stopped = true;
reject(e);
}
}
spam();
spam();
spam();
spam();

render(
<StrictMode>
<App />
</StrictMode>
);
}
);
});
});
40 changes: 19 additions & 21 deletions src/react/hooks/useReactiveVar.ts
Original file line number Diff line number Diff line change
@@ -1,26 +1,24 @@
import * as React from "react";
import type { ReactiveVar } from "../../core/index.js";
import { useSyncExternalStore } from "./useSyncExternalStore.js";

export function useReactiveVar<T>(rv: ReactiveVar<T>): T {
const value = rv();

// We don't actually care what useState thinks the value of the variable
// is, so we take only the update function from the returned array.
const setValue = React.useState(value)[1];

// We subscribe to variable updates on initial mount and when the value has
// changed. This avoids a subtle bug in React.StrictMode where multiple
// listeners are added, leading to inconsistent updates.
React.useEffect(() => {
const probablySameValue = rv();
if (value !== probablySameValue) {
// If the value of rv has already changed, we don't need to listen for the
// next change, because we can report this change immediately.
setValue(probablySameValue);
} else {
return rv.onNextChange(setValue);
}
}, [value]);

return value;
return useSyncExternalStore(
React.useCallback(
(update) => {
// By reusing the same onNext function in the nested call to
// rv.onNextChange(onNext), we can keep using the initial clean-up function
// returned by rv.onNextChange(function onNext(v){...}), without having to
// register the new clean-up function (returned by the nested
// rv.onNextChange(onNext)) with yet another callback.
return rv.onNextChange(function onNext() {
update();
rv.onNextChange(onNext);
});
},
[rv]
),
rv,
rv
);
}

0 comments on commit 51045c3

Please sign in to comment.