Allow adding cache tags that depend on the response of `fetch` or `unstable_cache`

Cross-posting this from GitHub discussion, in the hopes this gets more traction here.

Usecase

In some cases it might be useful to provide cache tags to a cache entry, after the result has been received.

For instance, I might fetch a product from the api:

await unstable_cache(
  () => api.products.get({ id }),
  ["product", id], 
  {
    tags: [`product:${id}`],
  },
)()
// { type: "product", id: 42, ... }

I pass the product:$42 tag so I can invalidate the cached item when the product with that id changes.
It is possible, because we know the id we’re trying to fetch ahead of time and so we can add the tag when calling unstable_cache.

Additionally, I might want to include related products in the response:

await unstable_cache(
  () => api.products.get({ id }, { related: true }),
  ["product", id],
  {
    tags: [`product:${id}`],
  },
)
// { type: "product", id: 42, related: [{ type: "product", id: 85 }, ...], ... }

Here it is incorrect to only add product:42 as a tag, since the result also contains data from other products.
We should also pass product:85 and other product tags from the response so we can correctly invalidate the response when related products change.

The way unstable_cache works now, it is not possible to do this atomically.

Proposal

It would be useful if we could calculate the tags from the response in unstable_cache so the tags can be set atomically:

In next something similar could look like:

await unstable_cache(
  () => api.products.get({ id }, { related: true }),
  ["product", id],
  {
    tags(product) {
      // return the related product's tags too
      return [`product:${id}`, ...product.related.map(p => `product:${p.id}`)]
    }
  },
)
// { type: "product", id: 42, related: [{ type: "product", id: 85 }, ...], ... }

This will allow sites to cache responses way more agressively, since they can tag the cache entries with info about their content.
Being able to this is a major blocker for me to do more fine-grained caching.

Background

One example where a similar pattern is used is RTK Query, where providesTags is allowed to be a function that takes the response as an argument and returns a list of tags.

Workaround

It does seem to be possible to add cache tags to an existing cache key (without invoking the cached function again), but that was only clear to me when I read the unstable_cache code and I think it’s an implementation detail, not an API guarantee.

Additionally, this is not atomic, so it might happen that a cache tag gets revalidated using revalidateTag between the first and second call to unstable_cache in the above function will cause the cache entry to not be cleared properly.

function async workaround_cache(fn, key, options) {
  const res = await unstable_cache(fn, key, {
    tags: typeof options.tags === "function" ? [] : options.tags,
  })
  const tags = typeof options.tags === "function" ? await options.tags(res) : options.tags

  // A second call does not call fn again, but because the key is the same, but it
  // will add the tags to the existing cache entry.
  // (!) DANGER: this is relying on an implementation detail
  // (!) DANGER: this introduces a race condition
  return await unstable_cache(fn, key, {
    tags,
  })
}

It would be good to add this to the unstable_cache API. I’m happy to help and set up a PR if this seems like a good idea.

It seems like the new "use cache" system would allow this to work, as long as we can call cacheTag after await!

function cached() {
  const res = await fn()
  const tags = options.tags(res)
  cacheTags(tags)
  return res
}
1 Like

Let us know if you have any more questions, @romeovs :slight_smile: Happy to pass onto the Next.js team!

This topic was automatically closed 3 days after the last reply. New replies are no longer allowed.