From 5926bbeff84362015acb134b886d64d646c270ef Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Mon, 31 Jul 2023 09:51:09 +0100 Subject: [PATCH] feat(nuxt): allow 'lazy' (non-blocking) server components (#21918) --- .../nuxt/src/app/components/nuxt-island.ts | 14 ++++--- .../components/runtime/server-component.ts | 4 +- test/basic.test.ts | 37 +++++++++++++++++++ .../pages/server-components/lazy/end.vue | 26 +++++++++++++ .../pages/server-components/lazy/start.vue | 10 +++++ 5 files changed, 84 insertions(+), 7 deletions(-) create mode 100644 test/fixtures/basic/pages/server-components/lazy/end.vue create mode 100644 test/fixtures/basic/pages/server-components/lazy/start.vue diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 3f8bfd3876..e2990cfed6 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -29,6 +29,7 @@ export default defineComponent({ type: String, required: true }, + lazy: Boolean, props: { type: Object, default: () => undefined @@ -66,7 +67,7 @@ export default defineComponent({ } } - const ssrHTML = ref('
') + const ssrHTML = ref('') if (process.client) { const renderedHTML = getFragmentHTML(instance.vnode?.el ?? null).join('') if (renderedHTML && nuxtApp.isHydrating) { @@ -79,7 +80,7 @@ export default defineComponent({ } }) } - ssrHTML.value = renderedHTML ?? '
' + ssrHTML.value = renderedHTML } const slotProps = computed(() => getSlotProps(ssrHTML.value)) const uid = ref(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID()) @@ -165,18 +166,19 @@ export default defineComponent({ watch(props, debounce(() => fetchComponent(), 100)) } - // TODO: allow lazy loading server islands - if (process.server || !nuxtApp.isHydrating) { + if (process.client && !nuxtApp.isHydrating && props.lazy) { + fetchComponent() + } else if (process.server || !nuxtApp.isHydrating) { await fetchComponent() } return () => { - if (error.value && slots.fallback) { + if ((!html.value || error.value) && slots.fallback) { return [slots.fallback({ error: error.value })] } const nodes = [createVNode(Fragment, { key: key.value - }, [h(createStaticVNode(html.value, 1))])] + }, [h(createStaticVNode(html.value || '
', 1))])] if (uid.value && (mounted.value || nuxtApp.isHydrating || process.server)) { for (const slot in slots) { if (availableSlots.value.includes(slot)) { diff --git a/packages/nuxt/src/components/runtime/server-component.ts b/packages/nuxt/src/components/runtime/server-component.ts index 4dcfa8cce5..777204209e 100644 --- a/packages/nuxt/src/components/runtime/server-component.ts +++ b/packages/nuxt/src/components/runtime/server-component.ts @@ -5,9 +5,11 @@ export const createServerComponent = (name: string) => { return defineComponent({ name, inheritAttrs: false, - setup (_props, { attrs, slots }) { + props: { lazy: Boolean }, + setup (props, { attrs, slots }) { return () => h(NuxtIsland, { name, + lazy: props.lazy, props: attrs }, slots) } diff --git a/test/basic.test.ts b/test/basic.test.ts index 06968d32ab..879bc8c886 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -1419,6 +1419,43 @@ describe('server components/islands', () => { await page.close() }) + it('lazy server components', async () => { + const page = await createPage('/server-components/lazy/start') + await page.waitForLoadState('networkidle') + await page.getByText('Go to page with lazy server component').click() + + const text = await page.innerText('pre') + expect(text).toMatchInlineSnapshot('" End page
Loading server component
"') + expect(text).not.toContain('async component that was very long') + expect(text).toContain('Loading server component') + + // Wait for all pending micro ticks to be cleared + // await page.waitForLoadState('networkidle') + // await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10))) + await page.waitForFunction(() => (document.querySelector('#no-fallback') as HTMLElement)?.innerText?.includes('async component')) + await page.waitForFunction(() => (document.querySelector('#fallback') as HTMLElement)?.innerText?.includes('async component')) + + await page.close() + }) + + it('non-lazy server components', async () => { + const page = await createPage('/server-components/lazy/start') + await page.waitForLoadState('networkidle') + await page.getByText('Go to page without lazy server component').click() + + const text = await page.innerText('pre') + expect(text).toMatchInlineSnapshot('" End page
This is a .server (20ms) async component that was very long ...
42
This is a .server (20ms) async component that was very long ...
42
"') + expect(text).toContain('async component that was very long') + + // Wait for all pending micro ticks to be cleared + // await page.waitForLoadState('networkidle') + // await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10))) + await page.waitForFunction(() => (document.querySelector('#no-fallback') as HTMLElement)?.innerText?.includes('async component')) + await page.waitForFunction(() => (document.querySelector('#fallback') as HTMLElement)?.innerText?.includes('async component')) + + await page.close() + }) + it.skipIf(isDev)('should allow server-only components to set prerender hints', async () => { // @ts-expect-error ssssh! untyped secret property const publicDir = useTestContext().nuxt._nitro.options.output.publicDir diff --git a/test/fixtures/basic/pages/server-components/lazy/end.vue b/test/fixtures/basic/pages/server-components/lazy/end.vue new file mode 100644 index 0000000000..7009c1e7ac --- /dev/null +++ b/test/fixtures/basic/pages/server-components/lazy/end.vue @@ -0,0 +1,26 @@ + + + diff --git a/test/fixtures/basic/pages/server-components/lazy/start.vue b/test/fixtures/basic/pages/server-components/lazy/start.vue new file mode 100644 index 0000000000..a14cf6eada --- /dev/null +++ b/test/fixtures/basic/pages/server-components/lazy/start.vue @@ -0,0 +1,10 @@ +