feat(nuxt): add mode: 'navigation' to callOnce (#30260)

This commit is contained in:
Alexander Lichter 2024-12-18 12:41:15 +01:00 committed by GitHub
parent c83507e315
commit f877823a43
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 87 additions and 29 deletions

View File

@ -1,18 +1,23 @@
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
type CallOnceOptions = {
mode?: 'navigation' | 'render'
}
/** /**
* An SSR-friendly utility to call a method once * 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 key a unique key ensuring the function can be properly de-duplicated across requests
* @param fn a function to call * @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 * @see https://nuxt.com/docs/api/utils/call-once
* @since 3.9.0 * @since 3.9.0
*/ */
export function callOnce (key?: string, fn?: (() => any | Promise<any>)): Promise<void> export function callOnce (key?: string, fn?: (() => any | Promise<any>), options?: CallOnceOptions): Promise<void>
export function callOnce (fn?: (() => any | Promise<any>)): Promise<void> export function callOnce (fn?: (() => any | Promise<any>), options?: CallOnceOptions): Promise<void>
export async function callOnce (...args: any): Promise<void> { export async function callOnce (...args: any): Promise<void> {
const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined const autoKey = typeof args[args.length - 1] === 'string' ? args.pop() : undefined
if (typeof args[0] !== 'string') { args.unshift(autoKey) } if (typeof args[0] !== 'string') { args.unshift(autoKey) }
const [_key, fn] = args as [string, (() => any | Promise<any>)] const [_key, fn, options] = args as [string, (() => any | Promise<any>), CallOnceOptions | undefined]
if (!_key || typeof _key !== 'string') { if (!_key || typeof _key !== 'string') {
throw new TypeError('[nuxt] [callOnce] key must be a string: ' + _key) throw new TypeError('[nuxt] [callOnce] key must be a string: ' + _key)
} }
@ -20,10 +25,18 @@ export async function callOnce (...args: any): Promise<void> {
throw new Error('[nuxt] [callOnce] fn must be a function: ' + fn) throw new Error('[nuxt] [callOnce] fn must be a function: ' + fn)
} }
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
if (options?.mode === 'navigation') {
nuxtApp.hooks.hookOnce('page:start', () => {
nuxtApp.payload.once.delete(_key)
})
}
// If key already ran // If key already ran
if (nuxtApp.payload.once.has(_key)) { if (nuxtApp.payload.once.has(_key)) {
return return
} }
nuxtApp._once = nuxtApp._once || {} nuxtApp._once = nuxtApp._once || {}
nuxtApp._once[_key] = nuxtApp._once[_key] || fn() || true nuxtApp._once[_key] = nuxtApp._once[_key] || fn() || true
await nuxtApp._once[_key] await nuxtApp._once[_key]

View File

@ -147,7 +147,7 @@ export default defineUntypedSchema({
keyedComposables: { keyedComposables: {
$resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [ $resolve: (val: Array<{ name: string, argumentLength: string }> | undefined) => [
{ name: 'useId', argumentLength: 1 }, { name: 'useId', argumentLength: 1 },
{ name: 'callOnce', argumentLength: 2 }, { name: 'callOnce', argumentLength: 3 },
{ 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

@ -1223,6 +1223,15 @@ describe('composables', () => {
const { page } = await renderPage('/once') const { page } = await renderPage('/once')
expect(await page.getByText('once:').textContent()).toContain('once: 2') 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<string>('/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 () => { 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 // TODO: work around interesting Vue bug where async components are loaded in a different order on first import
await $fetch<string>('/use-id') await $fetch<string>('/use-id')

View File

@ -0,0 +1,14 @@
<script setup>
const counter = useState('once', () => 0)
await callOnce(() => counter.value++, { mode: 'navigation' })
await callOnce('same-key', () => counter.value++, { mode: 'navigation' })
await callOnce('same-key', () => counter.value++, { mode: 'navigation' })
</script>
<template>
<div>
<div>once.vue</div>
<div>once: {{ counter }}</div>
</div>
</template>

View File

@ -1,6 +1,6 @@
/// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" /> /// <reference path="../fixtures/basic/.nuxt/nuxt.d.ts" />
import { describe, expect, it, vi } from 'vitest' import { afterEach, describe, expect, it, vi } from 'vitest'
import { defineEventHandler } from 'h3' import { defineEventHandler } from 'h3'
import { destr } from 'destr' import { destr } from 'destr'
@ -778,9 +778,18 @@ describe('useCookie', () => {
}) })
describe('callOnce', () => { describe('callOnce', () => {
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 () => { it('should only call composable once', async () => {
const fn = vi.fn() const fn = vi.fn()
const execute = () => callOnce(fn) const execute = () => options ? callOnce(fn, options) : callOnce(fn)
await execute() await execute()
await execute() await execute()
expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1)
@ -788,24 +797,37 @@ describe('callOnce', () => {
it('should only call composable once when called in parallel', async () => { it('should only call composable once when called in parallel', async () => {
const fn = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1))) const fn = vi.fn().mockImplementation(() => new Promise(resolve => setTimeout(resolve, 1)))
const execute = () => callOnce(fn) const execute = () => options ? callOnce(fn, options) : callOnce(fn)
await Promise.all([execute(), execute(), execute()]) await Promise.all([execute(), execute(), execute()])
expect(fn).toHaveBeenCalledTimes(1) expect(fn).toHaveBeenCalledTimes(1)
const fnSync = vi.fn().mockImplementation(() => {}) const fnSync = vi.fn().mockImplementation(() => {})
const executeSync = () => callOnce(fnSync) const executeSync = () => options ? callOnce(fnSync, options) : callOnce(fnSync)
await Promise.all([executeSync(), executeSync(), executeSync()]) await Promise.all([executeSync(), executeSync(), executeSync()])
expect(fnSync).toHaveBeenCalledTimes(1) expect(fnSync).toHaveBeenCalledTimes(1)
}) })
it('should use key to dedupe', async () => { it('should use key to dedupe', async () => {
const fn = vi.fn() const fn = vi.fn()
const execute = (key?: string) => callOnce(key, fn) const execute = (key?: string) => options ? callOnce(key, fn, options) : callOnce(key, fn)
await execute('first') await execute('first')
await execute('first') await execute('first')
await execute('second') await execute('second')
expect(fn).toHaveBeenCalledTimes(2) 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)
})
})
}) })
describe('route announcer', () => { describe('route announcer', () => {