Getting a hold of the `document` used by ProseMirror from `toDOM()`

There are two givens:

  1. ProseMirror takes a document parameter to serializeNode(). (It seems to be optional, but I believe it’s required in SSR.)
  2. ProseMirror allows toDOM() to return a DOM node (a result of document.createElement()).

My assumption is that somewhere in the calling context of toDOM() a document is always present—whether it was passed explicitly in (1) or be it ProseMirror using a global document because it runs in a browser.

Thus the question: wouldn’t it make sense to wire it up so that toDOM()’s body can get a hold of whichever document ProseMirror obtained during DOM serialization (or, more precisely, the createElement() that comes with it)? It seems suboptimal that if toDOM() wants to createElement() it has to come up with its own way of accessing a document that was already passed to PM.

Example:

During SSR my build invokes serializeNode() outside of browser environment. It rolls with (1) and passes it a document stub from JSDOM. JSDOM is a beast I don’t want to lug around at all, but it works (I recall trying some alternatives, but they did not work for PM).

Simultaneously, there are a couple of times where my schema implementation outputs a DOM element in toDOM(). To make those occasions work, I have to either A) (in SSR) patch through a global document where I define schema spec, or B) find a creative way of creating schemas on the fly from functions that I pass an explicit document (either JSDOM in SSR, or a global in browser).

I currently do (A), but it feels wrong.

What I have read:

Relevant, but not helping:

On the one hand, yes, having this would be a little cleaner. On the other hand, this is a browser library, and running it outside of that context is not a very important use case. I suppose you’re already running some of your tests in the browser. If I were starting from scratch I’d probably also run the prosemirror-model tests in a headless browser (as you already mention, JSDOM isn’t perfect). As such, I don’t really feel comfortable adding complexity to the library for this specific situation, and as you found, it’s not hard to work around this.

JSDOM is not perfect

Actually, it seems to be pretty close in terms of feature completeness. The problem is size. If anything, “what is the minimum I need to give ProseMirror for it to work” is the big question. Surely it’s not using the entire API surface of browser’s Document… If document object(s) need to be passed around while generating the site, it would at least help to pass strictly what’s needed.

I can see how it would make PM itself more testable, too (in addition to helping some users not lug around a massive dependency, if they need to build in Node or say in browser’s worker environment that has no access to DOM).

If one was to contribute at least on the documentation front (maybe even on narrowing down typing signatures) what’d be the best way to go about it?

That really doesn’t seem like a reasonable thing to do. You can create Node’s just fine in a DOM-less environment. But you cannot render them. And since you cannot display them either, that seems like it isn’t much of a problem.

You can create Node’s just fine in a DOM-less environment. But you cannot render them. And since you cannot display them either, that seems like it isn’t much of a problem.

The context is rendering static HTML deliverables. PM is called “off the DOM” to convert its JSON content tree to a string. (There are many benefits to using PM here, including stable document appearance to human readers and editors, single DRY code path for obtaining content representation while maintaining fast initial page opening times, graceful degradation without JS [sans node views], etc.)

This part seemed obvious enough that I didn’t explain it in the original post—I must be suffering from a bit of tunnel vision.

If I’m missing some way to accomplish this without a document global and without having to pass document to PM, I’d love to know what it is…

  1. Getting a hold of DOM serializer’s document in toDOM() seems to involve:

    • serializeNodeInner() (pass document to node spec’s toDOM()),
    • serializeNode() (pass document to serializeMark()),
    • serializeMark() (pass document to mark spec’s toDOM()), and
    • updating node/mark spec’s toDOM() TypeScript signature (backwards compatible).

    For example, in case of serializeNodeInner(), just this call (lines split for legibility) would change from

    renderSpec(
      doc(options),
      this.nodes[node.type.name](node),
      null,
      node.attrs)
    

    to

    const dom = doc(options)
    renderSpec(
      dom,
      this.nodes[node.type.name](node, dom),
      null,
      node.attrs)
    
  2. Regarding what document entails, for ProseMirror’s purposes (meaning both what callers must pass to serializeNode() and what it’d pass to spec’s toDOM() per (1) above) it seems to be a tiny subset of Document with only the following:

    • .createTextNode() (called here)
    • .createDocumentFragment() (called here)
    • .createElement[NS]() (called here, and presumably in a toDOM() that wishes to return a DOM node)

    Concerning the HTMLElement or DocumentFragment returned from the above functions, for PM’s purposes it seems they only must implement:

    • .nodeType property, only applicable to HTMLElement
    • .setAttribute[NS]() (called here), only applicable to HTMLElement
    • .appendChild() (called here and here)

    This is based on prosemirror-model’s to_dom.ts, let me know if there’re other modules of concern.

    Narrowing down the type can be done easily with TypeScript’s built-in Pick type:

    type BareDocument = Pick<Document,
      | "createTextNode"
      | "createDocumentFragment"
      | ...>;
    

Notes;

  • The above changes are backward compatible and don’t affect existing users.

  • The changes are independent of each other, though I would say it’d be better to narrow down Document first, if it’s to be added to toDOM()’s signature.

  • In fact, instead of giving toDOM() access to document, even a narrowed-down version, it could be worth passing just an element constructor function:

    toDOM(
      node: Node,
      createDOMElement: BareDocument["createElement"],
    ): ...
    

