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

  1. Audit the failing draw path for unmatched restore(); ensure every ctx.restore() has a preceding ctx.save() on every code path (including early returns).
  2. Use try/finally around the body between save() and restore() so the restore always runs.
  3. 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

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


AI-assisted analysis of mrdoob/three.js@da05705fa3 (2026-08-12). Data as JSON: /api/errors/0e4ae66d5f55685e. Report an issue: GitHub.