Skip to content

fix(nuxt): use single synced asyncdata instance per key#31373

Merged
danielroe merged 33 commits into
mainfrom
feat/synced-asyncdata
Apr 14, 2025
Merged

fix(nuxt): use single synced asyncdata instance per key#31373
danielroe merged 33 commits into
mainfrom
feat/synced-asyncdata

Conversation

@danielroe

@danielroe danielroe commented Mar 14, 2025

Copy link
Copy Markdown
Member

🔗 Linked issue

resolves #21532
resolves #24332 and therefore closes #25850
resolves #22348
resolves #27552
resolves #23522 and therefore closes #23993
resolves #27204
resolves #26733

partly implements #15438

📚 Description

This PR is a major reorganisation of the data fetching layer in Nuxt, providing performance/memory improvements + increased consistency.

⚠️ Breaking Changes (only if v4 compatibility is enabled)

1. getCachedData behavior change

The getCachedData option now:

  • Always gets called before refetching data, even when called by watch or refreshNuxtData
  • It receives a context object with additional information in a cause property
  • Can make more granular decisions about serving cached data
// Before:
useAsyncData('users', fetchUsers, {
  getCachedData: (key, nuxtApp) => {
    return nuxtApp.isHydrating 
      ? nuxtApp.payload.data[key] 
      : nuxtApp.static.data[key]
  }
})

// Now:
useAsyncData('users', fetchUsers, {
  getCachedData: (key, nuxtApp, ctx) => {
    // ctx.cause can be 'initial' | 'refresh:hook' | 'refresh:manual' | 'watch'
    
    // Example: Don't use cache on manual refresh
    if (ctx.cause === 'refresh:manual') return undefined
    
    return cachedData[key]
  }
})

🐞 Bug Fixes

1. Shared refs for the same key

All calls to useAsyncData or useFetch with the same key will now share not just the underlying data but also data, error, and status refs. This ensures consistency across components but may affect code that expected isolated instances.

// Before: These would be independent instances with separate refs
const { data: users1, status: status1 } = useAsyncData('users', () => $fetch('/api/users'))
const { data: users2, status: status2 } = useAsyncData('users', () => $fetch('/api/users'))

// Now: Both reference the same underlying state
// Any changes to one will affect the other

2. Warnings for inconsistent options

Multiple calls to useAsyncData with the same key but different options will now trigger development warnings. The following options must be consistent across all calls with the same key:

  • handler function
  • deep option
  • transform function
  • pick array
  • getCachedData function
  • default value

The following options can differ without triggering warnings:

  • server
  • lazy
  • immediate
  • dedupe
  • watch
// ❌ This will trigger a warning
const { data: users1 } = useAsyncData('users', () => $fetch('/api/users'), { deep: false })
const { data: users2 } = useAsyncData('users', () => $fetch('/api/users'), { deep: true })

// ✅ This is allowed
const { data: users1 } = useAsyncData('users', () => $fetch('/api/users'), { immediate: true })
const { data: users2 } = useAsyncData('users', () => $fetch('/api/users'), { immediate: false })

✨ New Features

1. Reactive keys

You can now use computed refs, plain refs or getter functions as keys, allowing for dynamic data fetching that automatically updates when dependencies change:

// Using a computed property as a key
const userId = ref('123')
const { data: user } = useAsyncData(
  computed(() => `user-${userId.value}`),
  () => fetchUser(userId.value)
)

// When userId changes, the data will be automatically refetched
// and the old data will be cleaned up if no other components use it
userId.value = '456'

2. Deduped watch calls

Multiple components watching the same data source (like route changes) will now trigger only a single data refetch:

// In ComponentA.vue
const { data: users } = useAsyncData(
  'users', 
  () => $fetch(`/api/users?page=${route.query.page}`),
  watch: [() => route.query.page]
)

// In ComponentB.vue
const { data: users } = useAsyncData(
  'users', 
  () => $fetch(`/api/users?page=${route.query.page}`),
  watch: [() => route.query.page]
)

// When route.query.page changes, only one fetch will be performed

🔄 Migration Guide

For getCachedData users

If you were using getCachedData, update your implementation to handle the new context parameter:

// Update your getCachedData implementation
useAsyncData('key', fetchFunction, {
  getCachedData: (key, ctx) => {
    // Handle the context object
    if (ctx.cause === 'refresh:manual') {
      // Skip cache on manual refresh
      return
    }
    
    return yourCacheImplementation.get(key)
  }
})

