This commit is contained in:
julien huang 2023-08-10 21:01:09 +02:00
parent c5f94be5d1
commit c5d930b2aa
7 changed files with 169 additions and 41 deletions

View File

@ -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
}
}
})

View File

@ -9,6 +9,7 @@ import type { FetchResponse } from 'ofetch'
// eslint-disable-next-line import/no-restricted-paths // eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer' import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
import { getFragmentHTML, getSlotProps } from './utils' import { getFragmentHTML, getSlotProps } from './utils'
import { useNuxtApp, useRuntimeConfig } from '#app/nuxt' import { useNuxtApp, useRuntimeConfig } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr' import { useRequestEvent } from '#app/composables/ssr'
@ -21,6 +22,23 @@ const SLOT_FALLBACK_RE = /<div nuxt-slot-fallback-start="([^"]*)"[^>]*><\/div>((
let id = 0 let id = 0
const getId = process.client ? () => (id++).toString() : randomUUID const getId = process.client ? () => (id++).toString() : randomUUID
const components = process.client ? new Map<string, unknown>() : undefined
async function loadComponents (paths: Record<string, string>) {
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({ export default defineComponent({
name: 'NuxtIsland', name: 'NuxtIsland',
@ -71,7 +89,9 @@ export default defineComponent({
head: { head: {
link: [], link: [],
style: [] style: []
} },
chunks: {},
props: {}
}) })
} }
ssrHTML.value = renderedHTML ?? '<div></div>' ssrHTML.value = renderedHTML ?? '<div></div>'
@ -90,6 +110,9 @@ export default defineComponent({
return content return content
}) })
}) })
// no need for reactivity
let interactiveComponentsList = {}
function setUid () { function setUid () {
uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId() as string uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId() as string
} }
@ -142,6 +165,11 @@ export default defineComponent({
await nextTick() await nextTick()
} }
setUid() setUid()
if (process.client) {
await loadComponents(res.chunks)
interactiveComponentsList = res.props
}
} }
if (import.meta.hot) { if (import.meta.hot) {
@ -163,6 +191,7 @@ export default defineComponent({
const nodes = [createVNode(Fragment, { const nodes = [createVNode(Fragment, {
key: key.value key: key.value
}, [h(createStaticVNode(html.value, 1))])] }, [h(createStaticVNode(html.value, 1))])]
if (uid.value && (mounted.value || nuxtApp.isHydrating || process.server)) { if (uid.value && (mounted.value || nuxtApp.isHydrating || process.server)) {
for (const slot in slots) { for (const slot in slots) {
if (availableSlots.value.includes(slot)) { 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 return nodes
} }

View File

@ -4,10 +4,16 @@ import { parseURL } from 'ufo'
import { createUnplugin } from 'unplugin' import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string' import MagicString from 'magic-string'
import { ELEMENT_NODE, parse, walk } from 'ultrahtml' import { ELEMENT_NODE, parse, walk } from 'ultrahtml'
import { hash } from 'ohash'
import { isVue } from '../core/utils' import { isVue } from '../core/utils'
interface ServerOnlyComponentTransformPluginOptions { interface ServerOnlyComponentTransformPluginOptions {
getComponents: () => Component[] 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 = /<script[^>]*>/g const SCRIPT_RE = /<script[^>]*>/g
@ -36,47 +42,55 @@ export const islandsTransform = createUnplugin((options: ServerOnlyComponentTran
const s = new MagicString(code) const s = new MagicString(code)
s.replace(SCRIPT_RE, (full) => { 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]) const ast = parse(template[0])
await walk(ast, (node) => { await walk(ast, (node) => {
if (node.type === ELEMENT_NODE && node.name === 'slot') { if (node.type === ELEMENT_NODE) {
const { attributes, children, loc, isSelfClosingTag } = node if (node.name === 'slot') {
const slotName = attributes.name ?? 'default' const { attributes, children, loc, isSelfClosingTag } = node
let vfor: [string, string] | undefined const slotName = attributes.name ?? 'default'
if (attributes['v-for']) { let vfor: [string, string] | undefined
vfor = attributes['v-for'].split(' in ').map((v: string) => v.trim()) as [string, string] if (attributes['v-for']) {
delete 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, `<div style="display: contents;" nuxt-ssr-slot-name="${slotName}" ${bindings}/>`)
} else {
s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, `<div style="display: contents;" nuxt-ssr-slot-name="${slotName}" ${bindings}>`)
s.overwrite(startingIndex + loc[1].start, startingIndex + loc[1].end, '</div>')
if (children.length > 1) {
// need to wrap instead of applying v-for on each child
const wrapperTag = `<div ${vfor ? `v-for="${vfor[0]} in ${vfor[1]}"` : ''} style="display: contents;">`
s.appendRight(startingIndex + loc[0].end, `<div nuxt-slot-fallback-start="${slotName}"/>${wrapperTag}`)
s.appendLeft(startingIndex + loc[1].start, '</div><div nuxt-slot-fallback-end/>')
} 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, `<div nuxt-slot-fallback-start="${slotName}"/>`)
s.appendLeft(startingIndex + loc[1].start, '<div nuxt-slot-fallback-end/>')
} }
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, `<div style="display: contents;" nuxt-ssr-slot-name="${slotName}" ${bindings}/>`)
} else {
s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, `<div style="display: contents;" nuxt-ssr-slot-name="${slotName}" ${bindings}>`)
s.overwrite(startingIndex + loc[1].start, startingIndex + loc[1].end, '</div>')
if (children.length > 1) {
// need to wrap instead of applying v-for on each child
const wrapperTag = `<div ${vfor ? `v-for="${vfor[0]} in ${vfor[1]}"` : ''} style="display: contents;">`
s.appendRight(startingIndex + loc[0].end, `<div nuxt-slot-fallback-start="${slotName}"/>${wrapperTag}`)
s.appendLeft(startingIndex + loc[1].start, '</div><div nuxt-slot-fallback-end/>')
} 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, `<div nuxt-slot-fallback-start="${slotName}"/>`)
s.appendLeft(startingIndex + loc[1].start, '<div nuxt-slot-fallback-end/>')
}
}
} 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, `<TeleportIfClient to="${node.name}-${uid}" ${options.rootDir ? `root-dir="${options.rootDir}"` : ''} :nuxt-client="${node.attributes['nuxt-client'] || 'true'}">${htmlCode}</TeleportIfClient>`)
} }
} }
}) })

