What happens
Calling deleteMMKV(id) for an instance created in the same JS runtime crashes the app the
next time it returns to the foreground:
Exception Type: EXC_CRASH (SIGABRT)
Termination: SIGNAL 6 Abort trap: 6
Crashed Thread: 2 com.facebook.react.runtime.JavaScript
0 libsystem_kernel.dylib __pthread_kill
1 libsystem_pthread.dylib pthread_kill
2 libsystem_c.dylib abort
3 libsystem_c.dylib __assert_rtn
4 mmkvdeletecrashrepro mmkv::ScopedLock<mmkv::ThreadLock>::ScopedLock(mmkv::ThreadLock*)
5 mmkvdeletecrashrepro mmkv::ScopedLock<mmkv::ThreadLock>::ScopedLock(mmkv::ThreadLock*)
6 mmkvdeletecrashrepro mmkv::MMKV::checkContentChanged()
7 mmkvdeletecrashrepro margelo::nitro::mmkv::HybridMMKV::checkContentChanged()
8 mmkvdeletecrashrepro margelo::nitro::HybridFunction::callMethod<HybridMMKVSpec, void>(...)
... nitro HybridFunction / JSI / Hermes frames ...
31 React facebook::jsi::Function::callWithThis(...)
32 React facebook::react::ReactInstance::callFunctionOnModule(...)
In a debug build the assertion is Assertion failed: (m_lock), function ScopedLock, file ScopedLock.hpp, line 45. Full symbolicated stack is in the repro repo.
Reproducible example
https://github.com/robertovieira/mmkv-delete-crash-repro
Steps:
- Tap 1. create user-A and user-B
- Tap 2. deleteMMKV('user-B')
- Send the app to the background and bring it back
- Crash
Control test: tapping only button 1 and then backgrounding does not crash. The deleteMMKV()
in step 2 is what breaks it.
Native MMKV log right after step 2, showing the instance being destructed:
<MMKV_IO.cpp:1713::removeStorage> remove storage [user-B]
<MMKV.cpp:369::close> close [user-B]
<MMKV.cpp:340::clearMemoryCache> clearMemoryCache [user-B]
<MMKV.cpp:154::~MMKV> destruct [user-B]
Why it happens
createMMKV() registers a foreground listener for every instance
(src/addContentChangedListener/addContentChangedListener.ts):
const weakMmkv = new WeakRef(mmkv)
const listener = AppState.addEventListener('change', (state) => {
if (state === 'active') weakMmkv.deref()?.checkContentChanged()
})
const finalization = new FinalizationRegistry((l) => l.remove())
finalization.register(mmkv, listener)
The WeakRef only guards against a garbage-collected wrapper. deleteMMKV() destroys the
native object while the JS wrapper is still alive and strongly reachable, so deref() returns
it and checkContentChanged() runs on a destructed instance whose m_lock is gone. The
FinalizationRegistry only fires after collection, which may never happen in time.
The repro keeps both instances in a module-level Map, which is what a real multi-account app
does. That is what makes the crash deterministic instead of dependent on GC timing.
Real-world trigger
A multi-account app that wipes the previous account's storage when another account signs in.
The OAuth flow itself backgrounds and foregrounds the app, so the crash lands a few seconds
after a successful login, with nothing in the Metro logs.
Suggested fix
Remove or invalidate the AppState listener inside deleteMMKV(), either by keeping a
Map<id, listener> alongside the instances, or by marking the HybridObject invalid on the
native side so checkContentChanged() becomes a no-op instead of asserting.
Workaround
Never call deleteMMKV() for an id already created in the current process. Call clearAll()
on the instance instead and defer the real deleteMMKV() to the next app launch, before any
createMMKV() for that id runs.
Environment
|
|
| react-native-mmkv |
4.3.2 |
| react-native-nitro-modules |
0.36.1 |
| React Native |
0.85.3 (new architecture) |
| Expo |
56 |
| MMKV core |
v2.4.0 |
| Reproduced on |
iPhone 17 simulator, iOS 26.5, debug build |
Also seen in a real app on a physical device (iPhone 15 Pro, iOS 26), same stack.
What happens
Calling
deleteMMKV(id)for an instance created in the same JS runtime crashes the app thenext time it returns to the foreground:
In a debug build the assertion is
Assertion failed: (m_lock), function ScopedLock, file ScopedLock.hpp, line 45.Full symbolicated stack is in the repro repo.Reproducible example
https://github.com/robertovieira/mmkv-delete-crash-repro
Steps:
Control test: tapping only button 1 and then backgrounding does not crash. The
deleteMMKV()in step 2 is what breaks it.
Native MMKV log right after step 2, showing the instance being destructed:
Why it happens
createMMKV()registers a foreground listener for every instance(
src/addContentChangedListener/addContentChangedListener.ts):The
WeakRefonly guards against a garbage-collected wrapper.deleteMMKV()destroys thenative object while the JS wrapper is still alive and strongly reachable, so
deref()returnsit and
checkContentChanged()runs on a destructed instance whosem_lockis gone. TheFinalizationRegistryonly fires after collection, which may never happen in time.The repro keeps both instances in a module-level
Map, which is what a real multi-account appdoes. That is what makes the crash deterministic instead of dependent on GC timing.
Real-world trigger
A multi-account app that wipes the previous account's storage when another account signs in.
The OAuth flow itself backgrounds and foregrounds the app, so the crash lands a few seconds
after a successful login, with nothing in the Metro logs.
Suggested fix
Remove or invalidate the AppState listener inside
deleteMMKV(), either by keeping aMap<id, listener>alongside the instances, or by marking the HybridObject invalid on thenative side so
checkContentChanged()becomes a no-op instead of asserting.Workaround
Never call
deleteMMKV()for an id already created in the current process. CallclearAll()on the instance instead and defer the real
deleteMMKV()to the next app launch, before anycreateMMKV()for that id runs.Environment
Also seen in a real app on a physical device (iPhone 15 Pro, iOS 26), same stack.