Skip to content

Commit

Permalink
Implement useReactiveVar hook.
Browse files Browse the repository at this point in the history
Closes #6859.
  • Loading branch information
benjamn committed Aug 20, 2020
1 parent 45179b9 commit 2f494c8
Show file tree
Hide file tree
Showing 4 changed files with 202 additions and 0 deletions.
3 changes: 3 additions & 0 deletions src/__tests__/__snapshots__/exports.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ Array [
"useLazyQuery",
"useMutation",
"useQuery",
"useReactiveVar",
"useSubscription",
]
`;
Expand Down Expand Up @@ -216,6 +217,7 @@ Array [
"useLazyQuery",
"useMutation",
"useQuery",
"useReactiveVar",
"useSubscription",
]
`;
Expand Down Expand Up @@ -262,6 +264,7 @@ Array [
"useLazyQuery",
"useMutation",
"useQuery",
"useReactiveVar",
"useSubscription",
]
`;
Expand Down
183 changes: 183 additions & 0 deletions src/react/hooks/__tests__/useReactiveVar.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import React from "react";
import { render, wait, act } from "@testing-library/react";

import { itAsync } from "../../../testing";
import { makeVar } from "../../../core";
import { useReactiveVar } from "../useReactiveVar";

describe("useReactiveVar Hook", () => {
itAsync("works with one component", (resolve, reject) => {
const counterVar = makeVar(0);
let renderCount = 0;

function Component() {
const count = useReactiveVar(counterVar);

switch (++renderCount) {
case 1:
expect(count).toBe(0);
act(() => {
counterVar(count + 1);
});
break;
case 2:
expect(count).toBe(1);
act(() => {
counterVar(counterVar() + 2);
});
break;
case 3:
expect(count).toBe(3);
break;
default:
reject(`too many (${renderCount}) renders`);
}

return null;
}

render(<Component/>);

return wait(() => {
expect(renderCount).toBe(3);
expect(counterVar()).toBe(3);
}).then(resolve, reject);
});

itAsync("works when two components share a variable", async (resolve, reject) => {
const counterVar = makeVar(0);

let parentRenderCount = 0;
function Parent() {
const count = useReactiveVar(counterVar);

switch (++parentRenderCount) {
case 1:
expect(count).toBe(0);
break;
case 2:
expect(count).toBe(1);
break;
case 3:
expect(count).toBe(11);
break;
default:
reject(`too many (${parentRenderCount}) parent renders`);
}

return <Child/>;
}

let childRenderCount = 0;
function Child() {
const count = useReactiveVar(counterVar);

switch (++childRenderCount) {
case 1:
expect(count).toBe(0);
break;
case 2:
expect(count).toBe(1);
break;
case 3:
expect(count).toBe(11);
break;
default:
reject(`too many (${childRenderCount}) child renders`);
}

return null;
}

render(<Parent/>);

await wait(() => {
expect(parentRenderCount).toBe(1);
expect(childRenderCount).toBe(1);
});

expect(counterVar()).toBe(0);
act(() => {
counterVar(1);
});

await wait(() => {
expect(parentRenderCount).toBe(2);
expect(childRenderCount).toBe(2);
});

expect(counterVar()).toBe(1);
act(() => {
counterVar(counterVar() + 10);
});

await wait(() => {
expect(parentRenderCount).toBe(3);
expect(childRenderCount).toBe(3);
});

expect(counterVar()).toBe(11);

resolve();
});

itAsync("does not update if component has been unmounted", (resolve, reject) => {
const counterVar = makeVar(0);
let renderCount = 0;
let attemptedUpdateAfterUnmount = false;

function Component() {
const count = useReactiveVar(counterVar);

switch (++renderCount) {
case 1:
expect(count).toBe(0);
act(() => {
counterVar(count + 1);
});
break;
case 2:
expect(count).toBe(1);
act(() => {
counterVar(counterVar() + 2);
});
break;
case 3:
expect(count).toBe(3);
setTimeout(() => {
unmount();
setTimeout(() => {
counterVar(counterVar() * 2);
attemptedUpdateAfterUnmount = true;
}, 10);
}, 10);
break;
default:
reject(`too many (${renderCount}) renders`);
}

return null;
}

// To detect updates of unmounted components, we have to monkey-patch
// the console.error method.
const consoleErrorArgs: any[][] = [];
const { error } = console;
console.error = function (...args: any[]) {
consoleErrorArgs.push(args);
return error.apply(this, args);
};

const { unmount } = render(<Component/>);

return wait(() => {
expect(attemptedUpdateAfterUnmount).toBe(true);
}).then(() => {
expect(renderCount).toBe(3);
expect(counterVar()).toBe(6);
expect(consoleErrorArgs).toEqual([]);
}).finally(() => {
console.error = error;
}).then(resolve, reject);
});
});
1 change: 1 addition & 0 deletions src/react/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './useLazyQuery';
export * from './useMutation';
export * from './useQuery';
export * from './useSubscription';
export * from './useReactiveVar';
15 changes: 15 additions & 0 deletions src/react/hooks/useReactiveVar.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { useState, useEffect } from 'react';
import { ReactiveVar } from '../../core';

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 mute = rv.onNextChange(useState(value)[1]);
// Once the component is unmounted, ignore future updates. Note that the
// useEffect function returns the mute function without calling it,
// allowing it to be called when the component unmounts. This is
// equivalent to useEffect(() => () => mute(), []), but shorter.
useEffect(() => mute, []);
return value;
}

1 comment on commit 2f494c8

@benjamn

This comment was marked as resolved.

Please sign in to comment.