nuxt-multi-cache icon indicating copy to clipboard operation
nuxt-multi-cache copied to clipboard

useCachedAsyncData composable

Open or2e opened this issue 1 year ago • 1 comments

I may be wrong, but most often we have to deal with asyncData To avoid routine, I suggest creating a wrap-composable useCachedAsyncData

Example
import type { NuxtApp, AsyncDataOptions } from 'nuxt/app';
import type { KeysOf } from 'nuxt/dist/app/composables/asyncData';

export function useCachedAsyncData<
    ResT,
    DataT = ResT,
    PickKeys extends KeysOf<DataT> = KeysOf<DataT>,
    DefaultT = null
>(
    key: string,
    handler: (ctx?: NuxtApp) => Promise<ResT>,
    options: AsyncDataOptions<ResT, DataT, PickKeys, DefaultT> & {
        cacheKey: string;
        cacheTags: string[];
        cacheExpires?: number;
    }
) {
    // We need to cache transformed value to prevent value from being transformed every time.
    const transform = options?.transform;
    // Remove transform from options, so useAsyncData doesn't transform it again
    const optionsWithoutTransform = { ...options, transform: undefined };

    return useAsyncData(
        key,
        async () => {
            const { value, addToCache } = await useDataCache<
                DataT | Awaited<ResT>
            >(options.cacheKey);

            if (value) {
                return value;
            }

            const _result = await handler();
            const result = transform ? transform(_result) : _result;

            addToCache(result, options.cacheTags, options.cacheExpires);

            return result;
        },
        optionsWithoutTransform
    );
}

or2e avatar Aug 06 '23 23:08 or2e

This sounds interesting, I will give this a try!

dulnan avatar Aug 12 '23 09:08 dulnan

Oh yes this would be so handy!

Crease29 avatar Jun 16 '24 11:06 Crease29

@or2e I'm now looking into implementing this. Was there a particular reason you added the cacheKey as an option instead of using the key argument provided? Just wondering, maybe I'm not thinking of a use case :smile:

dulnan avatar Jul 05 '24 12:07 dulnan

99% of the time they match

or2e avatar Jul 05 '24 12:07 or2e

Right - so I guess it would be fine to reuse that key and make it required.

For the cacheExpires and cacheTags options: I thought about making these methods, that receive the untransformed result. That way cacheability metadata like tags and expires coming from a backend response could be used.

dulnan avatar Jul 05 '24 12:07 dulnan

@or2e @Crease29 I've implemented it now and opened a PR, if you like you can take a look and tell me if the implementation makes sense :smile: The docs are here: https://deploy-preview-58--nuxt-multi-cache.netlify.app/composables/useCachedAsyncData

dulnan avatar Jul 05 '24 15:07 dulnan

Thank you so much! :)

Regarding the documentation: In the very first example, I'd actually replace weather with users and unify the cache key with the full example. In the full example I'd personally prefer showing the use with static cache tags, I think that's more common than the response including cache tags.

Crease29 avatar Jul 05 '24 15:07 Crease29

It seems your implementation uses the regular useAsyncData() composable in client side. IMHO, it would be nice to also have a kind of memoization with a TTL by using transform() and getCachedData().

That's what I've done in my current project.

Here is the implementation :

// useCacheableAsyncData.ts
import { toRef, toValue } from 'vue';

import { callWithNuxt, type NuxtApp } from '#app';
import type { AsyncDataOptions, KeysOf } from '#app/composables/asyncData';
import { useDataCache } from '#nuxt-multi-cache/composables';
import { assertValidCacheKey, assertValidTtl } from '~/lib/cache-utils';

export interface TimestampedPayload<T> {
  payload: T;
  issuedAt: number;
}

function wrapPayloadWithTimestamp<T>(payload: T, issuedAt = Date.now()): TimestampedPayload<T> {
  return {
    payload,
    issuedAt,
  };
}

function unwrapTimestampedPayload<T>({ payload }: TimestampedPayload<T>): T {
  return payload;
}

export type CachedAsyncDataOptions<
  // eslint-disable-next-line unicorn/prevent-abbreviations
  ResT,
  DataT = ResT,
  PickKeys extends KeysOf<DataT> = KeysOf<DataT>,
  DefaultT = null,
> = Omit<AsyncDataOptions<ResT, DataT, PickKeys, DefaultT>, 'transform' | 'getCachedData'> & {
  /**
   * Time To Live in milliseconds
   * @example 60_000 for 1 min
   */
  ttl?: number;
  cacheTags?: string[] | ((response: ResT) => string[]);
};