Alternatively, for now, you can disable this behaviour with:

export default defineNuxtConfig({
  experimental: {
    granularCachedData: false,
    purgeCachedData: false
  }
})

For duplicate key users

If you were intentionally using the same key in multiple places:

  1. Use unique keys if you need independent behavior
  2. Or, ensure consistent options across all calls with the same key (see the list of options that must be consistent above)
  3. Consider extracting your data fetching into a composable to ensure consistency:
// composables/useUserData.ts
export function useUserData(userId) {
  return useAsyncData(
    `user-${userId}`,
    () => fetchUser(userId),
    { 
      deep: true,
      transform: (user) => ({ ...user, lastAccessed: new Date() })
    }
  )
}

// Now use this composable everywhere instead of direct useAsyncData calls

🧪 Testing Recommendations

When updating to this version:

  1. Test components that use the same key in multiple places
  2. Verify that reactive UI updates correctly when using shared keys
  3. Test manual refresh behavior with any custom cache implementations
  4. Check that reactive keys work as expected when their dependencies change
  5. Run your app in development mode to catch any warnings about inconsistent options

🚧 TODO

  • consider caching utils to make it easier for people to migrate from getCachedData

@bolt-new-by-stackblitz

Copy link
Copy Markdown

Review PR in StackBlitz Codeflow Run & review this pull request in StackBlitz Codeflow.

@github-actions github-actions Bot added 3.x ✨ enhancement New feature or improvement to existing functionality labels Mar 14, 2025
@pkg-pr-new

pkg-pr-new Bot commented Mar 14, 2025

Copy link
Copy Markdown

Open in StackBlitz

@nuxt/kit

npm i https://pkg.pr.new/@nuxt/kit@31373

nuxt

npm i https://pkg.pr.new/nuxt@31373

@nuxt/rspack-builder

npm i https://pkg.pr.new/@nuxt/rspack-builder@31373

@nuxt/schema

npm i https://pkg.pr.new/@nuxt/schema@31373

@nuxt/vite-builder

npm i https://pkg.pr.new/@nuxt/vite-builder@31373

@nuxt/webpack-builder

npm i https://pkg.pr.new/@nuxt/webpack-builder@31373

commit: bfa5a95

@codspeed-hq

codspeed-hq Bot commented Mar 14, 2025

Copy link
Copy Markdown

CodSpeed Performance Report

Merging #31373 will not alter performance

Comparing feat/synced-asyncdata (bfa5a95) with main (31b46e3)

Summary

✅ 10 untouched benchmarks

@DamianGlowala DamianGlowala left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment thread docs/1.getting-started/6.data-fetching.md Outdated
Comment thread docs/3.api/2.composables/use-async-data.md Outdated
Comment thread docs/3.api/2.composables/use-fetch.md Outdated
Co-authored-by: Damian Głowala <damian.glowala.rebkow@gmail.com>
@Mini-ghost

Mini-ghost commented Mar 17, 2025

Copy link
Copy Markdown
Member

After this PR, would dedupe no longer be necessary?

From my testing, when the key is the same, setting dedupe to either cancel or defer does not seem to affect the final displayed result. However, when set to cancel, the API gets triggered twice in succession.

Here is the test code I used:

<script lang="ts">
const fetch = () => {
  return new Promise((resolve) => {
    console.log('fetch...')
    setTimeout(() => {
      resolve('Nuxt Data 1')
    }, 1000)
  })
}

function useMyAsyncData() {
  return useAsyncData('NUXT_DATA', fetch, { 
    dedupe: 'cancel' // or 'defer' 
  })
}
</script>

<script setup lang="ts">
const { data: d1 } = useMyAsyncData()
const { data: d2 } = useMyAsyncData()
</script>

Would it make sense to default dedupe to defer, or perhaps even remove this setting in v4 to prevent potential confusion for users regarding repeated API requests?

@danielroe danielroe marked this pull request as ready for review March 17, 2025 13:31
@coderabbitai

coderabbitai Bot commented Mar 17, 2025

Copy link
Copy Markdown
Contributor

Walkthrough

