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): allow client components within NuxtIsland #22649

Merged
merged 94 commits into from
Dec 19, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
94 commits
Select commit Hold shift + click to select a range
c5d930b
WIP
huang-julien Aug 10, 2023
2331d38
Merge branch 'feat/granular-client' of https://github.com/huang-julie…
huang-julien Aug 10, 2023
202fe05
feat(nuxt): working only with navigation (missing payload on app init…
huang-julien Aug 10, 2023
00da021
chore: add todo and go to sleep
huang-julien Aug 10, 2023
6f85659
feat: working in dev
huang-julien Aug 11, 2023
98527fd
feat: working in build with client-side navigation
huang-julien Aug 13, 2023
38ba957
fix: fix node issue on hydration only
huang-julien Aug 13, 2023
cbd94c3
feat: WORTKING IN BUIKLD
huang-julien Aug 14, 2023
e5ddf98
fix: hydration
huang-julien Aug 14, 2023
aceb4ae
chore: lint and types
huang-julien Aug 14, 2023
15782f7
refactor: use import.meta instead of process.env
huang-julien Aug 15, 2023
a3f76bf
feat: introduce `dangerouslyLoadCLientComponent`
huang-julien Aug 15, 2023
606a55f
feat: remove nuxt-client from component tag
huang-julien Aug 16, 2023
b308714
fix: fix payload and use reviver to partially retrieve data
huang-julien Aug 18, 2023
79fc8a1
fix: make it only vite compatible for now
huang-julien Aug 18, 2023
ba917cb
fix: send rootDir only in dev
huang-julien Aug 19, 2023
26dff83
test: unit test for islandsTransform
huang-julien Aug 19, 2023
08079ec
test: test transform island with dev mode
huang-julien Aug 19, 2023
5b76767
fix: fix behavior in lazy
huang-julien Aug 19, 2023
be6a1b4
fix: write empty paths in webpack
huang-julien Aug 19, 2023
d5fb848
test: update snapshots
huang-julien Aug 19, 2023
716b291
test: remove .only
huang-julien Aug 19, 2023
be862e8
test: avoid snapshot with hashes
huang-julien Aug 19, 2023
011516f
test: remove hashes from snapshots
huang-julien Aug 19, 2023
67eb50b
test: fix previous commit
huang-julien Aug 19, 2023
17ed315
test: test component interactivity
huang-julien Aug 19, 2023
85bc7d5
fix: only manually chunk client components
huang-julien Aug 19, 2023
41fcb19
test: set back old tests for webpack with isWebpack
huang-julien Aug 20, 2023
ecb66ae
fix: fix teleport issue on non hydration lazy mount
huang-julien Aug 26, 2023
fafe09d
fix: fix client-side nav client component loading (better refactor this)
huang-julien Aug 27, 2023
8c622e6
test: refactor tests
huang-julien Aug 28, 2023
a6d2ddd
[autofix.ci] apply automated fixes
autofix-ci[bot] Aug 28, 2023
b3fd481
feat: use `entry` instead of `manualChunk`
huang-julien Aug 28, 2023
d553fd0
chore: lint
huang-julien Aug 28, 2023
ee27963
fix: remove lib config
huang-julien Aug 29, 2023
38d0854
fix: preserveEntrySignatures
huang-julien Aug 29, 2023
aaca711
Merge branch 'main' into feat/granular-client
huang-julien Sep 8, 2023
09bf017
test: update snapshots
huang-julien Sep 9, 2023
91911b2
fix: use 'allow-extension' for preserveEntrySignatures
huang-julien Sep 9, 2023
e4dbbeb
chore: clean and rename
huang-julien Sep 9, 2023
ec89f87
test: update snapshots
huang-julien Sep 9, 2023
70f436d
Merge branch 'main' into feat/granular-client
huang-julien Sep 10, 2023
b9ae8eb
chore: rename
huang-julien Sep 10, 2023
3693523
docs: update api and components guide
huang-julien Sep 10, 2023
a0f097f
test: update snapshots
huang-julien Sep 10, 2023
fb63340
Merge branch 'main' into feat/granular-client
huang-julien Sep 14, 2023
c9d9582
refactor: refactor fafe09d80920057e5e65961871a0ef8d5c64afbe
huang-julien Sep 15, 2023
95eb11c
Merge branch 'feat/granular-client' of https://github.com/huang-julie…
huang-julien Sep 15, 2023
b4f708b
Merge remote-tracking branch 'origin/main' into feat/granular-client
huang-julien Sep 15, 2023
b8b90b8
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 15, 2023
dc4c74e
test: update snapshots
huang-julien Sep 15, 2023
7d5342c
Merge branch 'feat/granular-client' of https://github.com/huang-julie…
huang-julien Sep 15, 2023
570337d
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 15, 2023
ca45ef9
feat: allow dynamic binding into nux-client
huang-julien Sep 16, 2023
cb104be
perf: directly return default slot if not nuxtClient on teleport ssr …
huang-julien Sep 16, 2023
55f3bb4
Merge remote-tracking branch 'origin/main' into feat/granular-client
huang-julien Sep 20, 2023
442459d
refactor: mark new fields as optionnal for compability
huang-julien Sep 20, 2023
8f25266
chore: lint
huang-julien Sep 20, 2023
14eae71
[autofix.ci] apply automated fixes
autofix-ci[bot] Sep 20, 2023
8f8622b
Merge remote-tracking branch 'origin/main' into feat/granular-client
danielroe Oct 16, 2023
41f4cfa
fix: pass starting index to teleport component insertion
danielroe Oct 16, 2023
2beaae9
fix: ignore chunk source
danielroe Oct 16, 2023
2538092
fix: handle extraneous attributes (e.g. devtools)
danielroe Oct 16, 2023
31ff865
chore: remove comment
huang-julien Oct 18, 2023
08565d3
chore(nuxt): remove comments
huang-julien Oct 18, 2023
8b8b801
Merge remote-tracking branch 'origin/main' into feat/granular-client
danielroe Oct 19, 2023
d243bd8
fix: move getComponents in tansformInclude
huang-julien Oct 19, 2023
440820a
Merge branch 'feat/granular-client' of https://github.com/huang-julie…
huang-julien Oct 19, 2023
dea5613
test: add failing test to not preload unneeded chunks
huang-julien Oct 19, 2023
0c9b9e6
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 19, 2023
1f683be
Merge branch 'main' into feat/granular-client
huang-julien Oct 19, 2023
97e07aa
test: correct modulepreload tests
huang-julien Oct 20, 2023
65ba27b
Merge branch 'feat/granular-client' of https://github.com/huang-julie…
huang-julien Oct 20, 2023
ba419a3
test: update snapshots
huang-julien Oct 20, 2023
7d6c869
Merge branch 'main' into feat/granular-client
huang-julien Oct 20, 2023
d2f2628
test: update webpack snapshots
huang-julien Oct 20, 2023
ae83204
Merge branch 'feat/granular-client' of https://github.com/huang-julie…
huang-julien Oct 20, 2023
ee96945
[autofix.ci] apply automated fixes
autofix-ci[bot] Oct 20, 2023
fcb887c
feat(nuxt): move the feature behind a feature flag
huang-julien Oct 20, 2023
d6efef5
test: update unit tests
huang-julien Oct 21, 2023
d507aeb
docs(guide): add feature flag to doc
huang-julien Oct 21, 2023
4cf1b2d
Merge remote-tracking branch 'origin/main' into feat/granular-client
danielroe Nov 2, 2023
505ebcb
Update packages/schema/src/config/experimental.ts
huang-julien Nov 2, 2023
ac9f19b
Update test/basic.test.ts
huang-julien Nov 2, 2023
016d134
chore: lint
huang-julien Nov 2, 2023
0b16598
Merge branch 'feat/granular-client' of https://github.com/huang-julie…
huang-julien Nov 2, 2023
9a6db04
Merge remote-tracking branch 'origin/main' into feat/granular-client
huang-julien Nov 16, 2023
3a36671
Merge branch 'main' into feat/granular-client
huang-julien Nov 23, 2023
c08d53f
Merge branch 'main' into feat/granular-client
huang-julien Nov 28, 2023
f021da5
fix: rename all SugarCounter to Counter
huang-julien Nov 28, 2023
4b46a9b
[autofix.ci] apply automated fixes
autofix-ci[bot] Nov 28, 2023
67db1d8
fix: allow setting non SFC component as client
huang-julien Nov 29, 2023
d11fb21
chore: remove console logs
huang-julien Nov 30, 2023
5c14de4
Merge remote-tracking branch 'origin/main' into feat/granular-client
danielroe Dec 19, 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
22 changes: 22 additions & 0 deletions docs/2.guide/2.directory-structure/1.components.md
Original file line number Diff line number Diff line change
Expand Up @@ -295,6 +295,28 @@ Now you can register server-only components with the `.server` suffix and use th

Server-only components use [`<NuxtIsland>`](/docs/api/components/nuxt-island) under the hood, meaning that `lazy` prop and `#fallback` slot are both passed down to it.

#### Client components within server components

::alert{type=info}
This feature needs `experimental.componentIslands.selectiveClient` within your configuration to be true.
::

You can partially hydrate a component by setting a `nuxt-client` attribute on the component you wish to be loaded client-side.

```html [components/ServerWithClient.vue]
<template>
<div>
<HighlightedMarkdown markdown="# Headline" />
<!-- Counter will be loaded and hydrated client-side -->
<Counter nuxt-client :count="5" />
</div>
</template>
```

::alert{type=info}
This only works within a server component.
::

#### Server Component Context

When rendering a server-only or island component, `<NuxtIsland>` makes a fetch request which comes back with a `NuxtIslandResponse`. (This is an internal request if rendered on the server, or a request that you can see in the network tab if it's rendering on client-side navigation.)
Expand Down
4 changes: 4 additions & 0 deletions docs/3.api/1.components/8.nuxt-island.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,13 @@ Server only components use `<NuxtIsland>` under the hood
- **type**: `Record<string, any>`
- `source`: Remote source to call the island to render.
- **type**: `string`
- **dangerouslyLoadClientComponents**: Required to load components from a remote source.
- **type**: `boolean`
- **default**: `false`

::callout{color="blue" icon="i-ph-info-duotone"}
Remote islands need `experimental.componentIslands` to be `'local+remote'` in your `nuxt.config`.
It is strongly discouraged to enable `dangerouslyLoadClientComponents` as you can't trust a remote server's javascript.
::

## Slots
Expand Down
126 changes: 105 additions & 21 deletions packages/nuxt/src/app/components/nuxt-island.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, watch } from 'vue'
import type { Component } from 'vue'
import { Fragment, Teleport, computed, createStaticVNode, createVNode, defineComponent, getCurrentInstance, h, nextTick, onMounted, ref, toRaw, 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'
import { joinURL, withQuery } from 'ufo'
import type { FetchResponse } from 'ofetch'
import { join } from 'pathe'

// eslint-disable-next-line import/no-restricted-paths
import type { NuxtIslandResponse } from '../../core/runtime/nitro/renderer'
Expand All @@ -14,7 +16,7 @@ import { prerenderRoutes, useRequestEvent } from '../composables/ssr'
import { getFragmentHTML, getSlotProps } from './utils'

// @ts-expect-error virtual file
import { remoteComponentIslands } from '#build/nuxt.config.mjs'
import { remoteComponentIslands, selectiveClient } from '#build/nuxt.config.mjs'

const pKey = '_islandPromises'
const SSR_UID_RE = /nuxt-ssr-component-uid="([^"]*)"/
Expand All @@ -25,6 +27,31 @@ const SLOT_FALLBACK_RE = /<div nuxt-slot-fallback-start="([^"]*)"[^>]*><\/div>((
let id = 0
const getId = import.meta.client ? () => (id++).toString() : randomUUID

