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): full scoped slots support for server components #20284

Merged
merged 83 commits into from May 15, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 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
0ceb7bc
WIP slot state
huang-julien Apr 11, 2023
d0ecbc7
feat: first working slot implementation
huang-julien Apr 12, 2023
26e1d45
feat: scoped slots with v-for
huang-julien Apr 12, 2023
47b5803
feat: scoped slots with v-for
huang-julien Apr 13, 2023
7d3240a
feat: add v-for util
huang-julien Apr 14, 2023
e2ddd2a
Merge branch 'main' into feat/conditionnal-slots
huang-julien Apr 14, 2023
34cd560
fix: set back display contents and fix attrs transform
huang-julien Apr 14, 2023
ff8f1c7
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 14, 2023
28c7000
fix: fix regex
huang-julien Apr 14, 2023
24187f6
Merge branch 'feat/conditionnal-slots' of https://github.com/huang-ju…
huang-julien Apr 14, 2023
ab5fa17
perf: don't render non existing slots
huang-julien Apr 14, 2023
2d50c11
test: update tests
huang-julien Apr 14, 2023
780e44b
feat: first implementation of fallback slots
huang-julien Apr 16, 2023
86301d8
style and rename
huang-julien Apr 16, 2023
74e97da
fix: several fixes
huang-julien Apr 16, 2023
6434f4b
Merge branch 'main' into feat/conditionnal-slots
huang-julien Apr 16, 2023
1b9d0b0
docs: update doc
huang-julien Apr 16, 2023
e00d166
chore: remove sfc-composer
huang-julien Apr 16, 2023
e2a91f0
chore: clean island-renderer prop types
huang-julien Apr 16, 2023
a7a6f96
Merge branch 'main' into feat/conditionnal-slots
huang-julien Apr 16, 2023
f7e528c
Merge branch 'main' into feat/conditionnal-slots
huang-julien Apr 20, 2023
a6ca538
fix: slot teleportation with client side navigation and set back Nuxt…
huang-julien Apr 21, 2023
4c6d2e5
Merge branch 'feat/conditionnal-slots' of https://github.com/huang-ju…
huang-julien Apr 21, 2023
48365bb
Merge branch 'main' into feat/conditionnal-slots
huang-julien Apr 21, 2023
974cacc
chore: update lock
huang-julien Apr 21, 2023
c42e100
[autofix.ci] apply automated fixes
autofix-ci[bot] Apr 21, 2023
f05113a
fix: fix merge issue
huang-julien Apr 21, 2023
7ececc8
Merge branch 'feat/conditionnal-slots' of https://github.com/huang-ju…
huang-julien Apr 21, 2023
a40fa1e
test: update tests
huang-julien Apr 21, 2023
d098631
revert: revert teleport conditions
huang-julien Apr 21, 2023
4873b84
fix: fix island for client-side navigation load
huang-julien Apr 21, 2023
6187eeb
fix: fix server components for client side navigation
huang-julien Apr 21, 2023
1758b42
test: test server components with client side navigation
huang-julien Apr 21, 2023
a9de6e9
test: remove only
huang-julien Apr 21, 2023
79c94c6
refactor: remove useless code
huang-julien Apr 21, 2023
4028def
Merge branch 'main' into feat/conditionnal-slots
huang-julien Apr 29, 2023
c3e6fd5
Merge branch 'main' into feat/conditionnal-slots
huang-julien May 1, 2023
39c7ad4
[autofix.ci] apply automated fixes
autofix-ci[bot] May 1, 2023
8836767
Merge branch 'main' into feat/conditionnal-slots
huang-julien May 2, 2023
194fd0e
lint
huang-julien May 2, 2023
810147e
[autofix.ci] apply automated fixes
autofix-ci[bot] May 2, 2023
42186c4
Revert "[autofix.ci] apply automated fixes"
danielroe May 2, 2023
f1e01e0
perf: don't call `replaceServerOnlyComponentsSlots` if NUXT_COMPONENT…
huang-julien May 9, 2023
c269bcd
Merge branch 'feat/conditionnal-slots' of https://github.com/huang-ju…
huang-julien May 9, 2023
69b5851
Merge branch 'main' into feat/conditionnal-slots
huang-julien May 9, 2023
c1fd1a0
Merge branch 'main' into feat/conditionnal-slots
huang-julien May 9, 2023
1e478eb
Merge branch 'main' into feat/conditionnal-slots
danielroe May 10, 2023
2952e1f
Merge branch 'main' into feat/conditionnal-slots
huang-julien May 12, 2023
5ed2091
Merge branch 'main' into feat/conditionnal-slots
danielroe May 13, 2023
6309b04
refactor: use `replace` rather than `replaceAll`
danielroe May 15, 2023
9683195
Merge remote-tracking branch 'origin/main' into feat/conditionnal-slots
danielroe May 15, 2023
b4fcf50
chore: lint
danielroe May 15, 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
4 changes: 2 additions & 2 deletions docs/2.guide/2.directory-structure/1.components.md
Expand Up @@ -296,8 +296,8 @@ Now you can register server-only components with the `.server` suffix and use th
</template>
```

::alert{type=warning}
Slots are not supported by server components in their current state of development.
::alert{type=info}
Slots can be interactive and are wrapped within a `<div>` with `display: contents;`
::

### Paired with a `.client` component
Expand Down
1 change: 1 addition & 0 deletions packages/nuxt/package.json
Expand Up @@ -91,6 +91,7 @@
"strip-literal": "^1.0.1",
"ufo": "^1.1.2",
"ultrahtml": "^1.2.0",
"uncrypto": "^0.1.2",
"unctx": "^2.3.0",
"unenv": "^1.4.1",
"unimport": "^3.0.6",
Expand Down
3 changes: 2 additions & 1 deletion packages/nuxt/src/app/components/island-renderer.ts
Expand Up @@ -21,6 +21,7 @@ export default defineComponent({
statusMessage: `Island component not found: ${JSON.stringify(component)}`
})
}
return () => createVNode(component || 'span', props.context.props)

return () => createVNode(component || 'span', { ...props.context.props, 'nuxt-ssr-component-uid': '' })
}
})
102 changes: 58 additions & 44 deletions packages/nuxt/src/app/components/nuxt-island.ts
@@ -1,16 +1,20 @@
import type { RendererNode, Slots } from 'vue'
import { computed, createStaticVNode, defineComponent, getCurrentInstance, h, ref, watch } 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 { appendResponseHeader } 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 { getFragmentHTML, getSlotProps } from './utils'
import { useNuxtApp } from '#app/nuxt'
import { useRequestEvent } from '#app/composables/ssr'

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 nuxt-slot-fallback-start="([^"]*)"[^>]*><\/div>(((?!<div nuxt-slot-fallback-end[^>]*>)[\s\S])*)<div nuxt-slot-fallback-end[^>]*><\/div>/g

export default defineComponent({
name: 'NuxtIsland',
Expand All @@ -28,15 +32,37 @@ 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 mounted = ref(false)
onMounted(() => { mounted.value = true })
const ssrHTML = ref<string>(process.client ? getFragmentHTML(instance.vnode?.el ?? null).join('') ?? '<div></div>' : '<div></div>')
const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID())
const availableSlots = computed(() => {
return [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1])
})

const html = ref<string>(process.client ? getFragmentHTML(instance?.vnode?.el).join('') ?? '<div></div>' : '<div></div>')
const html = computed(() => {
const currentSlots = Object.keys(slots)
return ssrHTML.value.replace(SLOT_FALLBACK_RE, (full, slotName, content) => {
// remove fallback to insert slots
if (currentSlots.includes(slotName)) {
return ''
}
return content
})
})
function setUid () {
uid.value = ssrHTML.value.match(SSR_UID_RE)?.[1] ?? randomUUID() as string
}
const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] })
useHead(cHead)
const slotProps = computed(() => {
return getSlotProps(ssrHTML.value)
})

function _fetchComponent () {
const url = `/__nuxt_island/${props.name}:${hashId.value}`
Expand All @@ -55,16 +81,23 @@ export default defineComponent({
const key = ref(0)
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
ssrHTML.value = res.html.replace(UID_ATTR, () => {
return `nuxt-ssr-component-uid="${randomUUID()}"`
})
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 @@ -74,40 +107,21 @@ export default defineComponent({
if (process.server || !nuxtApp.isHydrating) {
await fetchComponent()
}
return () => h((_, { slots }) => (slots as Slots).default?.(), { key: key.value }, {
default: () => [createStaticVNode(html.value, 1)]
})
}
})

// TODO refactor with https://github.com/nuxt/nuxt/pull/19231
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)
return () => {
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)) {
nodes.push(createVNode(Teleport, { to: process.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))
}))
}
}
}
return nodes
}

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 === ']'
}
})
101 changes: 99 additions & 2 deletions packages/nuxt/src/app/components/utils.ts
@@ -1,7 +1,8 @@
import { h } from 'vue'
import type { Component } from 'vue'
import type { Component, RendererNode } from 'vue'
// eslint-disable-next-line
import { isString, isPromise, isArray } from '@vue/shared'
import { isString, isPromise, isArray, isObject } from '@vue/shared'
import destr from 'destr'

/**
* Internal utility
Expand Down Expand Up @@ -44,3 +45,99 @@ export function createBuffer () {
}
}
}

const TRANSLATE_RE = /&(nbsp|amp|quot|lt|gt);/g
const NUMSTR_RE = /&#(\d+);/gi
export function decodeHtmlEntities (html: string) {
const translateDict = {
nbsp: ' ',
amp: '&',
quot: '"',
lt: '<',
gt: '>'
} as const
return html.replace(TRANSLATE_RE, function (_, entity: keyof typeof translateDict) {
return translateDict[entity]
}).replace(NUMSTR_RE, function (_, numStr: string) {
const num = parseInt(numStr, 10)
return String.fromCharCode(num)
})
}

/**
* helper for NuxtIsland to generate a correct array for scoped data
*/
export function vforToArray (source: any): any[] {
if (isArray(source)) {
return source
} else if (isString(source)) {
return source.split('')
} else if (typeof source === 'number') {
if (process.dev && !Number.isInteger(source)) {
console.warn(`The v-for range expect an integer value but got ${source}.`)
}
const array = []
for (let i = 0; i < source; i++) {
array[i] = i
}
return array
} else if (isObject(source)) {
if (source[Symbol.iterator as any]) {
return Array.from(source as Iterable<any>, item =>
item
)
} else {
const keys = Object.keys(source)
const array = new Array(keys.length)
for (let i = 0, l = keys.length; i < l; i++) {
const key = keys[i]
array[i] = source[key]
}
return array
}
}
return []
}

export 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 === ']'
}
const SLOT_PROPS_RE = /<div[^>]*nuxt-ssr-slot-name="([^"]*)" nuxt-ssr-slot-data="([^"]*)"[^/|>]*>/g

export function getSlotProps (html: string) {
const slotsDivs = html.matchAll(SLOT_PROPS_RE)
const data:Record<string, any> = {}
for (const slot of slotsDivs) {
const [_, slotName, json] = slot
const slotData = destr(decodeHtmlEntities(json))
data[slotName] = slotData
}
return data
}
101 changes: 101 additions & 0 deletions packages/nuxt/src/components/islandsTransform.ts
@@ -0,0 +1,101 @@
import { pathToFileURL } from 'node:url'
import type { Component } from '@nuxt/schema'
import { parseURL } from 'ufo'
import { createUnplugin } from 'unplugin'
import MagicString from 'magic-string'
import { ELEMENT_NODE, parse, walk } from 'ultrahtml'

interface ServerOnlyComponentTransformPluginOptions {
getComponents: () => Component[]
}

const SCRIPT_RE = /<script[^>]*>/g

export const islandsTransform = createUnplugin((options: ServerOnlyComponentTransformPluginOptions) => {
return {
name: 'server-only-component-transform',
enforce: 'pre',
transformInclude (id) {
const components = options.getComponents()
const islands = components.filter(component =>
component.island || (component.mode === 'server' && !components.some(c => c.pascalName === component.pascalName && c.mode === 'client'))
)
const { pathname } = parseURL(decodeURIComponent(pathToFileURL(id).href))
return islands.some(c => c.filePath === pathname)
},
async transform (code, id) {
if (!code.includes('<slot ')) { return }
const template = code.match(/<template>([\s\S]*)<\/template>/)
if (!template) { return }
const s = new MagicString(code)

s.replace(SCRIPT_RE, (full) => {
return full + '\nimport { vforToArray as __vforToArray } from \'#app/components/utils\''
})

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(loc[0].start, loc[0].end, `<div style="display: contents;" nuxt-ssr-slot-name="${slotName}" ${bindings}/>`)
} else {
s.overwrite(loc[0].start, loc[0].end, `<div style="display: contents;" nuxt-ssr-slot-name="${slotName}" ${bindings}>`)
s.overwrite(loc[1].start, 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(loc[0].end, `<div nuxt-slot-fallback-start="${slotName}"/>${wrapperTag}`)
s.appendLeft(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(loc[0].start, loc[0].end, `<${name} v-for="${vfor[0]} in ${vfor[1]}" ${attrs} ${isSelfClosingTag ? '/' : ''}>`)
}

s.appendRight(loc[0].end, `<div nuxt-slot-fallback-start="${slotName}"/>`)
s.appendLeft(loc[1].start, '<div nuxt-slot-fallback-end/>')
}
}
}
})

if (s.hasChanged()) {
return {
code: s.toString(),
map: s.generateMap({ source: id, includeContent: true })
}
}
}
}
})

function isBinding (attr: string): boolean {
return attr.startsWith(':')
}

function getBindings (bindings: Record<string, string>, vfor?: [string, string]): string {
if (Object.keys(bindings).length === 0) { return '' }
const content = Object.entries(bindings).filter(b => b[0] !== '_bind').map(([name, value]) => isBinding(name) ? `${name.slice(1)}: ${value}` : `${name}: \`${value}\``).join(',')
const data = bindings._bind ? `mergeProps(${bindings._bind}, { ${content} })` : `{ ${content} }`
if (!vfor) {
return `:nuxt-ssr-slot-data="JSON.stringify([${data}])"`
} else {
return `:nuxt-ssr-slot-data="JSON.stringify(__vforToArray(${vfor[1]}).map(${vfor[0]} => (${data})))"`
}
}