Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
58 changes: 57 additions & 1 deletion __tests__/edgeCases.test.jsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import React, { Component, useState } from 'react';
import React, {
Component,
useState,
StrictMode,
useEffect,
useRef,
} from 'react';
import {
render,
cleanup,
fireEvent,
act,
} from '@testing-library/react/pure';
// eslint-disable-next-line import/no-unresolved
import { view, store, batch } from '@risingstack/react-easy-state';
Expand Down Expand Up @@ -111,6 +118,55 @@ describe('edge cases', () => {
expect(RawChild.mock.calls.length).toBe(1);
});

test('should not perform an update on an unmounted component (Strict Mode)', () => {
const person = store({ name: 'Bob' });

function MyComp() {
const [showChild, setChild] = useState(true);
return (
<StrictMode>
<div>
<button
onClick={() => setChild(value => !value)}
type="button"
>
Toggle Child
</button>
{showChild && <Child />}
</div>
</StrictMode>
);
}

const RawChild = jest.fn().mockImplementation(function Child() {
const isMouted = useRef(false);
useEffect(() => {
isMouted.current = true;
}, []);
return <p>{person.name}</p>;
});
const Child = view(RawChild);

jest.spyOn(global.console, 'error');

const { container } = render(<MyComp />);

// Hide the Child component.
act(() => {
fireEvent.click(container.querySelector('button'));
});
// Show the Child component again.
act(() => {
fireEvent.click(container.querySelector('button'));
});
// Trigger Child update.
act(() => {
person.name = 'Ann';
});

expect(global.console.error.mock.calls.length).toBe(0);
});

test('view() should respect custom deriveStoresFromProps', () => {
const MyComp = view(
class extends Component {
Expand Down
42 changes: 35 additions & 7 deletions src/view.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,11 @@
import { Component, useState, useEffect, useMemo, memo } from 'react';
import {
Component,
useState,
useEffect,
useMemo,
memo,
useRef,
} from 'react';
import {
observe,
unobserve,
Expand Down Expand Up @@ -36,22 +43,43 @@ export function view(Comp) {
ReactiveComp = props => {
// use a dummy setState to update the component
const [, setState] = useState();
// use a ref to store the reaction
const reaction = useRef();
// create a memoized reactive wrapper of the original component (render)
// at the very first run of the component function
const render = useMemo(
() =>
observe(Comp, {
scheduler: () => setState({}),
() => {
reaction.current = observe(Comp, {
scheduler: () => {
// trigger a new rerender if the component has been mounted
if (reaction.current.mounted) setState({});
// mark it as changed if the component has not been mounted yet
else reaction.current.changedBeforeMounted = true;
},
lazy: true,
}),
});
// initilalize a flag to know if the component was finally mounted
reaction.current.mounted = false;
// initilalize a flag to know if the was reaction was invalidated
// before the component was mounted
reaction.current.changedBeforeMounted = false;
return reaction.current;
},
// Adding the original Comp here is necessary to make React Hot Reload work
// it does not affect behavior otherwise
[Comp],
);

// cleanup the reactive connections after the very last render of the component
useEffect(() => {
return () => unobserve(render);
// mark the component as mounted.
reaction.current.mounted = true;

// if there was a change before the component was mounted, trigger a
// new rerender
if (reaction.current.changedBeforeMounted) setState({});

// cleanup the reactive connections after the very last render of the
return () => unobserve(reaction.current);
}, []);

// the isInsideFunctionComponent flag is used to toggle `store` behavior
Expand Down