The changes introduce a new experimental configuration option, granularCachedData, which controls whether cached responses from asynchronous data fetching are utilised during refreshes. The function signatures for both useAsyncData and useFetch have been updated to include an additional context parameter, providing detailed information on the cause of data requests. New types such as AsyncDataRequestContext and AsyncDataRefreshCause have been added to support this enhanced functionality. Furthermore, the composables now support reactive keys by allowing computed or plain refs as keys, which facilitates dynamic data fetching and the sharing of state across components. The Nuxt configuration has been extended with a new Vite plugin for enhanced development logging. Documentation and upgrade guides have been updated to clarify these modifications, while tests and fixtures have been revised to reflect the new behaviours, including removing outdated test cases.


📜 Recent review details

Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 65f7225 and bfa5a95.

📒 Files selected for processing (1)
  • packages/nuxt/src/app/composables/fetch.ts (4 hunks)
⏰ Context from checks skipped due to timeout of 90000ms (1)
  • GitHub Check: code
🔇 Additional comments (5)
packages/nuxt/src/app/composables/fetch.ts (5)

3-3: Import type enhancements for reactive support

The addition of MaybeRefOrGetter from Vue signals improved support for reactive values in the composable API.


38-38: Enhanced flexibility with reactive keys

The type change from string to MaybeRefOrGetter<string> enables dynamic data fetching by allowing computed properties or refs as keys, which aligns with the PR objective of supporting reactive keys.


100-102: Improved key generation with reactive support

The key generation logic has been correctly updated to:

  1. Use computed() to create a reactive key
  2. Handle reactive option keys with toValue()
  3. Properly check the value within the computed property

This change supports the PR goal of ensuring consistency when using the same key across components.


108-108: Reactive key computation

Making the final key computation reactive ensures that when a reactive key changes, cached data is properly invalidated and refetched. This is essential for the dynamic data fetching feature mentioned in the PR objectives.


270-272:

Details

❓ Verification inconclusive

Request body included in cache key

Including the request body hash in the cache key ensures requests with identical URLs but different payloads don't share the same cache entry. This is a necessary enhancement to prevent incorrect data being returned from cache.

However, there's a potential performance consideration when hashing large request bodies. For extremely large payloads, this could impact performance.


🏁 Script executed:

#!/bin/bash
# Check if there are any large request bodies being used in the codebase
rg -A 3 -B 3 "useFetch|fetch\(" --type vue --type js --type ts | grep -i -E "body:|body ="

Length of output: 120


Cache Key Improvement Confirmed – Manual Performance Check Advised

The implementation correctly incorporates a hash of the request body into the cache key to prevent mixing responses from requests with identical URLs but different payloads. Our initial automated search did not flag any usage of large request bodies in JavaScript or TypeScript files. However, due to an issue with recognising Vue file types during the search, please verify manually that no Vue components send exceptionally large payloads which could impact performance during hashing.

  • The changes in packages/nuxt/src/app/composables/fetch.ts (lines 270–272) are approved in principle.
  • A manual review of Vue files is recommended to ensure that hashing large bodies (if any) won’t degrade performance.
✨ Finishing Touches
  • 📝 Generate Docstrings

🪧 Tips

Chat

There are 3 ways to chat with CodeRabbit:

  • Review comments: Directly reply to a review comment made by CodeRabbit. Example:
    • I pushed a fix in commit <commit_id>, please review it.
    • Generate unit testing code for this file.
    • Open a follow-up GitHub issue for this discussion.
  • Files and specific lines of code (under the "Files changed" tab): Tag @coderabbitai in a new review comment at the desired location with your query. Examples:
    • @coderabbitai generate unit testing code for this file.
    • @coderabbitai modularize this function.
  • PR comments: Tag @coderabbitai in a new PR comment to ask questions about the PR branch. For the best results, please provide a very specific query, as very limited context is provided in this mode. Examples:
    • @coderabbitai gather interesting stats about this repository and render them as a table. Additionally, render a pie chart showing the language distribution in the codebase.
    • @coderabbitai read src/utils.ts and generate unit testing code.
    • @coderabbitai read the files in the src/scheduler package and generate a class diagram using mermaid and a README in the markdown format.
    • @coderabbitai help me debug CodeRabbit configuration file.

Note: Be mindful of the bot's finite context window. It's strongly recommended to break down tasks such as reading entire modules into smaller chunks. For a focused discussion, use review comments to chat about specific files and their changes, instead of using the PR comments.

