diff --git a/packages/nuxt/src/app/composables/once.ts b/packages/nuxt/src/app/composables/once.ts index 177ab4c9d5..ae8746fff0 100644 --- a/packages/nuxt/src/app/composables/once.ts +++ b/packages/nuxt/src/app/composables/once.ts @@ -1,18 +1,23 @@ import { useNuxtApp } from '../nuxt' +type CallOnceOptions = { + mode?: 'navigation' | 'render' +} + /** * 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 + * @param options Setup the mode, e.g. to re-execute on navigation * @see https://nuxt.com/docs/api/utils/call-once * @since 3.9.0 */ -export function callOnce (key?: string, fn?: (() => any | Promise)): Promise -export function callOnce (fn?: (() => any | Promise)): Promise +export function callOnce (key?: string, fn?: (() => any | Promise), options?: CallOnceOptions): Promise +export function callOnce (fn?: (() => any | Promise), options?: CallOnceOptions): 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)] + const [_key, fn, options] = args as [string, (() => any | Promise), CallOnceOptions | undefined] if (!_key || typeof _key !== 'string') { throw new TypeError('[nuxt] [callOnce] key must be a string: ' + _key) } @@ -20,10 +25,18 @@ export async function callOnce (...args: any): Promise { throw new Error('[nuxt] [callOnce] fn must be a function: ' + fn) } const nuxtApp = useNuxtApp() + + if (options?.mode === 'navigation') { + nuxtApp.hooks.hookOnce('page:start', () => { + nuxtApp.payload.once.delete(_key) + }) + } + // If key already ran if (nuxtApp.payload.once.has(_key)) { return } + nuxtApp._once = nuxtApp._once || {} nuxtApp._once[_key] = nuxtApp._once[_key] || fn() || true await nuxtApp._once[_key] diff --git a/packages/schema/src/config/build.ts b/packages/schema/src/config/build.ts index d884c89a0e..debe037a03 100644 --- a/packages/schema/src/config/build.ts +++ b/packages/schema/src/config/build.ts @@ -147,7 +147,7 @@ export default defineUntypedSchema({ keyedComposables: { $resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [ { name: 'useId', argumentLength: 1 }, - { name: 'callOnce', argumentLength: 2 }, + { name: 'callOnce', argumentLength: 3 }, { 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 98958a47f4..c8be67cf11 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -1223,6 +1223,15 @@ describe('composables', () => { const { page } = await renderPage('/once') expect(await page.getByText('once:').textContent()).toContain('once: 2') }) + it('`callOnce` should run code once with navigation mode during initial render', async () => { + const html = await $fetch('/once-nav-initial') + + expect(html).toContain('once.vue') + expect(html).toContain('once: 2') + + const { page } = await renderPage('/once-nav-initial') + expect(await page.getByText('once:').textContent()).toContain('once: 2') + }) it('`useId` should generate unique ids', async () => { // TODO: work around interesting Vue bug where async components are loaded in a different order on first import await $fetch('/use-id') diff --git a/test/fixtures/basic/pages/once-nav-initial.vue b/test/fixtures/basic/pages/once-nav-initial.vue new file mode 100644 index 0000000000..475d2a4617 --- /dev/null +++ b/test/fixtures/basic/pages/once-nav-initial.vue @@ -0,0 +1,14 @@ + + + diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index bbb06f0f82..86568528be 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -1,6 +1,6 @@ /// -import { describe, expect, it, vi } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { defineEventHandler } from 'h3' import { destr } from 'destr' @@ -778,33 +778,55 @@ describe('useCookie', () => { }) 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) - }) + describe.each([ + ['without options', undefined], + ['with "render" option', { mode: 'render' as const }], + ['with "navigation" option', { mode: 'navigation' as const }], + ])('%s', (_name, options) => { + const nuxtApp = useNuxtApp() + afterEach(() => { + nuxtApp.payload.once.clear() + }) + it('should only call composable once', async () => { + const fn = vi.fn() + const execute = () => options ? callOnce(fn, options) : 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 only call composable once when called in parallel', async () => { + const fn = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1))) + const execute = () => options ? callOnce(fn, options) : callOnce(fn) + await Promise.all([execute(), execute(), execute()]) + expect(fn).toHaveBeenCalledTimes(1) - const fnSync = vi.fn().mockImplementation(() => {}) - const executeSync = () => callOnce(fnSync) - await Promise.all([executeSync(), executeSync(), executeSync()]) - expect(fnSync).toHaveBeenCalledTimes(1) - }) + const fnSync = vi.fn().mockImplementation(() => {}) + const executeSync = () => options ? callOnce(fnSync, options) : callOnce(fnSync) + await Promise.all([executeSync(), executeSync(), executeSync()]) + expect(fnSync).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) + it('should use key to dedupe', async () => { + const fn = vi.fn() + const execute = (key?: string) => options ? callOnce(key, fn, options) : callOnce(key, fn) + await execute('first') + await execute('first') + await execute('second') + expect(fn).toHaveBeenCalledTimes(2) + }) + + it.runIf(options?.mode === 'navigation')('should rerun on navigation', async () => { + const fn = vi.fn() + const execute = () => options ? callOnce(fn, options) : callOnce(fn) + await execute() + await execute() + expect(fn).toHaveBeenCalledTimes(1) + + await nuxtApp.callHook('page:start') + await execute() + expect(fn).toHaveBeenCalledTimes(2) + }) }) })