fix(nuxt): proxy headers to islands + returned prerender hints (#21740)

This commit is contained in:
Daniel Roe 2023-06-25 17:38:15 +01:00 committed by GitHub
parent 5b7f52870c
commit 88bc32d42a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 74 additions and 47 deletions

View File

@ -4,6 +4,8 @@ import { hash } from 'ohash'
import { appendResponseHeader } from 'h3' import { appendResponseHeader } from 'h3'
import { useHead } from '@unhead/vue' import { useHead } from '@unhead/vue'
import { randomUUID } from 'uncrypto' import { randomUUID } from 'uncrypto'
import { withQuery } from 'ufo'
// 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'
@ -40,6 +42,7 @@ export default defineComponent({
const hashId = computed(() => hash([props.name, props.props, props.context])) const hashId = computed(() => hash([props.name, props.props, props.context]))
const instance = getCurrentInstance()! const instance = getCurrentInstance()!
const event = useRequestEvent() const event = useRequestEvent()
const eventFetch = process.server ? event.fetch : globalThis.fetch
const mounted = ref(false) const mounted = ref(false)
onMounted(() => { mounted.value = true }) onMounted(() => { mounted.value = true })
@ -78,13 +81,18 @@ export default defineComponent({
appendResponseHeader(event, 'x-nitro-prerender', url) appendResponseHeader(event, 'x-nitro-prerender', url)
} }
// TODO: Validate response // TODO: Validate response
const result = await $fetch<NuxtIslandResponse>(url, { const r = await eventFetch(withQuery(url, {
responseType: 'json', ...props.context,
params: { props: props.props ? JSON.stringify(props.props) : undefined
...props.context, }))
props: props.props ? JSON.stringify(props.props) : undefined const result = await r.json() as NuxtIslandResponse
// TODO: support passing on more headers
if (process.server && process.env.prerender) {
const hints = r.headers.get('x-nitro-prerender')
if (hints) {
appendResponseHeader(event, 'x-nitro-prerender', hints)
} }
}) }
nuxtApp.payload.data[key] = { nuxtApp.payload.data[key] = {
__nuxt_island: { __nuxt_island: {
key, key,

View File

@ -367,7 +367,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
await nitroApp.hooks.callHook('render:island', islandResponse, { event, islandContext }) await nitroApp.hooks.callHook('render:island', islandResponse, { event, islandContext })
const response: RenderResponse = { const response = {
body: JSON.stringify(islandResponse, null, 2), body: JSON.stringify(islandResponse, null, 2),
statusCode: event.node.res.statusCode, statusCode: event.node.res.statusCode,
statusMessage: event.node.res.statusMessage, statusMessage: event.node.res.statusMessage,
@ -375,7 +375,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
'content-type': 'application/json;charset=utf-8', 'content-type': 'application/json;charset=utf-8',
'x-powered-by': 'Nuxt' 'x-powered-by': 'Nuxt'
} }
} } satisfies RenderResponse
if (process.env.prerender) { if (process.env.prerender) {
ISLAND_CACHE!.set(`/__nuxt_island/${islandContext!.name}_${islandContext!.id}`, response) ISLAND_CACHE!.set(`/__nuxt_island/${islandContext!.name}_${islandContext!.id}`, response)
} }
@ -383,7 +383,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
} }
// Construct HTML response // Construct HTML response
const response: RenderResponse = { const response = {
body: renderHTMLDocument(htmlContext), body: renderHTMLDocument(htmlContext),
statusCode: event.node.res.statusCode, statusCode: event.node.res.statusCode,
statusMessage: event.node.res.statusMessage, statusMessage: event.node.res.statusMessage,
@ -391,7 +391,7 @@ export default defineRenderHandler(async (event): Promise<Partial<RenderResponse
'content-type': 'text/html;charset=utf-8', 'content-type': 'text/html;charset=utf-8',
'x-powered-by': 'Nuxt' 'x-powered-by': 'Nuxt'
} }
} } satisfies RenderResponse
return response return response
}) })
@ -456,7 +456,7 @@ async function renderInlineStyles (usedModules: Set<string> | string[]) {
} }
function renderPayloadResponse (ssrContext: NuxtSSRContext) { function renderPayloadResponse (ssrContext: NuxtSSRContext) {
return <RenderResponse> { return {
body: process.env.NUXT_JSON_PAYLOADS body: process.env.NUXT_JSON_PAYLOADS
? stringify(splitPayload(ssrContext).payload, ssrContext._payloadReducers) ? stringify(splitPayload(ssrContext).payload, ssrContext._payloadReducers)
: `export default ${devalue(splitPayload(ssrContext).payload)}`, : `export default ${devalue(splitPayload(ssrContext).payload)}`,
@ -466,7 +466,7 @@ function renderPayloadResponse (ssrContext: NuxtSSRContext) {
'content-type': process.env.NUXT_JSON_PAYLOADS ? 'application/json;charset=utf-8' : 'text/javascript;charset=utf-8', 'content-type': process.env.NUXT_JSON_PAYLOADS ? 'application/json;charset=utf-8' : 'text/javascript;charset=utf-8',
'x-powered-by': 'Nuxt' 'x-powered-by': 'Nuxt'
} }
} } satisfies RenderResponse
} }
function renderPayloadJsonScript (opts: { id: string, ssrContext: NuxtSSRContext, data?: any, src?: string }) { function renderPayloadJsonScript (opts: { id: string, ssrContext: NuxtSSRContext, data?: any, src?: string }) {

View File

@ -1,9 +1,10 @@
import { readdir } from 'node:fs/promises'
import { fileURLToPath } from 'node:url' import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { joinURL, withQuery } from 'ufo' import { joinURL, withQuery } from 'ufo'
import { isCI, isWindows } from 'std-env' import { isCI, isWindows } from 'std-env'
import { normalize } from 'pathe' import { join, normalize } from 'pathe'
import { $fetch, createPage, fetch, isDev, setup, startServer, url } from '@nuxt/test-utils' import { $fetch, createPage, fetch, isDev, setup, startServer, url, useTestContext } from '@nuxt/test-utils'
import { $fetchComponent } from '@nuxt/test-utils/experimental' import { $fetchComponent } from '@nuxt/test-utils/experimental'
import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer' import type { NuxtIslandResponse } from '../packages/nuxt/src/core/runtime/nitro/renderer'
@ -418,39 +419,6 @@ describe('pages', () => {
await page.close() await page.close()
}) })
it('/islands', async () => {
const page = await createPage('/islands')
await page.waitForLoadState('networkidle')
await page.locator('#increase-pure-component').click()
await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200)
await page.waitForLoadState('networkidle')
expect(await page.locator('#slot-in-server').first().innerHTML()).toContain('Slot with in .server component')
expect(await page.locator('#test-slot').first().innerHTML()).toContain('Slot with name test')
// test fallback slot with v-for
expect(await page.locator('.fallback-slot-content').all()).toHaveLength(2)
// test islands update
expect(await page.locator('.box').innerHTML()).toContain('"number": 101,')
await page.locator('#update-server-components').click()
await Promise.all([
page.waitForResponse(response => response.url().includes('/__nuxt_island/LongAsyncComponent') && response.status() === 200),
page.waitForResponse(response => response.url().includes('/__nuxt_island/AsyncServerComponent') && response.status() === 200)
])
await page.waitForLoadState('networkidle')
expect(await page.locator('#async-server-component-count').innerHTML()).toContain(('1'))
expect(await page.locator('#long-async-component-count').innerHTML()).toContain('1')
// test islands slots interactivity
await page.locator('#first-sugar-counter button').click()
expect(await page.locator('#first-sugar-counter').innerHTML()).toContain('Sugar Counter 13')
// test islands mounted client side with slot
await page.locator('#show-island').click()
expect(await page.locator('#island-mounted-client-side').innerHTML()).toContain('Interactive testing slot post SSR')
await page.close()
})
it('/legacy-async-data-fail', async () => { it('/legacy-async-data-fail', async () => {
const response = await fetch('/legacy-async-data-fail').then(r => r.text()) const response = await fetch('/legacy-async-data-fail').then(r => r.text())
expect(response).not.toContain('don\'t look at this') expect(response).not.toContain('don\'t look at this')
@ -1246,6 +1214,51 @@ describe.skipIf(isDev() || isWebpack)('inlining component styles', () => {
}) })
}) })
describe('server components/islands', () => {
it('/islands', async () => {
const page = await createPage('/islands')
await page.waitForLoadState('networkidle')
await page.locator('#increase-pure-component').click()
await page.waitForResponse(response => response.url().includes('/__nuxt_island/') && response.status() === 200)
await page.waitForLoadState('networkidle')
expect(await page.locator('#slot-in-server').first().innerHTML()).toContain('Slot with in .server component')
expect(await page.locator('#test-slot').first().innerHTML()).toContain('Slot with name test')
// test fallback slot with v-for
expect(await page.locator('.fallback-slot-content').all()).toHaveLength(2)
// test islands update
expect(await page.locator('.box').innerHTML()).toContain('"number": 101,')
await page.locator('#update-server-components').click()
await Promise.all([
page.waitForResponse(response => response.url().includes('/__nuxt_island/LongAsyncComponent') && response.status() === 200),
page.waitForResponse(response => response.url().includes('/__nuxt_island/AsyncServerComponent') && response.status() === 200)
])
await page.waitForLoadState('networkidle')
expect(await page.locator('#async-server-component-count').innerHTML()).toContain(('1'))
expect(await page.locator('#long-async-component-count').innerHTML()).toContain('1')
// test islands slots interactivity
await page.locator('#first-sugar-counter button').click()
expect(await page.locator('#first-sugar-counter').innerHTML()).toContain('Sugar Counter 13')
// test islands mounted client side with slot
await page.locator('#show-island').click()
expect(await page.locator('#island-mounted-client-side').innerHTML()).toContain('Interactive testing slot post SSR')
await page.close()
})
it.skipIf(isDev)('should allow server-only components to set prerender hints', async () => {
// @ts-expect-error ssssh! untyped secret property
const publicDir = useTestContext().nuxt._nitro.options.output.publicDir
expect(await readdir(join(publicDir, 'some', 'url', 'from', 'server-only', 'component')).catch(() => [])).toContain(
isRenderingJson
? '_payload.json'
: '_payload.js'
)
})
})
describe.skipIf(isDev() || isWindows || !isRenderingJson)('prefetching', () => { describe.skipIf(isDev() || isWindows || !isRenderingJson)('prefetching', () => {
it('should prefetch components', async () => { it('should prefetch components', async () => {
await expectNoClientErrors('/prefetch/components') await expectNoClientErrors('/prefetch/components')

View File

@ -4,6 +4,12 @@
</div> </div>
</template> </template>
<script setup>
import { appendResponseHeader } from 'h3'
appendResponseHeader(useRequestEvent(), 'x-nitro-prerender', '/some/url/from/server-only/component')
</script>
<style> <style>
:root { :root {
--server-only: 'server-only'; --server-only: 'server-only';