From 5b409f8579b97e833308ca2d7f1fcf95df6b14aa Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 19 Jul 2023 07:55:53 +0100 Subject: [PATCH] fix(nuxt): avoid premature hydration when using async layouts (#22198) --- packages/nuxt/src/app/components/layout.ts | 47 +++++++++++++++---- packages/nuxt/src/core/templates.ts | 3 +- test/basic.test.ts | 12 ++++- .../fixtures/basic/pages/hydration/layout.vue | 18 +++++++ 4 files changed, 67 insertions(+), 13 deletions(-) create mode 100644 test/fixtures/basic/pages/hydration/layout.vue diff --git a/packages/nuxt/src/app/components/layout.ts b/packages/nuxt/src/app/components/layout.ts index b4f4b793c8..bb1b149265 100644 --- a/packages/nuxt/src/app/components/layout.ts +++ b/packages/nuxt/src/app/components/layout.ts @@ -13,6 +13,21 @@ import layouts from '#build/layouts' import { appLayoutTransition as defaultLayoutTransition } from '#build/nuxt.config.mjs' import { useNuxtApp } from '#app' +// TODO: revert back to defineAsyncComponent when https://github.com/vuejs/core/issues/6638 is resolved +const LayoutLoader = defineComponent({ + name: 'LayoutLoader', + inheritAttrs: false, + props: { + name: String, + layoutProps: Object + }, + async setup (props, context) { + const LayoutComponent = await layouts[props.name]().then((r: any) => r.default || r) + + return () => h(LayoutComponent, props.layoutProps, context.slots) + } +}) + export default defineComponent({ name: 'NuxtLayout', inheritAttrs: false, @@ -46,14 +61,16 @@ export default defineComponent({ // We avoid rendering layout transition if there is no layout to render return _wrapIf(Transition, hasLayout && transitionProps, { default: () => h(Suspense, { suspensible: true, onResolve: () => { nextTick(done) } }, { - // @ts-expect-error seems to be an issue in vue types - default: () => h(LayoutProvider, { - layoutProps: mergeProps(context.attrs, { ref: layoutRef }), - key: layout.value, - name: layout.value, - shouldProvide: !props.name, - hasTransition: !!transitionProps - }, context.slots) + default: () => h( + // @ts-expect-error seems to be an issue in vue types + LayoutProvider, + { + layoutProps: mergeProps(context.attrs, { ref: layoutRef }), + key: layout.value, + name: layout.value, + shouldProvide: !props.name, + hasTransition: !!transitionProps + }, context.slots) }) }).default() } @@ -112,12 +129,22 @@ const LayoutProvider = defineComponent({ } if (process.dev && process.client && props.hasTransition) { - vnode = h(layouts[name], props.layoutProps, context.slots) + vnode = h( + // @ts-expect-error seems to be an issue in vue types + LayoutLoader, + { key: name, layoutProps: props.layoutProps, name }, + context.slots + ) return vnode } - return h(layouts[name], props.layoutProps, context.slots) + return h( + // @ts-expect-error seems to be an issue in vue types + LayoutLoader, + { key: name, layoutProps: props.layoutProps, name }, + context.slots + ) } } }) diff --git a/packages/nuxt/src/core/templates.ts b/packages/nuxt/src/core/templates.ts index f3c67bcc96..8979f829b1 100644 --- a/packages/nuxt/src/core/templates.ts +++ b/packages/nuxt/src/core/templates.ts @@ -175,10 +175,9 @@ export const layoutTemplate: NuxtTemplate = { filename: 'layouts.mjs', getContents ({ app }) { const layoutsObject = genObjectFromRawEntries(Object.values(app.layouts).map(({ name, file }) => { - return [name, `defineAsyncComponent(${genDynamicImport(file, { interopDefault: true })})`] + return [name, genDynamicImport(file, { interopDefault: true })] })) return [ - 'import { defineAsyncComponent } from \'vue\'', `export default ${layoutsObject}` ].join('\n') } diff --git a/test/basic.test.ts b/test/basic.test.ts index d1d68f765a..9c1d553664 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -1020,7 +1020,7 @@ describe('deferred app suspense resolve', () => { await page.goto(url(path)) await page.waitForLoadState('networkidle') - // Wait for all pending micro ticks to be cleared in case hydration haven't finished yet. + // Wait for all pending micro ticks to be cleared in case hydration hasn't finished yet. await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10))) const hydrationLogs = logs.filter(log => log.includes('isHydrating')) @@ -1034,6 +1034,16 @@ describe('deferred app suspense resolve', () => { it('should wait for all suspense instance on initial hydration', async () => { await behaviour('/internal-layout/async-parent/child') }) + it('should wait for suspense in parent layout', async () => { + const page = await createPage('/hydration/layout') + await page.waitForLoadState('networkidle') + + // Wait for all pending micro ticks to be cleared in case hydration hasn't finished yet. + await page.evaluate(() => new Promise(resolve => setTimeout(resolve, 10))) + + const html = await page.getByRole('document').innerHTML() + expect(html).toContain('Tests whether hydration is properly resolved within an async layout') + }) it('should fully hydrate even if there is a redirection on a page with `ssr: false`', async () => { const page = await createPage('/hydration/spa-redirection/start') await page.waitForLoadState('networkidle') diff --git a/test/fixtures/basic/pages/hydration/layout.vue b/test/fixtures/basic/pages/hydration/layout.vue new file mode 100644 index 0000000000..a28776ab6e --- /dev/null +++ b/test/fixtures/basic/pages/hydration/layout.vue @@ -0,0 +1,18 @@ + + +