Skip to content

Commit 8e9990d

Browse files
committed
Render pending async props when server rendering
The wrapper a view's pending props go through was chosen by reading the instance's suspense property, which is client only and always false while server rendering. The server therefore always got the wrapper that fills a ref from a promise handler, which the server renderer never awaits, so a view with an async props getter rendered nothing. Choose the awaiting wrapper when vue's ssr context is present instead. The server renderer awaits async setup whether or not a Suspense boundary exists, so this needs nothing from the app.
1 parent 5d2f92f commit 8e9990d

7 files changed

Lines changed: 146 additions & 9 deletions

File tree

‎src/components/routerView.spec.ts‎

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,14 @@
1-
import { createSSRApp } from 'vue'
1+
/* eslint-disable vue/one-component-per-file */
2+
import { createSSRApp, defineComponent, h } from 'vue'
23
import { describe, it, expect } from 'vitest'
34
import { createRoute } from '@/services/createRoute'
45
import { createRouter } from '@/services/createRouter'
56
import { renderToString } from 'vue/server-renderer'
67

8+
const sleep = (ms: number): Promise<void> => new Promise((resolve) => {
9+
setTimeout(resolve, ms)
10+
})
11+
712
describe('SSR', () => {
813
it('should render the route', async () => {
914
const route = createRoute({
@@ -26,4 +31,63 @@ describe('SSR', () => {
2631

2732
expect(html).toMatchInlineSnapshot('"hello world"')
2833
})
34+
35+
it('renders pending async props without Suspense and without render', async () => {
36+
const view = defineComponent({
37+
props: { name: { type: String, required: true } },
38+
setup: (props) => () => h('div', `hello ${props.name}`),
39+
})
40+
41+
const route = createRoute({ name: 'user', path: '/' })
42+
.addView(view, {
43+
props: async () => {
44+
await sleep(20)
45+
46+
return { name: 'craig' }
47+
},
48+
})
49+
50+
const router = createRouter([route], { initialUrl: '/' })
51+
52+
const app = createSSRApp({
53+
template: '<RouterView/>',
54+
})
55+
56+
app.use(router)
57+
58+
await router.start()
59+
60+
const html = await renderToString(app)
61+
62+
expect(html).toBe('<div>hello craig</div>')
63+
})
64+
65+
it('renders pending loader data without Suspense and without render', async () => {
66+
const view = defineComponent({
67+
props: { name: { type: String, required: true } },
68+
setup: (props) => () => h('div', `loaded ${props.name}`),
69+
})
70+
71+
const route = createRoute({ name: 'user', path: '/' })
72+
.addLoader(async () => {
73+
await sleep(20)
74+
75+
return 'craig'
76+
})
77+
.addView(view, { props: async (route) => ({ name: await route.data }) })
78+
79+
const router = createRouter([route], { initialUrl: '/' })
80+
81+
const app = createSSRApp({
82+
template: '<RouterView/>',
83+
})
84+
85+
app.use(router)
86+
87+
await router.start()
88+
89+
const html = await renderToString(app)
90+
91+
expect(html).toBe('<div>loaded craig</div>')
92+
})
2993
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { getCurrentInstance } from 'vue'
2+
3+
/**
4+
* Must be called during setup. Vue has no public api for this: the public `VNode.suspense` is not the
5+
* enclosing boundary and reads false inside one, and a boundary never appears in the `parent` chain.
6+
*/
7+
export function useHasSuspenseBoundary(): boolean {
8+
const instance = getCurrentInstance()
9+
10+
// @ts-expect-error suspense is not on the public ComponentInternalInstance type
11+
return Boolean(instance?.suspense)
12+
}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { createSSRApp, defineComponent, h } from 'vue'
2+
import { expect, test } from 'vitest'
3+
import { mount } from '@vue/test-utils'
4+
import { useIsServerRendering } from '@/compositions/useIsServerRendering'
5+
6+
const component = defineComponent({
7+
setup() {
8+
const isServerRendering = useIsServerRendering()
9+
10+
return () => h('div', String(isServerRendering))
11+
},
12+
})
13+
14+
test('is false on the client', () => {
15+
const wrapper = mount({ render: () => h(component) })
16+
17+
expect(wrapper.text()).toBe('false')
18+
})
19+
20+
test('is false while hydrating through createSSRApp', () => {
21+
const element = document.createElement('div')
22+
23+
element.innerHTML = '<div>true</div>'
24+
document.body.appendChild(element)
25+
26+
createSSRApp({ render: () => h(component) }).mount(element)
27+
28+
expect(element.textContent).toBe('false')
29+
})
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { createSSRApp, defineComponent, h } from 'vue'
2+
import { expect, test } from 'vitest'
3+
import { renderToString } from 'vue/server-renderer'
4+
import { useIsServerRendering } from '@/compositions/useIsServerRendering'
5+
6+
test('is true while server rendering', async () => {
7+
const component = defineComponent({
8+
setup() {
9+
const isServerRendering = useIsServerRendering()
10+
11+
return () => h('div', String(isServerRendering))
12+
},
13+
})
14+
15+
const html = await renderToString(createSSRApp({ render: () => h(component) }))
16+
17+
expect(html).toBe('<div>true</div>')
18+
})
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { inject, InjectionKey, ssrContextKey } from 'vue'
2+
3+
// vue exports this as a bare symbol, so it needs typing for inject to report whether it is there
4+
const SSR_CONTEXT_KEY = ssrContextKey as InjectionKey<Record<string, unknown>>
5+
6+
/**
7+
* True under `renderToString`, false on the client including while hydrating through `createSSRApp`.
8+
* Must be called during setup.
9+
*/
10+
export function useIsServerRendering(): boolean {
11+
return inject(SSR_CONTEXT_KEY, null) !== null
12+
}

‎src/services/component.browser.spec.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import { expect, test } from 'vitest'
33
import echo from '@/components/echo'
44
import { createRoute } from '@/services/createRoute'
55
import { createRouter } from '@/services/createRouter'
6-
import { h, defineComponent, getCurrentInstance } from 'vue'
6+
import { h, defineComponent } from 'vue'
7+
import { useHasSuspenseBoundary } from '@/compositions/useHasSuspenseBoundary'
78

89
test('renders component with sync props', async () => {
910
const route = createRoute({
@@ -64,11 +65,10 @@ test('renders component with async props', async () => {
6465
expect(wrapper.html()).toBe('echo')
6566
})
6667

67-
test('component instance has suspense property when suspense is used', async () => {
68+
test('useHasSuspenseBoundary reports whether suspense is used', async () => {
6869
const testComponent = defineComponent({
6970
setup() {
70-
// @ts-expect-error there isn't a way to check if suspense is used in the component without accessing a private property
71-
const hasSuspense = Boolean(getCurrentInstance()?.suspense)
71+
const hasSuspense = useHasSuspenseBoundary()
7272

7373
return () => h('span', hasSuspense)
7474
},

‎src/services/component.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
/* eslint-disable vue/require-prop-types */
22
/* eslint-disable vue/one-component-per-file */
3-
import { AsyncComponentLoader, Component, FunctionalComponent, InjectionKey, defineComponent, getCurrentInstance, h, ref, watch } from 'vue'
3+
import { AsyncComponentLoader, Component, FunctionalComponent, InjectionKey, defineComponent, h, ref, watch } from 'vue'
44
import { isPromise } from '@/utilities/promises'
55
import { PropsResult } from '@/utilities/props'
66
import { createUseRouteValueStore } from '@/compositions/useRouteValueStore'
77
import { Router } from '@/types/router'
88
import { createUseRoute } from '@/compositions/useRoute'
9+
import { useIsServerRendering } from '@/compositions/useIsServerRendering'
10+
import { useHasSuspenseBoundary } from '@/compositions/useHasSuspenseBoundary'
911

1012
type Constructor = new (...args: any) => any
1113

@@ -31,16 +33,16 @@ export function createComponentPropsWrapper(routerKey: InjectionKey<Router>, { i
3133
name: 'PropsWrapper',
3234
expose: [],
3335
setup() {
34-
const instance = getCurrentInstance()
3536
const store = useRouteValueStore()
3637
const route = useRoute()
38+
const isServerRendering = useIsServerRendering()
39+
const hasSuspenseBoundary = useHasSuspenseBoundary()
3740

3841
return () => {
3942
const result = store.getProps(id, name, route)
4043

4144
if (isPromise(result)) {
42-
// @ts-expect-error there isn't a way to check if suspense is used in the component without accessing a private property
43-
if (instance?.suspense) {
45+
if (isServerRendering || hasSuspenseBoundary) {
4446
return h(SuspenseAsyncComponentPropsWrapper, { component, props: result })
4547
}
4648

0 commit comments

Comments
 (0)