@dhis2/app-service-offline 3.17.0 → 3.17.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/.gitignore +5 -0
  2. package/build/cjs/declarations.d.js +1 -0
  3. package/build/es/declarations.d.js +1 -0
  4. package/d2.config.js +9 -0
  5. package/jest.config.js +14 -0
  6. package/package.json +3 -3
  7. package/src/__tests__/integration.test.tsx +341 -0
  8. package/src/declarations.d.ts +1 -0
  9. package/src/index.ts +12 -0
  10. package/src/lib/__tests__/cacheable-section-state.test.tsx +45 -0
  11. package/src/lib/__tests__/clear-sensitive-caches.test.ts +182 -0
  12. package/src/lib/__tests__/network-status.test.tsx +496 -0
  13. package/src/lib/__tests__/offline-provider.test.tsx +116 -0
  14. package/src/lib/__tests__/use-cacheable-section.test.tsx +280 -0
  15. package/src/lib/__tests__/use-online-status-message.test.tsx +27 -0
  16. package/src/lib/cacheable-section-state.tsx +269 -0
  17. package/src/lib/cacheable-section.tsx +193 -0
  18. package/src/lib/clear-sensitive-caches.ts +92 -0
  19. package/src/lib/dhis2-connection-status/dev-debug-log.ts +20 -0
  20. package/src/lib/dhis2-connection-status/dhis2-connection-status.test.tsx +947 -0
  21. package/src/lib/dhis2-connection-status/dhis2-connection-status.tsx +241 -0
  22. package/src/lib/dhis2-connection-status/index.ts +4 -0
  23. package/src/lib/dhis2-connection-status/is-ping-available.test.ts +32 -0
  24. package/src/lib/dhis2-connection-status/is-ping-available.ts +31 -0
  25. package/src/lib/dhis2-connection-status/smart-interval.ts +206 -0
  26. package/src/lib/dhis2-connection-status/use-ping-query.ts +14 -0
  27. package/src/lib/global-state-service.tsx +110 -0
  28. package/src/lib/network-status.ts +80 -0
  29. package/src/lib/offline-interface.tsx +57 -0
  30. package/src/lib/offline-provider.tsx +43 -0
  31. package/src/lib/online-status-message.tsx +47 -0
  32. package/src/setupRTL.ts +1 -0
  33. package/src/types.ts +66 -0
  34. package/src/utils/__tests__/render-counter.test.tsx +45 -0
  35. package/src/utils/render-counter.tsx +22 -0
  36. package/src/utils/test-mocks.ts +47 -0
  37. package/tsconfig.json +10 -0
