Change where/when Theme <style> Element is Injected

I’m running into issues with CodeMirror 6 on a production Next.js site. At various points, Next.js decides it needs to modify the page’s <head> and it ends up scrapping the <style> tag that CodeMirror injects.

  1. Is there a way to tell CodeMirror exactly where to inject the <style> element and/or can you provide a specific style element that CodeMirror can use?
  2. Is there a way to tell CodeMirror to re-inject the <style> element in case it gets removed?

I see the root/setRoot stuff, but that doesn’t specifically help here. I am looking at doing a Custom Element / ShadowDOM approach but that’s getting a bit messy with some other integrations I have that use global styles in CodeMirror.

This is a bit tough to test and debug since it only happens on deploy. In development, Next isn’t changing the in the same way.

Someone else has run into this issue as well:

Similar problems:

Well, that’s annoying. Have you been able to find out anything about why Next.js does this? It is simply assigning to document.head.innerHTML? Does it expose any functionality for code that needs to do its own <head> manipulation?

At the moment CodeMirror exposes no way to force recreation of the style element. Adding that could be a last-ditch workaround, but I’d much prefer to find out if there’s a way to make Next.js behave here.

Unfortunately there isn’t much documentation on how Next’s head manipulation works: Components: <Head> | Next.js

It does seem to keep track of the number of items it expects to be in the head, then I suspect it wipes out anything it doesn’t expect to be there on route changes, etc.

Right now we’re essentially forced to use Shadow DOM which was a bit of a pain to get working right, but not too bad.

It would still be helpful to have some better management of the <style> element to control where it goes and/or remount it if necessary.

RE: Why: next-head-count seems to be the expected number of elements before that <meta> tag. When Next chooses to update the head, it then walks back from that meta element and updates/removes the various tags it finds (See head-manager.ts). There does not seem to be any control for this within Next APIs.

I think CodeMirror’s <style> is getting filtered out in that updateElements function in the oldTags to newTags transition since it’s outside of the head count, but it’s not immediately clear.

If I move the CodeMirror <style> element below the meta[name="next-head-count"] tag, it does not get removed. Being able to change where that element is injected would definitely help.

Alternatively, it might be worth adding a MutationObserver to see if the style element is removed and add it back.

Looking more closely at that Next.js code, it looks like it is trying to avoid exactly this kind of thing (the head count, from the blame patch, represents the amount of elements that Next.js itself inserted, and is intended to make sure it replaces only that many). But it also looks very broken—the updateElements function is called repeatedly, for different types of elements, but it keeps using the same count, even though earlier calls will have mutated the DOM and thus invalidated the count (which is kept only once, not per element type).

CodeMirror injects its styles at the very top of the <head> tag, so that they have the very lowest relative precedence. I’d recommend setting up a simple test case where you inject a small style at the start of the <head> and do the minimum amount of Next.js stuff that makes it disappear, and submitting that as a bug there. There have been similar issues before (this ridiculous code was created as a reaction to one), and they do seem to consider that kind of behavior to be a bug in Next.js.

Edit: It seems there is code that updates the count in there, so it isn’t actually broken in the way I thought it was. Still, if it is deleting nodes that weren’t inserted by Next.js, it is broken somehow.

  useEffect(() => {
    if (!window.MutationObserver) {
      return;
    }

    const observer = new MutationObserver((records) => {
      records.forEach((record) => {
        if (!record.removedNodes.length) {
          return;
        }

        record.removedNodes.forEach((removedNode) => {
          if (removedNode instanceof HTMLStyleElement && !removedNode.hasAttributes()) {
            document.head.append(removedNode);
          }
        });
      });
    });

    observer.observe(document.head, {
      childList: true,
    });

    return () => observer.disconnect();
  }, []);

thx for idea, MutationObserver works)

1 Like
import { useEffect, useRef, useState } from "react";
import type { IUseCodeMirrorProps } from "./useCodeMirror.types";
import { EditorState } from "@codemirror/state";
import { EditorView } from "@codemirror/view";
import { javascript } from "@codemirror/lang-javascript";
import { basicSetup } from "codemirror";

export const useCodeMirror = <T extends Element>(props: IUseCodeMirrorProps) => {
  const { initialDoc, editorClassName = "editor" } = props;
  const containerRef = useRef<T>(null);

  const [editorView, setEditorView] = useState<EditorView>();

  useEffect(() => {
    if (!containerRef.current) {
      return;
    }

    const container = containerRef.current;
    let shadowRoot = container.shadowRoot || undefined;

    if (!shadowRoot) {
      shadowRoot = container.attachShadow({ mode: "open" });
    }

    const div = document.createElement("div");
    div.className = editorClassName;
    shadowRoot.append(div);

    const editorState = EditorState.create({
      doc: initialDoc,
      extensions: [basicSetup, javascript(), EditorView.editable.of(false)],
    });

    const editorView = new EditorView({
      state: editorState,
      parent: shadowRoot.querySelector(`.${editorClassName}`) || undefined,
      root: shadowRoot,
    });

    setEditorView(editorView);

    return () => {
      editorView.destroy();
    };
  }, [editorClassName, initialDoc]);

  return [containerRef, editorView] as const;
};

shadowRoot work too

1 Like

Yeah. I was thinking that, too. I’m not sure how the head count value ends up being correct at the end based on that updateElements function. I’ll dig in, see if I can do a reduced case, and file a bug with Next.

Thanks for putting that together, @dimanik94!