mrdoob/three.js · error · Error
"transform stack empty!
Error message
"transform stack empty!
What it means
Thrown by the canvas-wrapper polyfill (a manual/lesson resource) when ctx.restore() is called more times than ctx.save(). The wrapper maintains its own transform stack to emulate ctx.currentTransform on browsers that lack it; restore() pops that stack and throws if it is empty. Note the message is malformed: '"transform stack empty!' has a stray leading double-quote, but the failure is a genuine save/restore imbalance.
Source
Thrown at manual/resources/canvas-wrapper.js:129
stack.push( duplicate( ctx.currentTransform ) );
save();
};
}( ctx.save.bind( ctx ) );
ctx.restore = function ( restore ) {
return function () {
if ( stack.length ) {
ctx.currentTransform = stack.pop();
} else {
throw new Error( '"transform stack empty!' );
}
restore();
};
}( ctx.restore.bind( ctx ) );
ctx.transform = function ( transform ) {
return function ( m11, m12, m21, m22, dx, dy ) {
const m = new DOMMatrix();
m.a = m11;
m.b = m12;
m.c = m21;
m.d = m22;View on GitHub (pinned to da05705fa3)
Solutions
- Audit the failing draw path for unmatched restore(); ensure every ctx.restore() has a preceding ctx.save() on every code path (including early returns).
- Use try/finally around the body between save() and restore() so the restore always runs.
- Temporarily log stack.length before each restore to find the first offending call.
Example fix
// before
ctx.save();
ctx.translate( x, y );
if ( skip ) return; // restore skipped -> next restore() throws
ctx.restore();
// after — always restore in a finally
ctx.save();
try {
ctx.translate( x, y );
if ( skip ) return;
} finally {
ctx.restore();
} Defensive patterns
Strategy: validation
Validate before calling
// Track save/restore depth so you never over-restore when using the wrapped context.
let depth = 0;
const save = () => { ctx.save(); depth++; };
const restore = () => {
if ( depth === 0 ) throw new Error( 'restore() without matching save()' );
ctx.restore();
depth--;
}; Try / catch
// Defensive restore that tolerates an empty stack (e.g. legacy code paths).
try {
ctx.restore();
} catch ( err ) {
if ( /transform stack empty/.test( err.message ) ) {
console.warn( 'ignored unbalanced ctx.restore()' );
} else throw err;
} Prevention
- Wrap every save()/restore() pair in try/finally so restore always runs.
- Maintain your own depth counter in draw helpers and assert it returns to zero.
- Never place an early return between save() and restore().
When it happens
Trigger: Drawing code wrapped by wrapCanvasRenderingContext2D that calls ctx.restore() without a prior matching ctx.save(), or that calls restore() twice for a single save(). Anywhere the manual lesson demos (e.g. text-on-canvas helpers) drop a save but keep both restores.
Common situations: Early-return or throw inside a draw routine between a save() and its restore(), skipping the restore. Copy-pasting a draw block that included a trailing restore() but not its leading save(). Refactoring a function so a previously-paired restore() now runs unconditionally.
Related errors
- transform stack not 0
- no diagram ${name}
- no diagram ${name}
- no primitive ${name}
- no ui for ${primitiveName}:${ndx} param: ${name}
AI-assisted analysis of mrdoob/three.js@da05705fa3 (2026-08-12).
Data as JSON: /api/errors/0e4ae66d5f55685e.
Report an issue: GitHub.