Skip to content

Commit

Permalink
Introducing cacheLifetime and distinction between revalidation and …
Browse files Browse the repository at this point in the history
…invalidation
  • Loading branch information
dkzlv committed Apr 3, 2024
1 parent 93421af commit fa0b916
Show file tree
Hide file tree
Showing 4 changed files with 294 additions and 140 deletions.
55 changes: 31 additions & 24 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,21 +5,12 @@

A tiny data fetcher for [Nano Stores](https://github.com/nanostores/nanostores).

- **Small**. 1.62 Kb (minified and gzipped).
- **Familiar DX**. If you've used [`swr`](https://swr.vercel.app/) or
[`react-query`](https://react-query-v3.tanstack.com/), you'll get the same treatment,
but for 10-20% of the size.
- **Built-in cache**. `stale-while-revalidate` caching from
[HTTP RFC 5861](https://tools.ietf.org/html/rfc5861). User rarely sees unnecessary
loaders or stale data.
- **Revalidate cache**. Automaticallty revalidate on interval, refocus, network
recovery. Or just revalidate it manually.
- **Nano Stores first**. Finally, fetching logic *outside* of components. Plays nicely
with [store events](https://github.com/nanostores/nanostores#store-events),
[computed stores](https://github.com/nanostores/nanostores#computed-stores),
[router](https://github.com/nanostores/router), and the rest.
- **Transport agnostic**. Use GraphQL, REST codegen, plain fetch or anything,
that returns Promises.
- **Small**. 1.93 Kb (minified and gzipped).
- **Familiar DX**. If you've used [`swr`](https://swr.vercel.app/) or [`react-query`](https://react-query-v3.tanstack.com/), you'll get the same treatment, but for 10-20% of the size.
- **Built-in cache**. `stale-while-revalidate` caching from [HTTP RFC 5861](https://tools.ietf.org/html/rfc5861). User rarely sees unnecessary loaders or stale data.
- **Revalidate cache**. Automaticallty revalidate on interval, refocus, network recovery. Or just revalidate it manually.
- **Nano Stores first**. Finally, fetching logic *outside* of components. Plays nicely with [store events](https://github.com/nanostores/nanostores#store-events), [computed stores](https://github.com/nanostores/nanostores#computed-stores), [router](https://github.com/nanostores/router), and the rest.
- **Transport agnostic**. Use GraphQL, REST codegen, plain fetch or anything, that returns Promises (Web Workers, SubtleCrypto, calls to WASM, etc.).

<a href="https://evilmartians.com/?utm_source=nanostores-query">
<img src="https://evilmartians.com/badges/sponsored-by-evil-martians.svg"
Expand All @@ -34,14 +25,11 @@ npm install nanostores @nanostores/query

## Usage

See [Nano Stores docs](https://github.com/nanostores/nanostores#guide)
about using the store and subscribing to store’s changes in UI frameworks.
See [Nano Stores docs](https://github.com/nanostores/nanostores#guide) about using the store and subscribing to store’s changes in UI frameworks.

### Context

First, we define the context. It allows us to share the default fetcher
implementation between all fetcher stores, refetching settings, and allows for
simple mocking in tests and stories.
First, we define the context. It allows us to share the default fetcher implementation and general settings between all fetcher stores, and allows for simple mocking in tests and stories.

```ts
// store/fetcher.ts
Expand Down Expand Up @@ -102,17 +90,21 @@ type Options = {
// The async function that actually returns the data
fetcher?: (...keyParts: SomeKey[]) => Promise<unknown>;
// How much time should pass between running fetcher for the exact same key parts
// default = 4000 (= 4 seconds; provide all time in milliseconds)
// default = 4000 (=4 seconds; provide all time in milliseconds)
dedupeTime?: number;
// Lifetime for the stale cache. It present stale cache will be shown to a user.
// Cannot be less than `dedupeTime`.
// default = Infinity
cacheLifetime?: number;
// If we should revalidate the data when the window focuses
// default = false
refetchOnFocus?: boolean;
revalidateOnFocus?: boolean;
// If we should revalidate the data when network connection restores
// default = false
refetchOnReconnect?: boolean;
revalidateOnReconnect?: boolean;
// If we should run revalidation on an interval
// default = 0, no interval
refetchInterval?: number;
revalidateInterval?: number;
// Error handling for specific fetcher store. Will get whatever fetcher function threw
onError?: (error: any) => void;
}
Expand Down Expand Up @@ -228,6 +220,21 @@ Keep in mind: we're talking about the serialized singular form of keys here. You

## Recipes

### How cache works + Terminology

All of this is based on [`stale-while-revalidate`](https://tools.ietf.org/html/rfc5861) methodology. The goal is simple:

1. user visits `page 1` that fetches `/api/data/1`;
2. user visits `page 2` that fetches `/api/data/2`;
3. almost immediately user goes back to `page 1`. Instead of showing a spinner and loading data once again, we fetch it from cache.

So, using this example, let's try to explain different cache-related settings the library has:

- `dedupeTime` is the time that user needs to spend on `page 2` before going back for the library to trigger fetch function once again.
- `cacheLifetime` is the maximum possible time between first visit and second visit to `page 1` after which we will stop serving stale cache to user (so they will immediately see a spinner).
- `revalidate` forces the `dedupeTime` for this key to be 0, meaning, the very next time anything can trigger fetch (e.g., `refetchOnInterval`), it will call fetch function. If you were on the page during revalidation, you'd see cached value during loading.
- `invalidate` kills this cache value entirely—it's as if you never were on this page. If you were on the page during invalidation, you'd see a spinner immediately.

### Local state and Pagination

All examples above use module-scoped stores, therefore they can only have a single
Expand Down
167 changes: 115 additions & 52 deletions lib/__tests__/main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -251,15 +251,13 @@ describe("fetcher tests", () => {
test("do not send request if it was sent before dedupe time", async () => {
const keys = ["/api", "/key"];

const fetcher = vi
.fn()
.mockImplementation(() => new Promise((r) => r("data")));
const fetcher = vi.fn().mockImplementation(async () => "data");

const [makeFetcher] = nanoquery();
const store = makeFetcher(keys, { fetcher, dedupeTime: 20 });
const store = makeFetcher(keys, { fetcher, dedupeTime: 200 });
{
const unsub = store.listen(noop);
await advance(10);
await advance();
expect(store.get()).toEqual({ data: "data", loading: false });
unsub();
}
Expand All @@ -270,9 +268,9 @@ describe("fetcher tests", () => {
expect(store.get()).toEqual({ data: "data", loading: false });
unsub();
expect(fetcher).toHaveBeenCalledOnce();
await advance(30);
}

await advance(300);
store.listen(noop);
await advance();
expect(store.get()).toEqual({ data: "data", loading: false });
Expand Down Expand Up @@ -493,7 +491,7 @@ describe("fetcher tests", () => {
const store = makeFetcher(keys, {
fetcher,
dedupeTime: 0,
refetchInterval: 5,
revalidateInterval: 5,
});
const unsub = store.listen(() => null);
$id.set("");
Expand Down Expand Up @@ -548,31 +546,6 @@ describe("fetcher tests", () => {
expect(fetcher).toHaveBeenCalledTimes(2);
});

test("consecutive error does not wipe cache", async () => {
const keys = ["/api", "/key"];

const fetcher = vi.fn().mockImplementationOnce(async () => {
console.log("data fetcher");
return "data";
});

const [makeFetcher] = nanoquery();
const store = makeFetcher(keys, { fetcher, dedupeTime: 0 });
store.listen(noop);

await advance();
expect(store.get()).toEqual({ data: "data", loading: false });
fetcher.mockImplementationOnce(async () => {
console.log("err fetcher");
throw "err";
});

// Getting a new listener to spark a new fetch
store.listen(noop);
await advance();
expect(store.get()).toEqual({ error: "err", data: "data", loading: false });
});

test("onError handler is called whenever error happens", async () => {
const keys = ["/api", "/key"];

Expand Down Expand Up @@ -607,24 +580,69 @@ describe("fetcher tests", () => {

test("uses pre-set cache when fetching from a completely new context", async () => {
const keys = ["/api", "/key"];
const fetcher = vi.fn().mockImplementation(async () => "new data");
const fetcher = vi.fn();

const now = new Date().getTime();
const cache = new Map(),
initial = "old data";
initial = { data: "old data", created: now, expires: now + 1000 };
cache.set(keys.join(""), initial);

const [makeFetcher] = nanoquery({ fetcher, cache });
const $store = makeFetcher(keys);

const events: any[] = [];
$store.subscribe((v) => events.push(v));
$store.subscribe(noop);
await advance();

expect(events[0]).toMatchObject({ data: initial, loading: true });
expect(events[events.length - 1]).toEqual({
loading: false,
data: "new data",
expect($store.value).toMatchObject({ loading: false, data: initial.data });
expect(fetcher).toHaveBeenCalledTimes(0);
});

test("`cacheLifetime` higher than `dedupeTime` leads to stale cache showing despite running fetcher function", async () => {
let callCount = 0;

const fetcher = vi.fn().mockImplementation(async (key) => {
await delay(10);
return key + callCount++;
});
const [makeFetcher] = nanoquery({
fetcher,
cacheLifetime: 2000,
dedupeTime: 100,
});

const $key = atom("a");
const $fetcher = makeFetcher([$key]);
$fetcher.listen(noop);

await advance();
await advance(10);
await advance(10);
expect($fetcher.value).toMatchObject({ loading: false, data: "a0" });

$key.set("b");
await advance();
await advance(10);
await advance(10);
expect($fetcher.value).toMatchObject({ loading: false, data: "b1" });
await advance(100);
await advance(100);

// Dedupe time has passed, but cache lifetime is still ok!
$key.set("a");
await advance();
expect($fetcher.value).toMatchObject({ loading: true, data: "a0" });
await advance(100);
await advance(100);
expect($fetcher.value).toMatchObject({ loading: false, data: "a2" });

// Both dedupe time and cache lifetime are way past
await advance(50000);
$key.set("b");
await advance();
expect($fetcher.value!.loading).toBe(true);
expect($fetcher.value!.data).toBeUndefined();
await advance(100);
await advance(100);
expect($fetcher.value).toMatchObject({ loading: false, data: "b3" });
});
});

Expand All @@ -637,8 +655,8 @@ describe("refetch logic", () => {
const [makeFetcher] = nanoquery();
const store = makeFetcher(keys, {
fetcher,
refetchOnReconnect: true,
refetchOnFocus: true,
revalidateOnReconnect: true,
revalidateOnFocus: true,
dedupeTime: 0,
});
store.listen(noop);
Expand Down Expand Up @@ -667,7 +685,7 @@ describe("refetch logic", () => {
const [makeFetcher] = nanoquery();
const store = makeFetcher(keys, {
fetcher,
refetchInterval: 5,
revalidateInterval: 5,
dedupeTime: 0,
});

Expand Down Expand Up @@ -703,8 +721,8 @@ describe("refetch logic", () => {
const [makeFetcher] = nanoquery();
const store = makeFetcher(keys, {
fetcher,
refetchOnFocus: true,
refetchInterval: 100,
revalidateOnFocus: true,
revalidateInterval: 100,
dedupeTime: 2e200,
});

Expand All @@ -721,6 +739,38 @@ describe("refetch logic", () => {
}
expect(listener).toHaveBeenCalledTimes(2);
});

test("store doesn't reset its value after getting a revalidate/invalidate trigger if it has an active subscriber", async () => {
let count = 0;
const fetcher = vi.fn().mockImplementation(async () => {
await delay(10);
return count++;
});
const [makeFetcher] = nanoquery({
fetcher,
dedupeTime: 100,
cacheLifetime: 100,
});

const $store = makeFetcher("/key");
$store.listen(noop);
await advance(10);
await advance(10);
await advance(10);
expect($store.value).toMatchObject({ data: 0, loading: false });
$store.revalidate();
await advance(0);
expect($store.value).toMatchObject({ loading: true, data: 0 });
await advance(10);
await advance(10);
expect($store.value).toMatchObject({ loading: false, data: 1 });
$store.invalidate();
expect($store.value?.loading).toBe(true);
expect($store.value?.data).toBeUndefined();
await advance(10);
await advance(10);
expect($store.value).toMatchObject({ loading: false, data: 2 });
});
});

describe("mutator tests", () => {
Expand All @@ -741,6 +791,18 @@ describe("mutator tests", () => {
return pr;
});

test("mutator unsets its value after last subscriber stops listening", async () => {
const [, makeMutator] = nanoquery();
const $mutate = makeMutator<void, string>(async () => "hey");
const unsub = $mutate.listen(noop);
await $mutate.mutate();
expect($mutate.value?.loading).toBeFalsy();
expect($mutate.value?.data).toBe("hey");

unsub();
expect($mutate.value?.data).toBeUndefined();
});

test("client-side idempotency of mutation calls", async () => {
const [, makeMutator] = nanoquery();
const mock = vi.fn().mockImplementation(async () => {
Expand All @@ -749,6 +811,7 @@ describe("mutator tests", () => {
});

const $mutate = makeMutator<void, string>(mock);
$mutate.listen(noop);

expect($mutate.value!.loading).toBeFalsy();
for (let i = 0; i < 5; i++) {
Expand All @@ -772,6 +835,7 @@ describe("mutator tests", () => {
});

const $mutate = makeMutator<void, string>(mock, { throttleCalls: false });
$mutate.listen(noop);

expect($mutate.value!.loading).toBeFalsy();
for (let i = 0; i < 5; i++) {
Expand All @@ -790,6 +854,7 @@ describe("mutator tests", () => {
test(`transitions work if you're not subscribed to the store`, async () => {
const [, makeMutator] = nanoquery();
const $mutate = makeMutator<void, string>(async () => "hey");
$mutate.listen(noop);

const pr = $mutate.mutate();
await advance();
Expand Down Expand Up @@ -856,10 +921,9 @@ describe("mutator tests", () => {

const $mutate = makeMutator<string>(async ({ getCacheUpdater, data }) => {
try {
expect(data).toBe("hey");
const [mutateCache, prevData] = getCacheUpdater(keyParts.join(""));
expect(prevData).toBe(0);
mutateCache("mutated manually");
mutateCache(data);
} catch (error) {
console.error(error);
}
Expand All @@ -869,11 +933,10 @@ describe("mutator tests", () => {
await advance(10);
expect(store.get()).toEqual({ loading: false, data: 0 });

const { mutate } = $mutate.get();
await mutate("hey");
expect(store.get()).toMatchObject({
await $mutate.mutate("hey");
expect(store.value).toMatchObject({
loading: true,
data: "mutated manually",
data: "hey",
});

await advance();
Expand Down
Loading

0 comments on commit fa0b916

Please sign in to comment.