@getcircuit/snappy

Snappy was born from the idea of not creating duplicate snapshot listeners for a single document. Since then we've confirmed that Firebase already does that (hooray!). However, Snappy provides other useful features such as Bundles. Go check it!

Usage no npm install needed!

<script type="module">
  import getcircuitSnappy from 'https://cdn.skypack.dev/@getcircuit/snappy';
</script>

README

Snappy

Snappy was born from the idea of not creating duplicate snapshot listeners for a single document. Since then we've confirmed that Firebase already does that (hooray!). However, Snappy provides other useful features such as Bundles. Go check it!

Options

type Options = {
    // if true, debug logs will be output to the console
    // default: false
    debug?: boolean
    // the delay to unmount a snapshot listener when it has no observers
    // default: 30s
    cleanupDelay?: number
    // the actual firestore object
    firestore: firebase.firestore.Firestore
})

Options can be set via the setConfig method.

Watchers

Snappy provide watcher methods for one to subscribe to changes to a snapshot target.

function watchDocument(document, onNext, onError): UnsubcribeFn
function watchCollection(collection, onNext, onError): UnsubcribeFn
function watchQuery(query, onNext, onError): UnsubcribeFn
function watchBundle(bundleDescription, onNext): UnsubcribeFn

Every watcher returns an unsubscribe method that will cancel the subscription created by it. A watcher works by:

  1. Creating a listener to the passed target if it doesn't exist yet.
  2. Attaching a new observer to the listener.
  3. Returning a method to cancel the subscription, detaching the observer from the listener.

Once a listener doesn't have any observers left, a timer is schedulled to destroy the listener. The default delay for destroying a listener is 30s.

Note: a bundle listener is currently an exception as it's destroyed immediately.

API

Document, Collection and Query watchers

function watchDocument(document, onNext, onError): UnsubcribeFn
function watchCollection(collection, onNext, onError): UnsubcribeFn
function watchQuery(query, onNext, onError): UnsubcribeFn

Document, collection and query watchers support a onNext and onError parameters. The former being executed when a change is dispatched succesfully and the latter when an error occurs.

The onNext receives a single parameter, called bag, which hold the values of what the target being watched. The onNext method can also return a function that will be executed when the observer is unsubscribed.

readDocument

Allows to read the value of a document in non-callback manner.

function readDocument(
  document,
): Promise<{
  id: string
  ref: DocumentReference
  data: unknown
  exists: boolean
}>

Generally, you should read the value by watching it and using the value as it changes over time. Occasionally, you may need to retrieve the value to which you're not subscribed. readDocument allows you to do so.

This works by creating a subscription, reading the value, then unsubscribing. It's therefore not recommended in hot code paths.

Bundle

A bundle is a custom type of snapshot target that allows one to bundle information from multiple snapshot targets, such as documents, collections, queries and even other bundles into a single state.

Snappy provides a describeBundle method to help with a bundle's creation typings. It's just a method that retuns the object it received, but with the appropriate type-checks.

/** Helper function to create typings for the bundle */
function describeBundle<State, Bag = State>(bundle: Bundle): Bundle<State, Bag>

A bundle is an object in the format:

type Bundle<State, Bag = State> = {
  /**
   * ID to recognize this bundle whe deduplicate observers
   * Make sure this is unique.
   * */
  id: string
  /** Initial state for the target */
  initialState?: Partial<State>
  /**
   * Callback dispatched whenever the state is changed via state.assign(), state.set() or state.update()
   **/
  onStateChange: ({ state, next }: { state: Partial<State>, next: (value: Bag) => void }) => void
  /**
   * Method that runs when the bundle is watched.
   * The setup method of a nested bundle is invoked everytime it's parent observer is invoked.
   *  */
  setup: (setupArgs: { watchers: Watchers; state: BundleState<State> }) => void
  /**
   * Method executed when the bundler is being unlistened. Useful to cleanup timers and whatnot.
   */
  cleanup: (state: Partial<State>) => void
}

A bundle has three main aspects: its id, state and watchers.

  • The bundle id must be unique in the same manner an id is unique for a document. It's maily used to recognize if a bundle already has a listener for it.

  • Bundle watchers are similar to the root watchers, apart from two key differences:

    • Any subscription made with them is automatically cancelled once the bundle is not observed anymore.
    • Nesting watchers is supported. Any watcher executed inside another has its subscription automatically collected and it won't be created more than once. If the parent subscription is cancelled, the children's will also be.
  • The state is an object of the type BundleState, which is just a data structure that allows the developer to update the state while notifying any changes made to it via the onStateChange and the next parameter it receives.

