Skip to content

Commit c86eea7

Browse files
committed
install one listener per instance, not per hook
1 parent 70ea34d commit c86eea7

10 files changed

Lines changed: 111 additions & 114 deletions
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { AppState } from 'react-native'
2+
import type { AppStateStatus, NativeEventSubscription } from 'react-native'
3+
import { createMMKV } from '..'
4+
import { addContentChangedListener } from '../addContentChangedListener/addContentChangedListener'
5+
6+
function mockAppStateChangeListener(): {
7+
emit: (state: AppStateStatus) => void
8+
} {
9+
const listeners = new Set<(state: AppStateStatus) => void>()
10+
11+
jest
12+
.spyOn(AppState, 'addEventListener')
13+
.mockImplementation((type, listener): NativeEventSubscription => {
14+
if (type === 'change') {
15+
listeners.add(listener as (state: AppStateStatus) => void)
16+
}
17+
18+
return {
19+
remove: () => {
20+
listeners.delete(listener as (state: AppStateStatus) => void)
21+
},
22+
} as NativeEventSubscription
23+
})
24+
25+
return {
26+
emit: (state) => {
27+
listeners.forEach((listener) => listener(state))
28+
},
29+
}
30+
}
31+
32+
afterEach(() => {
33+
jest.restoreAllMocks()
34+
})
35+
36+
test('content changed listener checks content when app becomes active', () => {
37+
const appState = mockAppStateChangeListener()
38+
const storage = createMMKV({ id: 'content-changed-listener-test' })
39+
const checkContentChanged = jest.spyOn(storage, 'checkContentChanged')
40+
41+
addContentChangedListener(storage)
42+
43+
appState.emit('background')
44+
expect(checkContentChanged).not.toHaveBeenCalled()
45+
46+
appState.emit('active')
47+
expect(checkContentChanged).toHaveBeenCalledTimes(1)
48+
})
49+
50+
test('content changed listener registers once per instance', () => {
51+
mockAppStateChangeListener()
52+
const storage = createMMKV({ id: 'content-changed-listener-once-test' })
53+
54+
addContentChangedListener(storage)
55+
addContentChangedListener(storage)
56+
57+
expect(AppState.addEventListener).toHaveBeenCalledTimes(1)
58+
})

packages/react-native-mmkv/src/__tests__/hooks.test.tsx

Lines changed: 2 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
import React from 'react'
2-
import { AppState, Button, Text } from 'react-native'
3-
import type { AppStateStatus, NativeEventSubscription } from 'react-native'
2+
import { Button, Text } from 'react-native'
43
import {
54
act,
65
fireEvent,
@@ -10,7 +9,7 @@ import {
109
cleanup,
1110
waitFor,
1211
} from '@testing-library/react-native'
13-
import { createMMKV, useMMKVKeys, useMMKVNumber, useMMKVString } from '..'
12+
import { createMMKV, useMMKVNumber, useMMKVString } from '..'
1413

1514
const mmkv = createMMKV()
1615

@@ -21,37 +20,8 @@ beforeEach(() => {
2120

2221
afterEach(() => {
2322
cleanup()
24-
jest.restoreAllMocks()
2523
})
2624

27-
function mockAppStateChangeListener(): {
28-
emit: (state: AppStateStatus) => void
29-
} {
30-
const listeners = new Set<(state: AppStateStatus) => void>()
31-
32-
jest
33-
.spyOn(AppState, 'addEventListener')
34-
.mockImplementation((type, listener): NativeEventSubscription => {
35-
if (type === 'change') {
36-
listeners.add(listener as (state: AppStateStatus) => void)
37-
}
38-
39-
return {
40-
remove: () => {
41-
listeners.delete(listener as (state: AppStateStatus) => void)
42-
},
43-
} as NativeEventSubscription
44-
})
45-
46-
return {
47-
emit: (state) => {
48-
act(() => {
49-
listeners.forEach((listener) => listener(state))
50-
})
51-
},
52-
}
53-
}
54-
5525
test('hooks update when the value is changed directly through the instance', () => {
5626
const { result } = renderHook(() => useMMKVString('string-key', mmkv))
5727

@@ -159,40 +129,3 @@ test('useMMKV hook stays consistent during rapid updates', async () => {
159129
expect(result.current[0]).toBe(100)
160130
})
161131
})
162-
163-
test('useMMKV hook checks for external content changes when app becomes active', () => {
164-
const appState = mockAppStateChangeListener()
165-
const key = 'app-state-key'
166-
let value = 'before'
167-
168-
const checkContentChanged = jest.spyOn(mmkv, 'checkContentChanged')
169-
jest.spyOn(mmkv, 'getString').mockImplementation((requestedKey) => {
170-
return requestedKey === key ? value : undefined
171-
})
172-
173-
const { result } = renderHook(() => useMMKVString(key, mmkv))
174-
expect(result.current[0]).toStrictEqual('before')
175-
176-
value = 'after'
177-
appState.emit('active')
178-
179-
expect(checkContentChanged).toHaveBeenCalledTimes(1)
180-
expect(result.current[0]).toStrictEqual('after')
181-
})
182-
183-
test('useMMKVKeys checks for external content changes when app becomes active', () => {
184-
const appState = mockAppStateChangeListener()
185-
let keys: string[] = []
186-
187-
const checkContentChanged = jest.spyOn(mmkv, 'checkContentChanged')
188-
jest.spyOn(mmkv, 'getAllKeys').mockImplementation(() => keys)
189-
190-
const { result } = renderHook(() => useMMKVKeys(mmkv))
191-
expect(result.current).toStrictEqual([])
192-
193-
keys = ['external-key']
194-
appState.emit('active')
195-
196-
expect(checkContentChanged).toHaveBeenCalledTimes(1)
197-
expect(result.current).toStrictEqual(['external-key'])
198-
})
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { MMKV } from '../specs/MMKV.nitro'
2+
3+
export function addContentChangedListener(_mmkv: MMKV): void {
4+
// no-op in mocked environments
5+
}
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import { AppState } from 'react-native'
2+
import type { AppStateStatus, NativeEventSubscription } from 'react-native'
3+
import type { MMKV } from '../specs/MMKV.nitro'
4+
5+
const registeredInstances = new WeakSet<MMKV>()
6+
7+
export function addContentChangedListener(mmkv: MMKV): void {
8+
if (registeredInstances.has(mmkv)) {
9+
return
10+
}
11+
registeredInstances.add(mmkv)
12+
13+
if (global.WeakRef != null && global.FinalizationRegistry != null) {
14+
const weakMmkv = new WeakRef(mmkv)
15+
const listener = AppState.addEventListener(
16+
'change',
17+
(state: AppStateStatus) => {
18+
if (state === 'active') {
19+
weakMmkv.deref()?.checkContentChanged()
20+
}
21+
}
22+
)
23+
const finalization = new FinalizationRegistry(
24+
(l: NativeEventSubscription) => {
25+
l.remove()
26+
}
27+
)
28+
finalization.register(mmkv, listener)
29+
} else {
30+
AppState.addEventListener('change', (state: AppStateStatus) => {
31+
if (state === 'active') {
32+
mmkv.checkContentChanged()
33+
}
34+
})
35+
}
36+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import type { MMKV } from '../specs/MMKV.nitro'
2+
3+
export function addContentChangedListener(_mmkv: MMKV): void {
4+
// no-op on Web
5+
}

packages/react-native-mmkv/src/createMMKV/createMMKV.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { MMKV } from '../specs/MMKV.nitro'
22
import type { Configuration } from '../specs/MMKVFactory.nitro'
33
import { Platform } from 'react-native'
44
import { addMemoryWarningListener } from '../addMemoryWarningListener/addMemoryWarningListener'
5+
import { addContentChangedListener } from '../addContentChangedListener/addContentChangedListener'
56
import { isTest } from '../isTest'
67
import { createMockMMKV } from './createMockMMKV'
78
import { getMMKVFactory, getPlatformContext } from '../getMMKVFactory'
@@ -33,5 +34,7 @@ export function createMMKV(configuration?: Configuration): MMKV {
3334
const mmkv = factory.createMMKV(config)
3435
// Add a hook that trims the storage when we get a memory warning
3536
addMemoryWarningListener(mmkv)
37+
// Check for updates from app extensions/App Clips/background services when the app becomes active
38+
addContentChangedListener(mmkv)
3639
return mmkv
3740
}

packages/react-native-mmkv/src/hooks/addContentChangedAppStateListener.ts

Lines changed: 0 additions & 15 deletions
This file was deleted.

packages/react-native-mmkv/src/hooks/addContentChangedAppStateListener.web.ts

Lines changed: 0 additions & 12 deletions
This file was deleted.

packages/react-native-mmkv/src/hooks/createMMKVHook.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import { useCallback, useSyncExternalStore } from 'react'
22
import { getDefaultMMKVInstance } from '../createMMKV/getDefaultMMKVInstance'
33
import type { MMKV } from '../specs/MMKV.nitro'
4-
import { addContentChangedAppStateListener } from './addContentChangedAppStateListener'
54

65
export function createMMKVHook<
76
T extends (boolean | number | string | ArrayBufferLike) | undefined,
@@ -22,14 +21,7 @@ export function createMMKVHook<
2221
onStoreChange()
2322
}
2423
})
25-
const appStateListener = addContentChangedAppStateListener(
26-
mmkv,
27-
onStoreChange
28-
)
29-
return () => {
30-
listener.remove()
31-
appStateListener.remove()
32-
}
24+
return () => listener.remove()
3325
},
3426
[key, mmkv]
3527
),

packages/react-native-mmkv/src/hooks/useMMKVKeys.ts

Lines changed: 1 addition & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
1-
import { useEffect, useState } from 'react'
1+
import { useState } from 'react'
22
import type { MMKV } from '../specs/MMKV.nitro'
33
import { getDefaultMMKVInstance } from '../createMMKV/getDefaultMMKVInstance'
44
import { useMMKVListener } from './useMMKVListener'
5-
import { addContentChangedAppStateListener } from './addContentChangedAppStateListener'
65

76
/**
87
* Get a list of all keys that exist in the given MMKV {@linkcode instance}.
@@ -33,12 +32,5 @@ export function useMMKVKeys(instance?: MMKV): string[] {
3332
})
3433
}, mmkv)
3534

36-
useEffect(() => {
37-
const appStateListener = addContentChangedAppStateListener(mmkv, () => {
38-
setKeys(mmkv.getAllKeys())
39-
})
40-
return () => appStateListener.remove()
41-
}, [mmkv])
42-
4335
return allKeys
4436
}

0 commit comments

Comments
 (0)