CodeRabbit Commands (Invoked using PR comments)

  • @coderabbitai pause to pause the reviews on a PR.
  • @coderabbitai resume to resume the paused reviews.
  • @coderabbitai review to trigger an incremental review. This is useful when automatic reviews are disabled for the repository.
  • @coderabbitai full review to do a full review from scratch and review all the files again.
  • @coderabbitai summary to regenerate the summary of the PR.
  • @coderabbitai generate docstrings to generate docstrings for this PR.
  • @coderabbitai resolve resolve all the CodeRabbit review comments.
  • @coderabbitai plan to trigger planning for file edits and PR creation.
  • @coderabbitai configuration to show the current CodeRabbit configuration for the repository.
  • @coderabbitai help to get help.

Other keywords and placeholders

  • Add @coderabbitai ignore anywhere in the PR description to prevent this PR from being reviewed.
  • Add @coderabbitai summary to generate the high-level summary at a specific location in the PR description.
  • Add @coderabbitai anywhere in the PR title to generate the title automatically.

CodeRabbit Configuration File (.coderabbit.yaml)

  • You can programmatically configure CodeRabbit by adding a .coderabbit.yaml file to the root of your repository.
  • Please see the configuration documentation for more information.
  • If your editor has YAML language server enabled, you can add the path at the top of this file to enable auto-completion and validation: # yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json

Documentation and Community

  • Visit our Documentation for detailed information on how to use CodeRabbit.
  • Join our Discord Community to get help, request features, and share feedback.
  • Follow us on X/Twitter for updates and announcements.

@adamdehaven

adamdehaven commented Apr 28, 2025

Copy link
Copy Markdown
Contributor

@danielroe I'm pulling in the 3.17.0 release into my app and am noticing separate tests utilizing renderSuspended are now failing as the data appears to persist between tests (e.g. utilizing a computed fetchKey that is based on the route.path in the component) as long as the route for each test is the same.

  • Is this expected? Previously the data was seemingly cleared between tests
  • Is there a regular pattern for clearing Nuxt data between tests I should be using?

Example

const fetchKey = computed((): string => `page-${route.path.replace(/\//g, '-')}`)
const { data } = await useFetch('/api/v3/pages/my-page', {
  key: fetchKey,
})

@danielroe

Copy link
Copy Markdown
Member Author

@adamdehaven this is expected (from a Nuxt point of view) as the fetch instance is global and never unmounted/cleaned up. You can wrap it in a composable. Or call clearNuxtData after/before each test. Or maybe we can look at doing that automatically in @nuxt/test-utils. What do you think?

@adamdehaven

Copy link
Copy Markdown
Contributor

Or call clearNuxtData after/before each test. Or maybe we can look at doing that automatically in @nuxt/test-utils. What do you think?

I actually tried calling clearNuxtData in a before/after hook and it had zero impact 😬 -- Yes, I think doing this automatically in @nuxt/test-utils would be ideal to totally isolate tests

@metkm

metkm commented Apr 29, 2025

Copy link
Copy Markdown

@danielroe Shouldn't this feature also apply to useNuxtData? It looks like we can't give reactive key to useNuxtData yet?

@samirmhsnv

Copy link
Copy Markdown

@danielroe I believe there is a breaking change regarding FormData handling in fetch.js due to the newly added statement:

It originates from ohash. That library cannot hash File objects.

if (opts.body) {
  segments.push(hash(toValue(opts.body)))
}

Reference:
https://github.com/nuxt/nuxt/pull/31373/files#diff-3ace8ffd6a6f0f4eb7979e99189c489e15fa917b74b48c26ee7e12ad13dfb866R270-R272

When I attempt to upload a FormData containing a File in the body, I encounter the following error:
"Cannot serialize File"

const formData = new FormData();

formData.append("image", new_avatar.value);

await useAPI('/company', {
  method: 'POST',
  body: formData,
})

My current solution is to downgrade v3.16.2

@Tofandel

Tofandel commented May 8, 2025

Copy link
Copy Markdown
Contributor

There seems to be a breaking change in there as well, on route change without remounting components (via <NuxtPage :page-key="(route: RouteLocationResolved) =>(route.matched[0].name || route.path)"></NuxtPage>) then all the previously fetched data is cleared. This was not the case before

I'm trying to dig what's causing this