Bundle state

The BundleState is an object in the format:

type BundleState<Data = any> = {
  /** Get the raw state object */
  get(): Data
  /** Overrides the whole state object */
  set(value: Data): void
  /** Assign key/values to the state object */
  assign(value: Partial<Data>): void
  /**
   * Passes the raw state object to a function.
   * If the function returns anything but undefined, the state is overwritten with that value.
   * Otherwise, it's expected that the changes will happen on the same passed reference.
   * */
  update(updateFn: (obj: Data) => void | Data): void
}

These methods are accessible in the setup method of a bundle description and is responsible for assembly the state data. The setup method is invoked everytime that a bundle is listened to or whenever a parent observer is invoked again.

Watching a bundle

function watchBundle(bundleDescription, onNext): UnsubcribeFn

A bundle can be watched in the same way as a document. The onNext receives a single parameter, called bag, which hold the state of what the bundle. The onNext method can also return a function that will be executed when the observer is unsubscribed. This is specially useful to reset bundle states when using nesting bundles.

Bundle example

With these in mind, let's create a bundle for a structure that we're calling Consolidated Route. A consolidated route, per definition, is an object with:

  • Data from a Route document
  • Data from the driver of the route, fetched from the property driver of the Route document, which is a reference to another document.
  • Data from the stops of the route, fetched from a collection of the Route Document

If the route is deleted, we want undefined as the result of watching it.

function describeConsolidatedRouteBundle(routeRef: DocumentReference | string) {
  const ref = Snappy.getDocRef(routeRef)

  return Snappy.describeBundle<ConsolidatedRoute | undefined>({
    id: `route-${ref.id}`,
    initialState: {
      id: ref.id,
      data: undefined,
      stops: undefined,
      driver: undefined,
    },
    onStateChange(route, next) {
      if (route == null) return next(route)
      if (route.data == null) return
      if (route.stops == null) return
      if (route.driver == null) return

      next(route as ConsolidatedRoute)
    },
    setup({ watchers, state }) {
      watchers.watchDocument(routeRef, (routeBag) => {
        if (routeBag.exists === false) {
          state.set(undefined)
          return
        }

        state.assign({ data: routeBag.data })

        watchers.watchDocument(routeBag.data.driver, (driverBag) => {
          state.assign({ driver: driverBag.data })
        })

        watchers.watchCollection(
          routeBag.ref.collection('stops'),
          (stopsBag) => {
            state.assign({ stops: stopsBag.data })
          },
        )

        return () => {
          state.set(undefined)
        }
      })
    },
  })
}

Note that we're using watchers.watchCollection and watchers.watchDocument. The biggest difference from a root watcher and a bundle watcher is that bundle watchers are automatically unsubscribed once the parent subscriptions is cancelled. Using the root variants would make the developer need to do some house-keeping to prevent memory leaks.

And now, imagine that we want to use this bundle and load a consolidated route in a React application. Let's build a custom hook for it:

function useConsolidatedRoute(ref) {}
  const [ref, setRef] = useState<DocumentReference | undefined>(undefined)
  const [route, setRoute] = useState<ConsolidatedRoute | undefined>(undefined)

  const unloadRoute = () => setRoute(undefined)

  const loadRoute = (routeRefPath: string) => {
    setRef(Snappy.getDocRef(routeRefPath))
  }

  useEffect(() => {
    if (ref == null) return

    return Snappy.watchBundle(describeConsolidatedRouteBundle(ref), setRoute)
  }, [ref])

  return {
    loadRoute,
    unloadRoute,
    route,
  }
}

Glossary

Term: Target / Snapshot Target

A target is some structure that can be watched for changes.

  • In the case of firestore, a target can be a Document, a Collection or a Query.

Term: Listener

A listener is responsible for watching changes on a target and notify them to all its observers.

  • There's always a single listener for a given target. In the case of firestore, this means we only create a single snapshot listener for the given target.
  • A listener can have any amount of observers attached to it.

Term: Observer

An observer is an object that receives changes that a listener notified and does something with it. Observers should be synchronous.

Term: Watcher

A watcher is a method that creates a subscription to a target changes.

  • Root watchers are the methods exported by the Snappy module.
  • Bundle watchers are the methods binded to a bundle.

Term: Bundle

A bundler is a custom snapshpot target provided by Snappy. Read more about it in the bundle section.