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 + + + + + + + default content {{ count }} + + + + + content with props + + +content with empty args + +content with null props + + + + 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 ?? [])) + } } } }