Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

useCachedAsyncData composable #25

Closed
or2e opened this issue Aug 6, 2023 · 12 comments
Closed

useCachedAsyncData composable #25

or2e opened this issue Aug 6, 2023 · 12 comments
Assignees
Labels
enhancement New feature or request

Comments

@or2e
Copy link
Contributor

or2e commented Aug 6, 2023

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
    );
}
@dulnan
Copy link
Owner

dulnan commented Aug 12, 2023

This sounds interesting, I will give this a try!

@Crease29
Copy link
Contributor

Oh yes this would be so handy!

@dulnan dulnan added the enhancement New feature or request label Jun 16, 2024
@dulnan dulnan self-assigned this Jun 16, 2024
@dulnan
Copy link
Owner

dulnan commented Jul 5, 2024

@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 😄

@or2e
Copy link
Contributor Author

or2e commented Jul 5, 2024

99% of the time they match

@dulnan
Copy link
Owner

dulnan commented Jul 5, 2024

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
Copy link
Owner

dulnan commented Jul 5, 2024

@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 😄 The docs are here: https://deploy-preview-58--nuxt-multi-cache.netlify.app/composables/useCachedAsyncData

@Crease29
Copy link
Contributor

Crease29 commented Jul 5, 2024

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.

@bgondy
Copy link
Contributor

bgondy commented Jul 5, 2024

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 ?

@dulnan
Copy link
Owner

dulnan commented Jul 6, 2024

@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
Copy link
Owner

dulnan commented Jul 6, 2024

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!

@bgondy
Copy link
Contributor

bgondy commented Jul 6, 2024

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

This is so cool !

LGTM

dulnan added a commit that referenced this issue Jul 9, 2024
* feat: implement useCachedAsyncData (#25)

* check options before spreading

* prevent "Excessive stack depth comparing types"

* Apply suggestions from code review

Co-authored-by: Kai Neuwerth <[email protected]>

* feat: implement client-side caching in useCachedAsyncData (#25)

---------

Co-authored-by: Kai Neuwerth <[email protected]>
@dulnan
Copy link
Owner

dulnan commented Jul 28, 2024

Now available in 3.3.0

@dulnan dulnan closed this as completed Jul 28, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
enhancement New feature or request
Projects
None yet
Development

No branches or pull requests

4 participants