You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Add Collection.observeRecord for records that may be missing - #6
Adds Collection.observeRecord(id), a failsafe version of findAndObserve(id) for records that may not exist (yet), and makes @experimentalFailsafe relations use it so they recover when the related record appears.
findAndObserve(id) calls observer.error() when the record does not exist and observer.complete() when it is deleted. withObservables rethrows that error in render() and ignores completion. In practice:
A list row whose related record is missing (not synced yet, or deleted on another device) crashes the whole React tree unless every row has its own Error Boundary.
A record that arrives later, for example through sync, is never shown, because the observable has already errored.
Relation.observe() goes through findAndObserve, so relations pointing at missing records behave the same way.
@experimentalFailsafe wraps observe() in catchError(() => of(fallback)). That emits the fallback once and completes, so the relation never recovers either.
Changes
Collection.observeRecord(id) emits:
the record if it exists, then again on every change;
null while the record is missing;
the record once a created change for that id arrives on the collection;
null again instead of completing when the record is deleted, and keeps observing after that.
It still errors for invalid ids such as null, like find(). It reuses the existing plumbing: _fetchRecord now delegates to a new _fetchRecordIfExists that returns null instead of throwing, and creation is detected with Collection.experimentalSubscribe. There are no new dependencies. The behaviour of findAndObserve and find is unchanged.
Relation accepts a new failsafe option. When it is set, observe() uses observeRecord instead of findAndObserve, for both mutable and immutable relations.
@experimentalFailsafe marks the decorated relation as failsafe. A missing related record is mapped to the decorator's fallback value, while a relation whose id is null still emits null. The observable now recovers when the record is created. catchError stays in place for other errors and for non-relation values.
Flow types and the hand-maintained .d.ts for Collection and Relation.
Docs in CRUD.md, Components.md and Relation.md, plus a line in CHANGELOG-Unreleased.md.
I picked a separate method rather than findAndObserve(id, { failsafe: true }) because the emitted type differs (Observable<?Record> vs Observable<Record>). A separate name keeps both signatures precise in Flow and TypeScript.
Tests
src/Collection/test.js: the first tests for findAndObserve, covering emit, update, complete on delete, and error when missing. observeRecord cases cover missing → null, created later → record, deleted → null without completing, then re-created → record, and an uncached record whose id contains colons (12:Quiz:hand). There are also tests for a creation racing with the initial fetch, invalid ids, and cleanup on unsubscribe.
src/Relation/test.js: a non-failsafe relation still errors. Failsafe mutable and immutable relations emit null and recover on create, update, delete and re-point.
src/decorators/experimentalFailsafe/test.js (new): relations are marked failsafe, fetch() falls back, and observe() emits the fallback and then recovers. A null id stays null, and arbitrary observables still get catchError.
src/react/withObservables/withObservables.test.js: a null prop from observeRecord renders without throwing and re-renders when the record is created or deleted. Observable errors are still rethrown in render.
Jest for src/Collection src/Relation src/decorators src/react src/observation passes, as do eslint, tslint and flow check.
Example
withObservables(['postId'],({ postId, database })=>({post: database.get('posts').observeRecord(postId),// null until the post exists}))
findAndObserve(id) errors when the record does not exist and completes
when it is deleted. In withObservables this rethrows in render, so a row
whose related record is missing crashes the tree, and a record that
arrives later (e.g. via sync) is never shown. experimentalFailsafe only
catches the error and emits its fallback once, so it never recovers.
observeRecord(id) emits null while the record is missing, the record
once it exists or a record with that id is created, and null again
instead of completing when it is deleted. It keeps observing throughout.
Relations get a failsafe option that makes observe() use observeRecord,
and @experimentalFailsafe sets it, mapping a missing record to the
fallback value, so failsafe relations recover when the record appears.
Adds tests for findAndObserve, observeRecord, failsafe relations and
withObservables rendering a null prop, plus docs and a changelog entry.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Adds Collection.observeRecord(id) so consumers can observe records that may be missing without terminating observation, and enables failsafe relations to recover when related records appear.
Changes:
Adds nullable record observation across creation, updates, deletion, and recreation.
Integrates recovery with failsafe relations and updates Flow and TypeScript declarations.
Adds lifecycle and React integration tests, documentation, and a changelog entry.
File
Description
src/Relation/test.js
Tests missing-record errors and failsafe relation recovery.
src/Relation/index.js
Adds the failsafe relation option and state.
src/Relation/index.d.ts
Declares failsafe relation options in TypeScript.
src/Relation/helpers.js
Routes failsafe observation through observeRecord.
- Model and Collection notify a copy of their subscriber list, so a
subscriber that unsubscribes while being notified no longer makes the
next subscriber miss the notification (e.g. a second observeRecord
subscription missing the null on deletion).
- observeRecord subscribes to the record before emitting it, so a
consumer that unsubscribes synchronously does not leak the listener.
- observeRecord treats a fetched record marked as deleted as missing.
- Failsafe relations with a null id emit null instead of erroring.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Unresolved notification and fetch races can emit deleted or stale models, failsafe observation types omit possible null values, and retained listeners add O(N × M) batch-scanning work.
No open issues 🎉
3 previously missed issues
In code that hasn't changed since last review
Do not emit created records deleted in the same batch
src/Collection/index.js:171
Creating and deleting this ID inside Database.experimentalBatchNotifications() leaves the queued created notification pointing to an already-deleted model. This branch emits that model before null, so consumers using filter(Boolean), take(1) can finish with a deleted record. Check record.syncStatus !== 'deleted' before observing a creation, as the initial-fetch path already does.
Avoid scanning each batch for every observed record
src/Collection/index.js:173
Every subscription retains this collection listener after finding a live record, so each batch is scanned once per observed record, including updates already delivered through model listeners. With N observed rows and M changes, this adds O(N × M) work to the notification path, including for existing failsafe relations now using this method. Avoid full-batch scans for live records through ID-based dispatch or missing-only listeners, while preserving deletion/recreation handling within a batch.
Invalidate pending fetches after matching lifecycle notifications
src/Collection/index.js:179
If creation and deletion occur before the initial find callback returns, deletion clears observedRecord and lets the pending result through again. A delayed result containing pre-deletion raw data then repopulates the cache and emits an obsolete model after null. Permanently invalidate the initial fetch after matching lifecycle notifications, checking validity before converting raw data into cached models.
- On a change notification for the observed id, look the record up in
the record cache (which is updated before notifications are sent)
instead of trusting the notification's record. A record created and
deleted within batched notifications is no longer emitted, and a
record deleted and re-created within one batch is switched to
directly.
- Only subscribe to collection changes while the record is missing, so
observed live records no longer scan every change batch.
- Ignore the initial fetch result once a change notification for the id
has arrived, so a late result cannot emit an outdated record.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🟡 Changes recommended
Pending-fetch races can suppress the initial null emission and cache an outdated row as a live model, causing later lookups or subscriptions to return a deleted record.
Reject superseded fetch results before caching deleted records
src/Collection/index.js:353
When an initial fetch returns an outdated row after the record is created and deleted, this conversion caches a live-looking model before observeRecord checks initialFetchSuperseded. The original observer ignores the result, but a subsequent find() or observeRecord() returns the deleted record from cache. Reject superseded results before materializing or caching them, and extend the delayed-fetch regression test to check the cache and a fresh subscription.
If a record was created and deleted while the initial fetch was still
pending, the change notification superseded the fetch without emitting
anything, so the subscription never emitted its initial null. Emit null
in that case, without emitting null twice in a row.
Also document why a superseded fetch result is still turned into a
cached model: the adapter marks the record as sent to JS, so dropping
it would leave the adapter-side and JS caches out of sync.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The reason will be displayed to describe this comment to others. Learn more.
🔵 Needs a closer look
Human assessment is needed because findings omitted from the published review identify failsafe relation types that hide nullable results and batched notifications that can emit deleted records.
- With batched notifications, an update notification can arrive after
the record was deleted. observeRecord now only emits an update while
the record is still the cached one; the deletion notification that
follows emits null.
- In TypeScript, Relation now accepts a nullable record type (as Flow
already does), so failsafe relations and relations with an optional
ID can be declared as e.g. Relation<User | undefined>, and observe()
and fetch() are typed accordingly. Documented in Relation.md.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Offered upstream as Nozbe#1982, rebuilt on upstream master.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
Collection.observeRecord(id), a failsafe version offindAndObserve(id)for records that may not exist (yet), and makes@experimentalFailsaferelations use it so they recover when the related record appears.Refs Nozbe#1257 and Nozbe#1719.
Problem
findAndObserve(id)callsobserver.error()when the record does not exist andobserver.complete()when it is deleted.withObservablesrethrows that error inrender()and ignores completion. In practice:Relation.observe()goes throughfindAndObserve, so relations pointing at missing records behave the same way.@experimentalFailsafewrapsobserve()incatchError(() => of(fallback)). That emits the fallback once and completes, so the relation never recovers either.Changes
Collection.observeRecord(id)emits:nullwhile the record is missing;createdchange for that id arrives on the collection;nullagain instead of completing when the record is deleted, and keeps observing after that.It still errors for invalid ids such as
null, likefind(). It reuses the existing plumbing:_fetchRecordnow delegates to a new_fetchRecordIfExiststhat returnsnullinstead of throwing, and creation is detected withCollection.experimentalSubscribe. There are no new dependencies. The behaviour offindAndObserveandfindis unchanged.Relationaccepts a newfailsafeoption. When it is set,observe()usesobserveRecordinstead offindAndObserve, for both mutable and immutable relations.@experimentalFailsafemarks the decorated relation as failsafe. A missing related record is mapped to the decorator's fallback value, while a relation whose id isnullstill emitsnull. The observable now recovers when the record is created.catchErrorstays in place for other errors and for non-relation values.Flow types and the hand-maintained
.d.tsforCollectionandRelation.Docs in
CRUD.md,Components.mdandRelation.md, plus a line inCHANGELOG-Unreleased.md.I picked a separate method rather than
findAndObserve(id, { failsafe: true })because the emitted type differs (Observable<?Record>vsObservable<Record>). A separate name keeps both signatures precise in Flow and TypeScript.Tests
src/Collection/test.js: the first tests forfindAndObserve, covering emit, update, complete on delete, and error when missing.observeRecordcases cover missing →null, created later → record, deleted →nullwithout completing, then re-created → record, and an uncached record whose id contains colons (12:Quiz:hand). There are also tests for a creation racing with the initial fetch, invalid ids, and cleanup on unsubscribe.src/Relation/test.js: a non-failsafe relation still errors. Failsafe mutable and immutable relations emitnulland recover on create, update, delete and re-point.src/decorators/experimentalFailsafe/test.js(new): relations are marked failsafe,fetch()falls back, andobserve()emits the fallback and then recovers. Anullid staysnull, and arbitrary observables still getcatchError.src/react/withObservables/withObservables.test.js: anullprop fromobserveRecordrenders without throwing and re-renders when the record is created or deleted. Observable errors are still rethrown in render.Jest for
src/Collection src/Relation src/decorators src/react src/observationpasses, as do eslint, tslint andflow check.Example
🤖 Generated with Claude Code