Repository navigation
fix(res.render): do not mutate the options object - #7541
Enesuygurs wants to merge 2 commits into
Conversation
res.render() assigned res.locals to options._locals directly on the object passed by the caller. Rendering with a frozen options object threw a TypeError, and a shared options object kept a reference to the last request's res.locals. Merge res.locals into a shallow copy instead.
krzysdz
left a comment
There was a problem hiding this comment.
We definitely should not modify the options arguments. I found a PR from 2013 that removed one such mutation (#1458, utils-merge was later replaced by spread in #6091), but there are still similar patterns in our code.
Another case where a function argument is mutated is here:
Line 404 in 0dee2c5
This particular line is slightly wrong also for another reason, but #7467 deals with it, so I left a comment there.
View also modifies one of its arguments, but it is an expected behaviour and it is an internal function, so no changes are necessary.
| it('should support frozen options object', function(done){ | ||
| var app = createApp(); | ||
|
|
||
| app.set('views', path.join(__dirname, 'fixtures')) | ||
| var options = Object.freeze({ user: { name: 'tobi' } }); | ||
|
|
||
| app.use(function(req, res){ | ||
| res.render('user.tmpl', options); | ||
| }); | ||
|
|
||
| request(app) | ||
| .get('/') | ||
| .expect(200, '<p>tobi</p>', done); | ||
| }) |
There was a problem hiding this comment.
IMO this test is redundant
| var options = { user: { name: 'tobi' } }; | ||
|
|
||
| app.use(function(req, res){ | ||
| res.locals.foo = 'bar'; | ||
| res.render('user.tmpl', options); | ||
| }); | ||
|
|
||
| request(app) | ||
| .get('/') | ||
| .expect('<p>tobi</p>', function (err) { | ||
| if (err) return done(err) | ||
| assert.deepStrictEqual(Object.keys(options), ['user']) | ||
| done() | ||
| }) |
There was a problem hiding this comment.
| var options = { user: { name: 'tobi' } }; | |
| app.use(function(req, res){ | |
| res.locals.foo = 'bar'; | |
| res.render('user.tmpl', options); | |
| }); | |
| request(app) | |
| .get('/') | |
| .expect('<p>tobi</p>', function (err) { | |
| if (err) return done(err) | |
| assert.deepStrictEqual(Object.keys(options), ['user']) | |
| done() | |
| }) | |
| var options = { user: { name: 'tobi' } }; | |
| var optionsCopy = { ...options }; | |
| app.use(function(req, res){ | |
| res.locals.foo = 'bar'; | |
| res.render('user.tmpl', options); | |
| }); | |
| request(app) | |
| .get('/') | |
| .expect(() => assert.deepStrictEqual(options, optionsCopy)) | |
| .end(done) |
I think it's better to compare the objects as a whole.
|
Applied both suggestions: removed the frozen-options test, and the remaining test now compares the whole options object against a copy. Thanks for the review! |
res.render(view, options)mergesres.localsby assigning it tooptions._locals, directly on the object passed by the caller. This has two consequences:TypeError: Cannot add property _locals, object is not extensible.res.localsattached to it as_locals. That keeps the data alive and exposes it if the object is later inspected or serialized.This change merges
res.localsinto a shallow copy of the options instead. What view engines receive is unchanged, sinceapp.render()already builds a newrenderOptionsobject fromapp.locals,options._localsandoptions(own enumerable properties only).I added tests for both cases; they fail without the change.
npm testpasses with 1263 tests, compared to 1261 onmaster, andnpm run lintis clean.The same assignment exists on
4.x(lib/response.js). I'm happy to open a backport there if wanted.