{"record":{"id":"0e4ae66d5f55685e","repo":"mrdoob/three.js","slug":"transform-stack-empty","errorCode":null,"errorMessage":"\"transform stack empty!","messagePattern":"\"transform stack empty!","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"manual/resources/canvas-wrapper.js","lineNumber":129,"sourceCode":"\n\t\t\t\tstack.push( duplicate( ctx.currentTransform ) );\n\t\t\t\tsave();\n\n\t\t\t};\n\n\t\t}( ctx.save.bind( ctx ) );\n\n\t\tctx.restore = function ( restore ) {\n\n\t\t\treturn function () {\n\n\t\t\t\tif ( stack.length ) {\n\n\t\t\t\t\tctx.currentTransform = stack.pop();\n\n\t\t\t\t} else {\n\n\t\t\t\t\tthrow new Error( '\"transform stack empty!' );\n\n\t\t\t\t}\n\n\t\t\t\trestore();\n\n\t\t\t};\n\n\t\t}( ctx.restore.bind( ctx ) );\n\n\t\tctx.transform = function ( transform ) {\n\n\t\t\treturn function ( m11, m12, m21, m22, dx, dy ) {\n\n\t\t\t\tconst m = new DOMMatrix();\n\t\t\t\tm.a = m11;\n\t\t\t\tm.b = m12;\n\t\t\t\tm.c = m21;\n\t\t\t\tm.d = m22;","sourceCodeStart":111,"sourceCodeEnd":147,"githubUrl":"https://github.com/mrdoob/three.js/blob/da05705fa31f02710c19772a2aa70c7454807f0c/manual/resources/canvas-wrapper.js#L111-L147","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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."],"exampleFix":"// before\nctx.save();\nctx.translate( x, y );\nif ( skip ) return;      // restore skipped -> next restore() throws\nctx.restore();\n\n// after — always restore in a finally\nctx.save();\ntry {\n  ctx.translate( x, y );\n  if ( skip ) return;\n} finally {\n  ctx.restore();\n}","handlingStrategy":"validation","validationCode":"// Track save/restore depth so you never over-restore when using the wrapped context.\nlet depth = 0;\nconst save = () => { ctx.save(); depth++; };\nconst restore = () => {\n  if ( depth === 0 ) throw new Error( 'restore() without matching save()' );\n  ctx.restore();\n  depth--;\n};","typeGuard":null,"tryCatchPattern":"// Defensive restore that tolerates an empty stack (e.g. legacy code paths).\ntry {\n  ctx.restore();\n} catch ( err ) {\n  if ( /transform stack empty/.test( err.message ) ) {\n    console.warn( 'ignored unbalanced ctx.restore()' );\n  } else throw err;\n}","preventionTips":["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()."],"tags":["canvas","manual","transform","save-restore","lesson"],"backgroundTag":null,"analyzedSha":"da05705fa31f02710c19772a2aa70c7454807f0c","analyzedAt":"2026-08-12T22:51:37.160Z","schemaVersion":2},"datasetVersion":"2026-08-12T23:17:12.415Z"}