Skip to content

Commit

Permalink
feat(nuxt): usePreviewMode composable (#21705)
Browse files Browse the repository at this point in the history
  • Loading branch information
logotip4ik committed Mar 6, 2024
1 parent f0442d0 commit 98aa2c2
Show file tree
Hide file tree
Showing 11 changed files with 361 additions and 1 deletion.
81 changes: 81 additions & 0 deletions docs/3.api/2.composables/use-preview-mode.md
Original file line number Diff line number Diff line change
@@ -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]
<script setup>
const route = useRoute()
const { enabled, state } = usePreviewMode({
shouldEnable: () => {
return route.query.customPreview === 'true'
},
})
const { data } = await useFetch('/api/preview', {
query: {
apiKey: state.token
}
})
</script>
<template>
<div>
Some base content
<p v-if="enabled">
Only preview content: {{ state.token }}
<br>
<button @click="enabled = false">
disable preview mode
</button>
</p>
</div>
</template>
```
1 change: 1 addition & 0 deletions packages/nuxt/src/app/composables/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
91 changes: 91 additions & 0 deletions packages/nuxt/src/app/composables/preview.ts
Original file line number Diff line number Diff line change
@@ -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<any, unknown>
_initialized?: boolean
}

interface PreviewModeOptions<S> {
shouldEnable?: (state: Preview['state']) => boolean,
getState?: (state: Preview['state']) => S,
}

type EnteredState = Record<any, unknown> | null | undefined | void

let unregisterRefreshHook: (() => any) | undefined

/** @since 3.11.0 */
export function usePreviewMode<S extends EnteredState> (options: PreviewModeOptions<S> = {}) {
const preview = useState<Preview>('_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<S> & 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<S> & 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
}
4 changes: 4 additions & 0 deletions packages/nuxt/src/imports/presets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
55 changes: 55 additions & 0 deletions test/basic.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<boolean>((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', () => {
Expand Down
38 changes: 38 additions & 0 deletions test/fixtures/basic/pages/preview/index.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
<script setup>
const { enabled: isPreview } = usePreviewMode()
const { data } = await useAsyncData(async () => {
await new Promise(resolve => setTimeout(resolve, 200))
const fetchedOnClient = process.client
console.log(fetchedOnClient)
return { fetchedOnClient }
})
</script>

<template>
<div>
<NuxtLink
id="use-fetch-check"
href="/preview/with-use-fetch"
>
check useFetch
</NuxtLink>

<p
v-if="data && data.fetchedOnClient"
id="fetched-on-client"
>
fetched on client
</p>

<p
v-if="isPreview"
id="preview-mode"
>
preview mode enabled
</p>
</div>
</template>
17 changes: 17 additions & 0 deletions test/fixtures/basic/pages/preview/with-custom-enable.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<script setup>
const route = useRoute()
const { enabled } = usePreviewMode({
shouldEnable: () => {
return !!route.query.customPreview
}
})
</script>

<template>
<div>
<p id="enabled">
{{ enabled }}
</p>
</div>
</template>
39 changes: 39 additions & 0 deletions test/fixtures/basic/pages/preview/with-custom-state.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<script setup>
const data1 = ref('data1')
const { enabled, state } = usePreviewMode({
getState: () => {
return { data1, data2: 'data2' }
}
})
onMounted(() => {
data1.value = 'data1 updated'
})
</script>

<template>
<div>
<NuxtLink
id="with-use-fetch"
to="/preview/with-use-fetch"
>
fetch check
</NuxtLink>

<p id="data1">
{{ state.data1 }}
</p>

<p id="data2">
{{ state.data2 }}
</p>

<button
id="toggle-preview"
@click="enabled = !enabled"
>
toggle preview mode
</button>
</div>
</template>
25 changes: 25 additions & 0 deletions test/fixtures/basic/pages/preview/with-use-fetch.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<script setup>
const { enabled, state } = usePreviewMode()
const { data } = await useFetch('/api/preview', {
query: {
apiKey: state.token || undefined
}
})
</script>

<template>
<div>
<p id="enabled">
{{ enabled }}
</p>

<p id="token-check">
{{ state.token }}
</p>

<p id="correct-api-key-check">
{{ data && data.hehe }}
</p>
</div>
</template>
8 changes: 8 additions & 0 deletions test/fixtures/basic/server/api/preview.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
const apiKeyName = 'apiKey'
const apiKey = 'hehe'

export default defineEventHandler((event) => {
return {
hehe: getQuery(event)[apiKeyName] === apiKey
}
})
3 changes: 2 additions & 1 deletion test/nuxt/composables.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ describe('composables', () => {
'useLazyAsyncData',
'useRouter',
'useSeoMeta',
'useServerSeoMeta'
'useServerSeoMeta',
'usePreviewMode'
]
expect(Object.keys(composables).sort()).toEqual([...new Set([...testedComposables, ...skippedComposables])].sort())
})
Expand Down

0 comments on commit 98aa2c2

Please sign in to comment.