Aren’t you much better served with the array-style DOMOutputSpec for this situation, since almost anything you can do on a DOM node that you cannot do in such an array will be lost when serializing to HTML text again?

Running DOM creation code outside of the browser just seems like a messy approach. It blurs the lines of what capabilities a piece of code has—authors could be tempted, for example, to try and hook into other client-side APIs in this code.

This situation is kind of on me, for designing the output type of this method this way. But I don’t want to add further oddness to the interface for this use case (I really hope pulling in JSDOM for server-side rendering isn’t something a lot of people are doing). Given that, if you’re committed to that approach, it is just as easy to use your own mechanism to smuggle a reference to a Document instance into your methods, I don’t want to add extra parameters to the library for this.

Aren’t you much better served with the array-style DOMOutputSpec for this situation, since almost anything you can do on a DOM node that you cannot do in such an array will be lost when serializing to HTML text again?

@marijn Regarding toDOM() specifically, I do use arrays as much as feasible, and aim to return arrays everywhere eventually. However, I’m not sure what you mean about lost data. In this approach, a ProseMirror content structure is maintained separately, and the HTML string is obtained only for the purposes of pre-rendering a nice legible static page.

(During editing, once editor JS runs, the full ProseMirror is initialized with nodeViews and everything. Perhaps I’ll share a demo of how it works a bit later.)

Running DOM creation code outside of the browser just seems like a messy approach.

I distinguish between the two:

  1. Returning a DOM node from toDOM() does feel messy, though may be inevitable and is officially supported by ProseMirror.

  2. Obtaining a static HTML from a ProseMirror structure outside of the browser serves what strikes me personally as the opposite of a messy approach. Wouldn’t you agree that the alternative with two distinct code paths for obtaining content’s HTML representation—one for editing with ProseMirror, one for serving to static readers—would be the messy one in this regard?

If (2) requires running DOM creation code, because that’s how ProseMirror renders content, it’s not such a big deal—if we narrow down what DOM-related functionality is needed, it can be a tiny subset provided by a small library.

This situation is kind of on me, for designing the output type of this method this way.

I don’t think it’s a big deal, it serves the purpose, and in all regards ProseMirror is better suited for the clean approach I have in mind than any alternative I have seen so far. Headless DOM is a solved problem, though implementations can be of varying heft and feature completeness.

I don’t want to add further oddness to the interface for this use case

I’d be the first against oddness.

(I really hope pulling in JSDOM for server-side rendering isn’t something a lot of people are doing)

Between the three evils—1) having two code paths for generating content representation (one with PM and one without), or 2) having a web page that renders content only through PM, but only works with JS, or 3) using PM for everything and pre-rendering with the help of a headless DOM implementation—the third one strikes me as the best option by far.

(To reiterate, I think pulling JSDOM can be avoided if it is made clear what PM needs from the DOM. I have an issue to “get rid of JSDOM” and once I narrow down the minimum needed by ProseMirror I’ll certainly do it.)

Given that, if you’re committed to that approach, it is just as easy to use your own mechanism to smuggle a reference to a Document instance into your methods, I don’t want to add extra parameters to the library for this.

The item (2) in my post above does not add any parameters or change implementation, if anything it aims to remove (strictly on type declaration level) the extraneous features of the ever-growing browser’s Document API that ProseMirror does not care about in the first place. This makes it easy for a hypothetical minimal DOM library to say “we implement just this and you can pass our Document to PM in DOM-less environments”.

(The item (1) in my post does change the implementation slightly and adds an extra parameter in toDOM(). This item separate and is not required by item (2).)

Doesn’t this still require the document or some wrapper to be passed to toDOM functions?

In any case, if you’re okay with a narrowed DOM interface, it seems that that would have no more power than the array return format. So I still very much prefer the solution where you port your schema to use that format, and don’t need document at all.

No, the narrowing is fully independent, toDOM functions would be on their own. toDOM fix would benefit from narrowing the Document, but not the other way around.

There are two things somewhat related (they both concern DOM) but also fully independent from each other:

  • Narrowing the type of Document given to ProseMirror when running DOM serializer—this is applicable regardless of whether one returns an array or not.
  • Giving toDOM a handle on [a part of] the Document passed to ProseMirror’s serializer—I noted that you don’t want this, sure.

I see what you mean but the result of these methods is returned from the serializer methods, as type DocumentFragment or Node, and changing that to some narrowed type will break callers who are expecting an actual DOM node.

I see what you mean: basically, for my pre-rendering purposes, the resulting node or fragment probably only has to implement .innerHTML (I grab that and that’s it), but other callers (including potentially PM itself?) may expect a more complete instance.

I rather suspect it should be possible to guarantee with a slightly more conditional typing along the lines of “if you do not provide an explicit Document, you get a full version of what your runtime provides, but if you do, then you shall receive whatever you have given”. This should require just a change in the return signature of serializer, I suppose. In fact, this should also be desirable even now, to any current users who provide their own document parameter to the serializer.

I might come up with a more thorough proposal (perhaps one I will have tested in a fork) and create a separate thread for that, if it’s shown to work as I claim without implementation changes and with minimal typing changes…