fix(client): forward slots to client components (#5445)

pull/5464/head
lllleolin-max 1 week ago committed by GitHub
parent 50f3a74f9e
commit d702051859
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,21 @@
<script lang="ts">
if (typeof window === 'undefined') {
throw new Error('This component can only be imported in the browser')
}
</script>
<script setup lang="ts">
defineProps<{ message?: string }>()
</script>
<template>
<section class="client-component">
<header>
<slot name="header" :message="message || 'scoped value'">
header fallback
</slot>
</header>
<div class="default-slot"><slot>default fallback</slot></div>
<footer><slot name="footer" /></footer>
</section>
</template>

@ -0,0 +1,78 @@
describe('client components', () => {
beforeEach(async () => {
await goto('/client-component/')
await page.waitForSelector('#forwarded.client-component')
})
test('forwards default and scoped slots and keeps them reactive', async () => {
const header = page.locator('#forwarded header')
const content = page.locator('#forwarded .default-slot')
expect(await header.textContent()).toBe('scoped value 0')
expect((await content.textContent())?.trim()).toBe('default content 0')
expect(await page.locator('#forwarded footer').textContent()).toBe('')
await page.locator('#increment').click()
await expect.poll(() => header.textContent()).toBe('scoped value 1')
expect((await content.textContent())?.trim()).toBe('default content 1')
expect(await page.locator('#forwarded footer').textContent()).toBe(
'conditional slot'
)
await page.locator('#increment').click()
await expect.poll(() => header.textContent()).toBe('scoped value 2')
expect(await page.locator('#forwarded footer').textContent()).toBe('')
})
test('forwards slots when args only provide props', async () => {
expect(await page.locator('#with-props header').textContent()).toBe(
'from args'
)
expect(
(await page.locator('#with-props .default-slot').textContent())?.trim()
).toBe('content with props')
expect(await page.locator('#empty-args .default-slot').textContent()).toBe(
'content with empty args'
)
expect(await page.locator('#null-props .default-slot').textContent()).toBe(
'content with null props'
)
})
test('preserves explicitly provided slots and shorthand children', async () => {
expect(await page.locator('#explicit header').textContent()).toBe(
'explicit header'
)
expect(await page.locator('#explicit .default-slot').textContent()).toBe(
'explicit default'
)
for (const [id, content] of [
['string', 'string child'],
['array', 'array child'],
['vnode', 'vnode child'],
['function', 'function child'],
['number', '42'],
['null', 'default fallback'],
['undefined', 'default fallback'],
['multiple', 'firstsecond']
]) {
expect(
await page.locator(`#legacy-${id} .default-slot`).textContent()
).toBe(content)
}
})
test.runIf(!!process.env.VITE_TEST_BUILD)(
'does not render the component or its slots on the server',
async () => {
const response = await page.request.get(page.url())
const html = await response.text()
expect(html).not.toContain('class="client-component"')
expect(html).not.toContain('default content 0')
}
)
})

@ -0,0 +1,57 @@
<script setup>
import { h, ref } from 'vue'
import { defineClientComponent } from 'vitepress'
const load = () => import('./Slots.vue')
const ClientComponent = defineClientComponent(load)
const WithProps = defineClientComponent(load, [{ message: 'from args' }])
const WithEmptyArgs = defineClientComponent(load, [])
const WithNullProps = defineClientComponent(load, [null])
const WithExplicitSlots = defineClientComponent(load, [null, {
default: () => 'explicit default',
header: () => 'explicit header'
}])
const legacyChildren = [
['string', ['string child']],
['array', [[h('span', 'array child')]]],
['vnode', [h('span', 'vnode child')]],
['function', [() => 'function child']],
['number', [42]],
['null', [null, null]],
['undefined', [null, undefined]],
['multiple', [null, 'first', 'second']]
].map(([id, args]) => ({ id, component: defineClientComponent(load, args) }))
const count = ref(0)
</script>
# Client Components
<button id="increment" @click="count++">Increment</button>
<ClientComponent id="forwarded">
<template #header="{ message }">{{ message }} {{ count }}</template>
<template v-if="count % 2" #footer>conditional slot</template>
default content {{ count }}
</ClientComponent>
<WithProps id="with-props">
<template #header="{ message }">{{ message }}</template>
content with props
</WithProps>
<WithEmptyArgs id="empty-args">content with empty args</WithEmptyArgs>
<WithNullProps id="null-props">content with null props</WithNullProps>
<WithExplicitSlots id="explicit">
<template #header>ignored header</template>
ignored default
</WithExplicitSlots>
<component
v-for="{ id, component } in legacyChildren"
:is="component"
:id="`legacy-${id}`"
>
ignored content
</component>

@ -1,5 +1,12 @@
import { tryOnUnmounted } from '@vueuse/core'
import { h, onMounted, shallowRef, type AsyncComponentLoader } from 'vue'
import {
h,
isVNode,
onMounted,
shallowRef,
type AsyncComponentLoader,
type SetupContext
} from 'vue'
import {
EXTERNAL_URL_RE,
@ -116,7 +123,7 @@ export function defineClientComponent(
cb?: () => Awaitable<void>
) {
return {
setup() {
setup(_: unknown, { slots }: SetupContext) {
const comp = shallowRef()
onMounted(async () => {
let res = await loader()
@ -127,7 +134,23 @@ export function defineClientComponent(
comp.value = res
await cb?.()
})
return () => (comp.value ? h(comp.value, ...(args ?? [])) : null)
return () => {
if (!comp.value) return null
const props = args?.[0]
// Forward slots unless args already supply children in an h() overload.
if (
(!args || args.length < 2) &&
(props == null ||
(typeof props === 'object' &&
!Array.isArray(props) &&
!isVNode(props)))
) {
return h(comp.value, props, slots)
}
return h(comp.value, ...(args ?? []))
}
}
}
}

Loading…
Cancel
Save