Skip to content

Commit bddfc7c

Browse files
authored
Merge pull request #293 from gadget-inc/dont-render-on-redirect
Ensure we don't render if a hook or the render function already sends a reply
2 parents 6f30048 + f611908 commit bddfc7c

File tree

7 files changed

+143
-105
lines changed

7 files changed

+143
-105
lines changed

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

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -88,19 +88,21 @@ const FastifyRenderer = fp<FastifyRendererOptions>(
8888
routeOptions.handler = wrap('fastify-renderer.handler', async function (this: FastifyInstance, request, reply) {
8989
const props = await oldHandler.call(this, request, reply)
9090

91-
void reply.header('Vary', 'Accept')
92-
switch (request.accepts().type(['html', 'json'])) {
93-
case 'json':
94-
await reply.type('application/json').send({ props })
95-
break
96-
case 'html':
97-
void reply.type('text/html')
98-
const render: Render<typeof props> = { ...renderableRoute, request, reply, props, renderable }
99-
await plugin.renderer.render(render)
100-
break
101-
default:
102-
await reply.type('text/plain').send('Content type not supported')
103-
break
91+
if (!reply.sent) {
92+
void reply.header('Vary', 'Accept')
93+
switch (request.accepts().type(['html', 'json'])) {
94+
case 'json':
95+
await reply.type('application/json').send({ props })
96+
break
97+
case 'html':
98+
void reply.type('text/html')
99+
const render: Render<typeof props> = { ...renderableRoute, request, reply, props, renderable }
100+
await plugin.renderer.render(render)
101+
break
102+
default:
103+
await reply.type('text/plain').send('Content type not supported')
104+
break
105+
}
104106
}
105107
})
106108
}

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

Lines changed: 26 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -3,132 +3,93 @@ import path from 'path'
33
import * as Vite from 'vite'
44
import FastifyRenderer, { build } from '../src/node'
55
import { FastifyRendererPlugin } from '../src/node/Plugin'
6-
import { ReactRenderer } from '../src/node/renderers/react/ReactRenderer'
76
import { kRenderOptions } from '../src/node/symbols'
87
import { newFastify } from './helpers'
98

9+
const testComponent = require.resolve(path.join(__dirname, 'fixtures', 'test-module.tsx'))
10+
const testLayoutComponent = require.resolve(path.join(__dirname, 'fixtures', 'test-layout.tsx'))
11+
const options = { vite: { root: __dirname, logLevel: (process.env.LOG_LEVEL ?? 'info') as any } }
12+
1013
describe('FastifyRenderer', () => {
11-
beforeEach(() => {
14+
let server
15+
beforeEach(async () => {
1216
jest.clearAllMocks()
17+
18+
server = await newFastify()
19+
await server.register(FastifyRenderer, options)
20+
server.setRenderConfig({
21+
base: '',
22+
layout: testLayoutComponent,
23+
})
1324
})
1425

1526
test('should throw if the fastify-renderer plugin was already registered in the same fastify context', async () => {
16-
const server = await newFastify()
27+
server = await newFastify()
1728

18-
await server.register(FastifyRenderer)
29+
await server.register(FastifyRenderer, options)
1930
await expect(server.register(FastifyRenderer)).rejects.toThrow("The decorator 'vite' has already been added!")
2031
})
2132

2233
test('should throw if the fastify-renderer plugin was already registered in a different context', async () => {
23-
const server = await newFastify()
34+
server = await newFastify()
2435

25-
await server.register(FastifyRenderer)
36+
await server.register(FastifyRenderer, options)
2637
await expect(server.register(async (instance) => instance.register(FastifyRenderer))).rejects.toThrow()
2738
})
2839

2940
test('should decorate the fastify instance with a "setRenderConfig" method', async () => {
30-
const server = await newFastify()
31-
await server.register(FastifyRenderer)
41+
server = await newFastify()
42+
await server.register(FastifyRenderer, options)
3243
expect(server.setRenderConfig).toBeInstanceOf(Function)
44+
server.setRenderConfig({
45+
base: '/foo',
46+
})
3347
})
3448

3549
test('should set default render config for the instance', async () => {
36-
const server = await newFastify()
37-
38-
expect(server[kRenderOptions]).toBeUndefined()
39-
await server.register(FastifyRenderer)
4050
expect(server[kRenderOptions]).toBeInstanceOf(Object)
4151
})
4252

4353
test('should set the renderOptions on the new fastify instance context', async () => {
44-
const server = await newFastify()
45-
46-
await server.register(FastifyRenderer)
4754
await server.register(async (instance) => {
4855
expect(server[kRenderOptions]).toEqual(instance[kRenderOptions])
4956
})
5057
})
5158

5259
test('should mount vite routes at a prefix to avoid collision with user routes', async () => {
53-
const server = await newFastify()
54-
55-
await server.register(FastifyRenderer)
5660
expect(server.printRoutes()).toMatch('/.vite/')
5761
})
5862

59-
// test.skip('should use config from vite dev server in dev mode', async () => {
60-
// });
61-
6263
test('should close vite devServer when fastify server is closing in dev mode', async () => {
6364
const devServer = await Vite.createServer()
6465
const closeSpy = jest.spyOn(devServer, 'close')
6566
jest.spyOn(Vite, 'createServer').mockImplementation(async () => devServer)
6667

67-
const server = await newFastify()
68-
await server.register(FastifyRenderer, { devMode: true })
68+
server = await newFastify()
69+
await server.register(FastifyRenderer, { ...options, devMode: true })
6970
await server.listen(0)
7071
await server.close()
7172

7273
expect(closeSpy).toHaveBeenCalled()
7374
})
7475

7576
test('should do nothing if the registered route is not renderable', async () => {
76-
const server = await newFastify()
7777
const registerRouteSpy = jest.spyOn(FastifyRendererPlugin.prototype, 'registerRoute')
7878

79-
await server.register(FastifyRenderer)
8079
server.get('/', async (_request, reply) => reply.send('Hello'))
8180
await server.inject({ method: 'GET', url: '/' })
8281

8382
expect(registerRouteSpy).toHaveBeenCalledTimes(0)
8483
})
8584

8685
test("should register the route in the plugin if it's renderable", async () => {
87-
const server = await newFastify()
8886
const registerRouteSpy = jest.spyOn(FastifyRendererPlugin.prototype, 'registerRoute').mockImplementation(jest.fn())
8987

90-
await server.register(FastifyRenderer)
91-
server.get('/', { render: 'renderable-module-path' }, async (request, reply) => reply.send('Hello'))
88+
server.get('/', { render: testComponent }, async (request, reply) => reply.send('Hello'))
9289
await server.inject({ method: 'GET', url: '/' })
9390

9491
expect(registerRouteSpy).toHaveBeenCalledTimes(1)
9592
})
96-
97-
test('should return the route props if content-type is application/json', async () => {
98-
const server = await newFastify()
99-
jest.spyOn(FastifyRendererPlugin.prototype, 'registerRoute').mockImplementation(jest.fn())
100-
101-
await server.register(FastifyRenderer)
102-
server.get('/', { render: 'renderable-module-path' }, async (_request, _reply) => ({ a: 1, b: 2 }))
103-
const response = await server.inject({ method: 'GET', url: '/', headers: { Accept: 'application/json' } })
104-
105-
expect(response.body).toEqual(JSON.stringify({ props: { a: 1, b: 2 } }))
106-
})
107-
108-
test('should render and return html if content-type is text/html', async () => {
109-
const htmlContent = '<html><body>test content</body></html>'
110-
111-
jest.spyOn(ReactRenderer.prototype, 'render').mockImplementation(async (render) => render.reply.send(htmlContent))
112-
jest.spyOn(FastifyRendererPlugin.prototype, 'registerRoute').mockImplementation(jest.fn())
113-
114-
const server = await newFastify()
115-
await server.register(FastifyRenderer)
116-
server.get('/', { render: 'renderable-module-path' }, async (_request, _reply) => ({ a: 1, b: 2 }))
117-
const response = await server.inject({ method: 'GET', url: '/', headers: { Accept: 'text/html' } })
118-
119-
expect(response.body).toEqual(htmlContent)
120-
})
121-
122-
test('should set content-type to text/plain and return a message', async () => {
123-
const server = await newFastify()
124-
jest.spyOn(FastifyRendererPlugin.prototype, 'registerRoute').mockImplementation(jest.fn())
125-
126-
await server.register(FastifyRenderer)
127-
server.get('/', { render: 'renderable-module-path' }, async (_request, _reply) => ({ a: 1, b: 2 }))
128-
const response = await server.inject({ method: 'GET', url: '/', headers: { Accept: 'other' } })
129-
130-
expect(response.body).toEqual('Content type not supported')
131-
})
13293
})
13394

13495
describe('build()', () => {
@@ -139,7 +100,7 @@ describe('build()', () => {
139100

140101
test('should build client and server side assets', async () => {
141102
const server = await newFastify()
142-
await server.register(FastifyRenderer)
103+
await server.register(FastifyRenderer, options)
143104
await server.listen(0)
144105

145106
jest.spyOn(fs, 'writeFile').mockImplementation(jest.fn())
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import React, { Suspense } from 'react'
2+
import { LayoutProps } from '../../src/client/react'
3+
4+
// eslint-disable-next-line react/display-name
5+
export default function (props: LayoutProps) {
6+
return <Suspense fallback={'Loading'}>{props.children}</Suspense>
7+
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import React from 'react'
2+
3+
// eslint-disable-next-line react/display-name
4+
export default function (props: { a: string; b: number }) {
5+
if (typeof props.a == 'undefined') {
6+
throw new Error('expected to be passed props from render function')
7+
}
8+
return (
9+
<>
10+
<h1>{props.a}</h1>
11+
<p>{props.b}</p>
12+
</>
13+
)
14+
}

packages/fastify-renderer/test/helpers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { FastifyRendererOptions, FastifyRendererPlugin } from '../src/node/Plugi
55
import { RenderBus } from '../src/node/RenderBus'
66
import { ReactRenderer, ReactRendererOptions } from '../src/node/renderers/react/ReactRenderer'
77

8+
const logLevel = process.env.LOG_LEVEL || 'error'
9+
810
export const newFastify = async (options?: FastifyServerOptions) => {
9-
const server = fastify(options)
11+
const server = fastify({ ...options, logger: { level: logLevel, prettyPrint: true } })
1012
await server.register(fastifyAccepts)
1113
await server.register(Middie)
1214
return server
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import path from 'path'
2+
import FastifyRenderer from '../src/node'
3+
import { newFastify } from './helpers'
4+
5+
const testComponent = require.resolve(path.join(__dirname, 'fixtures', 'test-module.tsx'))
6+
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+
9+
describe('FastifyRenderer', () => {
10+
let server
11+
beforeAll(async () => {
12+
server = await newFastify()
13+
await server.register(FastifyRenderer, options)
14+
server.setRenderConfig({
15+
base: '',
16+
layout: testLayoutComponent,
17+
})
18+
19+
server.get('/plain', async (_request, reply) => reply.send('Hello'))
20+
server.get('/render-test', { render: testComponent }, async (_request, _reply) => ({ a: 1, b: 2 }))
21+
server.get(
22+
'/early-hook-reply',
23+
{
24+
preValidation: async (_request, reply) => {
25+
await reply.code(201).send('hello')
26+
},
27+
render: testComponent,
28+
},
29+
async (_request, _reply) => ({ a: 1, b: 2 })
30+
)
31+
server.get('/early-handler-reply', { render: testComponent }, async (request, reply) => {
32+
await reply.code(201).send('hello')
33+
return { a: 1, b: 2 }
34+
})
35+
await server.ready()
36+
})
37+
38+
test('should return the route props if content-type is application/json', async () => {
39+
const response = await server.inject({
40+
method: 'GET',
41+
url: '/render-test',
42+
headers: { Accept: 'application/json' },
43+
})
44+
45+
expect(response.body).toEqual(JSON.stringify({ props: { a: 1, b: 2 } }))
46+
})
47+
48+
test('should render and return html if content-type is text/html', async () => {
49+
const response = await server.inject({ method: 'GET', url: '/render-test', headers: { Accept: 'text/html' } })
50+
51+
expect(response.body).toMatch('<h1>1</h1><p>2</p>')
52+
})
53+
test('for unknown content types should set content-type to text/plain and return a message', async () => {
54+
const response = await server.inject({ method: 'GET', url: '/render-test', headers: { Accept: 'other' } })
55+
56+
expect(response.body).toEqual('Content type not supported')
57+
})
58+
59+
test('should not render anything if a hook replies before the render phase is reached', async () => {
60+
const response = await server.inject({ method: 'GET', url: '/early-hook-reply', headers: { Accept: 'text/html' } })
61+
62+
expect(response.statusCode).toEqual(201)
63+
expect(response.body).toEqual('hello')
64+
})
65+
66+
test('should not render anything if the handler replies before its end', async () => {
67+
const response = await server.inject({
68+
method: 'GET',
69+
url: '/early-handler-reply',
70+
headers: { Accept: 'text/html' },
71+
})
72+
73+
expect(response.statusCode).toEqual(201)
74+
expect(response.body).toEqual('hello')
75+
})
76+
})

yarn.lock

Lines changed: 2 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1356,21 +1356,14 @@
13561356
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.3.tgz#2ab0d5da2e5815f94b0b9d4b95d1e5f243ab2ca7"
13571357
integrity sha512-KfRL3PuHmqQLOG+2tGpRO26Ctg+Cq1E01D2DMriKEATHgWLfeNDmq9e29Q9WIky0dQ3NPkd1mzYH8Lm936Z9qw==
13581358

1359-
1359+
"@types/[email protected]", "@types/react-dom@^17.0.11":
13601360
version "17.0.4"
13611361
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.4.tgz#d65159a847aca2a0fc87a7544a2f8fece8754d04"
13621362
integrity sha512-Wb6rlnPJfqbhpkvYN39y1NM/pOGGPzzIRquu0RdUMvTwgXNvASFO7pdtrtvyxGTQNb9wzBaQxXAWDdEqegZw2A==
13631363
dependencies:
13641364
"@types/react" "*"
13651365

1366-
"@types/react-dom@^17.0.11":
1367-
version "17.0.11"
1368-
resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-17.0.11.tgz#e1eadc3c5e86bdb5f7684e00274ae228e7bcc466"
1369-
integrity sha512-f96K3k+24RaLGVu/Y2Ng3e1EbZ8/cVJvypZWd7cy0ofCBaf2lcM46xNhycMZ2xGwbBjRql7hOlZ+e2WlJ5MH3Q==
1370-
dependencies:
1371-
"@types/react" "*"
1372-
1373-
"@types/react@*", "@types/[email protected]":
1366+
"@types/react@*", "@types/[email protected]", "@types/react@^17.0.36":
13741367
version "17.0.4"
13751368
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.4.tgz#a67c6f7a460d2660e950d9ccc1c2f18525c28220"
13761369
integrity sha512-onz2BqScSFMoTRdJUZUDD/7xrusM8hBA2Fktk2qgaTYPCgPvWnDEgkrOs8hhPUf2jfcIXkJ5yK6VfYormJS3Jw==
@@ -1379,15 +1372,6 @@
13791372
"@types/scheduler" "*"
13801373
csstype "^3.0.2"
13811374

1382-
"@types/react@^17.0.36":
1383-
version "17.0.38"
1384-
resolved "https://registry.yarnpkg.com/@types/react/-/react-17.0.38.tgz#f24249fefd89357d5fa71f739a686b8d7c7202bd"
1385-
integrity sha512-SI92X1IA+FMnP3qM5m4QReluXzhcmovhZnLNm3pyeQlooi02qI7sLiepEYqT678uNiyc25XfCqxREFpy3W7YhQ==
1386-
dependencies:
1387-
"@types/prop-types" "*"
1388-
"@types/scheduler" "*"
1389-
csstype "^3.0.2"
1390-
13911375
"@types/sanitize-filename@^1.6.3":
13921376
version "1.6.3"
13931377
resolved "https://registry.yarnpkg.com/@types/sanitize-filename/-/sanitize-filename-1.6.3.tgz#182ebd5658fbd3fe36bcb771daad8b2623371705"
@@ -5967,14 +5951,6 @@ [email protected]:
59675951
loose-envify "^1.1.0"
59685952
object-assign "^4.1.1"
59695953

5970-
scheduler@^0.20.2:
5971-
version "0.20.2"
5972-
resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.20.2.tgz#4baee39436e34aa93b4874bddcbf0fe8b8b50e91"
5973-
integrity sha512-2eWfGgAqqWFGqtdMmcL5zCMK1U8KlXv8SQFGglL3CEtd0aDVDWgeF/YoCmvln55m5zSk3J/20hTaSBeSObsQDQ==
5974-
dependencies:
5975-
loose-envify "^1.1.0"
5976-
object-assign "^4.1.1"
5977-
59785954
secure-json-parse@^2.0.0:
59795955
version "2.4.0"
59805956
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-2.4.0.tgz#5aaeaaef85c7a417f76271a4f5b0cc3315ddca85"

0 commit comments

Comments
 (0)