Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
db95e7b
Resolve paths for views asynchronously
aredridel May 18, 2015
3dd0d30
Add limitStat to keep concurrent requests under control
aredridel May 18, 2015
0594ee6
fix(view): prevent limitStat queue stall when a callback throws
bjohansebas Jul 13, 2026
f5ad5a3
fix(view): treat stat errors as lookup misses like the sync implement…
bjohansebas Jul 13, 2026
48b6588
refactor(view): resolve the view path lazily in View#render
bjohansebas Jul 13, 2026
4786b4e
fix(view): deliver sync engine throws via callback on first render
bjohansebas Jul 13, 2026
d08d353
docs!
bjohansebas Jul 13, 2026
82742aa
refactor(view): drop dead error plumbing and derive the lookup file name
bjohansebas Jul 13, 2026
449845d
perf(view): coalesce concurrent lookups of the same view
bjohansebas Jul 13, 2026
9de5eb5
add test
bjohansebas Jul 13, 2026
8462786
fix(view): reset pendingRenders when lookup throws synchronously
bjohansebas Jul 13, 2026
de6eb66
fix(view): survive synchronous throws from fs.stat and skip falsy vie…
bjohansebas Jul 13, 2026
b250f32
fix(view): guarantee the render callback is invoked at most once
bjohansebas Jul 13, 2026
b367cdf
fix(view): handle synchronous stat failures asynchronously to prevent…
bjohansebas Jul 13, 2026
29a2d83
test: restore fs.stat via afterEach and simplify falsy-root check
bjohansebas Jul 13, 2026
df777dc
test: assert sync-parity error messages for misconfigured views
bjohansebas Jul 13, 2026
97ba3e4
fix(app): keep requiring path from custom view classes
bjohansebas Jul 13, 2026
38278a6
fix(app): drop views with failed lookups from the cache like the sync…
bjohansebas Jul 13, 2026
4859afa
fix(view): clarify path resolution behavior for custom view subclasses
bjohansebas Jul 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions History.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@

## ⚡ Performance