View File

@ -226,7 +226,8 @@ export default defineNuxtModule<ComponentsOptions>({
if (isServer && nuxt.options.experimental.componentIslands) { if (isServer && nuxt.options.experimental.componentIslands) {
config.plugins.push(islandsTransform.vite({ config.plugins.push(islandsTransform.vite({
getComponents getComponents,
rootDir: nuxt.options.rootDir
})) }))
} }
if (!isServer && nuxt.options.experimental.componentIslands) { if (!isServer && nuxt.options.experimental.componentIslands) {

View File

@ -269,6 +269,10 @@ async function initNuxt (nuxt: Nuxt) {
priority: 10, // built-in that we do not expect the user to override priority: 10, // built-in that we do not expect the user to override
filePath: resolve(nuxt.options.appDir, 'components/nuxt-island') filePath: resolve(nuxt.options.appDir, 'components/nuxt-island')
}) })
nuxt.hook('build:manifest', (manifest) => {
debugger
})
} }
// Add experimental cross-origin prefetch support using Speculation Rules API // Add experimental cross-origin prefetch support using Speculation Rules API

View File

@ -40,6 +40,10 @@ export interface NuxtIslandContext {
name: string name: string
props?: Record<string, any> props?: Record<string, any>
url?: string url?: string
// chunks to load components
chunks: Record<string, string>
// props to be sent back
propsData: Record<string, any>
} }
export interface NuxtIslandResponse { export interface NuxtIslandResponse {
@ -50,6 +54,8 @@ export interface NuxtIslandResponse {
link: (Record<string, string>)[] link: (Record<string, string>)[]
style: ({ innerHTML: string, key: string })[] style: ({ innerHTML: string, key: string })[]
} }
chunks: Record<string, string>
props: Record<string, Record<string, any>>
} }
export interface NuxtRenderResponse { export interface NuxtRenderResponse {
@ -164,7 +170,9 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
id: hashId, id: hashId,
name: componentName, name: componentName,
props: destr(context.props) || {}, props: destr(context.props) || {},
uid: destr(context.uid) || undefined uid: destr(context.uid) || undefined,
chunks: {},
propsData: {}
} }
return ctx return ctx
@ -360,12 +368,14 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
innerHTML: tag.innerHTML innerHTML: tag.innerHTML
})) }))
} }
const islandResponse: NuxtIslandResponse = { const islandResponse: NuxtIslandResponse = {
id: islandContext.id, id: islandContext.id,
head, head,
html: getServerComponentHTML(htmlContext.body), html: getServerComponentHTML(htmlContext.body),
state: ssrContext.payload.state state: ssrContext.payload.state,
chunks: islandContext.chunks,
props: islandContext.propsData
} }
await nitroApp.hooks.callHook('render:island', islandResponse, { event, islandContext }) await nitroApp.hooks.callHook('render:island', islandResponse, { event, islandContext })

View File

@ -5,6 +5,13 @@
<div id="async-server-component-count"> <div id="async-server-component-count">
{{ count }} {{ count }}
</div> </div>
<div style="border: solid 1px red;">
The component bellow is not a slot but declared as interactive
<SugarCounter nuxt-client :multiplier="1" />
</div>
<slot /> <slot />
</div> </div>
</template> </template>