From b9b53b9c9a39f75c2aaf016d7b9bd194ead32447 Mon Sep 17 00:00:00 2001 From: Enes Uygur Date: Thu, 22 Oct 2026 00:49:24 +0300 Subject: [PATCH 1/2] fix(res.render): do not mutate the options object 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. --- lib/response.js | 4 ++-- test/res.render.js | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+), 2 deletions(-) diff --git a/lib/response.js b/lib/response.js index 0d5a4ae4d4d..082712f7d74 100644 --- a/lib/response.js +++ b/lib/response.js @@ -907,8 +907,8 @@ res.render = function render(view, options, callback) { opts = {}; } - // merge res.locals - opts._locals = self.locals; + // merge res.locals (into a copy, so the caller's options are not mutated) + opts = { ...opts, _locals: self.locals }; // default callback to respond done = done || function (err, str) { diff --git a/test/res.render.js b/test/res.render.js index 114b398e0b4..aaad524600a 100644 --- a/test/res.render.js +++ b/test/res.render.js @@ -1,5 +1,6 @@ 'use strict' +var assert = require('node:assert') var express = require('..'); var path = require('node:path') var request = require('supertest'); @@ -295,6 +296,41 @@ describe('res', function(){ .get('/') .expect('

jane

', done); }) + + it('should not mutate the options object', function(done){ + var app = createApp(); + + app.set('views', path.join(__dirname, 'fixtures')) + var options = { user: { name: 'tobi' } }; + + app.use(function(req, res){ + res.locals.foo = 'bar'; + res.render('user.tmpl', options); + }); + + request(app) + .get('/') + .expect('

tobi

', function (err) { + if (err) return done(err) + assert.deepStrictEqual(Object.keys(options), ['user']) + done() + }) + }) + + 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, '

tobi

', done); + }) }) describe('.render(name, options, fn)', function(){ From 1a5457f41ba6a65cbc923b7c8568ace4aa222f50 Mon Sep 17 00:00:00 2001 From: Enes Uygur Date: Fri, 23 Oct 2026 09:32:52 +0300 Subject: [PATCH 2/2] test(res.render): compare the whole options object and drop the frozen-options test --- test/res.render.js | 23 +++-------------------- 1 file changed, 3 insertions(+), 20 deletions(-) diff --git a/test/res.render.js b/test/res.render.js index aaad524600a..965575db1d3 100644 --- a/test/res.render.js +++ b/test/res.render.js @@ -302,6 +302,7 @@ describe('res', function(){ app.set('views', path.join(__dirname, 'fixtures')) var options = { user: { name: 'tobi' } }; + var optionsCopy = { ...options }; app.use(function(req, res){ res.locals.foo = 'bar'; @@ -310,26 +311,8 @@ describe('res', function(){ request(app) .get('/') - .expect('

tobi

', function (err) { - if (err) return done(err) - assert.deepStrictEqual(Object.keys(options), ['user']) - done() - }) - }) - - 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, '

tobi

', done); + .expect(() => assert.deepStrictEqual(options, optionsCopy)) + .end(done) }) })