export default async function useCacheableAsyncData<
  // eslint-disable-next-line unicorn/prevent-abbreviations
  ResT,
  NuxtErrorDataT = unknown,
  DataT extends TimestampedPayload<ResT> = TimestampedPayload<ResT>,
  PickKeys extends KeysOf<DataT> = KeysOf<DataT>,
  DefaultT = DataT,
>(
  key: string,
  handler: (context?: NuxtApp) => Promise<ResT>,
  options?: CachedAsyncDataOptions<ResT, DataT, PickKeys, DefaultT>,
) {
  const { ttl, cacheTags = [], ...otherOptions } = options ?? {};

  if (ttl !== undefined) {
    assertValidTtl(ttl);
  }

  assertValidCacheKey(key);

  const { data, ...others } = await useAsyncData<
    ResT,
    NuxtErrorDataT,
    TimestampedPayload<ResT> | undefined,
    PickKeys,
    DefaultT
  >(
    key,
    async (nuxtApp) => {
      const { value, addToCache } = await useDataCache<ResT>(key);

      if (value) {
        return value;
      }

      const response = nuxtApp === undefined ? await handler(nuxtApp) : await callWithNuxt(nuxtApp, handler, [nuxtApp]);

      await addToCache(
        response,
        Array.isArray(cacheTags) ? cacheTags : cacheTags(response),
        ttl ? ttl / 1000 : undefined,
      );

      return response;
    },
    {
      ...otherOptions,
      transform(input) {
        return wrapPayloadWithTimestamp(input);
      },
      getCachedData(key, nuxtApp) {
        const data: DataT | undefined = (nuxtApp.payload.data[key] ?? nuxtApp.static.data[key]) as DataT | undefined;

        // No data in payload
        if (data === undefined) {
          return;
        }

        if (ttl !== undefined && data.issuedAt + ttl < Date.now()) {
          return;
        }

        return data;
      },
    },
  );

  // data.value cannot be undefined at this point. Using `as` to fix type. Maybe this is a typing issue in Nuxt source code
  const value = toValue(data.value) as TimestampedPayload<ResT>;

  return { data: toRef(() => unwrapTimestampedPayload(value)), ...others };
}

Usage:

const { data } = useCacheableAsyncData(
  'some:cache:key',
  async () => {
    return Promise.resolve('Some data');
  },
  {
    deep: false,
    ttl: import.meta.server ? CACHE_TTL_MS_12_HOURS : CACHE_TTL_MS_15_MIN,
  },
);

Using import.meta.server, I have even been able to specify different TTLs for server and client (as they have their own bundle) with way shorter TTLs in client.

I didn't found any downside for now.

WDYT ?

bgondy avatar Jul 05 '24 16:07 bgondy

@bgondy I actually have been thinking about extending useDataCache to the client-side as well. And indeed your approach with a maxAge that would also apply to the client side is a nice idea. My main concern is (the classic) question of cache invalidation client side. Obviously a simple browser refresh always "purges" the cache. But imho it would have to work a bit like useAsyncData's clear and refresh.

In this case here, however, since the underlying useAsyncData already does some rudimentary "caching" anyway, we could indeed add client-side caching here. Especially since the name useCachedAsyncData basically implies that things will be cached. I will give this a try.

dulnan avatar Jul 06 '24 05:07 dulnan

Alright, I gave this a shot and added client-side caching. I've used the example from @bgondy as a basis, but changed the behaviour:

  • It also stores and gets subsequent client-side handler results in nuxtApp.static.data
  • Behaviour during hydration is identical with Nuxt
  • It determines the expire date for payload cached data based on "time of first hydration"
  • I've renamed the options to serverMaxAge and serverCacheTags
  • To opt-in for client-side caching, a clientMaxAge value has to be provided in the form of a positive integer

I first wanted to have a single maxAge option for both client and server side. But the problem is that, when a method is provided, it can only receive the full (untransformed) data during SSR. On the client that full untransformed result is obviously not available anymore. The argument would have to be optional, but that's a bit annoying.

Let me know if this makes sense. And a big thanks to all for all the constructive feedback and ideas!

dulnan avatar Jul 06 '24 07:07 dulnan

It also stores and gets subsequent client-side handler results in nuxtApp.static.data

This is so cool !

LGTM

bgondy avatar Jul 06 '24 16:07 bgondy

Now available in 3.3.0

dulnan avatar Jul 28 '24 05:07 dulnan