diff --git a/__tests__/e2e/client-component/Slots.vue b/__tests__/e2e/client-component/Slots.vue
new file mode 100644
index 000000000..5e19a0def
--- /dev/null
+++ b/__tests__/e2e/client-component/Slots.vue
@@ -0,0 +1,21 @@
+
+
+
+
+
+
+
diff --git a/__tests__/e2e/client-component/client-component.test.ts b/__tests__/e2e/client-component/client-component.test.ts
new file mode 100644
index 000000000..10a4941fe
--- /dev/null
+++ b/__tests__/e2e/client-component/client-component.test.ts
@@ -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')
+ }
+ )
+})
diff --git a/__tests__/e2e/client-component/index.md b/__tests__/e2e/client-component/index.md
new file mode 100644
index 000000000..4740dc7b8
--- /dev/null
+++ b/__tests__/e2e/client-component/index.md
@@ -0,0 +1,57 @@
+
+
+# Client Components
+
+
+
+
+ {{ message }} {{ count }}
+ conditional slot
+ default content {{ count }}
+
+
+
+ {{ message }}
+ content with props
+
+
+content with empty args
+
+content with null props
+
+
+ ignored header
+ ignored default
+
+
+
+ ignored content
+
diff --git a/src/client/app/utils.ts b/src/client/app/utils.ts
index 63e4708a6..437837574 100644
--- a/src/client/app/utils.ts
+++ b/src/client/app/utils.ts
@@ -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
) {
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 ?? []))
+ }
}
}
}