mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-21 21:25:11 +00:00
feat(nuxt): add callOnce
util to allow running code only once (#24787)
This commit is contained in:
parent
9cd6c922e5
commit
d26822f3df
@ -57,9 +57,68 @@ const counter = useState('counter', () => Math.round(Math.random() * 1000))
|
||||
To globally invalidate cached state, see [`clearNuxtState`](/docs/api/utils/clear-nuxt-state) util.
|
||||
::
|
||||
|
||||
### Advanced Usage
|
||||
### Initializing State
|
||||
|
||||
In this example, we use a composable that detects the user's default locale from the HTTP request headers and keeps it in a `locale` state.
|
||||
Most of the time, you will want to initialize your state with data that resolves asynchronously. You can use the [`app.vue`](/docs/guide/directory-structure/app) component with the [`callOnce`](/docs/api/utils/call-once) util to do so.
|
||||
|
||||
```vue [app.vue]
|
||||
<script setup>
|
||||
const websiteConfig = useState('config')
|
||||
|
||||
await callOnce(async () => {
|
||||
websiteConfig.value = await $fetch('https://my-cms.com/api/website-config')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
::callout
|
||||
This is similar to the [`nuxtServerInit` action](https://v2.nuxt.com/docs/directory-structure/store/#the-nuxtserverinit-action) in Nuxt 2, which allows filling the initial state of your store server-side before rendering the page.
|
||||
::
|
||||
|
||||
:read-more{to="/docs/api/utils/call-once"}
|
||||
|
||||
### Usage with Pinia
|
||||
|
||||
In this example, we leverage the [Pinia module](/modules/pinia) to create a global store and use it across the app.
|
||||
|
||||
::callout
|
||||
Make sure to install the Pinia module with `npx nuxi@latest module add pinia` or follow the [module's installation steps](https://pinia.vuejs.org/ssr/nuxt.html#Installation).
|
||||
::
|
||||
|
||||
::code-group
|
||||
```ts [stores/website.ts]
|
||||
export const useWebsiteStore = defineStore('websiteStore', {
|
||||
state: () => ({
|
||||
name: '',
|
||||
description: ''
|
||||
}),
|
||||
actions: {
|
||||
async fetch() {
|
||||
const infos = await $fetch('https://api.nuxt.com/modules/pinia')
|
||||
|
||||
this.name = infos.name
|
||||
this.description = infos.description
|
||||
}
|
||||
}
|
||||
})
|
||||
```
|
||||
```vue [app.vue]
|
||||
<script setup>
|
||||
const website = useWebsiteStore()
|
||||
|
||||
await callOnce(website.fetch)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main>
|
||||
<h1>{{ website.name }}</h1>
|
||||
<p>{{ website.description }}</p>
|
||||
</main>
|
||||
</template>
|
||||
```
|
||||
::
|
||||
|
||||
## Advanced Usage
|
||||
|
||||
::code-group
|
||||
```ts [composables/locale.ts]
|
||||
|
@ -17,11 +17,9 @@ It automatically generates a key based on URL and fetch options, provides type h
|
||||
|
||||
## Usage
|
||||
|
||||
```vue [pages/index.vue]
|
||||
```vue [pages/modules.vue]
|
||||
<script setup>
|
||||
const route = useRoute()
|
||||
|
||||
const { data, pending, error, refresh } = await useFetch(`https://api.nuxtjs.dev/mountains/${route.params.slug}`, {
|
||||
const { data, pending, error, refresh } = await useFetch('/api/modules', {
|
||||
pick: ['title']
|
||||
})
|
||||
</script>
|
||||
@ -35,12 +33,12 @@ Using the `query` option, you can add search parameters to your query. This opti
|
||||
|
||||
```ts
|
||||
const param1 = ref('value1')
|
||||
const { data, pending, error, refresh } = await useFetch('https://api.nuxtjs.dev/mountains', {
|
||||
const { data, pending, error, refresh } = await useFetch('/api/modules', {
|
||||
query: { param1, param2: 'value2' }
|
||||
})
|
||||
```
|
||||
|
||||
The above example results in `https://api.nuxtjs.dev/mountains?param1=value1¶m2=value2`.
|
||||
The above example results in `https://api.nuxt.com/modules?param1=value1¶m2=value2`.
|
||||
|
||||
You can also use [interceptors](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors):
|
||||
|
||||
|
@ -13,7 +13,6 @@ links:
|
||||
```ts
|
||||
// Create a reactive state and set default value
|
||||
const count = useState('counter', () => Math.round(Math.random() * 100))
|
||||
|
||||
```
|
||||
|
||||
:read-more{to="/docs/getting-started/state-management"}
|
||||
|
54
docs/3.api/3.utils/call-once.md
Normal file
54
docs/3.api/3.utils/call-once.md
Normal file
@ -0,0 +1,54 @@
|
||||
---
|
||||
title: "callOnce"
|
||||
description: "Run a given function or block of code once during SSR or CSR."
|
||||
links:
|
||||
- label: Source
|
||||
icon: i-simple-icons-github
|
||||
to: https://github.com/nuxt/nuxt/blob/main/packages/nuxt/src/app/composables/once.ts
|
||||
size: xs
|
||||
---
|
||||
|
||||
## Purpose
|
||||
|
||||
The `callOnce` function is designed to execute a given function or block of code only once during:
|
||||
- server-side rendering but not hydration
|
||||
- client-side navigation
|
||||
|
||||
This is useful for code that should be executed only once, such as logging an event or setting up a global state.
|
||||
|
||||
## Usage
|
||||
|
||||
```vue [app.vue]
|
||||
<script setup>
|
||||
const websiteConfig = useState('config')
|
||||
|
||||
await callOnce(async () => {
|
||||
console.log('This will only be logged once')
|
||||
websiteConfig.value = await $fetch('https://my-cms.com/api/website-config')
|
||||
})
|
||||
</script>
|
||||
```
|
||||
|
||||
::callout{to="/docs/getting-started/state-management#usage-with-pinia"}
|
||||
`callOnce` is useful in combination with the [Pinia module](/modules/pinia) to call store actions.
|
||||
::
|
||||
|
||||
:read-more{to="/docs/getting-started/state-management"}
|
||||
|
||||
::callout{color="info" icon="i-ph-warning-duotone"}
|
||||
Note that `callOnce` doesn't return anything. You should use [`useAsyncData`](/docs/api/composables/use-async-data) or [`useFetch`](/docs/api/composables/use-fetch) if you want to do data fetching during SSR.
|
||||
::
|
||||
|
||||
::callout
|
||||
`callOnce` is a composable meant to be called directly in a setup function, plugin, or route middleware, because it needs to add data to the Nuxt payload to avoid re-calling the function on the client when the page hydrates.
|
||||
::
|
||||
|
||||
## Type
|
||||
|
||||
```ts
|
||||
callOnce(fn?: () => any | Promise<any>): Promise<void>
|
||||
callOnce(key: string, fn?: () => any | Promise<any>): Promise<void>
|
||||
```
|
||||
|
||||
- `key`: A unique key ensuring that the code is run once. If you do not provide a key, then a key that is unique to the file and line number of the instance of `callOnce` will be generated for you.
|
||||
- `fn`: The function to run once. This function can also return a `Promise` and a value.
|
@ -8,8 +8,6 @@ links:
|
||||
size: xs
|
||||
---
|
||||
|
||||
# `clearError`
|
||||
|
||||
Within your pages, components, and plugins, you can use `clearError` to clear all errors and redirect the user.
|
||||
|
||||
**Parameters:**
|
||||
|
@ -16,6 +16,7 @@ export { defineNuxtComponent } from './component'
|
||||
export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from './asyncData'
|
||||
export type { AsyncDataOptions, AsyncData } from './asyncData'
|
||||
export { useHydration } from './hydrate'
|
||||
export { callOnce } from './once'
|
||||
export { useState, clearNuxtState } from './state'
|
||||
export { clearError, createError, isNuxtError, showError, useError } from './error'
|
||||
export type { NuxtError } from './error'
|
||||
|
31
packages/nuxt/src/app/composables/once.ts
Normal file
31
packages/nuxt/src/app/composables/once.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import { useNuxtApp } from '../nuxt'
|
||||
|
||||
/**
|
||||
* An SSR-friendly utility to call a method once
|
||||
* @param key a unique key ensuring the function can be properly de-duplicated across requests
|
||||
* @param fn a function to call
|
||||
* @see https://nuxt.com/docs/api/utils/call-once
|
||||
*/
|
||||
export function callOnce (key?: string, fn?: (() => any | Promise<any>)): Promise<void>
|
||||
export function callOnce (fn?: (() => any | Promise<any>)): Promise<void>
|
||||
export async function callOnce (...args: any): Promise<void> {
|
||||
const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined
|
||||
if (typeof args[0] !== 'string') { args.unshift(autoKey) }
|
||||
const [_key, fn] = args as [string, (() => any | Promise<any>)]
|
||||
if (!_key || typeof _key !== 'string') {
|
||||
throw new TypeError('[nuxt] [callOnce] key must be a string: ' + _key)
|
||||
}
|
||||
if (fn !== undefined && typeof fn !== 'function') {
|
||||
throw new Error('[nuxt] [callOnce] fn must be a function: ' + fn)
|
||||
}
|
||||
const nuxtApp = useNuxtApp()
|
||||
// If key already ran
|
||||
if (nuxtApp.payload.once.has(_key)) {
|
||||
return
|
||||
}
|
||||
nuxtApp._once = nuxtApp._once || {}
|
||||
nuxtApp._once[_key] = nuxtApp._once[_key] || fn()
|
||||
await nuxtApp._once[_key]
|
||||
nuxtApp.payload.once.add(_key)
|
||||
delete nuxtApp._once[_key]
|
||||
}
|
@ -77,6 +77,7 @@ export interface NuxtPayload {
|
||||
prerenderedAt?: number
|
||||
data: Record<string, any>
|
||||
state: Record<string, any>
|
||||
once: Set<string>
|
||||
config?: Pick<RuntimeConfig, 'public' | 'app'>
|
||||
error?: Error | {
|
||||
url: string
|
||||
@ -126,6 +127,11 @@ interface _NuxtApp {
|
||||
named: Record<string, RouteMiddleware>
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_once: {
|
||||
[key: string]: Promise<any>
|
||||
}
|
||||
|
||||
/** @internal */
|
||||
_observer?: { observe: (element: Element, callback: () => void) => () => void }
|
||||
/** @internal */
|
||||
@ -232,6 +238,7 @@ export function createNuxtApp (options: CreateOptions) {
|
||||
payload: reactive({
|
||||
data: {},
|
||||
state: {},
|
||||
once: new Set<string>(),
|
||||
_errors: {},
|
||||
...(import.meta.client ? window.__NUXT__ ?? {} : { serverRendered: true })
|
||||
}),
|
||||
|
@ -149,7 +149,8 @@ const getSPARenderer = lazyCachedFunction(async () => {
|
||||
_errors: {},
|
||||
serverRendered: false,
|
||||
data: {},
|
||||
state: {}
|
||||
state: {},
|
||||
once: new Set<string>()
|
||||
}
|
||||
ssrContext.config = {
|
||||
public: config.public,
|
||||
|
@ -41,6 +41,10 @@ const granularAppPresets: InlinePreset[] = [
|
||||
imports: ['useHydration'],
|
||||
from: '#app/composables/hydrate'
|
||||
},
|
||||
{
|
||||
imports: ['callOnce'],
|
||||
from: '#app/composables/once'
|
||||
},
|
||||
{
|
||||
imports: ['useState', 'clearNuxtState'],
|
||||
from: '#app/composables/state'
|
||||
|
@ -136,6 +136,7 @@ export default defineUntypedSchema({
|
||||
*/
|
||||
keyedComposables: {
|
||||
$resolve: val => [
|
||||
{ name: 'callOnce', argumentLength: 2 },
|
||||
{ name: 'defineNuxtComponent', argumentLength: 2 },
|
||||
{ name: 'useState', argumentLength: 2 },
|
||||
{ name: 'useFetch', argumentLength: 3 },
|
||||
|
@ -863,6 +863,18 @@ describe('navigate external', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('composables', () => {
|
||||
it('should run code once', async () => {
|
||||
const html = await $fetch('/once')
|
||||
|
||||
expect(html).toContain('once.vue')
|
||||
expect(html).toContain('once: 2')
|
||||
|
||||
const { page } = await renderPage('/once')
|
||||
expect(await page.getByText('once:').textContent()).toContain('once: 2')
|
||||
})
|
||||
})
|
||||
|
||||
describe('middlewares', () => {
|
||||
it('should redirect to index with global middleware', async () => {
|
||||
const html = await $fetch('/redirect/')
|
||||
|
14
test/fixtures/basic/pages/once.vue
vendored
Normal file
14
test/fixtures/basic/pages/once.vue
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
<script setup>
|
||||
const counter = useState('once', () => 0)
|
||||
|
||||
await callOnce(() => counter.value++)
|
||||
await callOnce('same-key', () => counter.value++)
|
||||
await callOnce('same-key', () => counter.value++)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div>once.vue</div>
|
||||
<div>once: {{ counter }}</div>
|
||||
</div>
|
||||
</template>
|
@ -14,6 +14,7 @@ import { setResponseStatus, useRequestEvent, useRequestFetch, useRequestHeaders
|
||||
import { clearNuxtState, useState } from '#app/composables/state'
|
||||
import { useRequestURL } from '#app/composables/url'
|
||||
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
|
||||
import { callOnce } from '#app/composables/once'
|
||||
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
||||
|
||||
vi.mock('#app/compat/idle-callback', () => ({
|
||||
@ -87,6 +88,7 @@ describe('composables', () => {
|
||||
'useHydration',
|
||||
'getRouteRules',
|
||||
'onNuxtReady',
|
||||
'callOnce',
|
||||
'setResponseStatus',
|
||||
'prerenderRoutes',
|
||||
'useRequestEvent',
|
||||
@ -578,3 +580,29 @@ describe('defineNuxtComponent', () => {
|
||||
it.todo('should support Options API asyncData')
|
||||
it.todo('should support Options API head')
|
||||
})
|
||||
|
||||
describe('callOnce', () => {
|
||||
it('should only call composable once', async () => {
|
||||
const fn = vi.fn()
|
||||
const execute = () => callOnce(fn)
|
||||
await execute()
|
||||
await execute()
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should only call composable once when called in parallel', async () => {
|
||||
const fn = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1)))
|
||||
const execute = () => callOnce(fn)
|
||||
await Promise.all([execute(), execute(), execute()])
|
||||
expect(fn).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('should use key to dedupe', async () => {
|
||||
const fn = vi.fn()
|
||||
const execute = (key?: string) => callOnce(key, fn)
|
||||
await execute('first')
|
||||
await execute('first')
|
||||
await execute('second')
|
||||
expect(fn).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
|
Loading…
Reference in New Issue
Block a user