Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(nuxt): support basic slots on server only components #19851

Closed
wants to merge 39 commits into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
832b1e6
fix(nuxt): fix cross-request nuxt app pollution with nuxt islands
huang-julien Mar 30, 2023
857781e
Revert "fix(nuxt): fix cross-request nuxt app pollution with nuxt isl…
huang-julien Mar 30, 2023
7f061ec
feat: working nuxtisland slot
huang-julien Mar 30, 2023
bfa2a90
style: lint
huang-julien Mar 30, 2023
b579885
test(fixtures): add default slot
huang-julien Mar 30, 2023
152907b
fix(nuxt): fix islands mounted post ssr
huang-julien Mar 30, 2023
42b92db
perf(islands): don't try to render slots in slots in ssr
huang-julien Mar 30, 2023
3266be2
fix: fix slot on first mount
huang-julien Mar 30, 2023
68bfc6c
feat: first implementation of SSR server-only slots with hydration
huang-julien Mar 30, 2023
eb37764
chore: add uncrypto
huang-julien Mar 30, 2023
fefd2d7
refactor: send slotsName to islandRenderer
huang-julien Mar 30, 2023
7e3eec7
fix: fix static node rerendering in production
huang-julien Mar 30, 2023
f267ffa
perf(renderer): don't go through replace if no islandContext
huang-julien Mar 30, 2023
42d16c9
fix previous commit
huang-julien Mar 30, 2023
1d26548
fix(renderer): fix regex
huang-julien Mar 30, 2023
8e20f44
fix(islands): fix islands request uniqueness
huang-julien Mar 30, 2023
29c2a4d
test(fixtures): add fixtures for islands uniqueness and islands mount…
huang-julien Mar 30, 2023
4c00871
test(basic): test island slot and interactivity
huang-julien Mar 30, 2023
9fb431b
chore: lint and remove logs
huang-julien Mar 30, 2023
f99cdf7
feat(server-component): pass slot to NuxtIsland
huang-julien Mar 30, 2023
b28f5c4
chore: remove useless fragment
huang-julien Mar 30, 2023
c2e3af3
test: update test
huang-julien Mar 30, 2023
3081c05
chore: bump server bundle size
huang-julien Mar 30, 2023
334edf3
test: udpate test
huang-julien Mar 30, 2023
089a545
docs(server): update alert block
huang-julien Mar 30, 2023
07773cd
docs(server-components): update limitations
huang-julien Apr 2, 2023
8ac245d
Merge branch 'main' into feat/slot-server-component
huang-julien Apr 3, 2023
be40a57
chore(test): bump server bundle size
huang-julien Apr 3, 2023
6ee69f3
Merge branch 'feat/slot-server-component' of https://github.com/huang…
huang-julien Apr 3, 2023
4a94f32
chore: update lock
huang-julien Apr 3, 2023
8f4395e
chore: update bundle size
huang-julien Apr 3, 2023
8ce1319
Merge remote-tracking branch 'origin/main' into feat/slot-server-comp…
danielroe Apr 7, 2023
d1de35f
Merge remote-tracking branch 'origin/main' into feat/slot-server-comp…
danielroe Apr 7, 2023
0a8f5ec
Merge remote-tracking branch 'origin/main' into feat/slot-server-comp…
danielroe Apr 7, 2023
403ba33
Merge branch 'main' into feat/slot-server-component
huang-julien Apr 14, 2023
cdc10ee
chore: update lock
huang-julien Apr 14, 2023
97520d1
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 14, 2023
f2d3ba2
fix: set back NuxtServerComponent for prefetch
huang-julien Apr 20, 2023
0be91b2
Merge branch 'feat/slot-server-component' of https://github.com/huang…
huang-julien Apr 20, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
7 changes: 6 additions & 1 deletion docs/2.guide/2.directory-structure/1.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,13 @@ Now you can register server-only components with the `.server` suffix and use th
</template>
```

::alert{type=info}
Slots can be interactive and are wrapped within a `<div>` with `display: contents;`
::

::alert{type=warning}
Slots are not supported by server components in their current state of development.
Scoped slots are not supported by server components in their current state of development.
Server only components slots can't be conditionnal and must be called when rendering the component.
::

### Paired with a `.client` component
Expand Down
5 changes: 3 additions & 2 deletions packages/nuxt/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -94,8 +94,9 @@
"scule": "^1.0.0",
"strip-literal": "^1.0.1",
"ufo": "^1.1.1",
"unctx": "^2.2.0",
"unenv": "^1.3.1",
"uncrypto": "^0.1.2",
"unctx": "^2.1.2",
"unenv": "^1.2.2",
"unimport": "^3.0.6",
"unplugin": "^1.3.1",
"untyped": "^1.3.2",
Expand Down
15 changes: 13 additions & 2 deletions packages/nuxt/src/app/components/island-renderer.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { randomUUID } from 'uncrypto'
import type { defineAsyncComponent } from 'vue'
import { createVNode, defineComponent } from 'vue'

Expand All @@ -8,19 +9,29 @@ import { createError } from '#app/composables/error'
export default defineComponent({
props: {
context: {
type: Object as () => { name: string, props?: Record<string, any> },
type: Object as () => { name: string, props?: Record<string, any>, slotsName?: string[], uid?: string },
required: true
}
},
setup (props) {
const uid = props.context.uid ?? randomUUID()
const component = islandComponents[props.context.name] as ReturnType<typeof defineAsyncComponent>
const slots: Record<string, Function> = {}
if (props.context.slotsName) {
for (const slotName of props.context.slotsName) {
slots[slotName] = () => {
return createVNode('div', { 'v-ssr-slot-name': slotName, style: 'display: contents;' })
}
}
}

if (!component) {
throw createError({
statusCode: 404,
statusMessage: `Island component not found: ${JSON.stringify(component)}`
})
}
return () => createVNode(component || 'span', props.context.props)

return () => createVNode(component || 'span', { ...props.context.props, 'v-ssr-component-uid': uid }, slots)
}
})
98 changes: 86 additions & 12 deletions packages/nuxt/src/app/components/nuxt-island.ts
Original file line number Diff line number Diff line change
@@ -1,15 +1,18 @@
import { computed, createStaticVNode, defineComponent, getCurrentInstance, ref, watch } from 'vue'
import type { RendererNode, VNode } from 'vue'
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, watch } from 'vue'

import { debounce } from 'perfect-debounce'
import { hash } from 'ohash'
import { appendHeader } from 'h3'
import { useHead } from '@unhead/vue'

import { randomUUID } from 'uncrypto'
// eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
import { useNuxtApp } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr'

const pKey = '_islandPromises'
const SSR_UID_RE = /v-ssr-component-uid="([^"]*)"/

export default defineComponent({
name: 'NuxtIsland',
Expand All @@ -27,13 +30,19 @@ export default defineComponent({
default: () => ({})
}
},
async setup (props) {
async setup (props, { slots }) {
const nuxtApp = useNuxtApp()
const hashId = computed(() => hash([props.name, props.props, props.context]))
const instance = getCurrentInstance()!
const event = useRequestEvent()

const html = ref<string>(process.client ? instance.vnode.el?.outerHTML ?? '<div></div>' : '<div></div>')
const mounted = ref(false)
const key = ref(0)
onMounted(() => { mounted.value = true })
const html = ref<string>(process.client ? getFragmentHTML(instance.vnode?.el ?? null).join('') ?? '<div></div>' : '<div></div>')
const uid = ref<string>(html.value.match(SSR_UID_RE)?.[1] ?? randomUUID())
function setUid () {
uid.value = html.value.match(SSR_UID_RE)?.[1] as string
}
const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] })
useHead(cHead)

Expand All @@ -47,22 +56,29 @@ export default defineComponent({
return $fetch<NuxtIslandResponse>(url, {
params: {
...props.context,
props: props.props ? JSON.stringify(props.props) : undefined
props: props.props ? JSON.stringify(props.props) : undefined,
slotsName: JSON.stringify(Object.keys(slots))
}
})
}

async function fetchComponent () {
nuxtApp[pKey] = nuxtApp[pKey] || {}
if (!nuxtApp[pKey][hashId.value]) {
nuxtApp[pKey][hashId.value] = _fetchComponent().finally(() => {
delete nuxtApp[pKey]![hashId.value]
if (!nuxtApp[pKey][uid.value]) {
nuxtApp[pKey][uid.value] = _fetchComponent().finally(() => {
delete nuxtApp[pKey]![uid.value]
})
}
const res: NuxtIslandResponse = await nuxtApp[pKey][hashId.value]
const res: NuxtIslandResponse = await nuxtApp[pKey][uid.value]
cHead.value.link = res.head.link
cHead.value.style = res.head.style
html.value = res.html
key.value++
if (process.client) {
// must await next tick for Teleport to work correctly with static node re-rendering
await nextTick()
}
setUid()
}

if (process.client) {
Expand All @@ -72,7 +88,65 @@ export default defineComponent({
if (process.server || !nuxtApp.isHydrating) {
await fetchComponent()
}

return () => createStaticVNode(html.value, 1)
return () => {
// bypass hydration
if (!mounted.value && process.client && !html.value) {
html.value = getFragmentHTML(instance.vnode.el).join('')
setUid()
return [getStaticVNode(instance.vnode)]
}
const nodes = [createVNode(Fragment, {
key: key.value
}, [h(createStaticVNode(html.value, 1))])]
if (uid.value) {
for (const slot in slots) {
nodes.push(createVNode(Teleport, { to: process.client ? `[v-ssr-component-uid='${uid.value}'] [v-ssr-slot-name='${slot}']` : `uid=${uid.value};slot=${slot}` }, {
default: () => [slots[slot]?.()]
}))
}
}
return nodes
}
}
})

// TODO refactor with https://github.com/nuxt/nuxt/pull/19231
function getStaticVNode (vnode: VNode) {
const fragment = getFragmentHTML(vnode.el)

if (fragment.length === 0) {
return null
}
return createStaticVNode(fragment.join(''), fragment.length)
}

function getFragmentHTML (element: RendererNode | null) {
if (element) {
if (element.nodeName === '#comment' && element.nodeValue === '[') {
return getFragmentChildren(element)
}
return [element.outerHTML]
}
return []
}

function getFragmentChildren (element: RendererNode | null, blocks: string[] = []) {
if (element && element.nodeName) {
if (isEndFragment(element)) {
return blocks
} else if (!isStartFragment(element)) {
blocks.push(element.outerHTML)
}

getFragmentChildren(element.nextSibling, blocks)
}
return blocks
}

function isStartFragment (element: RendererNode) {
return element.nodeName === '#comment' && element.nodeValue === '['
}

function isEndFragment (element: RendererNode) {
return element.nodeName === '#comment' && element.nodeValue === ']'
}
7 changes: 5 additions & 2 deletions packages/nuxt/src/components/runtime/server-component.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { computed, createStaticVNode, defineComponent, h, watch } from 'vue'
import { Fragment, computed, createStaticVNode, createVNode, defineComponent, h, ref, watch } from 'vue'
import { debounce } from 'perfect-debounce'
import { hash } from 'ohash'
import { appendHeader } from 'h3'
Expand Down Expand Up @@ -42,6 +42,7 @@ const NuxtServerComponent = defineComponent({
},
async setup (props) {
const nuxtApp = useNuxtApp()
const key = ref(0)
const hashId = computed(() => hash([props.name, props.props, props.context]))

const event = useRequestEvent()
Expand Down Expand Up @@ -97,6 +98,8 @@ const NuxtServerComponent = defineComponent({

await res

return () => createStaticVNode(res.data.value!.html, 1)
return () => createVNode(Fragment, { key: key.value }, {
default: () => [createStaticVNode(res.data.value!.html, 1)]
})
}
})
22 changes: 20 additions & 2 deletions packages/nuxt/src/core/runtime/nitro/renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,9 @@ async function getIslandContext (event: H3Event): Promise<NuxtIslandContext> {
...context,
id: hashId,
name: componentName,
props: destr(context.props) || {}
props: destr(context.props) || {},
slotsName: destr(context.slotsName) || [],
uid: destr(context.uid) || undefined
}

return ctx
Expand Down Expand Up @@ -299,7 +301,7 @@ export default defineRenderHandler(async (event) => {
renderedMeta.bodyScriptsPrepend,
ssrContext.teleports?.body
]),
body: [_rendered.html],
body: [replaceServerOnlyComponentsSlots(ssrContext, _rendered.html)],
bodyAppend: normalizeChunks([
NO_SCRIPTS
? undefined
Expand Down Expand Up @@ -481,3 +483,19 @@ function getServerComponentHTML (body: string[]): string {
const match = body[0].match(ROOT_NODE_REGEX)
return match ? match[1] : body[0]
}

const SSR_TELEPORT_MARKER = /^uid=([^;]*);slot=(.*)$/
function replaceServerOnlyComponentsSlots (ssrContext: NuxtSSRContext, html: string): string {
const { teleports, islandContext } = ssrContext
if (islandContext || !teleports) { return html }
for (const key in teleports) {
const match = key.match(SSR_TELEPORT_MARKER)
if (!match) { continue }
const [, uid, slot] = match
if (!uid || !slot) { continue }
html = html.replace(new RegExp(`<div v-ssr-component-uid="${uid}">((?!v-ssr-slot-name="${slot}"|v-ssr-component-uid).)*<div v-ssr-slot-name="${slot}"([^>]*)>`), (full) => {
return full + teleports[key]
})
}
return html
}