This commit is contained in:
Julien Huang 2024-09-11 21:20:35 +02:00
parent 03058b5132
commit 3b51e4a148
11 changed files with 109 additions and 135 deletions

View File

@ -75,7 +75,8 @@ export default defineComponent({
}, },
emits: ['error'], emits: ['error'],
async setup (props, { slots, expose, emit }) { async setup (props, { slots, expose, emit }) {
let canTeleport = import.meta.server console.log(slots.default?.toString())
let canTeleport = import.meta.server
const teleportKey = ref(0) const teleportKey = ref(0)
const key = ref(0) const key = ref(0)
const canLoadClientComponent = computed(() => selectiveClient && (props.dangerouslyLoadClientComponents || !props.source)) const canLoadClientComponent = computed(() => selectiveClient && (props.dangerouslyLoadClientComponents || !props.source))

View File

@ -34,7 +34,6 @@ export default defineComponent({
// if there's already a teleport parent, we don't need to teleport or to render the wrapped component client side // if there's already a teleport parent, we don't need to teleport or to render the wrapped component client side
if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient || inject(NuxtTeleportIslandSymbol, false)) { return () => slots.default?.() } if (!nuxtApp.ssrContext?.islandContext || !props.nuxtClient || inject(NuxtTeleportIslandSymbol, false)) { return () => slots.default?.() }
provide(NuxtTeleportIslandSymbol, props.to) provide(NuxtTeleportIslandSymbol, props.to)
const islandContext = nuxtApp.ssrContext!.islandContext! const islandContext = nuxtApp.ssrContext!.islandContext!

View File

@ -1,5 +1,6 @@
import type { VNode } from 'vue' import type { VNode } from 'vue'
import { Teleport, createVNode, defineComponent, h, inject } from 'vue' import { Teleport, createVNode, defineComponent, h, inject } from 'vue'
import consola from 'consola'
import { useNuxtApp } from '../nuxt' import { useNuxtApp } from '../nuxt'
import { NuxtTeleportIslandSymbol } from './nuxt-teleport-island-component' import { NuxtTeleportIslandSymbol } from './nuxt-teleport-island-component'
@ -20,10 +21,12 @@ export default defineComponent({
*/ */
props: { props: {
type: Object as () => Array<any>, type: Object as () => Array<any>,
default: () => [],
}, },
}, },
setup (props, { slots }) { setup (props, { slots }) {
const nuxtApp = useNuxtApp() const nuxtApp = useNuxtApp()
console.log(slots.default?.toString())
const islandContext = nuxtApp.ssrContext?.islandContext const islandContext = nuxtApp.ssrContext?.islandContext
if (!islandContext) { if (!islandContext) {
return () => slots.default?.()[0] return () => slots.default?.()[0]

View File

@ -1,8 +1,11 @@
import { h } from 'vue' import { createVNode, h } from 'vue'
import type { Component, RendererNode } from 'vue' import type { Component, DefineComponent, RendererNode, VNode, renderSlot } from 'vue'
// eslint-disable-next-line // eslint-disable-next-line
import { isString, isPromise, isArray, isObject } from '@vue/shared' import { isString, isPromise, isArray, isObject } from '@vue/shared'
import type { RouteLocationNormalized } from 'vue-router' import type { RouteLocationNormalized } from 'vue-router'
import NuxtTeleportIslandSlot from './nuxt-teleport-island-slot'
import NuxtTeleportClient from './nuxt-teleport-island-component'
import { useNuxtApp } from '#app'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import { START_LOCATION } from '#build/pages' import { START_LOCATION } from '#build/pages'
@ -155,3 +158,74 @@ function isStartFragment (element: RendererNode) {
function isEndFragment (element: RendererNode) { function isEndFragment (element: RendererNode) {
return element.nodeName === '#comment' && element.nodeValue === ']' return element.nodeName === '#comment' && element.nodeValue === ']'
} }
/**
* convert a component to wrap all slots with a teleport
*/
export function withIslandTeleport (component: DefineComponent) {
if (import.meta.client) { return component }
return {
...component,
render: renderForIsland(component.render),
setup: setupForIsland(component.setup),
} as DefineComponent
}
/**
* export
*/
function renderForIsland (render: any) {
if (!render) { return undefined }
return (ctx, _cache, $props, $setup, $data, $options) => {
const _ctx = {
...ctx,
$slots: {
...ctx.$slots,
...Object.keys(ctx.$slots).reduce((acc, key) => {
acc[key] = (...args: any) => {
return h(NuxtTeleportIslandSlot, {
name: key,
props: [args],
}, { default: () => ctx.$slots[key]?.(...args) })
}
return acc
}, {}),
},
}
for (const key in ctx.$slots) {
ctx.$slots[key] = (...args: any) => {
return h(NuxtTeleportIslandSlot, {
name: key,
props: [args],
}, { default: () => ctx.$slots[key]?.(...args) })
}
}
console.log('renderForIsland', _ctx, ctx, $setup)
return render(_ctx, _cache, $props, $setup, $data, $options)
}
}
function setupForIsland (setup: (props, ctx) => any) {
if(!setup) { return}
return (props, ctx) => {
// const slots = Object.keys(ctx.slots).reduce((acc, key) => {
// return Object.assign(acc, {[key]: (...args: any) => {
// return createVNode(NuxtTeleportIslandSlot, {
// name: key,
// }, { default: () => ctx.slots[key]?.(...args) })
// }})
// }, {})
for(const key in ctx.slots) {
ctx.slots[key] = (...args: any) => {
return createVNode(NuxtTeleportIslandSlot, {
name: key,
}, { default: () => ctx.slots[key]?.(...args) })
}
}
return setup?.(props, ctx)
}
}

View File

@ -71,31 +71,7 @@ export const islandsTransform = createUnplugin((options: ServerOnlyComponentTran
const ast = parse(template[0]) const ast = parse(template[0])
await walk(ast, (node) => { await walk(ast, (node) => {
if (node.type === ELEMENT_NODE) { if (node.type === ELEMENT_NODE) {
if (node.name === 'slot') { if (options.selectiveClient && ('nuxt-client' in node.attributes || ':nuxt-client' in node.attributes)) {
const { attributes, children, loc } = node
const slotName = attributes.name ?? 'default'
if (attributes.name) { delete attributes.name }
if (attributes['v-bind']) {
attributes._bind = extractAttributes(attributes, ['v-bind'])['v-bind']
}
const teleportAttributes = extractAttributes(attributes, ['v-if', 'v-else-if', 'v-else'])
const bindings = getPropsToString(attributes)
// add the wrapper
s.appendLeft(startingIndex + loc[0].start, `<NuxtTeleportSsrSlot${attributeToString(teleportAttributes)} name="${slotName}" :props="${bindings}">`)
if (children.length) {
// pass slot fallback to NuxtTeleportSsrSlot fallback
const attrString = attributeToString(attributes)
const slice = code.slice(startingIndex + loc[0].end, startingIndex + loc[1].start).replaceAll(/:?key="[^"]"/g, '')
s.overwrite(startingIndex + loc[0].start, startingIndex + loc[1].end, `<slot${attrString.replaceAll(EXTRACTED_ATTRS_RE, '')}/><template #fallback>${attributes['v-for'] ? wrapWithVForDiv(slice, attributes['v-for']) : slice}</template>`)
} else {
s.overwrite(startingIndex + loc[0].start, startingIndex + loc[0].end, code.slice(startingIndex + loc[0].start, startingIndex + loc[0].end).replaceAll(EXTRACTED_ATTRS_RE, ''))
}
s.appendRight(startingIndex + loc[1].end, '</NuxtTeleportSsrSlot>')
} else if (options.selectiveClient && ('nuxt-client' in node.attributes || ':nuxt-client' in node.attributes)) {
hasNuxtClient = true hasNuxtClient = true
const { loc, attributes } = node const { loc, attributes } = node
const attributeValue = attributes[':nuxt-client'] || attributes['nuxt-client'] || 'true' const attributeValue = attributes[':nuxt-client'] || attributes['nuxt-client'] || 'true'

View File

@ -41,6 +41,7 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
const imports = new Set<string>() const imports = new Set<string>()
const map = new Map<Component, string>() const map = new Map<Component, string>()
const s = new MagicString(code) const s = new MagicString(code)
imports.add(genImport(resolve(distDir, 'app/components/utils'), [{ name: 'withIslandTeleport' }]))
// replace `_resolveComponent("...")` to direct import // replace `_resolveComponent("...")` to direct import
s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, name: string) => { s.replace(/(?<=[ (])_?resolveComponent\(\s*["'](lazy-|Lazy(?=[A-Z]))?([^'"]*)["'][^)]*\)/g, (full: string, lazy: string, name: string) => {
@ -59,6 +60,8 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
if (isServerOnly) { if (isServerOnly) {
imports.add(genImport(serverComponentRuntime, [{ name: 'createServerComponent' }])) imports.add(genImport(serverComponentRuntime, [{ name: 'createServerComponent' }]))
imports.add(`const ${identifier} = createServerComponent(${JSON.stringify(component.pascalName)})`) imports.add(`const ${identifier} = createServerComponent(${JSON.stringify(component.pascalName)})`)
imports.add(`const ${identifier}_converted = withIslandTeleport(${identifier})`)
identifier += '_converted'
if (!options.experimentalComponentIslands) { if (!options.experimentalComponentIslands) {
logger.warn(`Standalone server components (\`${name}\`) are not yet supported without enabling \`experimental.componentIslands\`.`) logger.warn(`Standalone server components (\`${name}\`) are not yet supported without enabling \`experimental.componentIslands\`.`)
} }
@ -70,13 +73,14 @@ export const loaderPlugin = createUnplugin((options: LoaderOptions) => {
imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }])) imports.add(genImport('#app/components/client-only', [{ name: 'createClientOnly' }]))
identifier += '_client' identifier += '_client'
} }
if (lazy) { if (lazy) {
imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }])) imports.add(genImport('vue', [{ name: 'defineAsyncComponent', as: '__defineAsyncComponent' }]))
identifier += '_lazy' identifier += '_lazy'
imports.add(`const ${identifier} = __defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => c.${component.export ?? 'default'} || c)${isClientOnly ? '.then(c => createClientOnly(c))' : ''})`) imports.add(`const ${identifier} = __defineAsyncComponent(${genDynamicImport(component.filePath, { interopDefault: false })}.then(c => withIslandTeleport(c.${component.export ?? 'default'} || c))${isClientOnly ? '.then(c => createClientOnly(c))' : ''})`)
} else { } else {
imports.add(genImport(component.filePath, [{ name: component._raw ? 'default' : component.export, as: identifier }])) imports.add(genImport(component.filePath, [{ name: component._raw ? 'default' : component.export, as: identifier }]))
imports.add(`const ${identifier}_converted = withIslandTeleport(${identifier})`)
identifier += '_converted'
if (isClientOnly) { if (isClientOnly) {
imports.add(`const ${identifier}_wrapped = createClientOnly(${identifier})`) imports.add(`const ${identifier}_wrapped = createClientOnly(${identifier})`)

View File

@ -238,9 +238,9 @@ export default defineNuxtModule<ComponentsOptions>({
if (nuxt.options.experimental.componentIslands) { if (nuxt.options.experimental.componentIslands) {
const selectiveClient = typeof nuxt.options.experimental.componentIslands === 'object' && nuxt.options.experimental.componentIslands.selectiveClient const selectiveClient = typeof nuxt.options.experimental.componentIslands === 'object' && nuxt.options.experimental.componentIslands.selectiveClient
writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}')
if (isClient && selectiveClient) { if (isClient && selectiveClient) {
writeFileSync(join(nuxt.options.buildDir, 'components-chunk.mjs'), 'export const paths = {}')
if (!nuxt.options.dev) { if (!nuxt.options.dev) {
config.plugins.push(componentsChunkPlugin.vite({ config.plugins.push(componentsChunkPlugin.vite({
getComponents, getComponents,

View File

@ -2,6 +2,7 @@ import { defineComponent, getCurrentInstance, h, ref } from 'vue'
import NuxtIsland from '#app/components/nuxt-island' import NuxtIsland from '#app/components/nuxt-island'
import { useRoute } from '#app/composables/router' import { useRoute } from '#app/composables/router'
import { isPrerendered } from '#app/composables/payload' import { isPrerendered } from '#app/composables/payload'
import { withIslandTeleport } from '#app/components/utils'
/* @__NO_SIDE_EFFECTS__ */ /* @__NO_SIDE_EFFECTS__ */
export const createServerComponent = (name: string) => { export const createServerComponent = (name: string) => {
@ -11,15 +12,15 @@ export const createServerComponent = (name: string) => {
props: { lazy: Boolean }, props: { lazy: Boolean },
emits: ['error'], emits: ['error'],
setup (props, { attrs, slots, expose, emit }) { setup (props, { attrs, slots, expose, emit }) {
const vm = getCurrentInstance() const vm = getCurrentInstance()
const islandRef = ref<null | typeof NuxtIsland>(null) const islandRef = ref<null | typeof NuxtIsland>(null)
expose({ expose({
refresh: () => islandRef.value?.refresh(), refresh: () => islandRef.value?.refresh(),
}) })
console.log(slots.default?.toString())
return () => { return () => {
return h(NuxtIsland, { return h( withIslandTeleport(NuxtIsland), {
name, name,
lazy: props.lazy, lazy: props.lazy,
props: attrs, props: attrs,

View File

@ -33,7 +33,8 @@ import type { NuxtPayload, NuxtSSRContext } from '#app'
import { appHead, appId, appRootAttrs, appRootTag, appTeleportAttrs, appTeleportTag, componentIslands, multiApp } from '#internal/nuxt.config.mjs' import { appHead, appId, appRootAttrs, appRootTag, appTeleportAttrs, appTeleportTag, componentIslands, multiApp } from '#internal/nuxt.config.mjs'
// @ts-expect-error virtual file // @ts-expect-error virtual file
import { buildAssetsURL, publicAssetsURL } from '#internal/nuxt/paths' import { buildAssetsURL, publicAssetsURL } from '#internal/nuxt/paths'
import consola from 'consola'
// @ts-expect-error private property consumed by vite-generated url helpers // @ts-expect-error private property consumed by vite-generated url helpers
globalThis.__buildAssetsURL = buildAssetsURL globalThis.__buildAssetsURL = buildAssetsURL
// @ts-expect-error private property consumed by vite-generated url helpers // @ts-expect-error private property consumed by vite-generated url helpers
@ -473,6 +474,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
// TODO: remove for v4 // TODO: remove for v4
islandHead.link = islandHead.link || [] islandHead.link = islandHead.link || []
islandHead.style = islandHead.style || [] islandHead.style = islandHead.style || []
consola.log('teleports', ssrContext.teleports)
const islandResponse: NuxtIslandResponse = { const islandResponse: NuxtIslandResponse = {
id: islandContext.id, id: islandContext.id,
@ -655,7 +657,7 @@ function getServerComponentHTML (body: string): string {
const SSR_SLOT_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/ const SSR_SLOT_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/
const SSR_CLIENT_TELEPORT_MARKER = /^uid=([^;]*);client=(.*)$/ const SSR_CLIENT_TELEPORT_MARKER = /^uid=([^;]*);client=(.*)$/
const SSR_CLIENT_SLOT_MARKER = /^island-slot=[^;]*;(.*)$/ const SSR_CLIENT_SLOT_MARKER = /^island-slot=[^;]*$/
function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['slots'] { function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['slots'] {
if (!ssrContext.islandContext || !Object.keys(ssrContext.islandContext.slots).length) { return undefined } if (!ssrContext.islandContext || !Object.keys(ssrContext.islandContext.slots).length) { return undefined }
@ -668,12 +670,13 @@ function getSlotIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse[
} }
return response return response
} }
const logger = consola
logger.wrapConsole()
function getClientIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['components'] { function getClientIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandResponse['components'] {
if (!ssrContext.islandContext || !Object.keys(ssrContext.islandContext.components).length) { return undefined } if (!ssrContext.islandContext || !Object.keys(ssrContext.islandContext.components).length) { return undefined }
const response: NuxtIslandResponse['components'] = {} const response: NuxtIslandResponse['components'] = {}
for (const clientUid in ssrContext.islandContext.components) {
for (const clientUid in ssrContext.islandContext.components) {
// remove teleport anchor to avoid hydration issues // remove teleport anchor to avoid hydration issues
const html = ssrContext.teleports?.[clientUid].replaceAll('<!--teleport start anchor-->', '') || '' const html = ssrContext.teleports?.[clientUid].replaceAll('<!--teleport start anchor-->', '') || ''
response[clientUid] = { response[clientUid] = {
@ -688,10 +691,9 @@ function getClientIslandResponse (ssrContext: NuxtSSRContext): NuxtIslandRespons
function getComponentSlotTeleport (teleports: Record<string, string>) { function getComponentSlotTeleport (teleports: Record<string, string>) {
const entries = Object.entries(teleports) const entries = Object.entries(teleports)
const slots: Record<string, string> = {} const slots: Record<string, string> = {}
for (const [key, value] of entries) {
for (const [key, value] of entries) { const match = key.match(SSR_CLIENT_SLOT_MARKER)
const match = key.match(SSR_CLIENT_SLOT_MARKER) if (match) {
if (match) {
const [, slot] = match const [, slot] = match
if (!slot) { continue } if (!slot) { continue }
slots[slot] = value slots[slot] = value

View File

@ -238,6 +238,7 @@ export default defineNuxtConfig({
}, },
}, },
features: { features: {
devLogs: false,
inlineStyles: id => !!id && !id.includes('assets.vue'), inlineStyles: id => !!id && !id.includes('assets.vue'),
}, },
experimental: { experimental: {

View File

@ -1,4 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
const islandProps = ref({ const islandProps = ref({
bool: true, bool: true,
number: 100, number: 100,
@ -15,102 +16,14 @@ const count = ref(0)
<template> <template>
<div> <div>
Pure island component: Pure island component:
<div class="box">
<NuxtIsland
name="PureComponent"
:props="islandProps"
/>
<div id="wrapped-client-only">
<ClientOnly>
<NuxtIsland
name="PureComponent"
:props="islandProps"
/>
</ClientOnly>
</div>
</div>
<button
id="increase-pure-component"
@click="islandProps.number++"
>
Increase
</button>
<hr>
Route island component:
<div
v-if="routeIslandVisible"
class="box"
>
<NuxtIsland
name="RouteComponent"
:context="{ url: '/test' }"
/>
</div>
<button
v-else
id="show-route"
@click="routeIslandVisible = true"
>
Show
</button>
<p>async .server component</p>
<AsyncServerComponent :count="count"> <AsyncServerComponent :count="count">
<div id="slot-in-server"> <div id="slot-in-server">
Slot with in .server component Slot with in .server component
</div> </div>
</AsyncServerComponent> </AsyncServerComponent>
<div>
Async component (1000ms):
<div>
<NuxtIsland
name="LongAsyncComponent"
:props="{ count }"
>
<div>Interactive testing slot</div>
<div id="first-sugar-counter">
<Counter :multiplier="testCount" />
</div>
<template #test="scoped">
<div id="test-slot">
Slot with name test - scoped data {{ scoped }}
</div>
</template>
<template #hello="scoped">
<div id="test-slot">
Slot with name hello - scoped data {{ scoped }}
</div>
</template>
</NuxtIsland>
<button
id="update-server-components"
@click="count++"
>
add +1 to count
</button>
</div>
</div>
<div>
<p>Island with props mounted client side</p>
<button
id="show-island"
@click="showIslandSlot = true"
>
Show Interactive island
</button>
<div id="island-mounted-client-side">
<NuxtIsland
v-if="showIslandSlot"
name="LongAsyncComponent"
:props="{ count }"
>
<div>Interactive testing slot post SSR</div>
<Counter :multiplier="testCount" />
</NuxtIsland>
</div>
</div>
<server-with-client />
<ServerWithNestedClient />
</div> </div>
</template> </template>