#11245·tinymce

StyleSheetLoader keeps a cached "loaded" state for a stylesheet that is no longer in the document, leaving later editors invisible

Author: xeonsCreated Aug 29, 2026Updated Sep 1, 2026
Labelstype: bug

TinyMCE version

8.8.2, self hosted. Reproduced in Edge 141 (Chromium), headless. The code involved is unchanged on develop.

Summary

StyleSheetLoader records load state per URL and resolves immediately when that state says "loaded", without checking that the <link> element it created is still in the document. The state is only cleared by unload(), which runs when an editor is removed.

So if the element is removed by something other than the loader, and the editor that loaded it is discarded without editor.remove() being called, the state is never cleared and never re-checked. Every editor created afterwards short circuits on that stale state, no stylesheet is added, and the editor is built with no skin.

Because the outer container is revealed by the skin rather than by script, the result is an editor that is fully constructed and functional through the API, and completely invisible.

Steps to reproduce

No framework required. Two textareas, #one and #two:

javascript
tinymce.init({ selector: '#one' }).then(() => {
  // Stands in for a host that reconciles <head> against server markup and discards the
  // editor's DOM, without TinyMCE being told the editor is gone.
  document.querySelector('link[href*="skin.min.css"]').remove();
  document.querySelector('.tox-tinymce').remove();

  return tinymce.init({ selector: '#two' });
}).then(() => {
  const container = document.querySelector('.tox-tinymce');
  console.log('skin links :', document.querySelectorAll('link[href*="skin.min.css"]').length);
  console.log('visibility :', getComputedStyle(container).visibility);
});

Expected

The second editor notices its skin is not present and loads it. Visible editor.

skin links : 1
visibility : visible

Actual

skin links : 0
visibility : hidden

The container is in the DOM with style="visibility: hidden" and a normal offsetHeight. Nothing is logged: no console error, no rejected promise, no SkinLoadError.

Both conditions are needed

I ran each combination to isolate it. Only the last one fails:

skin <link> removed first editor removed with remove() second editor
no yes visible
yes yes visible
no no, DOM discarded visible
yes no, DOM discarded hidden

Row two is the interesting control: removing the element is survivable on its own, because editor.remove() calls unload(), which drops the reference count to zero, deletes the state and lets the next init() inject a fresh <link>. It is only when nothing clears the state that the loader keeps asserting a stylesheet is present when it is not.

Cause

1. The container is hidden inline and revealed only by the skin. From modules/tinymce/src/themes/silver/main/ts/Render.ts:

typescript
styles: {
  // This is overridden by the skin, it helps avoid FOUC
  visibility: 'hidden',

and the override, identical in oxide and oxide-dark:

css
.tox-tinymce{ ... visibility:inherit!important }

Nothing in script clears that inline style, so no skin stylesheet means a permanently hidden editor. The comment is accurate; the failure is that the skin can be absent.

2. The loader trusts its own record over the document. In modules/tinymce/src/core/main/ts/api/dom/StyleSheetLoader.ts, load() returns early on the recorded status, and only an unset status injects an element:

typescript
if (state.status === 1) { return; }            // loading, wait for the in flight link
if (state.status === 2) { passed(); return; }  // loaded, resolve at once
if (state.status === 3) { failed(); return; }

The status === 2 branch never confirms the element behind that status is still connected.

3. Nothing else reconciles it. unload() clears the state only when the reference count reaches zero, and it is the editor's own teardown that decrements it. An editor whose DOM is taken away without remove() leaves count and status untouched.

Why this is worth fixing

Both conditions arrive together in any SPA-style host: a router that reconciles <head> against server markup removes elements injected at runtime, and the same navigation discards the editor's DOM before, or instead of, the teardown that would have called remove(). It is reachable from Blazor enhanced navigation, Turbo and htmx boosting. The first editor on a page works, and every editor after a navigation is invisible.

It is also unusually hard to diagnose:

  • nothing throws, so the console is clean and there is no failed promise to catch
  • the editor works through the API, so tinymce.get(id).getContent() returns content
  • forcing the container visible gives an unstyled editor, which reads as a layout bug rather than a missing stylesheet

Suggested fix

Before taking the status === 2 short circuit, confirm the element is still connected and fall through to injection if it is not. The state already carries the element id:

typescript
const stillPresent = (state: StyleSheetState): boolean => {
  const el = doc.getElementById(state.id);
  return el !== null && el.isConnected;
};

if (state.status === LOADED && stillPresent(state)) {
  passed();
  return;
}

Keeping the element reference on the state rather than looking it up by id would do as well. Either way the loader stops asserting something about the document that it has not checked.

Happy to open a PR if the approach looks right.


If you'd like to see this fixed sooner, add a reaction to this post.