@@ -0,0 +1,241 @@
1
+ import { useConfig } from '@dhis2/app-service-config'
2
+ import { throttle } from 'lodash'
3
+ import PropTypes from 'prop-types'
4
+ import React, {
5
+ useCallback,
6
+ useState,
7
+ useRef,
8
+ useMemo,
9
+ useEffect,
10
+ useContext,
11
+ } from 'react'
12
+ import { useOfflineInterface } from '../offline-interface'
13
+ import { devDebugLog } from './dev-debug-log'
14
+ import { isPingAvailable } from './is-ping-available'
15
+ import createSmartInterval, { SmartInterval } from './smart-interval'
16
+ import { usePingQuery } from './use-ping-query'
17
+
18
+ // Utils for saving 'last connected' datetime in local storage
19
+ const lastConnectedKey = 'dhis2.lastConnected'
20
+ type AppName = string | undefined
21
+ export const getLastConnectedKey = (appName?: AppName) =>
22
+ appName ? `${lastConnectedKey}.${appName}` : lastConnectedKey
23
+ const updateLastConnected = (appName: AppName) => {
24
+ // use Date.now() because it's easier to mock for easier unit testing
25
+ const now = new Date(Date.now())
26
+ localStorage.setItem(getLastConnectedKey(appName), now.toUTCString())
27
+ return now
28
+ }
29
+ const getLastConnected = (appName: AppName) => {
30
+ const lastConnected = localStorage.getItem(getLastConnectedKey(appName))
31
+ return lastConnected ? new Date(lastConnected) : null
32
+ }
33
+ const clearLastConnected = (appName: AppName) => {
34
+ localStorage.removeItem(getLastConnectedKey(appName))
35
+ }
36
+
37
+ export interface Dhis2ConnectionStatus {
38
+ isConnected: boolean
39
+ isDisconnected: boolean
40
+ lastConnected: Date | null
41
+ }
42
+
43
+ const Dhis2ConnectionStatusContext = React.createContext({
44
+ isConnected: true,
45
+ isDisconnected: false,
46
+ lastConnected: null,
47
+ } as Dhis2ConnectionStatus)
48
+
49
+ /**
50
+ * Provides a boolean indicating client's connection to the DHIS2 server,
51
+ * which is different from connection to the internet.
52
+ *
53
+ * The context provider subscribes to messages from the SW tracking successes
54
+ * and failures of requests to the DHIS2 server to determine connection status,
55
+ * and then will initiate periodic pings if there are no incidental requests in
56
+ * order to check the connection consistently
57
+ */
58
+ export const Dhis2ConnectionStatusProvider = ({
59
+ children,
60
+ }: {
61
+ children: React.ReactNode
62
+ }): JSX.Element => {
63
+ const offlineInterface = useOfflineInterface()
64
+ const { appName, serverVersion } = useConfig()
65
+ // The offline interface persists the latest update from the SW so that
66
+ // this hook can initialize to an accurate value. The App Adapter in the
67
+ // platform waits for this value to be populated before rendering the
68
+ // the App Runtime provider (including this), but if that is not done,
69
+ // `latestIsConnected` may be `null` depending on the outcome of race
70
+ // conditions between the SW and the React component tree.
71
+ const [isConnected, setIsConnected] = useState(
72
+ offlineInterface.latestIsConnected
73
+ )
74
+ const ping = usePingQuery()
75
+ const smartIntervalRef = useRef(null as null | SmartInterval)
76
+
77
+ /**
78
+ * Update state, reset ping backoff if changed, and update
79
+ * the lastConnected value in localStorage
80
+ */
81
+ const updateConnectedState = useCallback(
82
+ (newIsConnected: boolean | null) => {
83
+ // use 'set' with a function as param to get latest isConnected
84
+ // without needing it as a dependency for useCallback
85
+ setIsConnected((prevIsConnected) => {
86
+ devDebugLog('[D2CS] updating state:', {
87
+ prevIsConnected,
88
+ newIsConnected,
89
+ })
90
+
91
+ if (newIsConnected !== prevIsConnected) {
92
+ // if value changed, reset ping interval to initial delay
93
+ smartIntervalRef.current?.reset()
94
+
95
+ if (newIsConnected) {
96
+ // Need to clear this here so it doesn't affect another
97
+ // session that starts while offline
98
+ clearLastConnected(appName)
99
+ } else {
100
+ updateLastConnected(appName)
101
+ }
102
+ }
103
+ return newIsConnected
104
+ })
105
+ },
106
+ [appName]
107
+ )
108
+
109
+ // Note that the SW is configured to not cache ping requests and won't
110
+ // trigger `handleChange` below to avoid redundant signals. This also
111
+ // helps to detect the connectivity status when the SW is not available
112
+ // for some reason (maybe private browsing, first installation, or
113
+ // insecure browser context)
114
+ const pingAndHandleStatus = useCallback(() => {
115
+ return ping()
116
+ .then(() => {
117
+ // Ping is successful; set 'connected'
118
+ updateConnectedState(true)
119
+ })
120
+ .catch((err) => {
121
+ console.error('Ping failed:', err.message)
122
+ updateConnectedState(false)
123
+ })
124
+ }, [ping, updateConnectedState])
125
+
126
+ /** Called when SW reports updates from incidental network traffic */
127
+ const onUpdate = useCallback(
128
+ ({ isConnected: newIsConnected }: { isConnected: any }) => {
129
+ devDebugLog('[D2CS] handling update from sw')
130
+
131
+ // Snooze ping timer to reduce pings since we know state from SW
132
+ smartIntervalRef.current?.snooze()
133
+ updateConnectedState(newIsConnected)
134
+ },
135
+ [updateConnectedState]
136
+ )
137
+
138
+ useEffect(() => {
139
+ // If the /api/ping endpoint is not available on this instance, skip
140
+ // pinging with the smart interval. Just use the service worker
141
+ if (!serverVersion || !isPingAvailable(serverVersion)) {
142
+ return
143
+ }
144
+
145
+ // Only create the smart interval once
146
+ const smartInterval = createSmartInterval({
147
+ // don't ping if window isn't focused or visible
148
+ initialPauseValue:
149
+ !document.hasFocus() || document.visibilityState !== 'visible',
150
+ callback: pingAndHandleStatus,
151
+ })
152
+ smartIntervalRef.current = smartInterval
153
+
154
+ const handleBlur = () => smartInterval.pause()
155
+ const handleFocus = () => smartInterval.resume()
156
+ // Pinging when going offline should be low/no-cost in both online and
157
+ // local servers
158
+ const handleOffline = () => smartInterval.invokeCallbackImmediately()
159
+ // Pinging when going online has a cost but improves responsiveness of
160
+ // the connection status -- only do it once every 15 seconds at most
161
+ const handleOnline = throttle(
162
+ () => smartInterval.invokeCallbackImmediately(),
163
+ 15000
164
+ )
165
+
166
+ window.addEventListener('blur', handleBlur)
167
+ window.addEventListener('focus', handleFocus)
168
+ window.addEventListener('offline', handleOffline)
169
+ window.addEventListener('online', handleOnline)
170
+
171
+ return () => {
172
+ window.removeEventListener('blur', handleBlur)
173
+ window.removeEventListener('focus', handleFocus)
174
+ window.removeEventListener('offline', handleOffline)
175
+ window.removeEventListener('online', handleOnline)
176
+
177
+ // clean up smart interval and throttled function
178
+ smartInterval.clear()
179
+ handleOnline.cancel()
180
+ }
181
+ }, [pingAndHandleStatus, serverVersion])
182
+
183
+ useEffect(() => {
184
+ if (!offlineInterface.subscribeToDhis2ConnectionStatus) {
185
+ // Missing this functionality from the offline interface --
186
+ // use a ping on startup to get the status
187
+ smartIntervalRef.current?.invokeCallbackImmediately()
188
+ console.warn(
189
+ 'Please upgrade to @dhis2/cli-app-scripts@>10.3.8 for full connection status features'
190
+ )
191
+ return
192
+ }
193
+
194
+ const unsubscribe = offlineInterface.subscribeToDhis2ConnectionStatus({
195
+ onUpdate,
196
+ })
197
+ return () => {
198
+ unsubscribe()
199
+ }
200
+ }, [offlineInterface, onUpdate])
201
+
202
+ // Memoize this value to prevent unnecessary rerenders of context provider
203
+ const contextValue = useMemo(() => {
204
+ // in the unlikely circumstance that offlineInterface.latestIsConnected
205
+ // is `null` or `undefined` when this initializes, fail safe by defaulting to
206
+ // `isConnected: true`. A ping or SW update should update the status shortly.
207
+ const validatedIsConnected = isConnected ?? true
208
+ return {
209
+ isConnected: validatedIsConnected,
210
+ isDisconnected: !validatedIsConnected,
211
+ lastConnected: validatedIsConnected
212
+ ? null
213
+ : // Only evaluate if disconnected, since local storage
214
+ // is synchronous and disk-based.
215
+ // If lastConnected is not set in localStorage though, set it.
216
+ // (relevant on startup)
217
+ getLastConnected(appName) || updateLastConnected(appName),
218
+ }
219
+ }, [isConnected, appName])
220
+
221
+ return (
222
+ <Dhis2ConnectionStatusContext.Provider value={contextValue}>
223
+ {children}
224
+ </Dhis2ConnectionStatusContext.Provider>
225
+ )
226
+ }
227
+ Dhis2ConnectionStatusProvider.propTypes = {
228
+ children: PropTypes.node,
229
+ }
230
+
231
+ export const useDhis2ConnectionStatus = (): Dhis2ConnectionStatus => {
232
+ const context = useContext(Dhis2ConnectionStatusContext)
233
+
234
+ if (!context) {
235
+ throw new Error(
236
+ 'useDhis2ConnectionStatus must be used within a Dhis2ConnectionStatus provider'
237
+ )
238
+ }
239
+
240
+ return context
241
+ }
@@ -0,0 +1,4 @@
1
+ export {
2
+ useDhis2ConnectionStatus,
3
+ Dhis2ConnectionStatusProvider,
4
+ } from './dhis2-connection-status'
@@ -0,0 +1,32 @@
1
+ import type { Version } from '@dhis2/app-service-config'
2
+ import { isPingAvailable } from './is-ping-available'
3
+
4
+ const fixedVersions: Version[] = [
5
+ { full: 'unimportant', major: 2, minor: 40, patch: 0 },
6
+ { full: 'unimportant', major: 2, minor: 39, patch: 2 },
7
+ { full: 'unimportant', major: 2, minor: 38, patch: 4 },
8
+ { full: 'unimportant', major: 2, minor: 37, patch: 10 },
9
+ { full: 'unimportant', major: 2, minor: 40, tag: 'SNAPSHOT' },
10
+ { full: 'unimportant', major: 2, minor: 3291, patch: 0 },
11
+ ]
12
+
13
+ const unsupportedVersions: Version[] = [
14
+ { full: 'unimportant', major: 2, minor: 39, patch: 1 },
15
+ { full: 'unimportant', major: 2, minor: 38, patch: 2 },
16
+ { full: 'unimportant', major: 2, minor: 37, patch: 9 },
17
+ { full: 'unimportant', major: 2, minor: 36, patch: 12 },
18
+ { full: 'unimportant', major: 2, minor: 35, patch: 0 },
19
+ { full: 'unimportant', major: 2, minor: 0, patch: 0 },
20
+ ]
21
+
22
+ test('supported server versions pass', () => {
23
+ fixedVersions.forEach((version) => {
24
+ expect(isPingAvailable(version)).toBe(true)
25
+ })
26
+ })
27
+
28
+ test('unsupported server versions fail', () => {
29
+ unsupportedVersions.forEach((version) => {
30
+ expect(isPingAvailable(version)).toBe(false)
31
+ })
32
+ })
@@ -0,0 +1,31 @@
1
+ import type { Version } from '@dhis2/app-service-config'
2
+
3
+ /**
4
+ * Checks the server version to see if the /api/ping endpoint is available for
5
+ * this version.
6
+ *
7
+ * The endpoint was released for versions 2.37.10, 2.38.4, 2.39.2, and 2.40.0
8
+ * (see DHIS2-14531). All versions below that are considered unsupported
9
+ *
10
+ * If the patchVersion is undefined, it's assumed to be a dev server that's
11
+ * newer than the fix versions listed above
12
+ *
13
+ * Major versions above 2 aren't supported 🤷‍♂️
14
+ */
15
+ export function isPingAvailable(serverVersion: Version) {
16
+ if (!serverVersion) {
17
+ return false
18
+ }
19
+
20
+ const { minor, patch } = serverVersion
21
+ switch (minor) {
22
+ case 39:
23
+ return patch === undefined || patch >= 2
24
+ case 38:
25
+ return patch === undefined || patch >= 4
26
+ case 37:
27
+ return patch === undefined || patch >= 10
28
+ default:
29
+ return minor >= 40
30
+ }
31
+ }
@@ -0,0 +1,206 @@
1
+ import { devDebugLog } from './dev-debug-log'
2
+
3
+ // Exported for tests
4
+ export const DEFAULT_INITIAL_DELAY_MS = 1000 * 30 // 30 sec
5
+ export const DEFAULT_MAX_DELAY_MS = 1000 * 60 * 5 // 5 min
6
+ export const DEFAULT_INCREMENT_FACTOR = 1.5
7
+ const throwErrorIfNoCallbackIsProvided = (): void => {
8
+ throw new Error('Provide a callback')
9
+ }
10
+
11
+ export interface SmartInterval {
12
+ clear: () => void
13
+ pause: () => void
14
+ resume: () => void
15
+ invokeCallbackImmediately: () => void
16
+ snooze: () => void
17
+ reset: () => void
18
+ }
19
+
20
+ export default function createSmartInterval({
21
+ initialDelay = DEFAULT_INITIAL_DELAY_MS,
22
+ maxDelay = DEFAULT_MAX_DELAY_MS,
23
+ delayIncrementFactor = DEFAULT_INCREMENT_FACTOR,
24
+ initialPauseValue = false,
25
+ callback = throwErrorIfNoCallbackIsProvided,
26
+ } = {}): SmartInterval {
27
+ const state = {
28
+ paused: initialPauseValue,
29
+ delay: initialDelay,
30
+ // Timeout types are weird; this dummy timeout helps fix them:
31
+ // (named to help debugging in tests)
32
+ timeout: setTimeout(function dummyTimeout() {
33
+ return
34
+ }, 0),
35
+ standbyCallback: null as null | (() => void),
36
+ }
37
+
38
+ /** Increment delay by the increment factor, up to a max value */
39
+ function incrementDelay() {
40
+ const newDelay = Math.min(state.delay * delayIncrementFactor, maxDelay)
41
+ devDebugLog('[SI] incrementing delay', {
42
+ prev: state.delay,
43
+ new: newDelay,
44
+ })
45
+ state.delay = newDelay
46
+ }
47
+
48
+ function invokeCallbackAndHandleDelay(): void {
49
+ // Increment delay before calling callback, so callback can potentially
50
+ // reset the delay to initial before starting the next timeout
51
+ incrementDelay()
52
+ callback()
53
+ }
54
+
55
+ function clearTimeoutAndStart(): void {
56
+ devDebugLog('[SI] clearing and starting timeout', {
57
+ delay: state.delay,
58
+ })
59
+
60
+ // Prevent parallel timeouts from occuring
61
+ // (weird note: `if (this.timeout) { clearTimeout(this.timeout) }`
62
+ // does NOT work for some reason)
63
+ clearTimeout(state.timeout)
64
+
65
+ // A timeout is used instead of an interval for handling slow execution
66
+ // https://developer.mozilla.org/en-US/docs/Web/API/setInterval#ensure_that_execution_duration_is_shorter_than_interval_frequency
67
+ state.timeout = setTimeout(function callbackAndRestart() {
68
+ if (state.paused) {
69
+ devDebugLog('[SI] entering regular standby')
70
+
71
+ // If paused, prepare a 'standby callback' to be invoked when
72
+ // `resume()` is called (see its definition below).
73
+ // The timer will not be started again until the standbyCallback
74
+ // is invoked.
75
+ state.standbyCallback = () => {
76
+ invokeCallbackAndHandleDelay()
77
+ clearTimeoutAndStart()
78
+ }
79
+
80
+ return
81
+ }
82
+
83
+ // Otherwise, invoke callback
84
+ invokeCallbackAndHandleDelay()
85
+ // and start process over again
86
+ clearTimeoutAndStart()
87
+ }, state.delay)
88
+ }
89
+
90
+ /** Stop the interval. Used for cleaning up */
91
+ function clear(): void {
92
+ clearTimeout(state.timeout)
93
+ }
94
+
95
+ /**
96
+ * Invoke the provided callback immediately and start the timer over.
97
+ * The timeout to the next invocation will not be increased
98
+ * (unless the timer fully elapses while this interval is paused).
99
+ *
100
+ * If the interval is 'paused', it will not invoke the callback immediately,
101
+ * but enter a 'partial standby', which will invoke the callback upon
102
+ * resuming, but without incrementing the delay. If the regular timeout
103
+ * elapses while paused, the regular standby is entered, overwriting this
104
+ * partial standby.
105
+ */
106
+ function invokeCallbackImmediately(): void {
107
+ if (state.paused) {
108
+ if (state.standbyCallback === null) {
109
+ // If there is not an existing standbyCallback,
110
+ // set one to be called upon `resume()`
111
+ // (but don't overwrite a previous callback).
112
+ // See setTimeout call above too.
113
+ // The timed out function set in `clearTimeoutAndStart` may
114
+ // overwrite this callback if the timer elapses, so that the
115
+ // timeout delay gets incremented appropriately.
116
+ devDebugLog('[SI] entering standby without timer increment')
117
+
118
+ state.standbyCallback = () => {
119
+ // Invoke callback and start timer without incrementing
120
+ callback()
121
+ clearTimeoutAndStart()
122
+ }
123
+ }
124
+
125
+ // Skip rest of execution while paused
126
+ return
127
+ }
128
+
129
+ // Invoke callback and start timer without incrementing
130
+ callback()
131
+ clearTimeoutAndStart()
132
+ }
133
+
134
+ /**
135
+ * Sets a 'paused' flag (doesn't yet stop the timer):
136
+ *
137
+ * If the main timer elapses or `invokeCallbackImmediately` is called
138
+ * while the interval is paused, the timer will not be started again.
139
+ * Instead, a callback function will be saved that will be called when
140
+ * `resume()` is called (see its definition below)
141
+ *
142
+ * This decreases execution activity while 'paused'
143
+ */
144
+ function pause(): void {
145
+ devDebugLog('[SI] pausing')
146
+
147
+ state.paused = true
148
+ }
149
+
150
+ /**
151
+ * Removes 'paused' state
152
+ *
153
+ * If the interval is in 'standby', trigger the saved 'standbyCallback',
154
+ * which should start the interval timer again
155
+ */
156
+ function resume(): void {
157
+ devDebugLog('[SI] resuming', { standbyCb: state.standbyCallback })
158
+
159
+ // Clear paused state
160
+ state.paused = false
161
+
162
+ // If in standby, invoke the saved callback
163
+ // (invokeCallbackImmediately and clearTimeoutAndStart can set a
164
+ // standby callback)
165
+ if (state.standbyCallback !== null) {
166
+ state.standbyCallback()
167
+ // Remove existing standbyCallback
168
+ state.standbyCallback = null
169
+ }
170
+ }
171
+
172
+ /**
173
+ * Restart the timer to the next callback invocation, using the current
174
+ * delay
175
+ *
176
+ * Expected to be called to delay a ping in response to incidental network
177
+ * traffic, for example
178
+ */
179
+ function snooze(): void {
180
+ devDebugLog('[SI] snoozing timeout')
181
+
182
+ clearTimeoutAndStart()
183
+ }
184
+
185
+ /**
186
+ * Cancels the current timeout and starts a new one with the initial delay
187
+ */
188
+ function reset(): void {
189
+ devDebugLog('[SI] resetting interval from beginning')
190
+
191
+ state.delay = initialDelay
192
+ clearTimeoutAndStart()
193
+ }
194
+
195
+ // Start the timer!
196
+ clearTimeoutAndStart()
197
+
198
+ return {
199
+ clear,
200
+ pause,
201
+ resume,
202
+ invokeCallbackImmediately,
203
+ snooze,
204
+ reset,
205
+ }
206
+ }
@@ -0,0 +1,14 @@
1
+ import { useConfig } from '@dhis2/app-service-config'
2
+ import { useCallback } from 'react'
3
+
4
+ // This function has a separate file for easier mocking
5
+
6
+ export function usePingQuery(): () => Promise<any> {
7
+ const { baseUrl } = useConfig()
8
+
9
+ // This endpoint doesn't need any extra headers or handling since it's
10
+ // public. It doesn't extend the user session. See DHIS2-14531
11
+ const ping = useCallback(() => fetch(baseUrl + '/api/ping'), [baseUrl])
12
+
13
+ return ping
14
+ }
@@ -0,0 +1,110 @@
1
+ import isEqual from 'lodash/isEqual'
2
+ import PropTypes from 'prop-types'
3
+ import React, {
4
+ useEffect,
5
+ useCallback,
6
+ useContext,
7
+ useState,
8
+ useMemo,
9
+ } from 'react'
10
+ import {
11
+ GlobalStateStore,
12
+ GlobalStateStoreMutateMethod,
13
+ GlobalStateMutation,
14
+ GlobalStateStoreMutationCreator,
15
+ } from '../types'
16
+
17
+ // This file creates a redux-like state management service using React context
18
+ // that minimizes unnecessary rerenders that consume the context.
19
+ // See more at https://github.com/amcgee/state-service-poc
20
+
21
+ const identity = (state: any) => state
22
+
23
+ export const createStore = (initialState = {}): GlobalStateStore => {
24
+ const subscriptions: Set<(state: any) => void> = new Set()
25
+ let state = initialState
26
+
27
+ return {
28
+ getState: () => state,
29
+ subscribe: (callback) => {
30
+ subscriptions.add(callback)
31
+ },
32
+ unsubscribe: (callback) => {
33
+ subscriptions.delete(callback)
34
+ },
35
+ mutate: (mutation) => {
36
+ state = mutation(state)
37
+ for (const callback of subscriptions) {
38
+ callback(state)
39
+ }
40
+ },
41
+ }
42
+ }
43
+
44
+ const GlobalStateContext = React.createContext(createStore())
45
+ const useGlobalStateStore = () => useContext(GlobalStateContext)
46
+
47
+ export const GlobalStateProvider = ({
48
+ store,
49
+ children,
50
+ }: {
51
+ store: GlobalStateStore
52
+ children: React.ReactNode
53
+ }): JSX.Element => (
54
+ <GlobalStateContext.Provider value={store}>
55
+ {children}
56
+ </GlobalStateContext.Provider>
57
+ )
58
+ GlobalStateProvider.propTypes = {
59
+ children: PropTypes.node,
60
+ store: PropTypes.shape({}),
61
+ }
62
+
63
+ export const useGlobalState = (
64
+ selector = identity
65
+ ): [any, GlobalStateStoreMutateMethod] => {
66
+ const store = useGlobalStateStore()
67
+ const [selectedState, setSelectedState] = useState(
68
+ selector(store.getState())
69
+ )
70
+
71
+ useEffect(() => {
72
+ // NEW: deep equality check before updating
73
+ const callback = (state: any) => {
74
+ const newSelectedState = selector(state)
75
+ // Use this form to avoid having `selectedState` as a dep in here
76
+ setSelectedState((currentSelectedState: any) => {
77
+ // Second condition handles case where a selected object gets
78
+ // deleted, but state does not update
79
+ if (
80
+ !isEqual(currentSelectedState, newSelectedState) ||
81
+ currentSelectedState === undefined
82
+ ) {
83
+ return newSelectedState
84
+ }
85
+ return currentSelectedState
86
+ })
87
+ }
88
+ store.subscribe(callback)
89
+ // Make sure to update state when selector changes
90
+ callback(store.getState())
91
+ return () => store.unsubscribe(callback)
92
+ }, [store, selector])
93
+
94
+ return useMemo(
95
+ () => [selectedState, store.mutate],
96
+ [selectedState, store.mutate]
97
+ )
98
+ }
99
+
100
+ export function useGlobalStateMutation<Type>(
101
+ mutationCreator: GlobalStateStoreMutationCreator<Type>
102
+ ): GlobalStateMutation<Type> {
103
+ const store = useGlobalStateStore()
104
+ return useCallback(
105
+ (...args) => {
106
+ store.mutate(mutationCreator(...args))
107
+ },
108
+ [mutationCreator, store]
109
+ )
110
+ }