Skip to content

Subscription rebuild (readOnly / queryConstraints / path change) discards un-synced localState #26

Description

@TrystonPerry

Summary

useCollection / useDocument rebuild the underlying subscription whenever readOnly, the resolved path, or the semantic identity of queryConstraints changes (these are dependencies of the useMemo that creates the subscription). On rebuild, the previous subscription's cleanup calls subscription.stop(), which clears the autosave timer and discards localState. Any un-synced optimistic edit that was still inside the autosave debounce window is silently lost.

Where

Compiled dist/index.mjs (v0.1.2):

  • useCollection: subscription = useMemo(..., [enabled, store, definition, collectionPath, readOnly, stableConstraints, onPushUndo])readOnly and stableConstraints are deps.
  • subscribe cleanup returns () => { unsub(); subscription.stop(); }.
  • stop() clears autosaveTimeout and the subscription's local state goes away with the discarded closure.
  • useDocument has the equivalent shape.

Repro

  1. useCollection, wait for first snapshot.
  2. Make an edit → localState pending, autosave scheduled (e.g. 1000ms).
  3. Before autosave fires, change a prop that the subscription memo depends on — e.g. flip readOnly truefalse, or pass a new (semantically different) queryConstraints.
  4. The subscription is rebuilt; the old one is stop()ed.

Expected: a pending un-synced edit is preserved across a subscription rebuild (flushed before teardown, or carried into the new subscription).

Actual: the edit is discarded with the old subscription and never written.

Why it bites in practice

readOnly is commonly derived from async-loaded auth/role state, so it flips falsetruefalse during app startup, rebuilding every subscription. It can also change at runtime (permission changes, entering/leaving a read-only revision view). Each flip is a window where in-flight optimistic edits on that collection are dropped. A prior hand-rolled hook kept readOnly out of the listener's effect deps for exactly this reason — toggling read-only changed the write guard without tearing down the listener or its pending state.

Suggested fix

  • Don't tear the subscription down for changes that don't affect the query/identity. readOnly is a write-time guard, not part of the subscription identity — handle it like the runtime onError/onNavigate setters (mutate on the existing subscription) instead of including it in the memo deps.
  • For changes that genuinely require a new subscription (path/constraints), flush or hand off pending localState before stop() rather than discarding it (at minimum, attempt a final sync() on teardown when localState is set).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions