StyleSheetLoader keeps a cached "loaded" state for a stylesheet that is no longer in the document, leaving later editors invisible
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:
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 : visibleActual
skin links : 0
visibility : hiddenThe 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:
styles: {
// This is overridden by the skin, it helps avoid FOUC
visibility: 'hidden',and the override, identical in oxide and oxide-dark:
.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:
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:
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.
Source: tinymce/tinymce