const components = import.meta.client ? new Map<string, Component>() : undefined

async function loadComponents (source = '/', paths: Record<string, string>) {
const promises = []

for (const component in paths) {
if (!(components!.has(component))) {
promises.push((async () => {
const chunkSource = join(source, paths[component])
const c = await import(/* @vite-ignore */ chunkSource).then(m => m.default || m)
components!.set(component, c)
})())
}
}
await Promise.all(promises)
}

function emptyPayload () {
return {
chunks: {},
props: {},
teleports: {}
}
}

export default defineComponent({
name: 'NuxtIsland',
props: {
Expand All @@ -44,16 +71,23 @@ export default defineComponent({
source: {
type: String,
default: () => undefined
},
dangerouslyLoadClientComponents: {
type: Boolean,
default: false
}
},
async setup (props, { slots, expose }) {
const key = ref(0)
const canLoadClientComponent = computed(() => selectiveClient && (props.dangerouslyLoadClientComponents || !props.source))
const error = ref<unknown>(null)
const config = useRuntimeConfig()
const nuxtApp = useNuxtApp()
const filteredProps = computed(() => props.props ? Object.fromEntries(Object.entries(props.props).filter(([key]) => !key.startsWith('data-v-'))) : {})
const hashId = computed(() => hash([props.name, filteredProps.value, props.context, props.source]))
const instance = getCurrentInstance()!
const event = useRequestEvent()

// TODO: remove use of `$fetch.raw` when nitro 503 issues on windows dev server are resolved
const eventFetch = import.meta.server ? event.fetch : import.meta.dev ? $fetch.raw : globalThis.fetch
const mounted = ref(false)
Expand All @@ -65,50 +99,69 @@ export default defineComponent({
key,
...(import.meta.server && import.meta.prerender)
? {}
: { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } }
: { params: { ...props.context, props: props.props ? JSON.stringify(props.props) : undefined } },
result: {
chunks: result.chunks,
props: result.props,
teleports: result.teleports
}
},
...result
}
}
// needs to be non-reactive because we don't want to trigger re-renders
// at hydration, we only retrieve props/chunks/teleports from payload. See the reviver at nuxt\src\app\plugins\revive-payload.client.ts
// If not hydrating, fetchComponent() will set it
const rawPayload = nuxtApp.isHydrating ? toRaw(nuxtApp.payload.data)?.[`${props.name}_${hashId.value}`] ?? emptyPayload() : emptyPayload()

const nonReactivePayload: Pick<NuxtIslandResponse, 'chunks'| 'props' | 'teleports'> = {
chunks: rawPayload.chunks,
props: rawPayload.props,
teleports: rawPayload.teleports
}

const ssrHTML = ref<string>('')

if (import.meta.client) {
const renderedHTML = getFragmentHTML(instance.vnode?.el ?? null)?.join('') ?? ''
if (renderedHTML && nuxtApp.isHydrating) {
setPayload(`${props.name}_${hashId.value}`, {
html: getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') ?? '',
state: {},
head: {
link: [],
style: []
}
})
}
ssrHTML.value = renderedHTML
ssrHTML.value = getFragmentHTML(instance.vnode?.el ?? null, true)?.join('') || ''
}

const slotProps = computed(() => getSlotProps(ssrHTML.value))
const uid = ref<string>(ssrHTML.value.match(SSR_UID_RE)?.[1] ?? getId())
const availableSlots = computed(() => [...ssrHTML.value.matchAll(SLOTNAME_RE)].map(m => m[1]))

const html = computed(() => {
const currentSlots = Object.keys(slots)
return ssrHTML.value.replace(SLOT_FALLBACK_RE, (full, slotName, content) => {
let html = ssrHTML.value

if (import.meta.client && !canLoadClientComponent.value) {
for (const [key, value] of Object.entries(nonReactivePayload.teleports || {})) {
html = html.replace(new RegExp(`<div [^>]*nuxt-ssr-client="${key}"[^>]*>`), (full) => {
return full + value
})
}
}

return html.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] ?? getId() as string
}

const cHead = ref<Record<'link' | 'style', Array<Record<string, string>>>>({ link: [], style: [] })
useHead(cHead)

async function _fetchComponent (force = false) {
const key = `${props.name}_${hashId.value}`
if (nuxtApp.payload.data[key] && !force) { return nuxtApp.payload.data[key] }

if (nuxtApp.payload.data[key]?.html && !force) { return nuxtApp.payload.data[key] }

const url = remoteComponentIslands && props.source ? new URL(`/__nuxt_island/${key}.json`, props.source).href : `/__nuxt_island/${key}.json`

Expand All @@ -133,7 +186,7 @@ export default defineComponent({
setPayload(key, result)
return result
}
const key = ref(0)

async function fetchComponent (force = false) {
nuxtApp[pKey] = nuxtApp[pKey] || {}
if (!nuxtApp[pKey][uid.value]) {
Expand All @@ -150,6 +203,16 @@ export default defineComponent({
})
key.value++
error.value = null

if (selectiveClient && import.meta.client) {
if (canLoadClientComponent.value && res.chunks) {
await loadComponents(props.source, res.chunks)
}
nonReactivePayload.props = res.props
}
nonReactivePayload.teleports = res.teleports
nonReactivePayload.chunks = res.chunks

if (import.meta.client) {
// must await next tick for Teleport to work correctly with static node re-rendering
await nextTick()
Expand Down Expand Up @@ -178,23 +241,44 @@ export default defineComponent({
fetchComponent()
} else if (import.meta.server || !nuxtApp.isHydrating || !nuxtApp.payload.serverRendered) {
await fetchComponent()
} else if (selectiveClient && canLoadClientComponent.value && nonReactivePayload.chunks) {
await loadComponents(props.source, nonReactivePayload.chunks)
}

return () => {
if ((!html.value || error.value) && slots.fallback) {
return [slots.fallback({ error: error.value })]
if (!html.value || error.value) {
return [slots.fallback?.({ error: error.value }) ?? createVNode('div')]
}
const nodes = [createVNode(Fragment, {
key: key.value
}, [h(createStaticVNode(html.value || '<div></div>', 1))])]
if (uid.value && (mounted.value || nuxtApp.isHydrating || import.meta.server)) {

if (uid.value && (mounted.value || nuxtApp.isHydrating || import.meta.server) && html.value) {
for (const slot in slots) {
if (availableSlots.value.includes(slot)) {
nodes.push(createVNode(Teleport, { to: import.meta.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))
}))
}
}
if (import.meta.server) {
for (const [id, html] of Object.entries(nonReactivePayload.teleports ?? {})) {
nodes.push(createVNode(Teleport, { to: `uid=${uid.value};client=${id}` }, {
default: () => [createStaticVNode(html, 1)]
}))
}
}
if (selectiveClient && import.meta.client && canLoadClientComponent.value) {
for (const [id, props] of Object.entries(nonReactivePayload.props ?? {})) {
const component = components!.get(id.split('-')[0])!
const vnode = createVNode(Teleport, { to: `[nuxt-ssr-component-uid='${uid.value}'] [nuxt-ssr-client="${id}"]` }, {
default: () => {
return [h(component, props)]
}
})
nodes.push(vnode)
}
}
}
return nodes
}
Expand Down
62 changes: 62 additions & 0 deletions packages/nuxt/src/app/components/nuxt-teleport-ssr-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import type { Component, } from 'vue'
import { Teleport, defineComponent, h } from 'vue'
import { useNuxtApp } from '../nuxt'
// @ts-expect-error virtual file
import { paths } from '#build/components-chunk'

type ExtendedComponent = Component & {
__file: string,
__name: string
}

/**
* component only used with componentsIsland
* this teleport the component in SSR only if it needs to be hydrated on client
*/
export default defineComponent({
name: 'NuxtTeleportSsrClient',
props: {
to: {
type: String,
required: true
},
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 }) {
if (!props.nuxtClient) { return () => slots.default!() }

const app = useNuxtApp()
const islandContext = app.ssrContext!.islandContext!

return () => {
const slot = slots.default!()[0]
const slotType = (slot.type as ExtendedComponent)
const name = (slotType.__name || slotType.name) as string

if (import.meta.dev) {
const path = '_nuxt/' + paths[name]
islandContext.chunks[name] = path
} else {
islandContext.chunks[name] = paths[name]
}

islandContext.propsData[props.to] = slot.props || {}

return [h('div', {
style: 'display: contents;',
'nuxt-ssr-client': props.to
}, []), h(Teleport, { to: props.to }, slot)]
}
}
})
12 changes: 10 additions & 2 deletions packages/nuxt/src/app/plugins/revive-payload.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ const revivers: Record<string, (data: any) => any> = {
}

if (componentIslands) {
revivers.Island = ({ key, params }: any) => {
revivers.Island = ({ key, params, result }: any) => {
const nuxtApp = useNuxtApp()
if (!nuxtApp.isHydrating) {
nuxtApp.payload.data[key] = nuxtApp.payload.data[key] || $fetch(`/__nuxt_island/${key}.json`, {
Expand All @@ -29,7 +29,15 @@ if (componentIslands) {
return r
})
}
return null
return {
html: '',
state: {},
head: {
link: [],
style: []
},
...result
}
}
}

Expand Down