Rendering JavaScript
in Obsidian notes
How to render live animated 3D WebGL scenes inside Obsidian notes using JS Engine, the hard-learned lessons of WebGL context limits and DOM virtualisation, and why an iframe wrapper is the cleanest solution.
Rendering JavaScript in Obsidian Notes
I wanted a live 3D scene in one of my Obsidian notes. Not a screenshot of one, but the actual thing, rendering and animating in the middle of the page.
It took three attempts to get right, and the two failures are more instructive than the fix, because both are cases of fighting the platform instead of working with it.
You need the JS Engine plugin for any of this. It runs JavaScript in js-engine code blocks and hands you a few variables to work with: container (the element your block renders into), component (for lifecycle hooks), engine and app.
Attempt 1: paste the script in
I had a working standalone HTML page. I dropped its <script> contents into a js-engine block and got:
TypeError: Cannot read properties of null (reading 'getContext')
Obvious in hindsight. The block runs the script; the surrounding HTML never exists. document.getElementById('canvas') has nothing to find. Neither does <script src="...three.js"> do anything and addEventListener('load', init) never fires, because the window loaded minutes ago.
Attempt 2: build the DOM properly
So I rewrote it as a vault module: create the canvas, append it to container, fetch three.js at runtime, register cleanup on component. This worked. For a while.
Next session:
Error: Error creating WebGL context.
Chromium caps live WebGL contexts at roughly 8 to 16 per process. Obsidian restores your open tabs on launch and keeps panes alive, so contexts had been quietly accumulating across sessions. renderer.dispose() doesn't reliably free one (you need forceContextLoss() as well) and if Obsidian tears out the block's DOM without unloading the component, your cleanup hook never runs at all.
I added a registry, forced context loss on teardown and had the animation loop check wrap.isConnected so orphans could clean up after themselves.
That made it worse. Scroll away from the note and the animation vanished, coming back only on a full reload.
The thing I was missing
Obsidian virtualises long notes. When a section scrolls out of view it detaches that section's DOM, then re-attaches the same cached elements when you scroll back, often without re-running the code block.
So my isConnected check was reading a routine detach as a permanent teardown, disposing the renderer and removing the canvas. Obsidian then dutifully re-attached an empty container.
The general lesson: a markdown post-processor is expected to be cheap and idempotent. Obsidian reserves the right to unload, cache, detach and re-run it whenever it likes. Long-lived stateful GPU work won't survive that contract and no amount of defensive bookkeeping will make it. That isn't a plugin bug; JS Engine has no say in it.
Attempt 3: give it its own document
An iframe has a lifecycle Obsidian can't interfere with. It owns its WebGL context. The browser reclaims that context when the element goes away. If Obsidian does detach and re-attach it, it reloads and restarts rather than leaving a hole.
Best of all, your standalone page runs completely unmodified. position: fixed, the load event, all of it behaves correctly, because inside a frame it really is a standalone page.
One wrinkle. I sandbox the frame with allow-scripts so the embedded page can't reach the vault and that gives it an opaque origin and no base URL. Remote <script src> becomes unreliable and relative paths don't resolve at all. The fix is to inline every script tag before handing the HTML over.
Here's the whole loader.
/* embed.js: run any standalone HTML page inside an Obsidian note. */
const cache = new Map();
const SCRIPT_TAG = /<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>\s*<\/script>/gi;
const dirOf = (p) => { const i = p.lastIndexOf("/"); return i < 0 ? "" : p.slice(0, i + 1); };
async function sourceOf(app, src, base) {
if (cache.has(src)) return cache.get(src);
const p = (async () => {
if (/^https?:\/\//i.test(src)) {
const r = await fetch(src);
if (!r.ok) throw new Error("embed: could not fetch " + src + " (" + r.status + ")");
return r.text();
}
return app.vault.adapter.read(base + src.replace(/^\.\//, ""));
})().catch((e) => { cache.delete(src); throw e; });
cache.set(src, p);
return p;
}
export async function render(container, component, opts = {}) {
if (!container) throw new Error("embed: call this from a js-engine code block");
if (!opts.page) throw new Error('embed: pass { page: "scripts/thing.html" }');
const app = opts.app ?? globalThis.app;
const base = dirOf(opts.page);
let page = await app.vault.adapter.read(opts.page);
/* Inline every <script src>. A sandboxed frame has an opaque origin and no
base URL, so neither remote nor relative script tags resolve inside it.
Local paths are read relative to the page; the results are cached. */
const tags = [...page.matchAll(SCRIPT_TAG)];
const sources = await Promise.all(tags.map((m) => sourceOf(app, m[1], base)));
tags.forEach((m, i) => {
page = page.replace(m[0], () => "<script>" + sources[i] + "<\/script>");
});
const frame = document.createElement("iframe");
frame.setAttribute("sandbox", "allow-scripts"); // scripts only: no vault access
frame.setAttribute("scrolling", "no");
frame.style.cssText =
"display:block;width:100%;border:0;border-radius:8px;" +
"height:" + (opts.height ?? 460) + "px;" +
"background:" + (opts.background ?? "#000") + ";";
frame.srcdoc = page;
container.appendChild(frame);
const destroy = () => frame.remove();
if (component && typeof component.register === "function") component.register(destroy);
frame.destroyEmbed = destroy;
/* Returns nothing on purpose: JS Engine renders a block's return value, and
rendering a result clears the container, which deletes the frame. */
}The replacement uses a function rather than a string, incidentally, so a $& sitting inside minified library source can't be mistaken for a substitution pattern. That one would have been a miserable afternoon.
Using it
Drop embed.js and your page in scripts/. Every embed after that is four lines:
const embed = await engine.importJs("scripts/embed.js");
await embed.render(container, component, { page: "scripts/thing.html", height: 460 });Note the await and the absence of return. JS Engine renders whatever a block returns, and rendering a result clears the container, so returning the handle deletes the frame you just made and prints {"frame":{}} where your visualisation should be. I lost a good ten minutes to that.
Worth knowing
Pause when hidden. Put if (document.hidden) return; at the top of your animation loop. Background panes shouldn't burn GPU time and exhausted WebGL contexts persist until you fully quit Obsidian, not merely reload it.
Don't eat scroll events. A canvas that calls preventDefault() on every wheel event traps the reader mid-note. Detect the frame and require a modifier:
const EMBEDDED = window.self !== window.top;
canvas.addEventListener('wheel', e => {
if (EMBEDDED && !(e.ctrlKey || e.metaKey || e.shiftKey)) return;
e.preventDefault();
// zoom
}, { passive: false });Reload after editing. Cmd/Ctrl+R, since modules and pages are both cached.
Errors inside the frame go to devtools, not to the note. Cmd/Ctrl+Shift+I.
The part that generalises
Build the thing so it runs standalone in a browser. Let the note be a four-line wrapper.
Every bug then falls cleanly on one side of that line. Works in the browser but not in the note? It's the embedding. Fails in both? It's your code. Given how much of the debugging above came from not being able to tell those apart, that separation is worth more than the loader is.