feat(nuxt): experimental native async context support (#20918)

Co-authored-by: Daniel Roe <daniel@roe.dev>
This commit is contained in:
pooya parsa 2023-08-08 00:57:35 +02:00 committed by GitHub
parent 9c5b9b7d53
commit 554f868bce
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 126 additions and 4 deletions

View File

@ -17,7 +17,9 @@ import type { RouteMiddleware } from '../../app'
import type { NuxtError } from '../app/composables/error' import type { NuxtError } from '../app/composables/error'
import type { AsyncDataRequestStatus } from '../app/composables/asyncData' import type { AsyncDataRequestStatus } from '../app/composables/asyncData'
const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app') const nuxtAppCtx = /* #__PURE__ */ getContext<NuxtApp>('nuxt-app', {
asyncContext: !!process.env.NUXT_ASYNC_CONTEXT && process.server
})
type HookResult = Promise<void> | void type HookResult = Promise<void> | void

View File

@ -45,7 +45,8 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
buildDir: nuxt.options.buildDir, buildDir: nuxt.options.buildDir,
experimental: { experimental: {
// @ts-expect-error `typescriptBundlerResolution` coming in next nitro version // @ts-expect-error `typescriptBundlerResolution` coming in next nitro version
typescriptBundlerResolution: nuxt.options.experimental.typescriptBundlerResolution || nuxt.options.typescript?.tsConfig?.compilerOptions?.moduleResolution?.toLowerCase() === 'bundler' || _nitroConfig.typescript?.tsConfig?.compilerOptions?.moduleResolution?.toLowerCase() === 'bundler' typescriptBundlerResolution: nuxt.options.experimental.typescriptBundlerResolution || nuxt.options.typescript?.tsConfig?.compilerOptions?.moduleResolution?.toLowerCase() === 'bundler' || _nitroConfig.typescript?.tsConfig?.compilerOptions?.moduleResolution?.toLowerCase() === 'bundler',
asyncContext: nuxt.options.experimental.asyncContext
}, },
imports: { imports: {
autoImport: nuxt.options.imports.autoImport as boolean, autoImport: nuxt.options.imports.autoImport as boolean,
@ -191,6 +192,7 @@ export async function initNitro (nuxt: Nuxt & { _nitro?: Nitro }) {
'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles, 'process.env.NUXT_INLINE_STYLES': !!nuxt.options.experimental.inlineSSRStyles,
'process.env.NUXT_JSON_PAYLOADS': !!nuxt.options.experimental.renderJsonPayloads, 'process.env.NUXT_JSON_PAYLOADS': !!nuxt.options.experimental.renderJsonPayloads,
'process.env.NUXT_COMPONENT_ISLANDS': !!nuxt.options.experimental.componentIslands, 'process.env.NUXT_COMPONENT_ISLANDS': !!nuxt.options.experimental.componentIslands,
'process.env.NUXT_ASYNC_CONTEXT': !!nuxt.options.experimental.asyncContext,
'process.dev': nuxt.options.dev, 'process.dev': nuxt.options.dev,
__VUE_PROD_DEVTOOLS__: false __VUE_PROD_DEVTOOLS__: false
}, },

View File

@ -25,6 +25,7 @@ import { addModuleTranspiles } from './modules'
import { initNitro } from './nitro' import { initNitro } from './nitro'
import schemaModule from './schema' import schemaModule from './schema'
import { RemovePluginMetadataPlugin } from './plugins/plugin-metadata' import { RemovePluginMetadataPlugin } from './plugins/plugin-metadata'
import { AsyncContextInjectionPlugin } from './plugins/async-context'
import { resolveDeepImportsPlugin } from './plugins/resolve-deep-imports' import { resolveDeepImportsPlugin } from './plugins/resolve-deep-imports'
export function createNuxt (options: NuxtOptions): Nuxt { export function createNuxt (options: NuxtOptions): Nuxt {
@ -143,6 +144,11 @@ async function initNuxt (nuxt: Nuxt) {
addWebpackPlugin(() => DevOnlyPlugin.webpack({ sourcemap: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client })) addWebpackPlugin(() => DevOnlyPlugin.webpack({ sourcemap: nuxt.options.sourcemap.server || nuxt.options.sourcemap.client }))
} }
// Transform initial composable call within `<script setup>` to preserve context
if (nuxt.options.experimental.asyncContext) {
addBuildPlugin(AsyncContextInjectionPlugin(nuxt))
}
// TODO: [Experimental] Avoid emitting assets when flag is enabled // TODO: [Experimental] Avoid emitting assets when flag is enabled
if (nuxt.options.experimental.noScripts && !nuxt.options.dev) { if (nuxt.options.experimental.noScripts && !nuxt.options.dev) {
nuxt.hook('build:manifest', async (manifest) => { nuxt.hook('build:manifest', async (manifest) => {

View File

@ -0,0 +1,54 @@
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
import type { Nuxt } from '@nuxt/schema'
import type { Node } from 'estree-walker'
import { walk } from 'estree-walker'
import type { BlockStatement } from 'estree'
import { isVue } from '../utils'
export const AsyncContextInjectionPlugin = (nuxt: Nuxt) => createUnplugin(() => {
return {
name: 'nuxt:async-context-injection',
transformInclude (id) {
return isVue(id, { type: ['template', 'script'] })
},
transform (code) {
const s = new MagicString(code)
let importName: string
walk(this.parse(code) as Node, {
enter (node) {
// only interested in calls of defineComponent function
if (node.type === 'ImportDeclaration' && node.source.value === 'vue') {
importName = importName ?? node.specifiers.find(s => s.type === 'ImportSpecifier' && s.imported.name === 'defineComponent')?.local.name
}
// we only want to transform `async setup()` functions
if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === importName) {
walk(node, {
enter (setup) {
if (setup.type === 'Property' && setup.key.type === 'Identifier' && setup.key.name === 'setup') {
if (setup.value.type === 'FunctionExpression' && setup.value.async) {
const body: BlockStatement = setup.value.body
const { start, end } = body as BlockStatement & { start: number, end: number }
s.appendLeft(start, '{ return useNuxtApp().runWithContext(async () => ')
s.appendRight(end, ') }')
}
}
}
})
}
}
})
if (s.hasChanged()) {
return {
code: s.toString(),
map: nuxt.options.sourcemap.client || nuxt.options.sourcemap.server
? s.generateMap({ hires: true })
: undefined
}
}
}
}
})

View File

@ -1,3 +1,4 @@
import { AsyncLocalStorage } from 'node:async_hooks'
import { import {
createRenderer, createRenderer,
getPrefetchLinks, getPrefetchLinks,
@ -34,6 +35,11 @@ globalThis.__buildAssetsURL = buildAssetsURL
// @ts-expect-error private property consumed by vite-generated url helpers // @ts-expect-error private property consumed by vite-generated url helpers
globalThis.__publicAssetsURL = publicAssetsURL globalThis.__publicAssetsURL = publicAssetsURL
// Polyfill for unctx (https://github.com/unjs/unctx#native-async-context)
if (process.env.NUXT_ASYNC_CONTEXT && !('AsyncLocalStorage' in globalThis)) {
(globalThis as any).AsyncLocalStorage = AsyncLocalStorage
}
export interface NuxtRenderHTMLContext { export interface NuxtRenderHTMLContext {
island?: boolean island?: boolean
htmlAttrs: string[] htmlAttrs: string[]

View File

@ -63,7 +63,7 @@ export default defineUntypedSchema({
* is kept in sync with the current page in view in `<NuxtPage>`. This is not true for * is kept in sync with the current page in view in `<NuxtPage>`. This is not true for
* `vue-router`'s exported `useRoute` or for the default `$route` object available in your * `vue-router`'s exported `useRoute` or for the default `$route` object available in your
* Vue templates. * Vue templates.
* *
* By enabling this option a mixin will be injected to keep the `$route` template object * By enabling this option a mixin will be injected to keep the `$route` template object
* in sync with Nuxt's managed `useRoute()`. * in sync with Nuxt's managed `useRoute()`.
*/ */
@ -220,6 +220,13 @@ export default defineUntypedSchema({
*/ */
watcher: 'chokidar-granular', watcher: 'chokidar-granular',
/**
* Enable native async context to be accessable for nested composables
*
* @see https://github.com/nuxt/nuxt/pull/20918
*/
asyncContext: false,
/** /**
* Add the capo.js head plugin in order to render tags in of the head in a more performant way. * Add the capo.js head plugin in order to render tags in of the head in a more performant way.
* *

View File

@ -82,7 +82,10 @@ export async function bundle (nuxt: Nuxt) {
exclude: ['nuxt/app'] exclude: ['nuxt/app']
}, },
css: resolveCSSOptions(nuxt), css: resolveCSSOptions(nuxt),
define: { __NUXT_VERSION__: JSON.stringify(nuxt._version) }, define: {
__NUXT_VERSION__: JSON.stringify(nuxt._version),
'process.env.NUXT_ASYNC_CONTEXT': nuxt.options.experimental.asyncContext
},
build: { build: {
copyPublicDir: false, copyPublicDir: false,
rollupOptions: { rollupOptions: {

View File

@ -218,6 +218,7 @@ function getEnv (ctx: WebpackConfigContext) {
'process.env.NODE_ENV': JSON.stringify(ctx.config.mode), 'process.env.NODE_ENV': JSON.stringify(ctx.config.mode),
__NUXT_VERSION__: JSON.stringify(ctx.nuxt._version), __NUXT_VERSION__: JSON.stringify(ctx.nuxt._version),
'process.env.VUE_ENV': JSON.stringify(ctx.name), 'process.env.VUE_ENV': JSON.stringify(ctx.name),
'process.env.NUXT_ASYNC_CONTEXT': ctx.options.experimental.asyncContext,
'process.dev': ctx.options.dev, 'process.dev': ctx.options.dev,
'process.test': isTest, 'process.test': isTest,
'process.browser': ctx.isClient, 'process.browser': ctx.isClient,

View File

@ -1901,6 +1901,12 @@ describe.skipIf(isDev() || isWindows || !isRenderingJson)('payload rendering', (
}) })
}) })
describe('Async context', () => {
it('should be available', async () => {
expect(await $fetch('/async-context')).toContain('&quot;hasApp&quot;: true')
})
})
describe.skipIf(isWindows)('useAsyncData', () => { describe.skipIf(isWindows)('useAsyncData', () => {
it('single request resolves', async () => { it('single request resolves', async () => {
await expectNoClientErrors('/useAsyncData/single') await expectNoClientErrors('/useAsyncData/single')

View File

@ -0,0 +1,19 @@
const delay = () => new Promise(resolve => setTimeout(resolve, 10))
export async function nestedAsyncComposable () {
await delay()
return await fn1()
}
async function fn1 () {
await delay()
return await fn2()
}
async function fn2 () {
await delay()
const app = useNuxtApp()
return {
hasApp: !!app
}
}

View File

@ -188,6 +188,7 @@ export default defineNuxtConfig({
reactivityTransform: true, reactivityTransform: true,
treeshakeClientOnly: true, treeshakeClientOnly: true,
payloadExtraction: true, payloadExtraction: true,
asyncContext: true,
headCapoPlugin: true headCapoPlugin: true
}, },
appConfig: { appConfig: {

View File

@ -0,0 +1,12 @@
<template>
<div>
<Head>
<Title>Native Async Context</Title>
</Head>
{{ data }}
</div>
</template>
<script setup lang="ts">
const data = await nestedAsyncComposable()
</script>

View File

@ -11,6 +11,9 @@ export default defineConfig({
'@nuxt/test-utils': resolve('./packages/test-utils/src/index.ts') '@nuxt/test-utils': resolve('./packages/test-utils/src/index.ts')
} }
}, },
define: {
'process.env.NUXT_ASYNC_CONTEXT': 'false'
},
test: { test: {
globalSetup: './test/setup.ts', globalSetup: './test/setup.ts',
testTimeout: isWindows ? 60000 : 10000, testTimeout: isWindows ? 60000 : 10000,