diff --git a/packages/nuxt/src/app/components/TeleportIfClient.ts b/packages/nuxt/src/app/components/TeleportIfClient.ts new file mode 100644 index 0000000000..8974b4088b --- /dev/null +++ b/packages/nuxt/src/app/components/TeleportIfClient.ts @@ -0,0 +1,54 @@ +import { Teleport, defineComponent, h } from 'vue' +import { useNuxtApp } from '#app' +import { relative } from 'path' + +/** + * component only used with componentsIsland + * this teleport the component in SSR only if + */ +export default defineComponent({ + name: 'TeleportIfClient', + props: { + to: String, + nuxtClient: { + type: Boolean, + default: false + }, + /** + * ONLY used in dev mode since we use build:manifest result in production + * do not pass any value in production + */ + rootDir: { + type: String, + default: null + } + }, + setup (props, { slots }) { + + const app = useNuxtApp() + + const islandContext = app.ssrContext!.islandContext + + const slot = slots.default!()[0] + console.log(slot) + if (process.dev) { + console.log(app) + const path = '__nuxt/' + relative(props.rootDir, slot.type.__file) + + islandContext.chunks[slot.type.__name] = path + } + islandContext.propsData[props.to] = slot.props || {} + // todo set prop in payload + return () => { + + if (props.nuxtClient) { + return [h('div', { + style: 'display: contents;', + 'nuxt-ssr-client': props.to + }, [slot]), h(Teleport, { to: props.to }, slot)] + } + + return slot + } + } +}) diff --git a/packages/nuxt/src/app/components/nuxt-island.ts b/packages/nuxt/src/app/components/nuxt-island.ts index c268265d6c..b8c054302e 100644 --- a/packages/nuxt/src/app/components/nuxt-island.ts +++ b/packages/nuxt/src/app/components/nuxt-island.ts @@ -9,6 +9,7 @@ import type { FetchResponse } from 'ofetch' // eslint-disable-next-line import/no-restricted-paths import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' + import { getFragmentHTML, getSlotProps } from './utils' import { useNuxtApp, useRuntimeConfig } from '#app/nuxt' import { useRequestEvent } from '#app/composables/ssr' @@ -21,6 +22,23 @@ const SLOT_FALLBACK_RE = /
]*><\/div>(( let id = 0 const getId = process.client ? () => (id++).toString() : randomUUID +const components = process.client ? new Map() : undefined + +async function loadComponents (paths: Record) { + const promises = [] + + debugger + for (const component in paths) { + if (!(components!.has(component))) { + promises.push((async () => { + const c = await import('http://localhost:3000/__nuxt/components/SugarCounter.vue') + debugger + components!.set(component, c.default ?? c) + })()) + } + } + await Promise.all(promises) +} export default defineComponent({ name: 'NuxtIsland', @@ -71,7 +89,9 @@ export default defineComponent({ head: { link: [], style: [] - } + }, + chunks: {}, + props: {} }) } ssrHTML.value = renderedHTML ?? '
' @@ -90,6 +110,9 @@ export default defineComponent({ return content }) }) + + // no need for reactivity + let interactiveComponentsList = {} function setUid () { uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId() as string } @@ -142,6 +165,11 @@ export default defineComponent({ await nextTick() } setUid() + + if (process.client) { + await loadComponents(res.chunks) + interactiveComponentsList = res.props + } } if (import.meta.hot) { @@ -163,6 +191,7 @@ export default defineComponent({ const nodes = [createVNode(Fragment, { key: key.value }, [h(createStaticVNode(html.value, 1))])] + if (uid.value && (mounted.value || nuxtApp.isHydrating || process.server)) { for (const slot in slots) { if (availableSlots.value.includes(slot)) { @@ -171,6 +200,15 @@ export default defineComponent({ })) } } + if (process.client && html.value.includes('nuxt-ssr-client') && mounted.value) { + + for (const id in interactiveComponentsList) { + const vnode = createVNode(Teleport, { to: `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-client="${id}"]` }, { + default: () => [h(components!.get(id.split('-')[0]), interactiveComponentsList[id])] + }) + nodes.push(vnode) + } + } } return nodes } diff --git a/packages/nuxt/src/components/islandsTransform.ts b/packages/nuxt/src/components/islandsTransform.ts index 1cdc89a831..42a2624cc0 100644 --- a/packages/nuxt/src/components/islandsTransform.ts +++ b/packages/nuxt/src/components/islandsTransform.ts @@ -4,10 +4,16 @@ import { parseURL } from 'ufo' import { createUnplugin } from 'unplugin' import MagicString from 'magic-string' import { ELEMENT_NODE, parse, walk } from 'ultrahtml' +import { hash } from 'ohash' import { isVue } from '../core/utils' interface ServerOnlyComponentTransformPluginOptions { getComponents: () => Component[] + /** + * passed down to `TeleportIfClient` + * should be done only in dev mode as we use build:manifest result in production + */ + rootDir?: string } const SCRIPT_RE = /]*>/g @@ -36,47 +42,55 @@ export const islandsTransform = createUnplugin((options: ServerOnlyComponentTran const s = new MagicString(code) s.replace(SCRIPT_RE, (full) => { - return full + '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\'' + return full + '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\'' + '\nimport TeleportIfClient from \'#app/components/TeleportIfClient\'' }) const ast = parse(template[0]) await walk(ast, (node) => { - if (node.type === ELEMENT_NODE && node.name === 'slot') { - const { attributes, children, loc, isSelfClosingTag } = node - const slotName = attributes.name ?? 'default' - let vfor: [string, string] | undefined - if (attributes['v-for']) { - vfor = attributes['v-for'].split(' in ').map((v: string) => v.trim()) as [string, string] - delete attributes['v-for'] - } - if (attributes.name) { delete attributes.name } - if (attributes['v-bind']) { - attributes._bind = attributes['v-bind'] - delete attributes['v-bind'] - } - const bindings = getBindings(attributes, vfor) - - if (isSelfClosingTag) { - s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, `
`) - } else { - s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, `
`) - s.overwrite(startingIndex + loc[1].start, startingIndex + loc[1].end, '
') - - if (children.length > 1) { - // need to wrap instead of applying v-for on each child - const wrapperTag = `
` - s.appendRight(startingIndex + loc[0].end, `
${wrapperTag}`) - s.appendLeft(startingIndex + loc[1].start, '
') - } else if (children.length === 1) { - if (vfor && children[0].type === ELEMENT_NODE) { - const { loc, name, attributes, isSelfClosingTag } = children[0] - const attrs = Object.entries(attributes).map(([attr, val]) => `${attr}="${val}"`).join(' ') - s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, `<${name} v-for="${vfor[0]} in ${vfor[1]}" ${attrs} ${isSelfClosingTag ? '/' : ''}>`) - } - - s.appendRight(startingIndex + loc[0].end, `
`) - s.appendLeft(startingIndex + loc[1].start, '
') + if (node.type === ELEMENT_NODE) { + if (node.name === 'slot') { + const { attributes, children, loc, isSelfClosingTag } = node + const slotName = attributes.name ?? 'default' + let vfor: [string, string] | undefined + if (attributes['v-for']) { + vfor = attributes['v-for'].split(' in ').map((v: string) => v.trim()) as [string, string] + delete attributes['v-for'] } + if (attributes.name) { delete attributes.name } + if (attributes['v-bind']) { + attributes._bind = attributes['v-bind'] + delete attributes['v-bind'] + } + const bindings = getBindings(attributes, vfor) + + if (isSelfClosingTag) { + s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, `
`) + } else { + s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, `
`) + s.overwrite(startingIndex + loc[1].start, startingIndex + loc[1].end, '
') + + if (children.length > 1) { + // need to wrap instead of applying v-for on each child + const wrapperTag = `
` + s.appendRight(startingIndex + loc[0].end, `
${wrapperTag}`) + s.appendLeft(startingIndex + loc[1].start, '
') + } else if (children.length === 1) { + if (vfor && children[0].type === ELEMENT_NODE) { + const { loc, name, attributes, isSelfClosingTag } = children[0] + const attrs = Object.entries(attributes).map(([attr, val]) => `${attr}="${val}"`).join(' ') + s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, `<${name} v-for="${vfor[0]} in ${vfor[1]}" ${attrs} ${isSelfClosingTag ? '/' : ''}>`) + } + + s.appendRight(startingIndex + loc[0].end, `
`) + s.appendLeft(startingIndex + loc[1].start, '
') + } + } + } else if ('nuxt-client' in node.attributes) { + // handle granular interactivity + const htmlCode = code.slice(startingIndex + node.loc[0].start, startingIndex + node.loc[1].end) + const uid = hash(id + node.loc[0].start + node.loc[0].end) + + s.overwrite(node.loc[0].start, node.loc[1].end, `${htmlCode}`) } } }) diff --git a/packages/nuxt/src/components/module.ts b/packages/nuxt/src/components/module.ts index 94ecdac476..84f74f39e7 100644 --- a/packages/nuxt/src/components/module.ts +++ b/packages/nuxt/src/components/module.ts @@ -226,7 +226,8 @@ export default defineNuxtModule({ if (isServer && nuxt.options.experimental.componentIslands) { config.plugins.push(islandsTransform.vite({ - getComponents + getComponents, + rootDir: nuxt.options.rootDir })) } if (!isServer && nuxt.options.experimental.componentIslands) { diff --git a/packages/nuxt/src/core/nuxt.ts b/packages/nuxt/src/core/nuxt.ts index 9aa9a65dfc..d7f2ce693f 100644 --- a/packages/nuxt/src/core/nuxt.ts +++ b/packages/nuxt/src/core/nuxt.ts @@ -269,6 +269,10 @@ async function initNuxt (nuxt: Nuxt) { priority: 10, // built-in that we do not expect the user to override filePath: resolve(nuxt.options.appDir, 'components/nuxt-island') }) + + nuxt.hook('build:manifest', (manifest) => { + debugger + }) } // Add experimental cross-origin prefetch support using Speculation Rules API diff --git a/packages/nuxt/src/core/runtime/nitro/renderer.ts b/packages/nuxt/src/core/runtime/nitro/renderer.ts index 840e01281a..43086c753e 100644 --- a/packages/nuxt/src/core/runtime/nitro/renderer.ts +++ b/packages/nuxt/src/core/runtime/nitro/renderer.ts @@ -40,6 +40,10 @@ export interface NuxtIslandContext { name: string props?: Record url?: string + // chunks to load components + chunks: Record + // props to be sent back + propsData: Record } export interface NuxtIslandResponse { @@ -50,6 +54,8 @@ export interface NuxtIslandResponse { link: (Record)[] style: ({ innerHTML: string, key: string })[] } + chunks: Record + props: Record> } export interface NuxtRenderResponse { @@ -164,7 +170,9 @@ async function getIslandContext (event: H3Event): Promise { id: hashId, name: componentName, props: destr(context.props) || {}, - uid: destr(context.uid) || undefined + uid: destr(context.uid) || undefined, + chunks: {}, + propsData: {} } return ctx @@ -360,12 +368,14 @@ export default defineRenderHandler(async (event): Promise {{ count }}
+ +
+ The component bellow is not a slot but declared as interactive + + +
+