Replicache 12.0.0

Declarative indexes and 7x faster bulk populate performance

December 02, 2022

release

Summary

This release improves write performance by up to 7x. It also introduces a new declarative API for indexes.

🔌 Install

npm add replicache@12.0.0

Important: There are breaking changes that need to be considered while upgrading. Please see How to Upgrade for step-by-step instructions or read below for details.

🎁 Features

  • Indexes are now defined declaratively using the indexes parameter. This simplifies usage with tabbed browsing. See Declarative Indexes.

🧰 Fixes

  • Fixed some InvalidStateError: The database connection is closing errors (#973).
  • Exported Mutation. This is useful when implementing Replicache backends in TypeScript.
  • Fixed the type of PullRequest's cookie field (#1032).
  • Changed the type of SubscribeOptions.onError's parameter to unknown to correctly reflect that in JavaScript any value can be thrown.

⚠️ Breaking Changes

  • Values passed into and out of Replicache are no longer defensively cloned and should be considered immutable. See Immutability.
  • ReadonlyJSONObject and JSONObject no longer accept undefined values. See Undefined.

⚡️ Performance

  • Up to 7x performance improvement since v11.3.2 when populating Replicache.
  • Storing to IndexedDB got about 2x faster. This happens after a short delay so it does not impact WriteTransactions but storing to IDB happens on the main thread so it can cause jank if it takes too long.

Declarative Indexes

Before version 12, Replicache supported persistent indexes with createIndex() and dropIndex().

This imperative API had problems with tabs. If you wanted to change the definition of an index, there was no good way to know when to drop the old version of the index. There could be some other tab open that needed the index.

To solve this, Replicache 12 introduces declarative indexes:

const rep = new Replicache({
  name: 'foo',
  indexes: {
    byTitle: {
      prefix: 'todo/',
      jsonPointer: '/title',
    },
  },
});

Replicache creates an index if a client needs one that doesn't exist, and it drops indexes that haven't been used for awhile.

The createIndex and dropIndex methods have been deprecated.

Immutability

Replicache caches recently used values in memory for performance reasons.

Because of this, we need to prevent callers from modifying those cached values. For example, consider this mutator:

function createTodo(tx: WriteTransaction, todo: Todo) {
  const {id} = todo;
  await tx.put(id, todo);
}

If the caller modifies the passed todo object after calling createTodo() it can cause problems. The todo may have been previously read and in use elsewhere in the app. Modifications will also confuse Replicache's internal change detection used by subscribe().

Before version 12, Replicache prevented these issues by cloning the argument to put() defensively. That way, if the caller later modifies it, there's no effect on Replicache or previous reads. Similarly, Replicache in many cases cloned returned values from methods like get().

This worked, but the clones were very expensive. The cost was especially painful in the common case where the caller didn't actually need or want to mutate anything.

In version 12 we have removed these defensive clones throughout. Now all data passed into or returned from Replicache is considered immutable, and must not be changed by the application.

Immutability Example

The following common pattern is no longer supported:

async function markCompete(tx: WriteTransaction, id: string) {
  const todo = await tx.get(id);
  // Modify value returned from Replicache.
  // No longer supported!
  // Throws TypeError in dev mode.
  todo.complete = true;
  await tx.put(id, todo);
}

Instead, you must clone the returned value expicitly if you want to change it:

async function markCompete(tx: WriteTransaction, id: string) {
  const prev = await tx.get(id);
  const next = {...prev, complete: true};
  await tx.put(id, next);
}

Enforcement

To help prevent mistakenly modifying cached data, Replicache does two things:

  1. In dev mode (process.env.NODE_ENV !== "production") we freeze all data passed into or returned from Replicache. This causes attempts to mutate to throw an error.

  2. All data passed into or out of Replicache is marked ReadonlyJSONValue. This prevents some kinds of mutation at compile-time. When you see ReadonlyJSONValue you should treat that data as immutable. Do not cast away the readonly-ness. If you need to modify ReadonlyJSONValue, make a copy of it, then modify that.

We also recommend marking your own datatypes deeply readonly where possible. A simple TS type to do this can be found in this gist.

Undefined and JSON

Replicache defines JSONValue and ReadonlyJSONValue and these are used throughout the API.

Prior to Replicache 12, these types allowed undefined fields on objects as a convenience, to support patterns like:

const thing: string | undefined = getThing();
rep.mutate.foo({
  id: 123,
  bar: thing,
});

In the above case, bar will either have the value undefined or a string value.

However, undefined is not in fact a valid JSON type value. And by default JSON.stringify() ignores properties with undefined values:

> JSON.stringify({foo:undefined})
{}

To prevent subtle bugs related to this, version 11 and earlier filtered undefined values out when cloning inputs.

But since we are no longer cloning inputs there's no place to do this filtering. As such, we no longer support undefined at all.

Fixing Undefined Errors

Instead of the above pattern, you should write something like:

const args: {id: number; bar?: string} = {
  id: 123,
};
if (thing !== undefined) {
  args.bar = thing;
}
rep.mutate.foo(args);

or using spread:

const args = {
  id: 123,
  ...(thing !== undefined ? {bar: thing} : {}),
};
rep.mutate.foo(args);

Enforcement

In debug builds, version 12 validates JSONValue inputs and throws an error at runtime if undefined is found.

Unfortunately, this can't be caught at compile time with TypeScript's default settings. To catch these errors at compile time, consider enabling exactOptionalPropertyTypes in your tsconfig.

How to Upgrade to v12

Install the new version

npm add replicache@12.0.0

Test your app in development mode (compiled with NODE_ENV !== "production")

This will likely uncover errors like:

Error: Cannot assign to read only property

For example, many mutators prior to v12 would read data out of Replicache, modify it, then rewrite it. These must be changed to clone the read data first, mutate the clone, then write the mutated clone. See Immutability Example for an example of how to do this.

You may also encounter errors like:

Error: Invalid type: undefined, expected JSON value

This error means that you passed undefined to a place that wants JSONValue or ReadonlyJSONValue. You should change the code to avoid passing undefined. See Fixing Undefined Errors for an example.

Optional: Enable exactOptionalPropertyTypes

You can catch more instances of passing undefined to JSONValue at compile-time by enabling exactOptionalPropertyTypes in your tsconfig.