From 24d8ac588397d75e307e9beb7b3ebe0f65b254b3 Mon Sep 17 00:00:00 2001 From: yamachi4416 Date: Sun, 21 Jun 2026 07:25:28 +0900 Subject: [PATCH] feat(runtime): improve app.root and app.teleports html tag setup --- .../components/TestTeleport.vue | 9 ++++ .../app-vitest-full/pages/other/index.vue | 12 +++++ .../app-vitest-full/pages/other/teleports.vue | 8 +++ .../tests/nuxt/mount-suspended.spec.ts | 12 ++++- .../tests/nuxt/render-suspended.spec.ts | 12 ++++- .../app-vitest-workspace/app1/nuxt.config.ts | 15 ++++++ .../app1/test/app.nuxt.spec.ts | 22 ++++++++ .../app-vitest-workspace/app2/nuxt.config.ts | 5 ++ .../app2/test/nuxt/app.spec.ts | 6 +++ .../app3/test/nuxt/app.spec.ts | 15 ++++++ .../app3/vitest.config.ts | 12 +++++ src/config.ts | 54 +++++++++++++------ src/runtime/shared/environment.ts | 45 ++++++++++++---- tsconfig.json | 3 +- 14 files changed, 201 insertions(+), 29 deletions(-) create mode 100644 examples/app-vitest-full/components/TestTeleport.vue create mode 100644 examples/app-vitest-full/pages/other/index.vue create mode 100644 examples/app-vitest-full/pages/other/teleports.vue create mode 100644 examples/app-vitest-workspace/app3/test/nuxt/app.spec.ts diff --git a/examples/app-vitest-full/components/TestTeleport.vue b/examples/app-vitest-full/components/TestTeleport.vue new file mode 100644 index 000000000..3e3de3d69 --- /dev/null +++ b/examples/app-vitest-full/components/TestTeleport.vue @@ -0,0 +1,9 @@ + diff --git a/examples/app-vitest-full/pages/other/index.vue b/examples/app-vitest-full/pages/other/index.vue new file mode 100644 index 000000000..9bf43b5f3 --- /dev/null +++ b/examples/app-vitest-full/pages/other/index.vue @@ -0,0 +1,12 @@ + diff --git a/examples/app-vitest-full/pages/other/teleports.vue b/examples/app-vitest-full/pages/other/teleports.vue new file mode 100644 index 000000000..3b7763ca9 --- /dev/null +++ b/examples/app-vitest-full/pages/other/teleports.vue @@ -0,0 +1,8 @@ + diff --git a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts index 260fda2c7..fc6e92f90 100644 --- a/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/mount-suspended.spec.ts @@ -30,7 +30,7 @@ import ComponentWithCssVar from '~/components/ComponentWithCssVar.vue' import ComponentWithPluginProvidedValue from '~/components/ComponentWithPluginProvidedValue.vue' import GenericStateComponent from '~/components/GenericStateComponent.vue' -import { BoundAttrs } from '#components' +import { BoundAttrs, TestTeleport } from '#components' import DirectiveComponent from '~/components/DirectiveComponent.vue' import CustomComponent from '~/components/CustomComponent.vue' import WrapperElement from '~/components/WrapperElement.vue' @@ -592,6 +592,16 @@ it('element should be changed', async () => { expect(component.element.tagName).toBe('SPAN') }) +it('teleport should work', async () => { + expect(document.getElementById('teleport-title')).toBeFalsy() + + const wrapper = await mountSuspended(TestTeleport) + expect(document.getElementById('teleport-title')).toBeTruthy() + + wrapper.unmount() + expect(document.getElementById('teleport-title')).toBeFalsy() +}) + const { useCounterMock } = vi.hoisted(() => { return { useCounterMock: vi.fn(() => { diff --git a/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts b/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts index 07007f461..0a1bc1f66 100644 --- a/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts +++ b/examples/app-vitest-full/tests/nuxt/render-suspended.spec.ts @@ -23,7 +23,7 @@ import CompostionApi from '~/components/TestComponentWithCompostionApi.vue' import OptionsApiWithData from '~/components/TestComponentWithOptionsApiWithData.vue' import OptionsApiWithSetup from '~/components/TestComponentWithOptionsApiWithSetup.vue' -import { BoundAttrs, OptionsApiComputed, OptionsApiEmits, OptionsApiWatch, ScriptSetupEmits, ScriptSetupWatch } from '#components' +import { BoundAttrs, OptionsApiComputed, OptionsApiEmits, OptionsApiWatch, ScriptSetupEmits, ScriptSetupWatch, TestTeleport } from '#components' const formats = { ExportDefaultComponent, @@ -398,3 +398,13 @@ it('renders links correctly', async () => { " `) }) + +it('teleport should work', async () => { + expect(document.getElementById('teleport-title')).toBeFalsy() + + const wrapper = await renderSuspended(TestTeleport) + expect(document.getElementById('teleport-title')).toBeTruthy() + + wrapper.unmount() + expect(document.getElementById('teleport-title')).toBeFalsy() +}) diff --git a/examples/app-vitest-workspace/app1/nuxt.config.ts b/examples/app-vitest-workspace/app1/nuxt.config.ts index 5a2ebafa6..79e7eb6c8 100644 --- a/examples/app-vitest-workspace/app1/nuxt.config.ts +++ b/examples/app-vitest-workspace/app1/nuxt.config.ts @@ -1,5 +1,20 @@ // https://nuxt.com/docs/api/configuration/nuxt-config export default defineNuxtConfig({ devtools: { enabled: true }, + app: { + rootAttrs: { + class: 'nuxt-root-class', + style: '', + tabindex: 0, + spellcheck: true, + draggable: 'true', // as `
` + }, + teleportAttrs: { + class: 'teleport-class', + tabindex: -1, + spellcheck: false, + draggable: true, // as `
` + }, + }, compatibilityDate: '2024-04-03', }) diff --git a/examples/app-vitest-workspace/app1/test/app.nuxt.spec.ts b/examples/app-vitest-workspace/app1/test/app.nuxt.spec.ts index 8243b759c..318c338ad 100644 --- a/examples/app-vitest-workspace/app1/test/app.nuxt.spec.ts +++ b/examples/app-vitest-workspace/app1/test/app.nuxt.spec.ts @@ -9,4 +9,26 @@ describe('app', () => { // @ts-expect-error injected global, not typed expect(__NUXT_VITEST_RESOLVED__).toBe(true) }) + + it('should exist the app root element', () => { + const element = document.getElementById('__nuxt') + expect(element).toBeTruthy() + expect(element?.tagName).toBe('DIV') + expect(element?.getAttribute('class')).toBe('nuxt-root-class') + expect(element?.getAttribute('style')).toBe('') + expect(element?.getAttribute('tabindex')).toBe('0') + expect(element?.getAttribute('spellcheck')).toBe('') + expect(element?.getAttribute('draggable')).toBe('true') + }) + + it('should exist the app teleport element', () => { + const element = document.getElementById('teleports') + expect(element).toBeTruthy() + expect(element?.tagName).toBe('DIV') + expect(element?.getAttribute('class')).toBe('teleport-class') + expect(element?.getAttribute('style')).toBe(null) + expect(element?.getAttribute('tabindex')).toBe('-1') + expect(element?.getAttribute('spellcheck')).toBe(null) + expect(element?.getAttribute('draggable')).toBe('') + }) }) diff --git a/examples/app-vitest-workspace/app2/nuxt.config.ts b/examples/app-vitest-workspace/app2/nuxt.config.ts index c154ef59d..55fe9a6f8 100644 --- a/examples/app-vitest-workspace/app2/nuxt.config.ts +++ b/examples/app-vitest-workspace/app2/nuxt.config.ts @@ -3,5 +3,10 @@ export default defineNuxtConfig({ modules: ['@nuxtjs/color-mode', '@nuxt/ui'], pages: false, devtools: { enabled: true }, + app: { + rootAttrs: { + id: undefined, + }, + }, compatibilityDate: '2024-04-03', }) diff --git a/examples/app-vitest-workspace/app2/test/nuxt/app.spec.ts b/examples/app-vitest-workspace/app2/test/nuxt/app.spec.ts index 33b79310f..3be02e355 100644 --- a/examples/app-vitest-workspace/app2/test/nuxt/app.spec.ts +++ b/examples/app-vitest-workspace/app2/test/nuxt/app.spec.ts @@ -10,4 +10,10 @@ describe('app', () => { it('useRuntimeConfig', () => { expect(useRuntimeConfig().icon).toBeDefined() }) + + it('should exist the app root element', () => { + const element = document.getElementById('nuxt-test') + expect(element).toBeTruthy() + expect(element?.tagName).toBe('DIV') + }) }) diff --git a/examples/app-vitest-workspace/app3/test/nuxt/app.spec.ts b/examples/app-vitest-workspace/app3/test/nuxt/app.spec.ts new file mode 100644 index 000000000..9c9ea28aa --- /dev/null +++ b/examples/app-vitest-workspace/app3/test/nuxt/app.spec.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest' + +describe('app', () => { + it('should exist the app root element', () => { + const element = document.getElementById('__test_root') + expect(element).toBeTruthy() + expect(element?.tagName).toBe('MAIN') + }) + + it('should exist the app teleport element', () => { + const element = document.getElementById('__test_teleport') + expect(element).toBeTruthy() + expect(element?.tagName).toBe('P') + }) +}) diff --git a/examples/app-vitest-workspace/app3/vitest.config.ts b/examples/app-vitest-workspace/app3/vitest.config.ts index e11c7d6d8..329ab52a2 100644 --- a/examples/app-vitest-workspace/app3/vitest.config.ts +++ b/examples/app-vitest-workspace/app3/vitest.config.ts @@ -7,6 +7,18 @@ export default defineVitestProject({ environmentOptions: { nuxt: { rootDir: fileURLToPath(new URL('.', import.meta.url)), + overrides: { + app: { + rootAttrs: { + id: '__test_root', + }, + rootTag: 'main', + teleportAttrs: { + id: '__test_teleport', + }, + teleportTag: 'p', + }, + }, }, }, }, diff --git a/src/config.ts b/src/config.ts index d7f5a011f..a7596cf04 100644 --- a/src/config.ts +++ b/src/config.ts @@ -150,19 +150,27 @@ export async function getVitestConfigFromNuxt( }, test: { environmentOptions: { - nuxtRuntimeConfig: applyEnv(structuredClone(options.nuxt.options.runtimeConfig), { - prefix: 'NUXT_', - env: await setupDotenv(defu(loadNuxtOptions.dotenv, { - cwd: rootDir, - fileName: '.env.test', - })), - }), - nuxtRouteRules: defu( - {}, - options.nuxt.options.routeRules, - options.nuxt.options.nitro?.routeRules, - ), - }, + nuxtConfig: { + runtimeConfig: applyEnv(structuredClone(options.nuxt.options.runtimeConfig), { + prefix: 'NUXT_', + env: await setupDotenv(defu(loadNuxtOptions.dotenv, { + cwd: rootDir, + fileName: '.env.test', + })), + }), + routeRules: defu( + {}, + options.nuxt.options.routeRules, + options.nuxt.options.nitro?.routeRules, + ), + app: { + rootAttrs: options.nuxt.options.app.rootAttrs, + rootTag: options.nuxt.options.app.rootTag, + teleportAttrs: options.nuxt.options.app.teleportAttrs, + teleportTag: options.nuxt.options.app.teleportTag, + }, + }, + } satisfies Omit, server: { deps: { inline: [ @@ -225,14 +233,14 @@ export async function getVitestConfigFromNuxt( test: { environmentOptions: { nuxt: { - rootId: options.nuxt.options.app.rootId || undefined, + rootId: options.nuxt.options.app.rootAttrs?.id || undefined, h3Version: h3Info?.version?.startsWith('2.') ? 2 : 1, mock: { intersectionObserver: true, indexedDb: false, }, }, - }, + } satisfies NuxtEnvironmentResolvedOptions, } satisfies VitestConfig, }, ) as ViteUserConfig & { test: VitestConfig } @@ -405,6 +413,7 @@ export interface NuxtEnvironmentOptions { /** * The id of the root div to which the app should be mounted. You should also set `app.rootId` to the same value. * @default 'nuxt-test' + * @deprecated Prefer `overrides.app.rootAttrs.id` instead */ rootId?: string /** @@ -423,6 +432,21 @@ export interface NuxtEnvironmentOptions { } } +/** + * @internal + */ +export interface NuxtEnvironmentResolvedOptions { + nuxt: NuxtEnvironmentOptions + nuxtConfig?: { + app: Pick< + NonNullable, + 'rootAttrs' | 'rootTag' | 'teleportTag' | 'teleportAttrs' + > + runtimeConfig: NuxtConfig['runtimeConfig'] + routeRules: NuxtConfig['routeRules'] + } +} + declare module 'vitest/node' { interface EnvironmentOptions { nuxt?: NuxtEnvironmentOptions diff --git a/src/runtime/shared/environment.ts b/src/runtime/shared/environment.ts index 838eeab3e..397450e37 100644 --- a/src/runtime/shared/environment.ts +++ b/src/runtime/shared/environment.ts @@ -3,19 +3,20 @@ import { joinURL } from 'ufo' import { defineEventHandler } from './h3.ts' import { createRouter as createRadixRouter, exportMatcher, toRouteMatcher } from 'radix3' import type { NuxtWindow } from '../../vitest-environment.ts' -import type { NuxtEnvironmentOptions } from '../../config.ts' +import type { NuxtEnvironmentResolvedOptions } from '../../config.ts' import { createFetchForH3V1 } from './h3-v1.ts' import { createFetchForH3V2 } from './h3-v2.ts' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: NuxtEnvironmentOptions, nuxtRuntimeConfig?: Record, nuxtRouteRules?: Record }) { +export async function setupWindow(win: NuxtWindow, environmentOptions: NuxtEnvironmentResolvedOptions) { + const nuxtConfig = environmentOptions.nuxtConfig + win.__NUXT_VITEST_ENVIRONMENT__ = true win.__NUXT__ = { serverRendered: false, config: { public: {}, app: { baseURL: '/' }, - ...environmentOptions?.nuxtRuntimeConfig, + ...nuxtConfig?.runtimeConfig, }, data: {}, state: {}, @@ -29,10 +30,11 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N return consoleInfo(...args) } - const app = win.document.createElement('div') - // this is a workaround for a happy-dom bug with ids beginning with _ - app.id = environmentOptions.nuxt.rootId || 'nuxt-test' - win.document.body.appendChild(app) + createElementAndAppend(win, nuxtConfig?.app.rootTag || 'div', { + ...nuxtConfig?.app.rootAttrs, + id: environmentOptions.nuxt.rootId || 'nuxt-test', + }) + createElementAndAppend(win, nuxtConfig?.app.teleportTag || 'div', nuxtConfig?.app.teleportAttrs) if (!win.fetch || !('Request' in win)) { await import('node-fetch-native/polyfill') @@ -66,12 +68,12 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N // App manifest support const timestamp = Date.now() const routeRulesMatcher = toRouteMatcher( - createRadixRouter({ routes: environmentOptions.nuxtRouteRules || {} }), + createRadixRouter({ routes: nuxtConfig?.routeRules || {} }), ) const matcher = exportMatcher(routeRulesMatcher) const manifestOutputPath = joinURL( - environmentOptions?.nuxtRuntimeConfig?.app?.baseURL || '/', - environmentOptions?.nuxtRuntimeConfig?.app?.buildAssetsDir || '_nuxt', + nuxtConfig?.runtimeConfig?.app?.baseURL || '/', + nuxtConfig?.runtimeConfig?.app?.buildAssetsDir || '_nuxt', 'builds', ) const manifestBaseRoutePath = joinURL('/_', manifestOutputPath) @@ -103,3 +105,24 @@ export async function setupWindow(win: NuxtWindow, environmentOptions: { nuxt: N console.info = consoleInfo } } + +function createElementAndAppend( + win: NuxtWindow, + tag: string, + attrs: NonNullable['app']['rootAttrs'] + | NonNullable['app']['teleportAttrs'] + | undefined, +) { + if (attrs?.id && win.document.getElementById(attrs.id)) { + return + } + + const element = win.document.createElement(tag) + for (const [key, value] of Object.entries(attrs ?? {})) { + if (value !== false && value != null) { + element.setAttribute(key, value === true ? '' : String(value)) + } + } + + win.document.body.appendChild(element) +} diff --git a/tsconfig.json b/tsconfig.json index fae88a927..139610e7d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,8 @@ "extends": "./.nuxt/tsconfig.json", "compilerOptions": { "moduleResolution": "Bundler", - "allowImportingTsExtensions": true + "allowImportingTsExtensions": true, + "stripInternal": true, }, "exclude": [ "config.d.ts",