feat(nuxt): support async transform of object properties (#20182)

This commit is contained in:
Daniel Roe 2023-04-10 22:57:13 +01:00 committed by GitHub
parent 98d1c0e827
commit d6c3c2439a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 73 additions and 31 deletions

View File

@ -356,7 +356,7 @@ const { data } = await useFetch('/api/price')
## Using Async Setup ## Using Async Setup
If you are using `async setup()`, the current component instance will be lost after the first `await`. (This is a Vue 3 limitation.) If you want to use multiple async operations, such as multiple calls to `useFetch`, you will need to use `<script setup>` or await them together at the end of setup. If you are using `async setup()`, the current component instance will be lost after the first `await`. (This is a Vue 3 limitation.) If you want to use multiple async operations, such as multiple calls to `useFetch`, you will need to use `<script setup>`, await them together at the end of setup, or alternatively use `defineNuxtComponent` (which applies a custom transform to the setup function).
::alert{icon=👉} ::alert{icon=👉}
Using `<script setup>` is recommended, as it removes the limitation of using top-level await. [Read more](https://vuejs.org/api/sfc-script-setup.html#top-level-await) Using `<script setup>` is recommended, as it removes the limitation of using top-level await. [Read more](https://vuejs.org/api/sfc-script-setup.html#top-level-await)

View File

@ -54,7 +54,7 @@ When you are using the built-in Composition API composables provided by Vue and
During a component lifecycle, Vue tracks the temporary instance of the current component (and similarly, Nuxt tracks a temporary instance of `nuxtApp`) via a global variable, and then unsets it in same tick. This is essential when server rendering, both to avoid cross-request state pollution (leaking a shared reference between two users) and to avoid leakage between different components. During a component lifecycle, Vue tracks the temporary instance of the current component (and similarly, Nuxt tracks a temporary instance of `nuxtApp`) via a global variable, and then unsets it in same tick. This is essential when server rendering, both to avoid cross-request state pollution (leaking a shared reference between two users) and to avoid leakage between different components.
That means that (with very few exceptions) you cannot use them outside a Nuxt plugin, Nuxt route middleware or Vue setup function. On top of that, you must use them synchronously - that is, you cannot use `await` before calling a composable, except within `<script setup>` blocks, in `defineNuxtPlugin` or in `defineNuxtRouteMiddleware`, where we perform a transform to keep the synchronous context even after the `await`. That means that (with very few exceptions) you cannot use them outside a Nuxt plugin, Nuxt route middleware or Vue setup function. On top of that, you must use them synchronously - that is, you cannot use `await` before calling a composable, except within `<script setup>` blocks, within the setup function of a component declared with `defineNuxtComponent`, in `defineNuxtPlugin` or in `defineNuxtRouteMiddleware`, where we perform a transform to keep the synchronous context even after the `await`.
If you get an error message like `Nuxt instance is unavailable` then it probably means you are calling a Nuxt composable in the wrong place in the Vue or Nuxt lifecycle. If you get an error message like `Nuxt instance is unavailable` then it probably means you are calling a Nuxt composable in the wrong place in the Vue or Nuxt lifecycle.

View File

@ -2,7 +2,7 @@ import { getCurrentInstance, reactive, toRefs } from 'vue'
import type { DefineComponent, defineComponent } from 'vue' import type { DefineComponent, defineComponent } from 'vue'
import { useHead } from '@unhead/vue' import { useHead } from '@unhead/vue'
import type { NuxtApp } from '../nuxt' import type { NuxtApp } from '../nuxt'
import { useNuxtApp } from '../nuxt' import { callWithNuxt, useNuxtApp } from '../nuxt'
import { useAsyncData } from './asyncData' import { useAsyncData } from './asyncData'
import { useRoute } from './router' import { useRoute } from './router'
@ -14,7 +14,7 @@ async function runLegacyAsyncData (res: Record<string, any> | Promise<Record<str
const vm = getCurrentInstance()! const vm = getCurrentInstance()!
const { fetchKey } = vm.proxy!.$options const { fetchKey } = vm.proxy!.$options
const key = typeof fetchKey === 'function' ? fetchKey(() => '') : fetchKey || route.fullPath const key = typeof fetchKey === 'function' ? fetchKey(() => '') : fetchKey || route.fullPath
const { data } = await useAsyncData(`options:asyncdata:${key}`, () => fn(nuxt)) const { data } = await useAsyncData(`options:asyncdata:${key}`, () => callWithNuxt(nuxt, fn, [nuxt]))
if (data.value && typeof data.value === 'object') { if (data.value && typeof data.value === 'object') {
Object.assign(await res, toRefs(reactive(data.value))) Object.assign(await res, toRefs(reactive(data.value)))
} else if (process.dev) { } else if (process.dev) {
@ -38,7 +38,8 @@ export const defineNuxtComponent: typeof defineComponent =
[NuxtComponentIndicator]: true, [NuxtComponentIndicator]: true,
...options, ...options,
setup (props, ctx) { setup (props, ctx) {
const res = setup?.(props, ctx) || {} const nuxtApp = useNuxtApp()
const res = setup ? Promise.resolve(callWithNuxt(nuxtApp, setup, [props, ctx])).then(r => r || {}) : {}
const promises: Promise<any>[] = [] const promises: Promise<any>[] = []
if (options.asyncData) { if (options.asyncData) {

View File

@ -98,8 +98,12 @@ async function initNuxt (nuxt: Nuxt) {
nuxt.hook('modules:done', () => { nuxt.hook('modules:done', () => {
// Add unctx transform // Add unctx transform
addVitePlugin(UnctxTransformPlugin(nuxt).vite({ sourcemap: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client })) const options = {
addWebpackPlugin(UnctxTransformPlugin(nuxt).webpack({ sourcemap: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client })) sourcemap: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client,
transformerOptions: nuxt.options.optimization.asyncTransforms
}
addVitePlugin(UnctxTransformPlugin.vite(options))
addWebpackPlugin(UnctxTransformPlugin.webpack(options))
// Add composable tree-shaking optimisations // Add composable tree-shaking optimisations
const serverTreeShakeOptions: TreeShakeComposablesPluginOptions = { const serverTreeShakeOptions: TreeShakeComposablesPluginOptions = {

View File

@ -1,30 +1,42 @@
import { normalize } from 'pathe' import { pathToFileURL } from 'node:url'
import { parseQuery, parseURL } from 'ufo'
import type { TransformerOptions } from 'unctx/transform'
import { createTransformer } from 'unctx/transform' import { createTransformer } from 'unctx/transform'
import { createUnplugin } from 'unplugin' import { createUnplugin } from 'unplugin'
import type { Nuxt, NuxtApp } from 'nuxt/schema'
const TRANSFORM_MARKER = '/* _processed_nuxt_unctx_transform */\n' const TRANSFORM_MARKER = '/* _processed_nuxt_unctx_transform */\n'
export const UnctxTransformPlugin = (nuxt: Nuxt) => { interface UnctxTransformPluginOptions {
const transformer = createTransformer({ sourcemap?: boolean
asyncFunctions: ['defineNuxtPlugin', 'defineNuxtRouteMiddleware'] transformerOptions: TransformerOptions
}) }
let app: NuxtApp | undefined export const UnctxTransformPlugin = createUnplugin((options: UnctxTransformPluginOptions) => {
nuxt.hook('app:resolve', (_app) => { app = _app }) const transformer = createTransformer(options.transformerOptions)
return {
return createUnplugin((options: { sourcemap?: boolean } = {}) => ({
name: 'unctx:transform', name: 'unctx:transform',
enforce: 'post', enforce: 'post',
transformInclude (id) { transformInclude (id) {
if (id.includes('macro=true')) { return true } const { pathname, search } = parseURL(decodeURIComponent(pathToFileURL(id).href))
const query = parseQuery(search)
id = normalize(id).replace(/\?.*$/, '') // Vue files
return app?.plugins.some(i => i.src === id) || app?.middleware.some(m => m.path === id) if (
pathname.endsWith('.vue') ||
'macro' in query ||
('vue' in query && (query.type === 'template' || query.type === 'script' || 'setup' in query))
) {
return true
}
// JavaScript files
if (pathname.match(/\.((c|m)?j|t)sx?$/g)) {
return true
}
}, },
transform (code, id) { transform (code, id) {
// TODO: needed for webpack - update transform in unctx/unplugin? // TODO: needed for webpack - update transform in unctx/unplugin?
if (code.startsWith(TRANSFORM_MARKER)) { return } if (code.startsWith(TRANSFORM_MARKER) || !transformer.shouldTransform(code)) { return }
const result = transformer.transform(code) const result = transformer.transform(code)
if (result) { if (result) {
return { return {
@ -35,5 +47,5 @@ export const UnctxTransformPlugin = (nuxt: Nuxt) => {
} }
} }
} }
})) }
} })

View File

@ -156,8 +156,10 @@ export default defineNuxtModule({
layer => resolve(layer.config.srcDir, layer.config.dir?.pages || 'pages') layer => resolve(layer.config.srcDir, layer.config.dir?.pages || 'pages')
) )
} }
nuxt.hook('modules:done', () => {
addVitePlugin(PageMetaPlugin.vite(pageMetaOptions)) addVitePlugin(PageMetaPlugin.vite(pageMetaOptions))
addWebpackPlugin(PageMetaPlugin.webpack(pageMetaOptions)) addWebpackPlugin(PageMetaPlugin.webpack(pageMetaOptions))
})
// Add prefetching support for middleware & layouts // Add prefetching support for middleware & layouts
addPlugin(resolve(runtimeDir, 'plugins/prefetch.client')) addPlugin(resolve(runtimeDir, 'plugins/prefetch.client'))

View File

@ -23,6 +23,7 @@ export default defineBuildConfig({
'vue-bundle-renderer', 'vue-bundle-renderer',
'@unhead/schema', '@unhead/schema',
'vue', 'vue',
'unctx',
'hookable', 'hookable',
'nitropack', 'nitropack',
'webpack', 'webpack',

View File

@ -28,6 +28,7 @@
"@vitejs/plugin-vue-jsx": "^3.0.1", "@vitejs/plugin-vue-jsx": "^3.0.1",
"nitropack": "^2.3.2", "nitropack": "^2.3.2",
"unbuild": "latest", "unbuild": "latest",
"unctx": "^2.2.0",
"vite": "~4.2.1" "vite": "~4.2.1"
}, },
"dependencies": { "dependencies": {

View File

@ -184,5 +184,19 @@ export default defineUntypedSchema({
} }
} }
}, },
/**
* Options passed directly to the transformer from `unctx` that preserves async context
* after `await`.
*
* @type {import('unctx').TransformerOptions}
*/
asyncTransforms: {
asyncFunctions: ['defineNuxtPlugin', 'defineNuxtRouteMiddleware'],
objectDefinitions: {
defineNuxtComponent: ['asyncData', 'setup'],
definePageMeta: ['middleware', 'validate']
}
}
} }
}) })

View File

@ -739,6 +739,9 @@ importers:
unbuild: unbuild:
specifier: ^1.2.0 specifier: ^1.2.0
version: 1.2.0 version: 1.2.0
unctx:
specifier: ^2.2.0
version: 2.2.0
vite: vite:
specifier: ^4.2.1 specifier: ^4.2.1
version: 4.2.1(@types/node@18.15.11) version: 4.2.1(@types/node@18.15.11)
@ -5099,7 +5102,6 @@ packages:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
dependencies: dependencies:
'@types/estree': 1.0.0 '@types/estree': 1.0.0
dev: false
/esutils@2.0.3: /esutils@2.0.3:
resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==}
@ -8712,7 +8714,6 @@ packages:
estree-walker: 3.0.3 estree-walker: 3.0.3
magic-string: 0.30.0 magic-string: 0.30.0
unplugin: 1.3.1 unplugin: 1.3.1
dev: false
/undici@5.20.0: /undici@5.20.0:
resolution: {integrity: sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==} resolution: {integrity: sha512-J3j60dYzuo6Eevbawwp1sdg16k5Tf768bxYK4TUJRH7cBM4kFCbf3mOnM/0E3vQYXvpxITbbWmBafaDbxLDz3g==}

View File

@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
definePageMeta({ definePageMeta({
middleware: defineNuxtRouteMiddleware(async (to, from) => { async middleware (to, from) {
await new Promise(resolve => setTimeout(resolve, 1))
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
if (process.client && from !== to && !nuxtApp.isHydrating) { if (process.client && from !== to && !nuxtApp.isHydrating) {
// trigger a loading error when navigated to via client-side navigation // trigger a loading error when navigated to via client-side navigation
await import(/* webpackIgnore: true */ /* @vite-ignore */ `some-non-exis${''}ting-module`) await import(/* webpackIgnore: true */ /* @vite-ignore */ `some-non-exis${''}ting-module`)
} }
}) }
}) })
const someValue = useState('val', () => 1) const someValue = useState('val', () => 1)
</script> </script>

View File

@ -6,7 +6,12 @@
<script> <script>
export default defineNuxtComponent({ export default defineNuxtComponent({
async setup () {
await nextTick()
useRuntimeConfig()
},
async asyncData () { async asyncData () {
await nextTick()
return { return {
hello: await $fetch('/api/hello') hello: await $fetch('/api/hello')
} }

View File

@ -4,9 +4,9 @@
<script setup> <script setup>
definePageMeta({ definePageMeta({
middleware: defineNuxtRouteMiddleware(async () => { middleware: async () => {
await new Promise(resolve => setTimeout(resolve, 1)) await new Promise(resolve => setTimeout(resolve, 1))
return navigateTo({ path: '/' }, { redirectCode: 307 }) return navigateTo({ path: '/' }, { redirectCode: 307 })
}) }
}) })
</script> </script>