useObject registers its insertion listener without the isInTransaction guard used elsewhere in realm-react

#7151 · open · 0 comments

View on GitHub ↗

waveo-wangxiao

## Summary `@realm/react` guards listener registration against open write transactions in two places, but not in the third. `useObject`'s "object doesn't exist yet, watch for its insertion" effect calls `collection.addListener` unconditionally, so it throws `Cannot create asynchronous query after making changes in a write transaction` when a write is open on the JS thread at that moment. This looks like an incomplete application of the fix for #4375. ## The inconsistency Guarded — `packages/realm-react/src/cachedCollection.ts:189`: ```ts if (realm.isInTransaction) { setImmediateId = setImmediate(() => { collection.addListener(listenerCallback, keyPaths); }); } else { collection.addListener(listenerCallback, keyPaths); } ``` Guarded — `packages/realm-react/src/cachedObject.ts:147`: same shape. **Not guarded** — `packages/realm-react/src/useObject.tsx:168` (current `main`): ```ts if (!originalObjectRef.current) { collection.addListener(collectionListener); } ``` ## Why it is reachable in ordinary code That branch runs whenever the requested object is **absent**. A component that renders before its id is known still has to call the hook (rules of hooks), so "no object yet" is a normal render state rather than an edge case: ```tsx // id is not known on the first render const item = useObject(Item, id ?? SOME_PLACEHOLDER_KEY); ``` Any key that misses takes the unguarded path. If a write is open when that effect runs — a background batch write, for instance — Realm throws and the error surfaces as unhandled. Note the throw requires the transaction to have **made changes**: an empty `realm.write(() => {})` does not trigger it. ## Reproduction With a real Realm: ```ts const collection = realm.objects('Item'); realm.write(() => { realm.create('Item', { /* ... */ }); // the write must actually mutate something collection.addListener(() => {}); // throws }); ``` In app terms: mount a component calling `useObject` with a primary key that matches nothing, while a write is in flight. ## Environment - `@realm/react` 0.20.0 (latest published) - `realm` 20.x, React Native 0.83.2 and 0.86.2 - Observed on iOS 18.7.8; the code path is platform-independent ## Suggested fix Apply the same guard already used at the other two sites: ```ts let deferredListenerId: ReturnType<typeof setImmediate> | undefined; if (!originalObjectRef.current) { if (realm.isInTransaction) { deferredListenerId = setImmediate(() => { deferredListenerId = undefined; if (!realm.isClosed && collection && !originalObjectRef.current) { collection.addListener(collectionListener); } }); } else { collection.addListener(collectionListener); } } return () => { if (deferredListenerId !== undefined) { clearImmediate(deferredListenerId); } // ... existing cleanup }; ``` Two details beyond a literal copy of the existing guard: - the deferred callback re-checks `isClosed` and object presence, since the object may have been inserted (or the realm closed) during the wait; - teardown clears the pending `setImmediate`, so unmounting before the write commits does not register a listener on a torn-down component. Deferring rather than catching matters here: swallowing the error would leave the subscription dead, and the component would never re-render when the object is finally inserted. ## Related - #4375 — same symptom; closed after guarding `cachedCollection` / `cachedObject`. This third site appears to have been missed. - #3889 — still open, reports the same error generally but without this attribution.

Comments