From d26822f3dfce825b5639da455dd8a47d0b2b0367 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?S=C3=A9bastien=20Chopin?= Date: Tue, 19 Dec 2023 12:00:11 +0100 Subject: [PATCH] feat(nuxt): add `callOnce` util to allow running code only once (#24787) --- docs/1.getting-started/7.state-management.md | 63 ++++++++++++++++++- docs/3.api/2.composables/use-fetch.md | 10 ++- docs/3.api/2.composables/use-state.md | 1 - docs/3.api/3.utils/call-once.md | 54 ++++++++++++++++ docs/3.api/3.utils/clear-error.md | 2 - packages/nuxt/src/app/composables/index.ts | 1 + packages/nuxt/src/app/composables/once.ts | 31 +++++++++ packages/nuxt/src/app/nuxt.ts | 7 +++ .../nuxt/src/core/runtime/nitro/renderer.ts | 3 +- packages/nuxt/src/imports/presets.ts | 4 ++ packages/schema/src/config/build.ts | 1 + test/basic.test.ts | 12 ++++ test/fixtures/basic/pages/once.vue | 14 +++++ test/nuxt/composables.test.ts | 28 +++++++++ 14 files changed, 219 insertions(+), 12 deletions(-) create mode 100644 docs/3.api/3.utils/call-once.md create mode 100644 packages/nuxt/src/app/composables/once.ts create mode 100644 test/fixtures/basic/pages/once.vue diff --git a/docs/1.getting-started/7.state-management.md b/docs/1.getting-started/7.state-management.md index 4687e0eb05..9cec1e9c5c 100644 --- a/docs/1.getting-started/7.state-management.md +++ b/docs/1.getting-started/7.state-management.md @@ -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] + +``` + +::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] + + + +``` +:: + +## Advanced Usage ::code-group ```ts [composables/locale.ts] diff --git a/docs/3.api/2.composables/use-fetch.md b/docs/3.api/2.composables/use-fetch.md index 2fb3593978..5222b0fcb6 100644 --- a/docs/3.api/2.composables/use-fetch.md +++ b/docs/3.api/2.composables/use-fetch.md @@ -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] @@ -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): diff --git a/docs/3.api/2.composables/use-state.md b/docs/3.api/2.composables/use-state.md index 6d5a8d4294..d4be8390b5 100644 --- a/docs/3.api/2.composables/use-state.md +++ b/docs/3.api/2.composables/use-state.md @@ -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"} diff --git a/docs/3.api/3.utils/call-once.md b/docs/3.api/3.utils/call-once.md new file mode 100644 index 0000000000..b148c39975 --- /dev/null +++ b/docs/3.api/3.utils/call-once.md @@ -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] + +``` + +::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): Promise +callOnce(key: string, fn?: () => any | Promise): Promise +``` + +- `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. diff --git a/docs/3.api/3.utils/clear-error.md b/docs/3.api/3.utils/clear-error.md index f2f8897a91..915bd7852e 100644 --- a/docs/3.api/3.utils/clear-error.md +++ b/docs/3.api/3.utils/clear-error.md @@ -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:** diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index a815dbd05e..5855089873 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -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' diff --git a/packages/nuxt/src/app/composables/once.ts b/packages/nuxt/src/app/composables/once.ts new file mode 100644 index 0000000000..52ece887c4 --- /dev/null +++ b/packages/nuxt/src/app/composables/once.ts @@ -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)): Promise +export function callOnce (fn?: (() => any | Promise)): Promise +export async function callOnce (...args: any): Promise { + 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)] + 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] +} diff --git a/packages/nuxt/src/app/nuxt.ts b/packages/nuxt/src/app/nuxt.ts index 8ce0d22f06..218e252c50 100644 --- a/packages/nuxt/src/app/nuxt.ts +++ b/packages/nuxt/src/app/nuxt.ts @@ -77,6 +77,7 @@ export interface NuxtPayload { prerenderedAt?: number data: Record state: Record + once: Set config?: Pick error?: Error | { url: string @@ -126,6 +127,11 @@ interface _NuxtApp { named: Record } + /** @internal */ + _once: { + [key: string]: Promise + } + /** @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(), _errors: {}, ...(import.meta.client ? window.__NUXT__ ?? {} : { serverRendered: true }) }), diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 72f33b42b3..9085acddfa 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -149,7 +149,8 @@ const getSPARenderer = lazyCachedFunction(async () => { _errors: {}, serverRendered: false, data: {}, - state: {} + state: {}, + once: new Set() } ssrContext.config = { public: config.public, diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 3e4d8d1da4..4404893b4a 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -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' diff --git a/packages/schema/src/config/build.ts b/packages/schema/src/config/build.ts index 9025441b1c..ec93de6b1f 100644 --- a/packages/schema/src/config/build.ts +++ b/packages/schema/src/config/build.ts @@ -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 }, diff --git a/test/basic.test.ts b/test/basic.test.ts index 1b2172c7e3..80c723cf29 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -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/') diff --git a/test/fixtures/basic/pages/once.vue b/test/fixtures/basic/pages/once.vue new file mode 100644 index 0000000000..963014ee7f --- /dev/null +++ b/test/fixtures/basic/pages/once.vue @@ -0,0 +1,14 @@ + + + diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index 65573137a5..f9229a01d2 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -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) + }) +})