fix(nuxt): use useId for island client component teleport id (#30151)

This commit is contained in:
Julien Huang 2024-12-09 11:35:37 +01:00 committed by GitHub
parent 374967ba10
commit 231b7d17c3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 17 additions and 24 deletions

View File

@ -1,5 +1,5 @@
import type { Component, InjectionKey } from 'vue' import type { Component, InjectionKey } from 'vue'
import { Teleport, defineComponent, h, inject, provide } from 'vue' import { Teleport, defineComponent, h, inject, provide, useId } from 'vue'
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import { paths } from '#build/components-chunk' import { paths } from '#build/components-chunk'
@ -20,10 +20,6 @@ export default defineComponent({
name: 'NuxtTeleportIslandComponent', name: 'NuxtTeleportIslandComponent',
inheritAttrs: false, inheritAttrs: false,
props: { props: {
to: {
type: String,
required: true,
},
nuxtClient: { nuxtClient: {
type: Boolean, type: Boolean,
default: false, default: false,
@ -31,11 +27,12 @@ export default defineComponent({
}, },
setup (props, { slots }) { setup (props, { slots }) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
const to = useId()
// if there's already a teleport parent, we don't need to teleport or to render the wrapped component client side // if there's already a teleport parent, we don't need to teleport or to render the wrapped component client side
if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient || inject(NuxtTeleportIslandSymbol, false)) { return () => slots.default?.() } if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient || inject(NuxtTeleportIslandSymbol, false)) { return () => slots.default?.() }
provide(NuxtTeleportIslandSymbol, props.to) provide(NuxtTeleportIslandSymbol, to)
const islandContext = nuxtApp.ssrContext!.islandContext! const islandContext = nuxtApp.ssrContext!.islandContext!
return () => { return () => {
@ -43,7 +40,7 @@ export default defineComponent({
const slotType = slot.type as ExtendedComponent const slotType = slot.type as ExtendedComponent
const name = (slotType.__name || slotType.name) as string const name = (slotType.__name || slotType.name) as string
islandContext.components[props.to] = { islandContext.components[to] = {
chunk: import.meta.dev ? nuxtApp.$config.app.buildAssetsDir + paths[name] : paths[name], chunk: import.meta.dev ? nuxtApp.$config.app.buildAssetsDir + paths[name] : paths[name],
props: slot.props || {}, props: slot.props || {},
} }
@ -51,8 +48,8 @@ export default defineComponent({
return [h('div', { return [h('div', {
'style': 'display: contents;', 'style': 'display: contents;',
'data-island-uid': '', 'data-island-uid': '',
'data-island-component': props.to, 'data-island-component': to,
}, []), h(Teleport, { to: props.to }, slot)] }, []), h(Teleport, { to }, slot)]
} }
}, },
}) })

View File

@ -6,7 +6,6 @@ import { parseURL } from 'ufo'
import { createUnplugin } from 'unplugin' import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string' import MagicString from 'magic-string'
import { ELEMENT_NODE, parse, walk } from 'ultrahtml' import { ELEMENT_NODE, parse, walk } from 'ultrahtml'
import { hash } from 'ohash'
import { resolvePath } from '@nuxt/kit' import { resolvePath } from '@nuxt/kit'
import defu from 'defu' import defu from 'defu'
import { isVue } from '../../core/utils' import { isVue } from '../../core/utils'
@ -113,8 +112,6 @@ export const IslandsTransformPlugin = (options: ServerOnlyComponentTransformPlug
const { loc, attributes } = node const { loc, attributes } = node
const attributeValue = attributes[':nuxt-client'] || attributes['nuxt-client'] || 'true' const attributeValue = attributes[':nuxt-client'] || attributes['nuxt-client'] || 'true'
const uid = hash(id + node.loc[0].start + node.loc[0].end)
const wrapperAttributes = extractAttributes(attributes, ['v-if', 'v-else-if', 'v-else']) const wrapperAttributes = extractAttributes(attributes, ['v-if', 'v-else-if', 'v-else'])
let startTag = code.slice(startingIndex + loc[0].start, startingIndex + loc[0].end).replace(NUXTCLIENT_ATTR_RE, '') let startTag = code.slice(startingIndex + loc[0].start, startingIndex + loc[0].end).replace(NUXTCLIENT_ATTR_RE, '')
@ -122,7 +119,7 @@ export const IslandsTransformPlugin = (options: ServerOnlyComponentTransformPlug
startTag = startTag.replaceAll(EXTRACTED_ATTRS_RE, '') startTag = startTag.replaceAll(EXTRACTED_ATTRS_RE, '')
} }
s.appendLeft(startingIndex + loc[0].start, `<NuxtTeleportIslandComponent${attributeToString(wrapperAttributes)} to="${node.name}-${uid}" :nuxt-client="${attributeValue}">`) s.appendLeft(startingIndex + loc[0].start, `<NuxtTeleportIslandComponent${attributeToString(wrapperAttributes)} :nuxt-client="${attributeValue}">`)
s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, startTag) s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, startTag)
s.appendRight(startingIndex + loc[1].end, '</NuxtTeleportIslandComponent>') s.appendRight(startingIndex + loc[1].end, '</NuxtTeleportIslandComponent>')
}) })

View File

