Skip to content

Commit 4fa2e68

Browse files
authored
Merge pull request #310 from gadget-inc/ssr-styles-sync
Call renderToString before running head hooks so that ssr styles are generated
2 parents 88a4ce2 + 45926ab commit 4fa2e68

File tree

7 files changed

+187
-21
lines changed

7 files changed

+187
-21
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ You can optionally provide configuration options to the plugin:
4949
- `heads` - Function that will return html tags to be appended to the document head tag
5050
- `tails` - Function that will return html tags to be appended to the document body tag
5151
- `transform` - Function that will be run to transform the root react element
52+
- `postRenderHeads` - Function (called after render) that will return html tags to be appended to the document head tag. Useful when injecting styles that rely on rendering first.
5253

5354

5455
## Setting up a route

packages/fastify-renderer/src/node/Plugin.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import { ReactRenderer, ReactRendererOptions } from './renderers/react/ReactRend
99
import { RenderableRoute, Renderer } from './renderers/Renderer'
1010
import './types' // necessary to make sure that the fastify types are augmented
1111
import { FastifyRendererHook, ServerEntrypointManifest, ViteClientManifest } from './types'
12-
import { unthunk } from './utils'
1312

1413
export interface FastifyRendererOptions {
1514
renderer?: ReactRendererOptions
@@ -31,7 +30,7 @@ export class FastifyRendererPlugin {
3130
clientOutDir: string
3231
serverOutDir: string
3332
assetsHost: string
34-
hooks: FastifyRendererHook[]
33+
hooks: (FastifyRendererHook | (() => FastifyRendererHook))[]
3534
clientManifest?: ViteClientManifest
3635
serverEntrypointManifest?: ServerEntrypointManifest
3736
routes: RenderableRoute[] = []
@@ -43,7 +42,7 @@ export class FastifyRendererPlugin {
4342
this.vite.base ??= '/.vite/'
4443
this.viteBase = this.vite.base
4544
this.assetsHost = incomingOptions.assetsHost || ''
46-
this.hooks = (incomingOptions.hooks || []).map(unthunk)
45+
this.hooks = incomingOptions.hooks || []
4746

4847
const outDir = incomingOptions.outDir || path.join(process.cwd(), 'dist')
4948
this.clientOutDir = path.join(outDir, 'client', this.viteBase)

packages/fastify-renderer/src/node/renderers/react/ReactRenderer.tsx

Lines changed: 40 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ import { normalizePath } from 'vite/dist/node'
77
import { FastifyRendererPlugin } from '../../Plugin'
88
import { RenderBus } from '../../RenderBus'
99
import { wrap } from '../../tracing'
10-
import { mapFilepathToEntrypointName } from '../../utils'
10+
import { FastifyRendererHook } from '../../types'
11+
import { mapFilepathToEntrypointName, unthunk } from '../../utils'
1112
import { Render, RenderableRoute, Renderer } from '../Renderer'
1213

1314
const CLIENT_ENTRYPOINT_PREFIX = '/@fstr!entrypoint:'
@@ -79,9 +80,11 @@ export class ReactRenderer implements Renderer {
7980
/** Renders a given request and sends the resulting HTML document out with the `reply`. */
8081
private wrappedRender = wrap('fastify-renderer.render', async <Props,>(render: Render<Props>): Promise<void> => {
8182
const bus = this.startRenderBus(render)
83+
const hooks = this.plugin.hooks.map(unthunk)
8284

8385
try {
8486
const url = this.entrypointRequirePathForServer(render)
87+
8588
// we load all the context needed for this render from one `loadModule` call, which is really important for keeping the same copy of React around in all of the different bits that touch it.
8689
const { React, ReactDOMServer, Router, RenderBusContext, Layout, Entrypoint } = (await this.loadModule(url))
8790
.default
@@ -96,16 +99,16 @@ export class ReactRenderer implements Renderer {
9699
</RenderBusContext.Provider>
97100
)
98101

99-
for (const hook of this.plugin.hooks) {
102+
for (const hook of hooks) {
100103
if (hook.transform) {
101104
app = hook.transform(app)
102105
}
103106
}
104107

105108
if (this.options.mode == 'streaming') {
106-
await render.reply.send(this.renderStreamingTemplate(app, bus, ReactDOMServer, render))
109+
await render.reply.send(this.renderStreamingTemplate(app, bus, ReactDOMServer, render, hooks))
107110
} else {
108-
await render.reply.send(this.renderSynchronousTemplate(app, bus, ReactDOMServer, render))
111+
await render.reply.send(this.renderSynchronousTemplate(app, bus, ReactDOMServer, render, hooks))
109112
}
110113
} catch (error: unknown) {
111114
this.devServer?.ssrFixStacktrace(error as Error)
@@ -202,10 +205,21 @@ export class ReactRenderer implements Renderer {
202205
return bus
203206
}
204207

205-
private renderStreamingTemplate<Props>(app: JSX.Element, bus: RenderBus, ReactDOMServer: any, render: Render<Props>) {
206-
this.runHeadHooks(bus)
208+
private renderStreamingTemplate<Props>(
209+
app: JSX.Element,
210+
bus: RenderBus,
211+
ReactDOMServer: any,
212+
render: Render<Props>,
213+
hooks: FastifyRendererHook[]
214+
) {
215+
this.runHeadHooks(bus, hooks)
216+
// There are not postRenderHead hooks for streaming templates
217+
// so let's end the head stack
218+
bus.push('head', null)
207219
const contentStream = ReactDOMServer.renderToNodeStream(app)
208-
contentStream.on('end', () => this.runTailHooks(bus))
220+
contentStream.on('end', () => {
221+
this.runTailHooks(bus, hooks)
222+
})
209223

210224
return render.document({
211225
content: contentStream,
@@ -219,11 +233,13 @@ export class ReactRenderer implements Renderer {
219233
app: JSX.Element,
220234
bus: RenderBus,
221235
ReactDOMServer: any,
222-
render: Render<Props>
236+
render: Render<Props>,
237+
hooks: FastifyRendererHook[]
223238
) {
224-
this.runHeadHooks(bus)
239+
this.runHeadHooks(bus, hooks)
225240
const content = ReactDOMServer.renderToString(app)
226-
this.runTailHooks(bus)
241+
this.runPostRenderHeadHooks(bus, hooks)
242+
this.runTailHooks(bus, hooks)
227243

228244
return render.document({
229245
content,
@@ -233,19 +249,28 @@ export class ReactRenderer implements Renderer {
233249
})
234250
}
235251

236-
private runHeadHooks(bus: RenderBus) {
252+
private runPostRenderHeadHooks(bus: RenderBus, hooks: FastifyRendererHook[]) {
253+
// Run any heads hooks that might want to push something after the content
254+
for (const hook of hooks) {
255+
if (hook.postRenderHeads) {
256+
bus.push('head', hook.postRenderHeads())
257+
}
258+
}
259+
bus.push('head', null)
260+
}
261+
262+
private runHeadHooks(bus: RenderBus, hooks: FastifyRendererHook[]) {
237263
// Run any heads hooks that might want to push something before the content
238-
for (const hook of this.plugin.hooks) {
264+
for (const hook of hooks) {
239265
if (hook.heads) {
240266
bus.push('head', hook.heads())
241267
}
242268
}
243-
bus.push('head', null)
244269
}
245270

246-
private runTailHooks(bus: RenderBus) {
271+
private runTailHooks(bus: RenderBus, hooks: FastifyRendererHook[]) {
247272
// when we're done rendering the content, run any hooks that might want to push more stuff after the content
248-
for (const hook of this.plugin.hooks) {
273+
for (const hook of hooks) {
249274
if (hook.tails) {
250275
bus.push('tail', hook.tails())
251276
}

packages/fastify-renderer/src/node/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ export interface FastifyRendererHook {
2525
tails?: () => string
2626
heads?: () => string
2727
transform?: (app: ReactElement) => ReactElement
28+
postRenderHeads?: () => string
2829
}
2930

3031
export interface ViteClientManifest {

packages/fastify-renderer/test/helpers.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import fastify, { FastifyServerOptions } from 'fastify'
22
import fastifyAccepts from 'fastify-accepts'
33
import Middie from 'middie'
4+
import path from 'path'
5+
import { Readable } from 'stream'
46
import { FastifyRendererOptions, FastifyRendererPlugin } from '../src/node/Plugin'
57
import { RenderBus } from '../src/node/RenderBus'
68
import { ReactRenderer, ReactRendererOptions } from '../src/node/renderers/react/ReactRenderer'
9+
import { Render } from '../src/node/renderers/Renderer'
710

811
const logLevel = process.env.LOG_LEVEL || 'error'
912

@@ -26,3 +29,22 @@ export const newReactRenderer = (options?: ReactRendererOptions): ReactRenderer
2629
const plugin = newFastifyRendererPlugin({ renderer: options })
2730
return plugin.renderer as ReactRenderer
2831
}
32+
33+
export const getMockRender = <T>(props: T): Render<T> => {
34+
return {
35+
props,
36+
renderable: path.resolve(__dirname, 'fixtures', 'test-module.tsx'),
37+
url: 'test-url',
38+
layout: path.resolve(__dirname, 'fixtures', 'test-layout.tsx'),
39+
base: '',
40+
document: (data) => Readable.from(''),
41+
request: {
42+
url: 'test-url',
43+
} as any,
44+
reply: {
45+
send: (payload: unknown) => {
46+
throw new Error('Send is not implemented')
47+
},
48+
} as any,
49+
}
50+
}

packages/fastify-renderer/test/renderers/ReactRenderer.spec.ts

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,10 @@
1-
// import { ReactRenderer } from "../../src/node/renderers/react/ReactRenderer";
1+
import path from 'path'
2+
import React from 'react'
23
import { DefaultDocumentTemplate } from '../../src/node/DocumentTemplate'
34
import { RenderableRoute } from '../../src/node/renderers/Renderer'
4-
import { newReactRenderer } from '../helpers'
5+
import { getMockRender, newReactRenderer, newRenderBus } from '../helpers'
6+
7+
const testLayoutComponent = require.resolve(path.join(__dirname, '..', 'fixtures', 'test-layout.tsx'))
58

69
describe('ReactRenderer', () => {
710
test('should create an instance and initialize the client module path', async () => {
@@ -45,6 +48,37 @@ describe('ReactRenderer', () => {
4548
test.skip('should throw on rendering failure', async () => {
4649
return
4750
})
51+
52+
test('should call postRenderHooks after dom render', async () => {
53+
const renderer = newReactRenderer()
54+
const callOrder: string[] = []
55+
56+
renderer['renderSynchronousTemplate'](
57+
React.createElement(testLayoutComponent, {}),
58+
newRenderBus(),
59+
{
60+
renderToString: () => {
61+
callOrder.push('render')
62+
return 'test'
63+
},
64+
},
65+
getMockRender({}),
66+
[
67+
{
68+
heads: () => {
69+
callOrder.push('heads')
70+
return 'heads'
71+
},
72+
postRenderHeads: () => {
73+
callOrder.push('postRenderHeads')
74+
return 'postRenderHeads'
75+
},
76+
},
77+
]
78+
)
79+
80+
expect(callOrder).toEqual(['heads', 'render', 'postRenderHeads'])
81+
})
4882
})
4983

5084
describe('buildVirtualClientEntrypointModuleID()', () => {

packages/fastify-renderer/test/routes.spec.ts

Lines changed: 85 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,48 @@
11
import path from 'path'
22
import FastifyRenderer from '../src/node'
3+
import { unthunk } from '../src/node/utils'
34
import { newFastify } from './helpers'
45

56
const testComponent = require.resolve(path.join(__dirname, 'fixtures', 'test-module.tsx'))
67
const testLayoutComponent = require.resolve(path.join(__dirname, 'fixtures', 'test-layout.tsx'))
7-
const options = { vite: { root: __dirname, logLevel: (process.env.LOG_LEVEL ?? 'info') as any } }
8+
let thunkId = 0
9+
10+
const options = {
11+
vite: { root: __dirname, logLevel: (process.env.LOG_LEVEL ?? 'info') as any },
12+
devMode: true,
13+
renderer: {
14+
mode: 'sync' as const,
15+
type: 'react' as const,
16+
},
17+
hooks: [
18+
{
19+
heads: () => {
20+
return 'head'
21+
},
22+
transform: (app) => {
23+
return app
24+
},
25+
postRenderHeads: () => {
26+
return 'postRenderHead'
27+
},
28+
},
29+
() => {
30+
const id = thunkId++
31+
32+
return {
33+
heads: () => {
34+
return `<style>#${id} {}</style>`
35+
},
36+
transform: (app) => {
37+
return app
38+
},
39+
postRenderHeads: () => {
40+
return ''
41+
},
42+
}
43+
},
44+
],
45+
}
846

947
describe('FastifyRenderer', () => {
1048
let server
@@ -35,6 +73,10 @@ describe('FastifyRenderer', () => {
3573
await server.ready()
3674
})
3775

76+
beforeEach(() => {
77+
thunkId = 0
78+
})
79+
3880
test('should return the route props if content-type is application/json', async () => {
3981
const response = await server.inject({
4082
method: 'GET',
@@ -73,4 +115,46 @@ describe('FastifyRenderer', () => {
73115
expect(response.statusCode).toEqual(201)
74116
expect(response.body).toEqual('hello')
75117
})
118+
119+
test('should call hooks in correct order', async () => {
120+
const callOrder: string[] = []
121+
const hook = unthunk(options.hooks[0])
122+
jest.spyOn(hook, 'heads').mockImplementation(() => {
123+
callOrder.push('heads')
124+
return 'head'
125+
})
126+
jest.spyOn(hook, 'transform').mockImplementation((app) => {
127+
callOrder.push('transforms')
128+
return app
129+
})
130+
jest.spyOn(hook, 'postRenderHeads').mockImplementation(() => {
131+
callOrder.push('postRenders')
132+
return 'postRender'
133+
})
134+
135+
await server.inject({
136+
method: 'GET',
137+
url: '/render-test',
138+
headers: { Accept: 'text/html' },
139+
})
140+
141+
expect(callOrder).toEqual(['transforms', 'heads', 'postRenders'])
142+
})
143+
144+
test('should unthunk hooks on every render', async () => {
145+
const firstResponse = await server.inject({
146+
method: 'GET',
147+
url: '/render-test',
148+
headers: { Accept: 'text/html' },
149+
})
150+
151+
const secondResponse = await server.inject({
152+
method: 'GET',
153+
url: '/render-test',
154+
headers: { Accept: 'text/html' },
155+
})
156+
157+
expect(firstResponse.body).toMatch('<style>#0 {}</style>')
158+
expect(secondResponse.body).toMatch('<style>#1 {}</style>')
159+
})
76160
})

0 commit comments

Comments
 (0)