diff --git a/docs/3.api/2.composables/use-preview-mode.md b/docs/3.api/2.composables/use-preview-mode.md new file mode 100644 index 0000000000..3af249e023 --- /dev/null +++ b/docs/3.api/2.composables/use-preview-mode.md @@ -0,0 +1,81 @@ +--- +title: "usePreviewMode" +description: "Use usePreviewMode to check and control preview mode in Nuxt" +--- + +# `usePreviewMode` + +You can use the built-in `usePreviewMode` composable to access and control preview state in Nuxt. If the composable detects preview mode it will automatically force any updates necessary for [`useAsyncData`](/docs/api/composables/use-async-data) and [`useFetch`](/docs/api/composables/use-fetch) to rerender preview content. + +```js +const { enabled, state } = usePreviewMode() +``` + +## Options + +### Custom `enable` check + +You can specify a custom way to enable preview mode. By default the `usePreviewMode` composable will enable preview mode if there is a `preview` param in url that is equal to `true` (for example, `http://localhost:3000?preview=true`). You can wrap the `usePreviewMode` into custom composable, to keep options consistent across usages and prevent any errors. + +```js +export function useMyPreviewMode () { + return usePreviewMode({ + shouldEnable: () => { + return !!route.query.customPreview + } + }); +}``` + +### Modify default state + +`usePreviewMode` will try to store the value of a `token` param from url in state. You can modify this state and it will be available for all [`usePreviewMode`](/docs/api/composables/use-preview-mode) calls. + +```js +const data1 = ref('data1') + +const { enabled, state } = usePreviewMode({ + getState: (currentState) => { + return { data1, data2: 'data2' } + } +}) +``` + +::alert{icon=👉} +The `getState` function will append returned values to current state, so be careful not to accidentally overwrite important state. +:: + +## Example + +```vue [pages/some-page.vue] + + + +``` diff --git a/packages/nuxt/src/app/composables/index.ts b/packages/nuxt/src/app/composables/index.ts index 784ded227d..7b0810d0b0 100644 --- a/packages/nuxt/src/app/composables/index.ts +++ b/packages/nuxt/src/app/composables/index.ts @@ -35,4 +35,5 @@ export type { NuxtAppManifest, NuxtAppManifestMeta } from './manifest' export type { ReloadNuxtAppOptions } from './chunk' export { reloadNuxtApp } from './chunk' export { useRequestURL } from './url' +export { usePreviewMode } from './preview' export { useId } from './id' diff --git a/packages/nuxt/src/app/composables/preview.ts b/packages/nuxt/src/app/composables/preview.ts new file mode 100644 index 0000000000..af2c3f33cb --- /dev/null +++ b/packages/nuxt/src/app/composables/preview.ts @@ -0,0 +1,91 @@ +import { toRef, watch } from 'vue' + +import { useState } from './state' +import { refreshNuxtData } from './asyncData' +import { useRoute, useRouter } from './router' + +interface Preview { + enabled: boolean + state: Record + _initialized?: boolean +} + +interface PreviewModeOptions { + shouldEnable?: (state: Preview['state']) => boolean, + getState?: (state: Preview['state']) => S, +} + +type EnteredState = Record | null | undefined | void + +let unregisterRefreshHook: (() => any) | undefined + +/** @since 3.11.0 */ +export function usePreviewMode (options: PreviewModeOptions = {}) { + const preview = useState('_preview-state', () => ({ + enabled: false, + state: {} + })) + + if (preview.value._initialized) { + return { + enabled: toRef(preview.value, 'enabled'), + state: preview.value.state as S extends void ? Preview['state'] : (NonNullable & Preview['state']), + } + } + + if (import.meta.client) { + preview.value._initialized = true + } + + if (!preview.value.enabled) { + const shouldEnable = options.shouldEnable ?? defaultShouldEnable + const result = shouldEnable(preview.value.state) + + if (typeof result === 'boolean') { preview.value.enabled = result } + } + + watch(() => preview.value.enabled, (value) => { + if (value) { + const getState = options.getState ?? getDefaultState + const newState = getState(preview.value.state) + + if (newState !== preview.value.state) { + Object.assign(preview.value.state, newState) + } + + if (import.meta.client && !unregisterRefreshHook) { + refreshNuxtData() + + unregisterRefreshHook = useRouter().afterEach((() => refreshNuxtData())) + } + } else if (unregisterRefreshHook) { + unregisterRefreshHook() + + unregisterRefreshHook = undefined + } + }, { immediate: true, flush: 'sync' }) + + return { + enabled: toRef(preview.value, 'enabled'), + state: preview.value.state as S extends void ? Preview['state'] : (NonNullable & Preview['state']), + } +} + +function defaultShouldEnable () { + const route = useRoute() + const previewQueryName = 'preview' + + return route.query[previewQueryName] === 'true' +} + +function getDefaultState (state: Preview['state']) { + if (state.token !== undefined) { + return state + } + + const route = useRoute() + + state.token = Array.isArray(route.query.token) ? route.query.token[0] : route.query.token + + return state +} diff --git a/packages/nuxt/src/imports/presets.ts b/packages/nuxt/src/imports/presets.ts index 79096eb0a0..9d3932b27a 100644 --- a/packages/nuxt/src/imports/presets.ts +++ b/packages/nuxt/src/imports/presets.ts @@ -101,6 +101,10 @@ const granularAppPresets: InlinePreset[] = [ imports: ['useRequestURL'], from: '#app/composables/url' }, + { + imports: ['usePreviewMode'], + from: '#app/composables/preview' + }, { imports: ['useId'], from: '#app/composables/id' diff --git a/test/basic.test.ts b/test/basic.test.ts index 879dda2155..c865cfd5b8 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -568,6 +568,61 @@ describe('nuxt composables', () => { expect(text).toContain('foobar') await page.close() }) + + it('respects preview mode with a token', async () => { + const token = 'hehe' + const page = await createPage(`/preview?preview=true&token=${token}`) + + const hasRerunFetchOnClient = await new Promise((resolve) => { + page.on('console', (message) => { + setTimeout(() => resolve(false), 4000) + + if (message.text() === 'true') { resolve(true) } + }) + }) + + expect(hasRerunFetchOnClient).toBe(true) + + expect(await page.locator('#fetched-on-client').textContent()).toContain('fetched on client') + expect(await page.locator('#preview-mode').textContent()).toContain('preview mode enabled') + + await page.click('#use-fetch-check') + await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath.includes('/preview/with-use-fetch')) + + expect(await page.locator('#token-check').textContent()).toContain(token) + expect(await page.locator('#correct-api-key-check').textContent()).toContain('true') + await page.close() + }) + + it('respects preview mode with custom state', async () => { + const { page } = await renderPage('/preview/with-custom-state?preview=true') + + expect(await page.locator('#data1').textContent()).toContain('data1 updated') + expect(await page.locator('#data2').textContent()).toContain('data2') + + await page.click('#toggle-preview') // manually turns off preview mode + await page.click('#with-use-fetch') + await page.waitForFunction(() => window.useNuxtApp?.()._route.fullPath.includes('/preview/with-use-fetch')) + + expect(await page.locator('#enabled').textContent()).toContain('false') + expect(await page.locator('#token-check').textContent()).toEqual('') + expect(await page.locator('#correct-api-key-check').textContent()).toContain('false') + await page.close() + }) + + it('respects preview mode with custom enable', async () => { + const { page } = await renderPage('/preview/with-custom-enable?preview=true') + + expect(await page.locator('#enabled').textContent()).toContain('false') + await page.close() + }) + + it('respects preview mode with custom enable and customPreview', async () => { + const { page } = await renderPage('/preview/with-custom-enable?customPreview=true') + + expect(await page.locator('#enabled').textContent()).toContain('true') + await page.close() + }) }) describe('rich payloads', () => { diff --git a/test/fixtures/basic/pages/preview/index.vue b/test/fixtures/basic/pages/preview/index.vue new file mode 100644 index 0000000000..df9c2c6dd1 --- /dev/null +++ b/test/fixtures/basic/pages/preview/index.vue @@ -0,0 +1,38 @@ + + + diff --git a/test/fixtures/basic/pages/preview/with-custom-enable.vue b/test/fixtures/basic/pages/preview/with-custom-enable.vue new file mode 100644 index 0000000000..2c1246657b --- /dev/null +++ b/test/fixtures/basic/pages/preview/with-custom-enable.vue @@ -0,0 +1,17 @@ + + + diff --git a/test/fixtures/basic/pages/preview/with-custom-state.vue b/test/fixtures/basic/pages/preview/with-custom-state.vue new file mode 100644 index 0000000000..cba330fdb5 --- /dev/null +++ b/test/fixtures/basic/pages/preview/with-custom-state.vue @@ -0,0 +1,39 @@ + + + diff --git a/test/fixtures/basic/pages/preview/with-use-fetch.vue b/test/fixtures/basic/pages/preview/with-use-fetch.vue new file mode 100644 index 0000000000..e033433db3 --- /dev/null +++ b/test/fixtures/basic/pages/preview/with-use-fetch.vue @@ -0,0 +1,25 @@ + + + diff --git a/test/fixtures/basic/server/api/preview.ts b/test/fixtures/basic/server/api/preview.ts new file mode 100644 index 0000000000..3b228c4c96 --- /dev/null +++ b/test/fixtures/basic/server/api/preview.ts @@ -0,0 +1,8 @@ +const apiKeyName = 'apiKey' +const apiKey = 'hehe' + +export default defineEventHandler((event) => { + return { + hehe: getQuery(event)[apiKeyName] === apiKey + } +}) diff --git a/test/nuxt/composables.test.ts b/test/nuxt/composables.test.ts index 8cd41e951d..8104a1340b 100644 --- a/test/nuxt/composables.test.ts +++ b/test/nuxt/composables.test.ts @@ -128,7 +128,8 @@ describe('composables', () => { 'useLazyAsyncData', 'useRouter', 'useSeoMeta', - 'useServerSeoMeta' + 'useServerSeoMeta', + 'usePreviewMode' ] expect(Object.keys(composables).sort()).toEqual([...new Set([...testedComposables, ...skippedComposables])].sort()) })