@ -271,7 +271,7 @@ withDefaults(defineProps<{ things?: any[]; somethingElse?: string }>(), {
"<template> "<template>
<div> <div>
<HelloWorld /> <HelloWorld />
<NuxtTeleportIslandComponent to="HelloWorld-CyH3UXLuYA" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> <NuxtTeleportIslandComponent :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent>
</div> </div>
</template> </template>
@ -305,7 +305,7 @@ withDefaults(defineProps<{ things?: any[]; somethingElse?: string }>(), {
"<template> "<template>
<div> <div>
<HelloWorld /> <HelloWorld />
<NuxtTeleportIslandComponent to="HelloWorld-eo0XycWCUV" :nuxt-client="nuxtClient"><HelloWorld /></NuxtTeleportIslandComponent> <NuxtTeleportIslandComponent :nuxt-client="nuxtClient"><HelloWorld /></NuxtTeleportIslandComponent>
</div> </div>
</template> </template>
@ -376,7 +376,7 @@ withDefaults(defineProps<{ things?: any[]; somethingElse?: string }>(), {
import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot'</script><template> import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot'</script><template>
<div> <div>
<HelloWorld /> <HelloWorld />
<NuxtTeleportIslandComponent to="HelloWorld-CyH3UXLuYA" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> <NuxtTeleportIslandComponent :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent>
</div> </div>
</template> </template>
@ -402,9 +402,9 @@ withDefaults(defineProps<{ things?: any[]; somethingElse?: string }>(), {
import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component' import NuxtTeleportIslandComponent from '#app/components/nuxt-teleport-island-component'
import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot'</script><template> import NuxtTeleportSsrSlot from '#app/components/nuxt-teleport-island-slot'</script><template>
<div> <div>
<NuxtTeleportIslandComponent v-if="false" to="HelloWorld-D9uaHyzL7X" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> <NuxtTeleportIslandComponent v-if="false" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent>
<NuxtTeleportIslandComponent v-else-if="true" to="HelloWorld-o4RZMtArnE" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> <NuxtTeleportIslandComponent v-else-if="true" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent>
<NuxtTeleportIslandComponent v-else to="HelloWorld-m1IbXHdd8O" :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent> <NuxtTeleportIslandComponent v-else :nuxt-client="true"><HelloWorld /></NuxtTeleportIslandComponent>
</div> </div>
</template> </template>
" "

View File

@ -1960,12 +1960,12 @@ describe('server components/islands', () => {
await page.waitForLoadState('networkidle') await page.waitForLoadState('networkidle')
await page.getByText('Go to page without lazy server component').click() await page.getByText('Go to page without lazy server component').click()
const text = (await page.innerText('pre')).replaceAll(/ data-island-uid="([^"]*)"/g, '').replace(/data-island-component="([^"]*)"/g, (_, content) => `data-island-component="${content.split('-')[0]}"`) const text = (await page.innerText('pre')).replaceAll(/ data-island-uid="([^"]*)"/g, '').replace(/data-island-component="([^"]*)"/g, 'data-island-component')
if (isWebpack) { if (isWebpack) {
expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <div class="sugar-counter" nuxt-client=""> Sugar Counter 12 x 1 = 12 <button> Inc </button></div></div></div>"') expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <div class="sugar-counter" nuxt-client=""> Sugar Counter 12 x 1 = 12 <button> Inc </button></div></div></div>"')
} else { } else {
expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>"') expect(text).toMatchInlineSnapshot('" End page <pre></pre><section id="fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><section id="no-fallback"><div> This is a .server (20ms) async component that was very long ... <div id="async-server-component-count">42</div><div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><!--[--><div style="display: contents;" data-island-slot="default"><!--teleport start--><!--teleport end--></div><!--]--></div></section><div> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-component></div><!--teleport start--><!--teleport end--><!--]--></div></div>"')
} }
expect(text).toContain('async component that was very long') expect(text).toContain('async component that was very long')
@ -2316,7 +2316,7 @@ describe('component islands', () => {
const { components } = result const { components } = result
result.components = {} result.components = {}
result.slots = {} result.slots = {}
result.html = result.html.replace(/ data-island-component="([^"]*)"/g, (_, content) => ` data-island-component="${content.split('-')[0]}"`) result.html = result.html.replace(/data-island-component="([^"]*)"/g, 'data-island-component')
const teleportsEntries = Object.entries(components || {}) const teleportsEntries = Object.entries(components || {})
@ -2327,12 +2327,11 @@ describe('component islands', () => {
"link": [], "link": [],
"style": [], "style": [],
}, },
"html": "<div data-island-uid> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-uid data-island-component="Counter"></div><!--teleport start--><!--teleport end--><!--]--></div></div>", "html": "<div data-island-uid> ServerWithClient.server.vue : <p>count: 0</p> This component should not be preloaded <div><!--[--><div>a</div><div>b</div><div>c</div><!--]--></div> This is not interactive <div class="sugar-counter"> Sugar Counter 12 x 1 = 12 <button> Inc </button></div><div class="interactive-component-wrapper" style="border:solid 1px red;"> The component below is not a slot but declared as interactive <!--[--><div style="display: contents;" data-island-uid data-island-component></div><!--teleport start--><!--teleport end--><!--]--></div></div>",
"slots": {}, "slots": {},
} }
`) `)
expect(teleportsEntries).toHaveLength(1) expect(teleportsEntries).toHaveLength(1)
expect(teleportsEntries[0]![0].startsWith('Counter-')).toBeTruthy()
expect(teleportsEntries[0]![1].props).toMatchInlineSnapshot(` expect(teleportsEntries[0]![1].props).toMatchInlineSnapshot(`
{ {
"multiplier": 1, "multiplier": 1,