From 986786a4a925cdac346a34df50b4aeb6d6f7fcb9 Mon Sep 17 00:00:00 2001 From: Julien Huang Date: Tue, 16 Jan 2024 14:22:50 +0100 Subject: [PATCH] refactor(nuxt): refactor island response + improve rendering (#25190) --- .../src/app/components/island-renderer.ts | 2 +- .../nuxt/src/app/components/nuxt-island.ts | 169 +++++++++--------- ...ient.ts => nuxt-teleport-island-client.ts} | 26 ++- .../components/nuxt-teleport-island-slot.ts | 47 +++++ packages/nuxt/src/app/components/utils.ts | 17 +- .../nuxt/src/components/islandsTransform.ts | 83 ++++----- .../nuxt/src/core/runtime/nitro/renderer.ts | 98 ++++++---- packages/nuxt/test/islandTransform.test.ts | 70 +++++--- test/basic.test.ts | 141 +++++++++------ 9 files changed, 372 insertions(+), 281 deletions(-) rename packages/nuxt/src/app/components/{nuxt-teleport-ssr-client.ts => nuxt-teleport-island-client.ts} (68%) create mode 100644 packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts diff --git a/packages/nuxt/src/app/components/island-renderer.ts b/packages/nuxt/src/app/components/island-renderer.ts index 8e77445742..6fc2fd479d 100644 --- a/packages/nuxt/src/app/components/island-renderer.ts +++ b/packages/nuxt/src/app/components/island-renderer.ts @@ -27,6 +27,6 @@ export default defineComponent({ console.log(e) }) - return () => createVNode(component || 'span', { ...props.context.props, 'nuxt-ssr-component-uid': '' }) + return () => createVNode(component || 'span', { ...props.context.props, 'data-island-uid': '' }) } }) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index 60e023ce68..b129f88dea 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -1,5 +1,5 @@ import type { Component } from 'vue' -import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, toRaw, watch } from 'vue' +import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, toRaw, watch, withMemo } from 'vue' import { debounce } from 'perfect-debounce' import { hash } from 'ohash' import { appendResponseHeader } from 'h3' @@ -13,29 +13,29 @@ import { join } from 'pathe' import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' import { useNuxtApp, useRuntimeConfig } from '../nuxt' import { prerenderRoutes, useRequestEvent } from '../composables/ssr' -import { getFragmentHTML, getSlotProps } from './utils' +import { getFragmentHTML } from './utils' // @ts-expect-error virtual file import { remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs' const pKey = '_islandPromises' -const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/ -const UID_ATTR = /nuxt-ssr-component-uid(="([^"]*)")?/ -const SLOTNAME_RE = /nuxt-ssr-slot-name="([^"]*)"/g -const SLOT_FALLBACK_RE = /
]*><\/div>(((?!
]*>)[\s\S])*)
]*><\/div>/g +const SSR_UID_RE = /data-island-uid="([^"]*)"/ +const DATA_ISLAND_UID_RE = /data-island-uid/g +const SLOTNAME_RE = /data-island-slot="([^"]*)"/g +const SLOT_FALLBACK_RE = / data-island-slot="([^"]*)"[^>]*>/g -let id = 0 +let id = 1 const getId = import.meta.client ? () => (id++).toString() : randomUUID const components = import.meta.client ? new Map() : undefined -async function loadComponents (source = '/', paths: Record) { +async function loadComponents (source = '/', paths: NuxtIslandResponse['components']) { const promises = [] for (const component in paths) { if (!(components!.has(component))) { promises.push((async () => { - const chunkSource = join(source, paths[component]) + const chunkSource = join(source, paths[component].chunk) const c = await import(/* @vite-ignore */ chunkSource).then(m => m.default || m) components!.set(component, c) })()) @@ -44,14 +44,6 @@ async function loadComponents (source = '/', paths: Record) { await Promise.all(promises) } -function emptyPayload () { - return { - chunks: {}, - props: {}, - teleports: {} - } -} - export default defineComponent({ name: 'NuxtIsland', props: { @@ -78,6 +70,8 @@ export default defineComponent({ } }, async setup (props, { slots, expose }) { + let canTeleport = import.meta.server + const teleportKey = ref(0) const key = ref(0) const canLoadClientComponent = computed(() => selectiveClient && (props.dangerouslyLoadClientComponents || !props.source)) const error = ref(null) @@ -91,7 +85,7 @@ export default defineComponent({ // TODO: remove use of `$fetch.raw` when nitro 503 issues on windows dev server are resolved const eventFetch = import.meta.server ? event.fetch : import.meta.dev ? $fetch.raw : globalThis.fetch const mounted = ref(false) - onMounted(() => { mounted.value = true }) + onMounted(() => { mounted.value = true; teleportKey.value++ }) function setPayload (key: string, result: NuxtIslandResponse) { nuxtApp.payload.data[key] = { @@ -101,60 +95,51 @@ export default defineComponent({ ? {} : { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } }, result: { - chunks: result.chunks, props: result.props, - teleports: result.teleports + slots: result.slots, + components: result.components } }, ...result } } - // needs to be non-reactive because we don't want to trigger re-renders - // at hydration, we only retrieve props/chunks/teleports from payload. See the reviver at nuxt\src\app\plugins\revive-payload.client.ts - // If not hydrating, fetchComponent() will set it - const rawPayload = nuxtApp.isHydrating ? toRaw(nuxtApp.payload.data)?.[`${props.name}_${hashId.value}`] ?? emptyPayload() : emptyPayload() - const nonReactivePayload: Pick = { - chunks: rawPayload.chunks, - props: rawPayload.props, - teleports: rawPayload.teleports + const payloadSlots: NonNullable = {} + const payloadComponents: NonNullable = {} + + if (nuxtApp.isHydrating) { + Object.assign(payloadSlots, toRaw(nuxtApp.payload.data[`${props.name}_${hashId.value}`])?.slots ?? {}) + Object.assign(payloadComponents, toRaw(nuxtApp.payload.data[`${props.name}_${hashId.value}`])?.components ?? {}) } const ssrHTML = ref('') - if (import.meta.client) { + if (import.meta.client && nuxtApp.isHydrating) { ssrHTML.value = getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') || '' } - const slotProps = computed(() => getSlotProps(ssrHTML.value)) const uid = ref(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId()) const availableSlots = computed(() => [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1])) - const html = computed(() => { const currentSlots = Object.keys(slots) let html = ssrHTML.value if (import.meta.client && !canLoadClientComponent.value) { - for (const [key, value] of Object.entries(nonReactivePayload.teleports || {})) { - html = html.replace(new RegExp(`
]*nuxt-ssr-client="${key}"[^>]*>`), (full) => { - return full + value + for (const [key, value] of Object.entries(payloadComponents || {})) { + html = html.replace(new RegExp(` data-island-uid="${uid.value}" data-island-client="${key}"[^>]*>`), (full) => { + return full + value.html }) } } - return html.replace(SLOT_FALLBACK_RE, (full, slotName, content) => { - // remove fallback to insert slots - if (currentSlots.includes(slotName)) { - return '' + return html.replaceAll(SLOT_FALLBACK_RE, (full, slotName) => { + if (!currentSlots.includes(slotName)) { + return full + payloadSlots[slotName]?.fallback ?? '' } - return content + return full }) }) - function setUid () { - uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId() as string - } - const cHead = ref>>>({ link: [], style: [] }) useHead(cHead) @@ -198,26 +183,25 @@ export default defineComponent({ const res: NuxtIslandResponse = await nuxtApp[pKey][uid.value] cHead.value.link = res.head.link cHead.value.style = res.head.style - ssrHTML.value = res.html.replace(UID_ATTR, () => { - return `nuxt-ssr-component-uid="${getId()}"` - }) + ssrHTML.value = res.html.replaceAll(DATA_ISLAND_UID_RE, `data-island-uid="${uid.value}"`) key.value++ error.value = null + Object.assign(payloadSlots, res.slots || {}) + Object.assign(payloadComponents, res.components || {}) if (selectiveClient && import.meta.client) { - if (canLoadClientComponent.value && res.chunks) { - await loadComponents(props.source, res.chunks) + if (canLoadClientComponent.value && res.components) { + await loadComponents(props.source, res.components) } - nonReactivePayload.props = res.props } - nonReactivePayload.teleports = res.teleports - nonReactivePayload.chunks = res.chunks if (import.meta.client) { // must await next tick for Teleport to work correctly with static node re-rendering - await nextTick() + nextTick(() => { + canTeleport = true + teleportKey.value++ + }) } - setUid() } catch (e) { error.value = e } @@ -241,46 +225,61 @@ export default defineComponent({ fetchComponent() } else if (import.meta.server || !nuxtApp.isHydrating || !nuxtApp.payload.serverRendered) { await fetchComponent() - } else if (selectiveClient && canLoadClientComponent.value && nonReactivePayload.chunks) { - await loadComponents(props.source, nonReactivePayload.chunks) + } else if (selectiveClient && canLoadClientComponent.value) { + await loadComponents(props.source, payloadComponents) } - return () => { + return (_ctx: any, _cache: any) => { if (!html.value || error.value) { return [slots.fallback?.({ error: error.value }) ?? createVNode('div')] } - const nodes = [createVNode(Fragment, { - key: key.value - }, [h(createStaticVNode(html.value || '
', 1))])] + return [ + withMemo([key.value], () => { + return createVNode(Fragment, { key: key.value }, [h(createStaticVNode(html.value || '
', 1))]) + }, _cache, 0), - if (uid.value && (mounted.value || nuxtApp.isHydrating || import.meta.server) && html.value) { - for (const slot in slots) { - if (availableSlots.value.includes(slot)) { - nodes.push(createVNode(Teleport, { to: import.meta.client ? `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, { - default: () => (slotProps.value[slot] ?? [undefined]).map((data: any) => slots[slot]?.(data)) - })) - } - } - if (import.meta.server) { - for (const [id, html] of Object.entries(nonReactivePayload.teleports ?? {})) { - nodes.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, { - default: () => [createStaticVNode(html, 1)] - })) - } - } - if (selectiveClient && import.meta.client && canLoadClientComponent.value) { - for (const [id, props] of Object.entries(nonReactivePayload.props ?? {})) { - const component = components!.get(id.split('-')[0])! - const vnode = createVNode(Teleport, { to: `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-client="${id}"]` }, { - default: () => { - return [h(component, props)] + // should away be triggered ONE tick after re-rendering the static node + withMemo([teleportKey.value], () => { + const teleports = [] + // this is used to force trigger Teleport when vue makes the diff between old and new node + const isKeyOdd = teleportKey.value === 0 || !!(teleportKey.value && !(teleportKey.value % 2)) + + if (uid.value && html.value && (import.meta.server || props.lazy ? canTeleport : mounted.value || nuxtApp.isHydrating)) { + for (const slot in slots) { + if (availableSlots.value.includes(slot)) { + teleports.push(createVNode(Teleport, + // use different selectors for even and odd teleportKey to force trigger the teleport + { to: import.meta.client ? `${isKeyOdd ? 'div' : ''}[data-island-uid="${uid.value}"][data-island-slot="${slot}"]` : `uid=${uid.value};slot=${slot}` }, + { default: () => (payloadSlots[slot].props?.length ? payloadSlots[slot].props : [{}]).map((data: any) => slots[slot]?.(data)) }) + ) } - }) - nodes.push(vnode) + } + if (import.meta.server) { + for (const [id, info] of Object.entries(payloadComponents ?? {})) { + const { html } = info + teleports.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, { + default: () => [createStaticVNode(html, 1)] + })) + } + } + if (selectiveClient && import.meta.client && canLoadClientComponent.value) { + for (const [id, info] of Object.entries(payloadComponents ?? {})) { + const { props } = info + const component = components!.get(id)! + // use different selectors for even and odd teleportKey to force trigger the teleport + const vnode = createVNode(Teleport, { to: `${isKeyOdd ? 'div' : ''}[data-island-uid='${uid.value}'][data-island-client="${id}"]` }, { + default: () => { + return [h(component, props)] + } + }) + teleports.push(vnode) + } + } } - } - } - return nodes + + return h(Fragment, teleports) + }, _cache, 1) + ] } } }) diff --git a/packages/nuxt/src/app/components/nuxt-teleport-ssr-client.ts b/packages/nuxt/src/app/components/nuxt-teleport-island-client.ts similarity index 68% rename from packages/nuxt/src/app/components/nuxt-teleport-ssr-client.ts rename to packages/nuxt/src/app/components/nuxt-teleport-island-client.ts index cbcba69f59..aca5f791ed 100644 --- a/packages/nuxt/src/app/components/nuxt-teleport-ssr-client.ts +++ b/packages/nuxt/src/app/components/nuxt-teleport-island-client.ts @@ -1,4 +1,4 @@ -import type { Component, } from 'vue' +import type { Component } from 'vue' import { Teleport, defineComponent, h } from 'vue' import { useNuxtApp } from '../nuxt' // @ts-expect-error virtual file @@ -14,7 +14,7 @@ type ExtendedComponent = Component & { * this teleport the component in SSR only if it needs to be hydrated on client */ export default defineComponent({ - name: 'NuxtTeleportSsrClient', + name: 'NuxtTeleportIslandClient', props: { to: { type: String, @@ -34,28 +34,26 @@ export default defineComponent({ } }, setup (props, { slots }) { - if (!props.nuxtClient) { return () => slots.default!() } + const nuxtApp = useNuxtApp() - const app = useNuxtApp() - const islandContext = app.ssrContext!.islandContext! + if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient) { return () => slots.default!() } + + const islandContext = nuxtApp.ssrContext!.islandContext! return () => { const slot = slots.default!()[0] const slotType = (slot.type as ExtendedComponent) const name = (slotType.__name || slotType.name) as string - - if (import.meta.dev) { - const path = '_nuxt/' + paths[name] - islandContext.chunks[name] = path - } else { - islandContext.chunks[name] = paths[name] - } - islandContext.propsData[props.to] = slot.props || {} + islandContext.components[props.to] = { + chunk: import.meta.dev ? '_nuxt/' + paths[name] : paths[name], + props: slot.props || {} + } return [h('div', { style: 'display: contents;', - 'nuxt-ssr-client': props.to + 'data-island-uid': '', + 'data-island-client': props.to }, []), h(Teleport, { to: props.to }, slot)] } } diff --git a/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts b/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts new file mode 100644 index 0000000000..eb1713280e --- /dev/null +++ b/packages/nuxt/src/app/components/nuxt-teleport-island-slot.ts @@ -0,0 +1,47 @@ +import { Teleport, defineComponent, h } from 'vue' +import { useNuxtApp } from '../nuxt' + +/** + * component only used within islands for slot teleport + */ +export default defineComponent({ + name: 'NuxtTeleportIslandSlot', + props: { + name: { + type: String, + required: true + }, + /** + * must be an array to handle v-for + */ + props: { + type: Object as () => Array + } + }, + setup (props, { slots }) { + const nuxtApp = useNuxtApp() + const islandContext = nuxtApp.ssrContext?.islandContext + + if(!islandContext) { + return () => slots.default?.() + } + + islandContext.slots[props.name] = { + props: (props.props || []) as unknown[] + } + + return () => { + const vnodes = [h('div', { + style: 'display: contents;', + 'data-island-uid': '', + 'data-island-slot': props.name, + })] + + if (slots.fallback) { + vnodes.push(h(Teleport, { to: `island-fallback=${props.name}`}, slots.fallback())) + } + + return vnodes + } + } +}) diff --git a/packages/nuxt/src/app/components/utils.ts b/packages/nuxt/src/app/components/utils.ts index 5995e333a8..11108cb583 100644 --- a/packages/nuxt/src/app/components/utils.ts +++ b/packages/nuxt/src/app/components/utils.ts @@ -2,7 +2,6 @@ import { h } from 'vue' import type { Component, RendererNode } from 'vue' // eslint-disable-next-line import { isString, isPromise, isArray, isObject } from '@vue/shared' -import destr from 'destr' import type { RouteLocationNormalized } from '#vue-router' // @ts-expect-error virtual file import { START_LOCATION } from '#build/pages' @@ -143,7 +142,7 @@ export function getFragmentHTML (element: RendererNode | null, withoutSlots = fa } if (withoutSlots) { const clone = element.cloneNode(true) - clone.querySelectorAll('[nuxt-ssr-slot-name]').forEach((n: Element) => { n.innerHTML = '' }) + clone.querySelectorAll('[data-island-slot]').forEach((n: Element) => { n.innerHTML = '' }) return [clone.outerHTML] } return [element.outerHTML] @@ -158,7 +157,7 @@ function getFragmentChildren (element: RendererNode | null, blocks: string[] = [ } else if (!isStartFragment(element)) { const clone = element.cloneNode(true) as Element if (withoutSlots) { - clone.querySelectorAll('[nuxt-ssr-slot-name]').forEach((n) => { n.innerHTML = '' }) + clone.querySelectorAll('[data-island-slot]').forEach((n) => { n.innerHTML = '' }) } blocks.push(clone.outerHTML) } @@ -175,15 +174,3 @@ function isStartFragment (element: RendererNode) { function isEndFragment (element: RendererNode) { return element.nodeName === '#comment' && element.nodeValue === ']' } -const SLOT_PROPS_RE = /]*nuxt-ssr-slot-name="([^"]*)" nuxt-ssr-slot-data="([^"]*)"[^/|>]*>/g - -export function getSlotProps (html: string) { - const slotsDivs = html.matchAll(SLOT_PROPS_RE) - const data: Record = {} - for (const slot of slotsDivs) { - const [_, slotName, json] = slot - const slotData = destr(decodeHtmlEntities(json)) - data[slotName] = slotData - } - return data -} diff --git a/packages/nuxt/src/components/islandsTransform.ts b/packages/nuxt/src/components/islandsTransform.ts index 84b8c71718..a3b6875b8f 100644 --- a/packages/nuxt/src/components/islandsTransform.ts +++ b/packages/nuxt/src/components/islandsTransform.ts @@ -11,17 +11,17 @@ import { resolvePath } from '@nuxt/kit' import { isVue } from '../core/utils' interface ServerOnlyComponentTransformPluginOptions { - getComponents: () => Component[] - /** - * passed down to `NuxtTeleportSsrClient` - * should be done only in dev mode as we use build:manifest result in production - */ - rootDir?: string - isDev?: boolean - /** - * allow using `nuxt-client` attribute on components - */ - selectiveClient?: boolean + getComponents: () => Component[] + /** + * passed down to `NuxtTeleportIslandClient` + * should be done only in dev mode as we use build:manifest result in production + */ + rootDir?: string + isDev?: boolean + /** + * allow using `nuxt-client` attribute on components + */ + selectiveClient?: boolean } interface ComponentChunkOptions { @@ -33,7 +33,11 @@ const SCRIPT_RE = /]*>/g const HAS_SLOT_OR_CLIENT_RE = /(]*>)|(nuxt-client)/ const TEMPLATE_RE = / @@ -166,7 +171,8 @@ describe('islandTransform - server and island components', () => { " @@ -234,13 +241,14 @@ describe('islandTransform - server and island components', () => { " " @@ -269,13 +277,14 @@ describe('islandTransform - server and island components', () => { "