Fix duplicate constructor call during analysis.#352
Conversation
| assert(!!container2.querySelector('#target')); | ||
| }); | ||
|
|
||
| it('does not create phantom instances during template analysis', () => { |
There was a problem hiding this comment.
First we test the thing. This does indeed fail ahead of adding the fix. Hopefully this reads clearly enough that the expectation is “constructor should only be called once if it’s only meant to render once.”
There was a problem hiding this comment.
Nice test. Perhaps the test should read it('only calls the constructor once in the active document context') or similar?
There was a problem hiding this comment.
I am going to change it to does not call custom element constructor during template analysis — which is about as specific as I can think. This really is about preventing this single inadvertent constructor invocation during template analysis.
The user expectation is “I expect the constructor to be called if and only if it is being created for use in my DOM” (or something). The user does not expect that an internal implementation detail to cache a computation for later re-use would also call that constructor — which is what we are now guarding against with the test.
| static #fragment = new DocumentFragment(); | ||
| // Inert document used during template analysis to avoid triggering custom | ||
| // element constructors when building the template fragment. | ||
| static #document = document.implementation.createHTMLDocument(''); |
There was a problem hiding this comment.
Here’s the sneak: createHTMLDocument. I believe this is what dom purifiers will use as well. It’s definitely reserved for meta-program-y stuff, but I think we are within the target use-case here.
There was a problem hiding this comment.
Big sneaky. TIL document.implementation
| // Create and prepare a document fragment to be injected. | ||
| const { [TemplateEngine.#ANALYSIS]: analysis } = rawResult; | ||
| const fragment = analysis.fragment.cloneNode(true); | ||
| const fragment = document.importNode(analysis.fragment, true); |
There was a problem hiding this comment.
Here’s where we go from inert document to real document (and the constructor actually gets called).
There was a problem hiding this comment.
Could this mix contexts? globalThis.document and analysis.fragment.ownerDocument are not guaranteed to be the same. Maybe a simple fix is something like analysis.fragment.ownerDocument.importNode(analysis.fragment, true) or similar. The primary goal: pure static function should avoid consulting any externals whenever possible
There was a problem hiding this comment.
It will always be the case that analysis.fragment.ownerDocument !== globalThis.document. That’s by design. I think the point you are making is that node.ownerDocument doesn’t necessarily equal globalThis.document (perhaps if there is some cross-iframe usage or something happening 🤔).
I’m not sure on the exact mechanism by which that would happen, but I am not opposed to refactoring things from a philosophical perspective. We can absolutely replace globalThis.document with node.ownerDocument in all the functions. The node must exist (or we’d have a bug) so this lookup should reliably succeed.
I can make that change, but lemme know if I’m missing a broader point you are trying to make!
There was a problem hiding this comment.
awesome — "strict functional purity" sounds like the move
bc27dda to
9717af8
Compare
|
And… the remaining burning question… performance? I would have to do a deeper dive to make any claims that it improves things. At a minimum it doesn’t seem to greatly degrade things. In cursory testing, I might see a slight improvement at |
9717af8 to
cc4f563
Compare
| // textNode.textContent = value; | ||
| const textNode = document.createTextNode(value ?? ''); | ||
| const ownerDocument = node.ownerDocument; | ||
| const textNode = ownerDocument.createTextNode(value ?? ''); |
There was a problem hiding this comment.
Note that we now consistently compute the ownerDocument versus assume it will be equivalent to globalThis.document. While we don’t yet have a true use-case for this, from a philosophical perspective, there is no reason to depend on a global in these functions, so why do it 🤘
| // setting its textContent. It’s exactly equivalent to the | ||
| // following code, but faster. | ||
| // const textNode = document.createTextNode(''); | ||
| // const textNode = ownerDocument.createTextNode(''); |
There was a problem hiding this comment.
const textNode = ownerDocument.createTextNode('').textContent = value;
There was a problem hiding this comment.
Gonna leave this one as is. We chatted a while back about this one case where we would do some magic (value ?? '') as a performance gain so long as we documented why it is in fact not a philosophical departure and not actually non-default behavior. Point taken about the other dead code in the codebase — we can work to migrate those to PRs or something in the future 🤘
|
Awesome, thank you! LG2M! |
cc4f563 to
0d05ba5
Compare
Previously, we used the global `document` to `createElement` during analysis when the initial fragment is being created. Because the global document has _context_, it can lookup / construct custom elements during analysis. However, this feels unexpected from a developer perspective that you would get multiple invocations of the constructor “for a single render”. I.e., one for the cached fragment computation, and then one for each actual injection into the connected DOM. We can fix this by creating an inert, contextless document and using _that_ to create / manage cached fragment computations. Only when you then call `importNode` on this inert fragment to pull it into the _real_ document, does the custom element lookup succeed and the constructor is called, once! Note: We also make a small change to consistently use a node’s owner document when using document APIs (e.g., `createTextNode`). While it would be rare that `node.ownerDocument !== globalThis.document`, from a philosophical perspective, there is no need to depend on an external global in our functions and the input node is guaranteed to exist. Closes #351.
0d05ba5 to
2a63fdd
Compare
|
Final passing thought — by using Gonna merge. |
Previously, we used the global
documenttocreateElementduring analysis when the initial fragment is being created. Because the global document has context, it can lookup / construct custom elements during analysis.However, this feels unexpected from a developer perspective that you would get multiple invocations of the constructor “for a single render”. I.e., one for the cached fragment computation, and then one for each actual injection into the connected DOM.
We can fix this by creating an inert, contextless document and using that to create / manage cached fragment computations. Only when you then call
importNodeon this inert fragment to pull it into the real document, does the custom element lookup succeed and the constructor is called, once!Closes #351.