From 1951751b2a703717e08aca22afd9426668f39d88 Mon Sep 17 00:00:00 2001 From: Emilio Astarita Date: Wed, 22 Jul 2026 09:32:57 -0300 Subject: [PATCH] Fix res.render resolving views against the outer app instead of the mounted sub-app Express resolves res.render() through this.req.app, which points at the mounted sub-app during its dispatch, so a sub-app's 'views', 'view engine' and engine() settings apply to its own routes. ultimate-express used this.app, which is fixed at response construction to the outer app, so a mounted sub-app's view configuration was ignored and rendering failed with a view lookup error. Three small pieces: - res.render now delegates to this.req.app.render(), matching express. - The optimized router path now swaps req.app to the mounted Application, mirroring what normal dispatch does, so req.app is consistent between the optimized and normal paths. - When an optimized path is exhausted and dispatch falls back to normal routing, req.app is restored to the top-level app, so a sub-app route calling next() into an outer handler doesn't leak the sub-app. --- src/response.js | 3 +- src/router.js | 10 +++++- tests/parts/subapp/index.ejs | 2 ++ tests/tests/res/res-render-subapp.js | 50 ++++++++++++++++++++++++++++ 4 files changed, 63 insertions(+), 2 deletions(-) create mode 100644 tests/parts/subapp/index.ejs create mode 100644 tests/tests/res/res-render-subapp.js diff --git a/src/response.js b/src/response.js index f8798ec9..730b2f3e 100644 --- a/src/response.js +++ b/src/response.js @@ -679,7 +679,8 @@ module.exports = class Response extends Writable { this.send(str); }); - this.app.render(view, options, done); + // use req.app like express does, so mounted sub-apps resolve views with their own settings + this.req.app.render(view, options, done); } cookie(name, value, options) { const opt = {...(options ?? {})}; // create a new ref because we change original object (https://github.com/dimdenGD/ultimate-express/issues/68) diff --git a/src/router.js b/src/router.js index 3846e0cb..9981add5 100644 --- a/src/router.js +++ b/src/router.js @@ -241,7 +241,9 @@ module.exports = class Router extends EventEmitter { [...chainPrefix, ...pathToMount, { ...route, callbacks: [], - keepMount: true + keepMount: true, + // mounted sub-apps become req.app during their dispatch, like express + mountApp: route.callbacks[0].constructor.name === 'Application' ? route.callbacks[0] : undefined }] ); } @@ -495,6 +497,7 @@ module.exports = class Router extends EventEmitter { return false; } // on optimized routes, there can be more routes, so we have to use unoptimized routing and skip until we find route we stopped at + req.app = this; // restore app in case the optimized path swapped it to a mounted sub-app return this._routeRequest(req, res, 0, this._routes, false, skipUntil); } let callbackindex = 0; @@ -507,6 +510,11 @@ module.exports = class Router extends EventEmitter { const strictRouting = this.get('strict routing'); if(route.use) { + if(route.mountApp) { + // optimized chain: normal dispatch swaps req.app when it enters a mounted Application, + // but the compiled mount route has no callback to do it, so swap it here + req.app = route.mountApp; + } req._stack.push(route.path); const fullMountpath = this.getFullMountpath(req); req._opPath = fullMountpath !== EMPTY_REGEX ? req._originalPath.replace(fullMountpath, '') : req._originalPath; diff --git a/tests/parts/subapp/index.ejs b/tests/parts/subapp/index.ejs new file mode 100644 index 00000000..8fdbc0b3 --- /dev/null +++ b/tests/parts/subapp/index.ejs @@ -0,0 +1,2 @@ +

subapp <%= message %>

+<%= asdf %> diff --git a/tests/tests/res/res-render-subapp.js b/tests/tests/res/res-render-subapp.js new file mode 100644 index 00000000..ad334d66 --- /dev/null +++ b/tests/tests/res/res-render-subapp.js @@ -0,0 +1,50 @@ +// res.render must use the views and engine of the sub-app handling the request + +const express = require("express"); + +const app = express(); +app.set('view engine', 'ejs'); +app.set('views', 'tests/parts'); +app.set('env', 'production'); + +const subApp = express(); +subApp.set('view engine', 'ejs'); +subApp.set('views', 'tests/parts/subapp'); +subApp.set('env', 'production'); + +subApp.get('/page', (req, res) => { + res.locals.asdf = 'locals test'; + res.render('index.ejs', { message: 'from subapp' }); +}); + +subApp.get('/fallthrough', (req, res, next) => { + next(); +}); + +app.use('/sub', subApp); + +app.use((req, res) => { + res.locals.asdf = 'locals test'; + res.render('index.ejs', { title: 'Outer', message: 'from outer' }); +}); + +app.use((err, req, res, next) => { + console.log(err.message.split('\n')[0]); + res.status(500).send('whoops!'); +}); + +app.listen(13333, async () => { + console.log('Server is running on port 13333'); + + // before the router optimization kicks in + console.log(await fetch('http://localhost:13333/sub/page').then(res => res.text())); + console.log(await fetch('http://localhost:13333/sub/fallthrough').then(res => res.text())); + + await new Promise(resolve => setTimeout(resolve, 1000)); + + // after the router optimization kicks in + console.log(await fetch('http://localhost:13333/sub/page').then(res => res.text())); + console.log(await fetch('http://localhost:13333/sub/fallthrough').then(res => res.text())); + + process.exit(0); +});