feat(nuxt): add callOnce util to allow running code only once (#24787)

This commit is contained in:
Sébastien Chopin 2023-12-19 12:00:11 +01:00 committed by GitHub
parent 9cd6c922e5
commit d26822f3df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 219 additions and 12 deletions

View File

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

View File

@ -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&param2=value2`. The above example results in `https://api.nuxt.com/modules?param1=value1&param2=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):

View File

@ -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"}

View 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.

View File

@ -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:**

View File

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

View 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]
}

View File

@ -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 })
}), }),

View File

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

View File

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

View File

@ -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 },

View File

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

View File

@ -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)
})
})