Okay so after digging, this is entirely due to the key now being computed based on the params
https://github.com/nuxt/nuxt/pull/31373/files#diff-3ace8ffd6a6f0f4eb7979e99189c489e15fa917b74b48c26ee7e12ad13dfb866R100

This means that if any of the params change the key changes and it switches to another data. But that means there is no data until it's fetched again while before what it would do is keep the previous data but fetch the new one while the previous data was available, this is a problem when you are expecting to always have data via top level await in setup components

A workaround for now seems to be to hardcode a static key

@GerryWilko

Copy link
Copy Markdown

@danielroe Thank you so much for your work!

I'm a little confused by some of the changes to useAsyncData here so I thought I would add my thoughts here and hopefully you can clarify when you get the time.

When initially reading about this upgrade to useAsyncData I initially thought this would resolve the typical issues we would get where using useAsyncData in multiple places with the same key could cause multiple triggers of the handler to be fired and therefore fire unnecessary requests.

This would typically lead me to the discussions around the fact that useAsyncData does not cache data and instead caching needs to be handled independently however we previously couldn't stop the handler from firing on initial call of useAsyncData even if the key provided matched an existing useAsyncData call.

This is where I presumed these changes came in attempting to unify the instance returned by useAsyncData and therefore allowing for the getCachedData function to be respected on subsequent useAsyncData calls.

However from my testing I'm struggling to understand how this works I have a simple unit test here that illustrates the problem:

import type { NuxtApp } from '#app'
import type { AsyncDataRefreshCause } from '#app/composables/asyncData'
import { expect, it, vi } from 'vitest'

const handler = vi.fn(async () => Promise.resolve('hello'))

const getCachedData = vi.fn((key: string, nuxtApp: NuxtApp, ctx: { cause: AsyncDataRefreshCause }) => {
  if (nuxtApp.isHydrating) {
    return nuxtApp.payload.data[key]
  }

  const { data } = useNuxtData(key)
  if (ctx.cause !== 'refresh:manual' && ctx.cause !== 'refresh:hook' && data.value) {
    return data.value
  }
})

// eslint-disable-next-line ts/promise-function-async
function testAsyncData() {
  return useAsyncData('test-key', handler, {
    getCachedData,
  })
}

it('test duplicate calls are not made after first call has finished', async () => {
  const { status, data } = await testAsyncData()
  expect(status.value).toBe('success') // pass
  expect(data.value).toBe('hello') // pass
  expect(handler).toHaveBeenCalledTimes(1) // pass
  const { status: status2, data: data2 } = testAsyncData()
  expect.soft(handler).toHaveBeenCalledTimes(1) // fail - called twice
  expect.soft(getCachedData).toHaveBeenCalledTimes(2)
  expect.soft(data.value).toBe('hello') // pass
  expect.soft(data2.value).toBe('hello') // pass
  expect.soft(status.value).toBe('success') // fail - value is 'pending'
  expect.soft(status2.value).toBe('success') // fail - value is 'pending'
})

Effectively what I am trying to do here is to adjust the default getCachedData but instead always respect the data we have unless you explicitly try to refresh it.

However what actually happens is that whilst the getCachedData is called twice with cause initial the second call is effectively discarded as it is passed only to new useAsyncData instances as the initialCachedData.

// Avoid fetching same key that is already fetched
if (granularCachedData || opts.cause === 'initial' || nuxtApp.isHydrating) {
const cachedData = opts.cause === 'initial' ? initialCachedData : options.getCachedData!(key, nuxtApp, { cause: opts.cause ?? 'refresh:manual' })
if (typeof cachedData !== 'undefined') {
nuxtApp.payload.data[key] = asyncData.data.value = cachedData
asyncData.error.value = asyncDataDefaults.errorValue
asyncData.status.value = 'success'
return Promise.resolve(cachedData)
}
}

It is then used here but it only ever uses the initialCachedData value even though the most recent evaluation of getCachedData returned a value. I presume there are some security considerations here that is the concern? Could this perhaps be adjusted to be that on client we always respect the current cached value (I think this should always be safe on client)?

At present it seems to be that we cannot stop the handler from being triggered on each call to useAsyncData (I believe some deduping occurs whilst the handler is in flight).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

3.x ✨ enhancement New feature or improvement to existing functionality

Projects

None yet

9 participants