Unmount singleton hook if no consumers left

This commit is contained in:
Bartłomiej Głownia 2022-03-18 13:24:57 +01:00
parent 56b6933ea3
commit 319d3adf23
11 changed files with 194 additions and 4 deletions

View File

@ -19,7 +19,15 @@
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
"rules": {
"unicorn/filename-case": [
"error",
{
"case": "kebabCase",
"ignore": ["react-singleton-hook/**/*.js"]
}
]
}
}
],
"env": {

View File

@ -0,0 +1,19 @@
import { useLayoutEffect, useRef } from 'react';
export const SingleItemContainer = ({ initValue, useHookBody, applyStateChange }) => {
const lastState = useRef(initValue);
if (typeof useHookBody !== 'function') {
throw new Error(`function expected as hook body parameter. got ${typeof useHookBody}`);
}
const val = useHookBody();
//useLayoutEffect is safe from SSR perspective because SingleItemContainer should never be rendered on server
useLayoutEffect(() => {
if (lastState.current !== val) {
lastState.current = val;
applyStateChange(val);
}
}, [applyStateChange, val]);
return null;
};

View File

@ -0,0 +1,60 @@
import React, { useState, useEffect } from 'react';
import { SingleItemContainer } from './SingleItemContainer';
import { mount } from '../utils/env';
import { warning } from '../utils/warning';
let SingletonHooksContainerMounted = false;
let SingletonHooksContainerRendered = false;
let SingletonHooksContainerMountedAutomatically = false;
let mountQueue = [];
const mountIntoContainerDefault = (item) => {
mountQueue.push(item);
return () => {
mountQueue = mountQueue.filter(i => i !== item);
}
};
let mountIntoContainer = mountIntoContainerDefault;
export const SingletonHooksContainer = () => {
SingletonHooksContainerRendered = true;
useEffect(() => {
if (SingletonHooksContainerMounted) {
warning('SingletonHooksContainer is mounted second time. '
+ 'You should mount SingletonHooksContainer before any other component and never unmount it.'
+ 'Alternatively, dont use SingletonHooksContainer it at all, we will handle that for you.');
}
SingletonHooksContainerMounted = true;
}, []);
const [hooks, setHooks] = useState([]);
useEffect(() => {
mountIntoContainer = item => {
setHooks(hooks => [...hooks, item]);
return () => {
setHooks(hooks => hooks.filter(i => i !== item));
}
}
setHooks(mountQueue);
}, []);
return <>{hooks.map((h, i) => <SingleItemContainer {...h} key={i}/>)}</>;
};
export const addHook = hook => {
if (!SingletonHooksContainerRendered && !SingletonHooksContainerMountedAutomatically) {
SingletonHooksContainerMountedAutomatically = true;
mount(SingletonHooksContainer);
}
return mountIntoContainer(hook);
};
export const resetLocalStateForTests = () => {
SingletonHooksContainerMounted = false;
SingletonHooksContainerRendered = false;
SingletonHooksContainerMountedAutomatically = false;
mountQueue = [];
mountIntoContainer = mountIntoContainerDefault;
};

View File

@ -0,0 +1,14 @@
import { singletonHook } from './singletonHook';
import { SingletonHooksContainer } from './components/SingletonHooksContainer';
export {
singletonHook,
SingletonHooksContainer
};
const ReactSingletonHook = {
singletonHook,
SingletonHooksContainer
};
export default ReactSingletonHook;

View File

@ -0,0 +1,51 @@
import { useEffect, useState } from 'react';
import { addHook } from './components/SingletonHooksContainer';
import { batch } from './utils/env';
export const singletonHook = (initValue, useHookBody, unmount = false) => {
let mounted = false;
let removeHook = undefined
let initStateCalculated = false;
let lastKnownState = undefined;
let consumers = [];
const applyStateChange = (newState) => {
lastKnownState = newState;
batch(() => consumers.forEach(c => c(newState)));
};
const stateInitializer = () => {
if (!initStateCalculated) {
lastKnownState = typeof initValue === 'function' ? initValue() : initValue;
initStateCalculated = true;
}
return lastKnownState;
};
return () => {
const [state, setState] = useState(stateInitializer);
useEffect(() => {
if (!mounted) {
mounted = true;
removeHook = addHook({ initValue, useHookBody, applyStateChange });
}
consumers.push(setState);
if (lastKnownState !== state) {
setState(lastKnownState);
}
return () => {
consumers.splice(consumers.indexOf(setState), 1);
if (consumers.length === 0 && unmount) {
removeHook();
mounted = false;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return state;
};
};

View File

@ -0,0 +1,22 @@
import React from 'react';
/* eslint-disable import/no-unresolved */
import { unstable_batchedUpdates, render } from 'react-dom';
import { warning } from './warning';
// from https://github.com/purposeindustries/window-or-global/blob/master/lib/index.js
// avoid direct usage of 'window' because `window is not defined` error might happen in babel-node
const globalObject = (typeof self === 'object' && self.self === self && self)
|| (typeof global === 'object' && global.global === global && global)
|| this;
export const batch = cb => unstable_batchedUpdates(cb);
export const mount = C => {
if (globalObject.document && globalObject.document.createElement) {
render(<C/>, globalObject.document.createElement('div'));
} else {
warning('Can not mount SingletonHooksContainer on server side. '
+ 'Did you manage to run useEffect on server? '
+ 'Please mount SingletonHooksContainer into your components tree manually.');
}
};

View File

@ -0,0 +1,9 @@
/* eslint-disable import/no-unresolved */
import { unstable_batchedUpdates } from 'react-native';
import { warning } from './warning';
export const batch = cb => unstable_batchedUpdates(cb);
export const mount = C => {
warning('Can not mount SingletonHooksContainer with react native.'
+ 'Please mount SingletonHooksContainer into your components tree manually.');
};

View File

@ -0,0 +1,6 @@
export const warning = (message) => {
if (console && console.warn) {
console.warn(message);
}
};

View File

@ -76,7 +76,6 @@ export const useMarkets = (): UseMarkets => {
data: update,
};
}
return m;
});
});

View File

@ -22,4 +22,6 @@ const Markets = () => {
);
};
export default Markets;
const TwoMarkets = () => (<><div style={{height: '50%'}}><Markets /></div><div style={{height: '50%'}}><Markets /></div></>)
export default TwoMarkets;

View File

@ -6,7 +6,7 @@ describe('MarketListTable', () => {
it('should render successfully', () => {
const { baseElement } = render(
<MockedProvider>
<MarketListTable width={100} height={100} />
<MarketListTable />
</MockedProvider>
);
expect(baseElement).toBeTruthy();