mirror of
https://github.com/nuxt/nuxt.git
synced 2024-11-22 13:45:18 +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.
|
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
|
::code-group
|
||||||
```ts [composables/locale.ts]
|
```ts [composables/locale.ts]
|
||||||
|
@ -17,11 +17,9 @@ It automatically generates a key based on URL and fetch options, provides type h
|
|||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
```vue [pages/index.vue]
|
```vue [pages/modules.vue]
|
||||||
<script setup>
|
<script setup>
|
||||||
const route = useRoute()
|
const { data, pending, error, refresh } = await useFetch('/api/modules', {
|
||||||
|
|
||||||
const { data, pending, error, refresh } = await useFetch(`https://api.nuxtjs.dev/mountains/${route.params.slug}`, {
|
|
||||||
pick: ['title']
|
pick: ['title']
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@ -35,12 +33,12 @@ Using the `query` option, you can add search parameters to your query. This opti
|
|||||||
|
|
||||||
```ts
|
```ts
|
||||||
const param1 = ref('value1')
|
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' }
|
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):
|
You can also use [interceptors](https://github.com/unjs/ofetch#%EF%B8%8F-interceptors):
|
||||||
|
|
||||||
|
@ -13,7 +13,6 @@ links:
|
|||||||
```ts
|
```ts
|
||||||
// Create a reactive state and set default value
|
// Create a reactive state and set default value
|
||||||
const count = useState('counter', () => Math.round(Math.random() * 100))
|
const count = useState('counter', () => Math.round(Math.random() * 100))
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
:read-more{to="/docs/getting-started/state-management"}
|
: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
|
size: xs
|
||||||
---
|
---
|
||||||
|
|
||||||
# `clearError`
|
|
||||||
|
|
||||||
Within your pages, components, and plugins, you can use `clearError` to clear all errors and redirect the user.
|
Within your pages, components, and plugins, you can use `clearError` to clear all errors and redirect the user.
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
|
@ -16,6 +16,7 @@ export { defineNuxtComponent } from './component'
|
|||||||
export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from './asyncData'
|
export { useAsyncData, useLazyAsyncData, useNuxtData, refreshNuxtData, clearNuxtData } from './asyncData'
|
||||||
export type { AsyncDataOptions, AsyncData } from './asyncData'
|
export type { AsyncDataOptions, AsyncData } from './asyncData'
|
||||||
export { useHydration } from './hydrate'
|
export { useHydration } from './hydrate'
|
||||||
|
export { callOnce } from './once'
|
||||||
export { useState, clearNuxtState } from './state'
|
export { useState, clearNuxtState } from './state'
|
||||||
export { clearError, createError, isNuxtError, showError, useError } from './error'
|
export { clearError, createError, isNuxtError, showError, useError } from './error'
|
||||||
export type { NuxtError } 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
|
prerenderedAt?: number
|
||||||
data: Record<string, any>
|
data: Record<string, any>
|
||||||
state: Record<string, any>
|
state: Record<string, any>
|
||||||
|
once: Set<string>
|
||||||
config?: Pick<RuntimeConfig, 'public' | 'app'>
|
config?: Pick<RuntimeConfig, 'public' | 'app'>
|
||||||
error?: Error | {
|
error?: Error | {
|
||||||
url: string
|
url: string
|
||||||
@ -126,6 +127,11 @@ interface _NuxtApp {
|
|||||||
named: Record<string, RouteMiddleware>
|
named: Record<string, RouteMiddleware>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @internal */
|
||||||
|
_once: {
|
||||||
|
[key: string]: Promise<any>
|
||||||
|
}
|
||||||
|
|
||||||
/** @internal */
|
/** @internal */
|
||||||
_observer?: { observe: (element: Element, callback: () => void) => () => void }
|
_observer?: { observe: (element: Element, callback: () => void) => () => void }
|
||||||
/** @internal */
|
/** @internal */
|
||||||
@ -232,6 +238,7 @@ export function createNuxtApp (options: CreateOptions) {
|
|||||||
payload: reactive({
|
payload: reactive({
|
||||||
data: {},
|
data: {},
|
||||||
state: {},
|
state: {},
|
||||||
|
once: new Set<string>(),
|
||||||
_errors: {},
|
_errors: {},
|
||||||
...(import.meta.client ? window.__NUXT__ ?? {} : { serverRendered: true })
|
...(import.meta.client ? window.__NUXT__ ?? {} : { serverRendered: true })
|
||||||
}),
|
}),
|
||||||
|
@ -149,7 +149,8 @@ const getSPARenderer = lazyCachedFunction(async () => {
|
|||||||
_errors: {},
|
_errors: {},
|
||||||
serverRendered: false,
|
serverRendered: false,
|
||||||
data: {},
|
data: {},
|
||||||
state: {}
|
state: {},
|
||||||
|
once: new Set<string>()
|
||||||
}
|
}
|
||||||
ssrContext.config = {
|
ssrContext.config = {
|
||||||
public: config.public,
|
public: config.public,
|
||||||
|
@ -41,6 +41,10 @@ const granularAppPresets: InlinePreset[] = [
|
|||||||
imports: ['useHydration'],
|
imports: ['useHydration'],
|
||||||
from: '#app/composables/hydrate'
|
from: '#app/composables/hydrate'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
imports: ['callOnce'],
|
||||||
|
from: '#app/composables/once'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
imports: ['useState', 'clearNuxtState'],
|
imports: ['useState', 'clearNuxtState'],
|
||||||
from: '#app/composables/state'
|
from: '#app/composables/state'
|
||||||
|
@ -136,6 +136,7 @@ export default defineUntypedSchema({
|
|||||||
*/
|
*/
|
||||||
keyedComposables: {
|
keyedComposables: {
|
||||||
$resolve: val => [
|
$resolve: val => [
|
||||||
|
{ name: 'callOnce', argumentLength: 2 },
|
||||||
{ name: 'defineNuxtComponent', argumentLength: 2 },
|
{ name: 'defineNuxtComponent', argumentLength: 2 },
|
||||||
{ name: 'useState', argumentLength: 2 },
|
{ name: 'useState', argumentLength: 2 },
|
||||||
{ name: 'useFetch', argumentLength: 3 },
|
{ 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', () => {
|
describe('middlewares', () => {
|
||||||
it('should redirect to index with global middleware', async () => {
|
it('should redirect to index with global middleware', async () => {
|
||||||
const html = await $fetch('/redirect/')
|
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 { clearNuxtState, useState } from '#app/composables/state'
|
||||||
import { useRequestURL } from '#app/composables/url'
|
import { useRequestURL } from '#app/composables/url'
|
||||||
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
|
import { getAppManifest, getRouteRules } from '#app/composables/manifest'
|
||||||
|
import { callOnce } from '#app/composables/once'
|
||||||
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
import { useLoadingIndicator } from '#app/composables/loading-indicator'
|
||||||
|
|
||||||
vi.mock('#app/compat/idle-callback', () => ({
|
vi.mock('#app/compat/idle-callback', () => ({
|
||||||
@ -87,6 +88,7 @@ describe('composables', () => {
|
|||||||
'useHydration',
|
'useHydration',
|
||||||
'getRouteRules',
|
'getRouteRules',
|
||||||
'onNuxtReady',
|
'onNuxtReady',
|
||||||
|
'callOnce',
|
||||||
'setResponseStatus',
|
'setResponseStatus',
|
||||||
'prerenderRoutes',
|
'prerenderRoutes',
|
||||||
'useRequestEvent',
|
'useRequestEvent',
|
||||||
@ -578,3 +580,29 @@ describe('defineNuxtComponent', () => {
|
|||||||
it.todo('should support Options API asyncData')
|
it.todo('should support Options API asyncData')
|
||||||
it.todo('should support Options API head')
|
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