From 9d7b0c4df17f6e919f7f76e337664e61bae6749d Mon Sep 17 00:00:00 2001 From: Vadim Yanushkevich Date: Mon, 8 Aug 2022 15:58:23 +0300 Subject: [PATCH] React 18 migration and bugfixes --- README.md | 4 + package.json | 4 +- src/components/SingletonHooksContainer.js | 103 +++++++++++------- src/utils/env.js | 8 +- .../SingletonHooksContainer.spec.js | 14 ++- 5 files changed, 86 insertions(+), 47 deletions(-) diff --git a/README.md b/README.md index 05b5664..da809d6 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,10 @@ with a module bundler like [Webpack](https://webpack.js.org/) or [Browserify](http://browserify.org/) to consume [CommonJS modules](https://webpack.js.org/api/module-methods/#commonjs). +### React 18 breaking change +`react-singleton-hook` version 4.0.0 starts using new React DOM API and is only compatible with react 18. +Please use 3.x.x if you have to stay on lover React versions. + ## What is a singleton hook - Singleton hooks very similar to React Context in terms of functionality. Each singleton hook has a body, you might think of it as of Context Provider body. Hook has a return value, it's similar to the value provided by context. diff --git a/package.json b/package.json index bf9138b..b1696f5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-singleton-hook", - "version": "3.4.0", + "version": "4.0.0", "description": "Share custom hook state across all components", "keywords": [ "react", @@ -36,7 +36,7 @@ "ncu:apply": "ncu --reject react -u" }, "peerDependencies": { - "react": "15 - 18" + "react": "18" }, "peerDependenciesMeta": { "react-dom": { diff --git a/src/components/SingletonHooksContainer.js b/src/components/SingletonHooksContainer.js index 164abeb..118dd01 100644 --- a/src/components/SingletonHooksContainer.js +++ b/src/components/SingletonHooksContainer.js @@ -1,61 +1,86 @@ -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useRef } 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 nextKey = 1; +let automaticRender = false; +let manualRender = false; +const workingSet = []; +const renderedContainers = []; -let mountQueue = []; -const mountIntoContainerDefault = (item) => { - mountQueue.push(item); - return () => { - throw new Error('Can not unmount container! It is like a bug in react-singleton-hook library, because of unmountIfNoConsumers: true'); - // mountQueue = mountQueue.filter(i => i !== item); - }; +const notifyContainersAsync = () => { + renderedContainers.forEach(updateRenderedHooks => updateRenderedHooks()); }; -let mountIntoContainer = mountIntoContainerDefault; -export const SingletonHooksContainer = () => { - SingletonHooksContainerRendered = true; +export const SingletonHooksContainer = ({ automaticContainerInternalUseOnly }) => { + const [hooks, setHooks] = useState([]); + const currentHooksRef = useRef(); + currentHooksRef.current = hooks; + + // if there was no automaticRender, and this one is not automatic as well + if (!automaticContainerInternalUseOnly && automaticRender === false) { + manualRender = 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.'); + let mounted = true; + + function updateRenderedHooks() { + if (!mounted) return; + + if (renderedContainers[0] !== updateRenderedHooks) { + if (!automaticContainerInternalUseOnly && automaticRender === true) { + warning('SingletonHooksContainer is mounted after some singleton hook has been used.' + + 'Your SingletonHooksContainer will not be used in favor of internal one.'); + } + setHooks(_ => []); + return; + } + + setHooks([...workingSet]); } - SingletonHooksContainerMounted = true; - }, []); - const [hooks, setHooks] = useState([]); + renderedContainers.push(updateRenderedHooks); + notifyContainersAsync(); - useEffect(() => { - mountIntoContainer = item => { - setHooks(hooks => [...hooks, item]); - return () => { - setHooks(hooks => hooks.filter(i => i !== item)); - }; + return () => { + mounted = false; + + if (currentHooksRef.current.length > 0) { + warning('SingletonHooksContainer is unmounted, but it has active singleton hooks. ' + + 'They will be reevaluated once SingletonHooksContainer is mounted again'); + } + + renderedContainers.splice(renderedContainers.indexOf(updateRenderedHooks), 1); + notifyContainersAsync(); }; - setHooks(mountQueue); - }, []); + }, [automaticContainerInternalUseOnly]); - return <>{hooks.map((h, i) => )}; + return <>{hooks.map(({ hook, key }) => )}; }; - export const addHook = hook => { - if (!SingletonHooksContainerRendered && !SingletonHooksContainerMountedAutomatically) { - SingletonHooksContainerMountedAutomatically = true; + const key = nextKey++; + workingSet.push({ hook, key }); + + // no container and and no previous manually rendered containers + if (renderedContainers.length === 0 && manualRender === false) { + automaticRender = true; mount(SingletonHooksContainer); } - return mountIntoContainer(hook); + + notifyContainersAsync(); + + return () => { + workingSet.splice(workingSet.findIndex(h => h.key === key), 1); + notifyContainersAsync(); + }; }; export const resetLocalStateForTests = () => { - SingletonHooksContainerMounted = false; - SingletonHooksContainerRendered = false; - SingletonHooksContainerMountedAutomatically = false; - mountQueue = []; - mountIntoContainer = mountIntoContainerDefault; + automaticRender = false; + manualRender = false; + workingSet.splice(0, workingSet.length); + renderedContainers.splice(0, renderedContainers.length); }; diff --git a/src/utils/env.js b/src/utils/env.js index 6c539c9..ac93044 100644 --- a/src/utils/env.js +++ b/src/utils/env.js @@ -1,6 +1,6 @@ import React from 'react'; -/* eslint-disable import/no-unresolved */ -import { unstable_batchedUpdates, render } from 'react-dom'; +import { createRoot } from 'react-dom/client'; +import { unstable_batchedUpdates } from 'react-dom'; import { warning } from './warning'; // from https://github.com/purposeindustries/window-or-global/blob/master/lib/index.js @@ -13,7 +13,9 @@ const globalObject = (typeof self === 'object' && self.self === self && self) export const batch = cb => unstable_batchedUpdates(cb); export const mount = C => { if (globalObject.document && globalObject.document.createElement) { - render(, globalObject.document.createElement('div')); + const container = globalObject.document.createElement('div'); + const root = createRoot(container); + root.render(); } else { warning('Can not mount SingletonHooksContainer on server side. ' + 'Did you manage to run useEffect on server? ' diff --git a/test/components/SingletonHooksContainer.spec.js b/test/components/SingletonHooksContainer.spec.js index 410a63e..67274ba 100644 --- a/test/components/SingletonHooksContainer.spec.js +++ b/test/components/SingletonHooksContainer.spec.js @@ -12,16 +12,24 @@ describe('SingletonHooksContainer', () => { rtl.render(); }); - it('second mount prints a warning', () => { + it('mount after automatic mount prints a warning a warning', () => { let msg = ''; const spy = jest.spyOn(console, 'warn').mockImplementation(data => { msg += data; }); + + rtl.act(() => { + addHook({ + initValue: 'hello', + applyStateChange: (_) => { }, + useHookBody: () => { } + }); + }); + rtl.render(
-
); spy.mockRestore(); - expect(msg).toContain('SingletonHooksContainer is mounted second time'); + expect(msg).toContain('Your SingletonHooksContainer will not be used in favor of internal one.'); }); it('adds hooks to mounted container', () => {