* Resolve view paths asynchronously, so `res.render()` and `app.render()` no longer block the event loop with synchronous filesystem calls. The render callback is now always invoked asynchronously, `view.path` is resolved on first render instead of at construction, `View.prototype.lookup` is callback-based, and `View.prototype.resolve` was removed - by [@aredridel](https://github.com/aredridel) and [@bjohansebas](https://github.com/bjohansebas) in [#2653](https://github.com/expressjs/express/pull/2653)

* Avoid duplicate Content-Type header processing in `res.send()` when sending string responses without an explicit Content-Type header - by [@bjohansebas](https://github.com/bjohansebas) in [#6991](https://github.com/expressjs/express/pull/6991)

5.2.1 / 2025-12-01
Expand Down
20 changes: 16 additions & 4 deletions lib/application.js
Original file line number Diff line number Diff line change
Expand Up @@ -547,18 +547,21 @@ app.render = function render(name, options, callback) {

// view
if (!view) {
var View = this.get('view');
var ViewClass = this.get('view');

view = new View(name, {
view = new ViewClass(name, {
defaultEngine: this.get('view engine'),
root: this.get('views'),
engines: engines
});

if (!view.path) {
// only the built-in View resolves its path lazily; any other view
// class (custom or a subclass) keeps the classic contract where a
// missing `path` after construction signals a failed lookup
if (view.constructor !== View && !view.path) {
var dirs = Array.isArray(view.root) && view.root.length > 1
? 'directories "' + view.root.slice(0, -1).join('", "') + '" or "' + view.root[view.root.length - 1] + '"'
: 'directory "' + view.root + '"'
: 'directory "' + view.root + '"';
var err = new Error('Failed to lookup view "' + name + '" in views ' + dirs);
err.view = view;
return done(err);
Expand All @@ -567,6 +570,15 @@ app.render = function render(name, options, callback) {
// prime the cache
if (renderOptions.cache) {
cache[name] = view;

var callerDone = done;
done = function (err, str) {
// match the sync behavior: a view whose lookup failed is not kept
if (err && !view.path) {
delete cache[name];
}
callerDone(err, str);
};
}
}

Expand Down
251 changes: 202 additions & 49 deletions lib/view.js
Original file line number Diff line number Diff line change
Expand Up @@ -61,15 +61,11 @@ function View(name, options) {
throw new Error('No default engine was specified and no extension was provided.');
}

var fileName = name;

if (!this.ext) {
// get extension from default engine name
this.ext = this.defaultEngine[0] !== '.'
? '.' + this.defaultEngine
: this.defaultEngine;

fileName += this.ext;
}

if (!opts.engines[this.ext]) {
Expand All @@ -90,116 +86,273 @@ function View(name, options) {
// store loaded engine
this.engine = opts.engines[this.ext];

// lookup path
this.path = this.lookup(fileName);
// resolved on first render
this.path = null;

// renders waiting on the in-flight path lookup
this.pendingRenders = null;
}

/**
* Lookup view by the given `name`
* Lookup the view file, calling `cb` with `(err, filePath)` where
* `filePath` is `null` when the view was not found.
*
* @param {string} name
* @param {function} cb
* @private
*/

View.prototype.lookup = function lookup(name) {
var path;
var roots = [].concat(this.root);
View.prototype.lookup = function lookup(cb) {
const roots = [].concat(this.root);
const ext = this.ext;

// append the default-engine extension unless the name already has one
const name = extname(this.name)
? this.name
: this.name + ext;

debug('lookup "%s"', name);

for (var i = 0; i < roots.length && !path; i++) {
var root = roots[i];
tryNextRoot();

function tryNextRoot() {
if (roots.length === 0) {
return cb(null, null);
}

const root = roots.shift();

// resolve the path
var loc = resolve(root, name);
var dir = dirname(loc);
var file = basename(loc);
let loc;
try {
loc = resolve(root, name);
} catch (err) {
// an invalid entry in the views setting throws the same error
// the synchronous implementation did
return cb(err);
}

// resolve the file
path = this.resolve(dir, file);
resolveView(dirname(loc), basename(loc), ext, onResolved);
}

return path;
function onResolved(filePath) {
if (filePath) {
return cb(null, filePath);
}

// not found; try the next root
tryNextRoot();
}
};

/**
* Render with the given options.
*
* Resolves the view path on first render and memoizes it
* on the instance for subsequent renders.
*
* @param {object} options
* @param {function} callback
* @private
*/

View.prototype.render = function render(options, callback) {
var sync = true;
const view = this;

if (this.path) {
return renderFile(this, options, callback);
}

// coalesce concurrent renders onto the in-flight lookup
if (this.pendingRenders) {
this.pendingRenders.push([options, callback]);
return;
}

this.pendingRenders = [[options, callback]];

// lookup path on first render
this.lookup(onLookup);

function onLookup(err, filePath) {
const pending = view.pendingRenders;
view.pendingRenders = null;

if (filePath) {
view.path = filePath;
}

for (let i = 0; i < pending.length; i++) {
if (filePath) {
renderFile(view, pending[i][0], pending[i][1]);
} else {
// deliver asynchronously so one throwing callback cannot skip
// the remaining coalesced renders
process.nextTick(pending[i][1], err || lookupFailedError(view));
}
}
}
};

/**
* Render the resolved view path with the given options.
*
* @param {View} view
* @param {object} options
* @param {function} callback
* @private
*/

function renderFile(view, options, callback) {
let sync = true;
let called = false;

debug('render "%s"', this.path);
debug('render "%s"', view.path);

try {
// render, normalizing sync callbacks
view.engine(view.path, options, onRender);
} catch (err) {
// deliver sync engine throws through the callback
return onRender(err);
}

sync = false;

function onRender() {
// ignore engines that call back more than once (or throw after
// calling back)
if (called) return;
called = true;

// render, normalizing sync callbacks
this.engine(this.path, options, function onRender() {
if (!sync) {
return callback.apply(this, arguments);
}

// copy arguments
var args = new Array(arguments.length);
var cntx = this;
const args = new Array(arguments.length);
const cntx = this;

for (var i = 0; i < arguments.length; i++) {
for (let i = 0; i < arguments.length; i++) {
args[i] = arguments[i];
}

// force callback to be async
return process.nextTick(function renderTick() {
return callback.apply(cntx, args);
});
});
}
}

sync = false;
};
/**
* Build the error for a failed view lookup.
*
* @param {View} view
* @return {Error}
* @private
*/

function lookupFailedError(view) {
const dirs = Array.isArray(view.root) && view.root.length > 1
? 'directories "' + view.root.slice(0, -1).join('", "') + '" or "' + view.root[view.root.length - 1] + '"'
: 'directory "' + view.root + '"';
const err = new Error('Failed to lookup view "' + view.name + '" in views ' + dirs);
err.view = view;
return err;
}

/**
* Resolve the file within the given directory.
* Resolve the file within the given directory, calling `cb`
* with the resolved path or `null` when not found.
*
* @param {string} dir
* @param {string} file
* @param {string} ext
* @param {function} cb
* @private
*/

View.prototype.resolve = function resolve(dir, file) {
var ext = this.ext;
function resolveView(dir, file, ext, cb) {
const filePath = join(dir, file);
const indexPath = join(dir, basename(file, ext), 'index' + ext);

// <path>.<ext>
var path = join(dir, file);
var stat = tryStat(path);
limitStat(filePath, onFileStat);

function onFileStat(err, stat) {
if (!err && stat.isFile()) {
return cb(filePath);
}

if (stat && stat.isFile()) {
return path;
// <path>/index.<ext>
limitStat(indexPath, onIndexStat);
}

// <path>/index.<ext>
path = join(dir, basename(file, ext), 'index' + ext);
stat = tryStat(path);
function onIndexStat(err, stat) {
if (!err && stat.isFile()) {
return cb(indexPath);
}

if (stat && stat.isFile()) {
return path;
// treat any stat error as a miss, like the sync implementation did
cb(null);
}
};
}

/**
* Module variables for stat concurrency limiting.
* @private
*/

const MAX_PENDING_STATS = 10;
const pendingStats = [];
let numPendingStats = 0;

/**
* Return a stat, maybe.
* An fs.stat call that limits the number of outstanding requests.
*
* @param {string} path
* @return {fs.Stats}
* @param {function} cb
* @private
*/

function tryStat(path) {
debug('stat "%s"', path);
function limitStat(path, cb) {
pendingStats.push([path, cb]);
statNext();
}

try {
return fs.statSync(path);
} catch (e) {
return undefined;
/**
* Drain the pending stat queue while under the concurrency limit,
* always dispatching further work before invoking a callback so a
* throwing callback cannot stall the queue.
*
* @private
*/

function statNext() {
while (numPendingStats < MAX_PENDING_STATS && pendingStats.length > 0) {
const next = pendingStats.shift();
const path = next[0];
const cb = next[1];

numPendingStats++;
debug('stat "%s"', path);

try {
fs.stat(path, function onStat(err, stat) {
numPendingStats--;

// dispatch the next queued stat before invoking the callback so a
// throwing callback cannot stall the queue
statNext();

cb(err, stat);
});
} catch (err) {
// fs.stat can throw synchronously (e.g. a path with a null byte);
// release the slot and deliver the error asynchronously like a real
// stat failure, then keep draining the queue
numPendingStats--;
process.nextTick(cb, err, null);
}
}
}
Loading