-
Notifications
You must be signed in to change notification settings - Fork 300
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Proposal: a DocumentFragment whose nodes do not get removed once inserted #736
Comments
FWIW this is apparently a very demanded feature from the Web Developers community Previously on a similar proposal: https://discourse.wicg.io/t/proposal-live-fragments/2507 |
As simplified approach, and for demo sake, I'll leave a playground that works out of the box in Chrome Canary (but not yet in Code Pen, however I've informed them about it) Now I'll wait for any outcome 👋 |
@annevk just FYI I think there's a typo in the title: onse => once also, if I might ask, what does the label "needs implementer interest" mean? What can I do to move this forward? Should it be me the implementer? Thanks. |
Do https://whatwg.org/working-mode#changes and https://whatwg.org/faq#adding-new-features help? I suspect we'll need something like this to build templating on top of. |
This is interesting. Other names I've seen used to casually refer to something along these lines are "Persistent Fragment" (given that it doesn't empty when appended) and just "Range Fragment". Example: https://discourse.wicg.io/t/proposal-fragments/2312 |
Edit: I didn't see that in this version of the idea the fragment doesn't live in the tree. I think that makes it functionally equivalent to NodePart from TemplateInstantiation, or a wrapper around StaticRange, and addresses most of the issue below, which were based on other variations of the idea I'm familiar with. @annevk I think the Template Instantiation One concern with a new node type is that much existing tree traversal code will not know how to handle it, so a live fragment and its children will likely be skipped. Depending on where such live fragments are intended to be used this may or may not be a problem. There is a lot of code out there that assumes that only Elements can contain other Elements. Another concern is that right now I believe that ever We'd also have to consider how other APIs work. Do live fragments show up on event.path? Can children of a live fragment be slotted into the fragment's parent's ShadowRoot? etc... I'm not sure if the issues are insurmountable, but I've been working on Template Instantiation with the theory that the least disruption will be caused with a new non-Node interface that lives outside the tree, like Range. Then all existing tree processing code will work as-is. |
Yeah, we've definitely considered this approach before making the template instantiation proposal but fundamentally, all we need is tracking where the inserted contents need to be. I don't think there is any reason to create a new node type and keep it in the DOM if we can avoid it. |
Nothing is kept in the DOM. It's a fragment that acts like a fragment. |
So, I've uploaded the previously mentioned polyfill, which should have 100% code coverage. There is a live test page too. the whatThe idea is to have a 1:1 DocumentFragment alter-ego that doesn't lose its nodes. The fragment is exposed only to the owner, so that there is no way to retrieve it from third parts, unless passed around, and there's nothing live on the DOM, if not, eventually its child nodes. The proposal exposes to the owner common nodes methods based on its content. As example, The DPF (in short) has only one extra method, compared to DocumentFragment, which is All operations performed through the DPF are reflected live on the document, and while this might be just a stretch goal, it is super easy and nice to simply update an owned reference and see everything changing live. Nodes ownershipIt is possible to grab random nodes and destroy these, or change these, affecting indirectly the content owned by the DPF instance, but it's always been possible to be obtrusive on the DOM and destroy third parts libraries so I think this shouldn't be concern. However, I could implement the This, however, would introduce an ownership concept that is too different from what we've used so far, but I believe this proposal is for all libraries that need such persistent fragment, and that owns their own nodes, libraries that are currently somehow already breaking things if 3rd parts obtrusive libraries destroy, or manipulate, DOM nodes in the wild. As summaryThe fact, beside some Safari glitch I'm sure I can solve, this whole proposal can be already polyfilled, and the fact browsers have a way to optimize it and make it blazing fast, should be considered as a plus, 'cause instantly adoptable by the community, so that we can have quick feedbacks of how much this is welcomed or needed out there. Please don't hesitate to file issues there or ask me more here before discarding this proposal. Thank You. |
@rniwa FYI I've filed the bug that makes current polyfill edit I've fixed the current polyfill with a workaround after a feature detection, so this can work on Safari/WebKit too 👋 |
Then what you created is indistinguishable from |
@rniwa |
It's nothing to do with Shadow DOM.
In the latest iterations of the proposal @justinfagnani at Google and we're working on, NoteTemplatePart is a thing that could be used without any template although we probably need to rename it to something else. |
@rniwa I've no idea what is this It's a document-fragment at all effects, it's indeed inheriting the same constructor, but it works transparently and only if the owner/creator keeps a reference around. If this is exactly what this Thanks. |
would you mind amending/canceling that comment since nothing in there is relevant to this proposal, so that people don't get distracted by concerns that are not part of this proposal? links to the solutions previously discussed would be more than welcome too. Thanks. |
The context you're missing is https://github.com/w3c/webcomponents/blob/gh-pages/proposals/Template-Instantiation.md and some F2F discussion (probably somewhere in minutes linked from issues in that repository) that encouraged making the parts there true primitives instead of tightly coupled with templating. (Also, please try to consolidate your replies a bit. Each new comment triggers a new notification for some and there's over a hundred people watching this repository. When in doubt, edit an existing reply.) There's another meeting coming up, and I hope @justinfagnani and @rniwa can make the current iteration a bit more concrete by then, as referencing it in this issue as if it's a thing everyone should be aware of is a lil weird. |
Thank you, I'm glad I read the second part of that comment about true primitives. I had read that proposal before and admittedly a little terrified that the DOM spec would include such an opinionated implementation. That spec reads like designing a render framework. I mean I'm sure we could do worse, but I'm encouraged to know that simpler proposals are under consideration. What I like about this proposal is the transparent use with Node API's appendChild/removeChild since it can be treated interchangeably with actual DOM nodes when say returned from some sort of Template instantiation. I think ownership is the challenge with a lot of value comes from having a clear single ancestor(whether single node or fragment). It lets JS context be tied to specific parent nodes without making more elements in the DOM. But by their very nature I don't see how you'd avoid nesting. Like a loop over these fragments with a top level conditional in it that also returns a fragment. In the end the DOM structure would be pretty flat but you'd have persistent fragments in persistent fragments. Since they aren't part of the actual rendered tree it becomes harder to understand what falls under each since nested dynamic content in this case could change what is in the top fragment. I would absolutely love to see a solution in this space having spent a lot of time figuring out tricks to accomplish similar things. Everyone writing a render library where they don't virtualize the tree hits this issue sooner or later. |
which is fine, and since appending a fragment to itself breaks, there's nothing different on the DOM. Current implementation / proposal allows shared nodes between fragments, which is the same as creating a fragment on the fly and append any node found in the wild: nothing stops your from doing that, everything works, no error thrown. The current idea is that keeping it simple is the only way to quickly move forward, while any ownership concept would require bigger, non backward compatible, changes. Libraries and frameworks authors won't worry about that anyway, 'cause they are the one creating nodes too, and they are those virtualizing thee in trees. Having a mechanism to move N nodes at once, accordingly with any DPF appended live, it's also a feature that would simplify state-machine driven UIs. Last, but not least, hyperHTML has this primitive since long time and it works already, but it doesn't play super nice with the DOM if thrown there as is, and it requires special treatment when used right away. This proposal would cover that case can much more. |
Actually CData sections and processing instruction nodes are simply completely broken in |
Is there any continued interest in this proposal? Would a persistent fragment have access to some DOM methods like |
I like this proposal a lot more after sitting with it a bit longer. At first I was thinking this was about library code managing the moving and managing of ranges of elements, but this is more. This helps with giving the users of said libraries the equivalent of React JSX Fragments. Like consider: const div = html`<div></div>`
const frag = html`<div></div><div></div>`
// further down
const view = html`<div>${ condition ? div : frag }</div>` I had a user ask why the div always worked, but the second time they attached the fragment why did it not render anything. The answer of course was that In a library based on KVO Observables top level dynamic updates that execute independently of top down reconciliation having a standardized DOM API that works the same whether attached or not is hugely helpful in this scenarios. Beyond that all these libraries have a similar concept. Being just a DOM node works very consistently with the whole // tagged templates
const el = html`______` //or
// jsx
const el = <______ /> way of approaching rendering which has been gaining steam (looking at the API's on the top end of performance in the JS Frameworks Benchmark). More and more non-virtual DOM libraries are picking this approach and showing it is performant. |
For <template> nodes we need to grab from content.childNodes. Closures around a template can also be expected to only care about node.childNodes while other closures might want the complete node. All of this is in preparation for unifying the whole append/replace/remove node shebang. We wouldn't have to if it wasn't for DocumentFragments. As document fragments 1. do not have persistent children (i.e. cannot be appended multiple times) and 2. are not positioned (they don't have a position in the document when they have 0 children) we have to do the housekeeping ourselves and wrap those methods. It's nice [1] to know others are sad about this as well [2]. [1] https://xkcd.com/979/ [2] whatwg/dom#736
For <template> nodes we need to grab from content.childNodes. Closures around a template can also be expected to only care about node.childNodes while other closures might want the complete node. All of this is in preparation for unifying the whole append/replace/remove node shebang. We wouldn't have to if it wasn't for DocumentFragments. As document fragments 1. do not have persistent children (i.e. cannot be appended multiple times) and 2. are not positioned (they don't have a position in the document when they have 0 children) we have to do the housekeeping ourselves and wrap those methods. It's nice [1] to know others are sad about this as well [2]. [1] https://xkcd.com/979/ [2] whatwg/dom#736
@WebReflection (I was the one pinging you on twitter, too) I have a few more questions :):
For context: how I found this proposal was that I would like to keep track of nodes added from a document fragment so I could update or remove them later. But (differently from this proposal?) after adding the contents of the fragment to live DOM I wouldn't add nodes inserted in between the original fragment contents to the fragment. (I.e if a fragment had two EDIT: And one more question: does the persistent fragment get moved to the live DOM? I.e after appending a persistent fragment to live DOM do its content nodes still have the persistent fragment as parent, or is the new parent the element into which the fragment was appended to? Do the elements in the live DOM still somehow point back to the persistent parent? |
@eyeinsky apologies for the late reply, I have missed these questions, somehow. To me, the behavior is the one provided by the polyfill, meaning that:
This proposal tackle a very specific need for libraries that do diffing, either vDOM or directly live on the DOM, without needing 3rd party helpers (that's just an example, many libraries authors confirmed they need what my polyfill provides). About the last question: the polyfill shows what's meant. The persistent fragment is not live, or should never be discoverable live as a node, so its childNodes will have regular parentNode, like it is for regular fragments, but it's possible to move, or remove, persistent fragments. |
Hello, Web reflection 👋. I am Charles Ikechukwu, the creator of the NixixJS framework. I was reading about the document fragment web API stumbled upon the use of a Livefragment. I read all your comments on this and even tried the code example to see how I can build such. But it didn't work at all. Would you mind explaining the code example a bit to me? |
This NodeTemplatePart must be something related to Lit-html or LitElement, because the have identifier names mostly suffixed "part" |
@michTheBrandofficial there is a repo https://github.com/WebReflection/document-persistent-fragment and a live test page that test everything that repo offers https://webreflection.github.io/document-persistent-fragment/ |
Thank you, I will look into it now 🙂 |
Came here to suggest something very similar (possibly the same?). Basically a For example, in the following structure: <select>
<#fragment>
<option></option>
<#/fragment>
</select> Here's what
This also means if there are no references to it, it can be garbage collected, making it potentially possible to perhaps not even subclass There could even be a declarative version like A primary use case is indeed, templating, but in the broader sense, that includes reactive conditionals, loops, etc. For regular templating that is output-only, maintaining references to fragments is less useful. But every reactive templating language (VueJS, Alpine, Mavo, etc) needs a primitive like this and currently either forces users to use containers (and a valid option does not even always exist), or does complicated stuff with markers, HTML comments, and whatnot. Given the overwhelming support expressed via reactions in the first post, I’m surprised there isn't more implementor interest… |
Second that, this is one of the "obvious" things that should be shipped, I've no idea how nobody is working on this yet. |
for what I could tell, there was only one main early blocker that kinda disappeared but no solution is still out there: #736 (comment) oddly enough, this feature would've unlocked way more lit "power" if already implemented. |
Hello, @WebReflection I have a fully working solution for this. The code is https://github.com/michTheBrandofficial/NixixJS/tree/main/live-fragment |
@michTheBrandofficial so do I #736 (comment) the point is that we should really have this backed in as opposite of having dozen implementations of the same thing, imho. |
Reading through this issue, I think (although I might be misreading some of the comments) that different people involved have been proposing or thinking of two different things here: Proposal A:
Proposal B:
For some of the comments above it's clear to me which of A or B are being described -- but there are a bunch of comments where it's not clear to me, so it's hard for me to tell which of these has more interest (and also for which uses the less-desired one would still be acceptable). |
All issues solved in a way or another by my libraries ... a persistent fragment pre-inject a pinned comment and append a pinned comment too, so that any dom operation in it will simply trap live nodes between the pinned node and the outer ... these are comments with content |
P.S. nobody might like pre-inserted or appended comments out of the blue, but that's what any hydration story would dream about from libraries authors. edit If implementation details are needed, any PersistentFragment would have a skip node on firstChild and lastChild as their pinned comments shold be out of equations, for any internal DOM related operation too. |
I don't understand how this proposal is different than a JavaScript array of nodes. |
I think because it's live: the DOM tree where it's inserted would continue to reflect changes made to the fragment, and (probably) vice-versa. |
From the title I was expecting this to be a standard |
Yup, looking back to my use cases, this would be an actual part of the DOM in terms of parentNode/childNodes, but would not affect CSS selector matching. Though now I think perhaps a way to wrap lightweight shadow trees around arbitrary nodes may be a more general solution. But that needs a ton more fleshing out before becoming an actual proposal. |
if we could append a ShadowRoot as entity a part detached from its parent, that'd be it indeed ... but that would also feel like some SD abuse and it could be done via a persistent fragment added as node that could work even within ShadowRoots, imho. P.S. that also wouldn't solve tbody and ul/ol use cases for LI or TD/TR nodes |
@thoughtsunificator that would still require an explicit "PersistentFragment" definition because somebody could reuse a document fragment to append or move other nodes later on. In that regard, having a For runtime/dynamic sake though, that fragment should be able to perform regular operations and update such internal list of nodes without needing to remove these from the living DOM. example const a = document.createTextNode('a');
const c = document.createTextNode('c');
const fragment = new PersistentFragment(a, c);
fragment.ownNodes; // [a, c]
document.body.append(fragment);
document.body.textContent; // ac
fragment.insertBefore(document.createTextNode('b'), c);
fragment.ownNodes; // [a, b, c]
document.body.textContent; // abc In this scenario fragment can play around its own childNodes but also live-update their state if their are live and not still strictly attached or contained within that fragment. Implementation Details
This makes things robust for libraries authors and sloppy for obtrusive JS code that changes stuff without ownership. The only extra, optional, field needed for Elements in general, is an This helps persistent-fragment operations to also validate initial boundaries around nodes when a move or remove operation is performed, so that elements in between that don't belong to the persistent-fragment itself could be safely dropped from the current node. If needed, I could provide another implementation of this idea, as it might even be better than the one I've provided ages ago, just let me know. |
OK, I went ahead and created this implementation which leaves just a few details to discuss but it works already great: persistent-fragment.js import native from 'https://esm.run/custom-function/factory';
const ownedNodes = new Map;
const { defineProperty, getPrototypeOf, hasOwn } = Object;
const augment = (proto, key) => {
if (hasOwn(proto, key)) {
const native = proto[key];
defineProperty(proto, key, {
value(...args) {
return native.apply(this, args.map(asValueOf));
}
});
}
};
const asValueOf = node => node instanceof PersistentFragment ? node.valueOf() : node;
const illegalOperation = () => { throw new Error('Illegal operation') };
const fr = new FinalizationRegistry(value => {
for (const [node, owner] of ownedNodes) {
if (owner === value)
ownedNodes.delete(node);
}
});
export default class PersistentFragment extends native(DocumentFragment) {
#childNodes;
#owner = Symbol();
constructor(...childNodes) {
super(document.createDocumentFragment());
this.#childNodes = childNodes.map(asOwnedNode, this.#owner);
super.replaceChildren(...childNodes);
fr.register(this, this.#owner);
}
append(...childNodes) {
childNodes = childNodes.map(asOwnedNode, this.#owner);
if (this.#childNodes.length)
this.#childNodes.at(-1).after(...childNodes);
else
super.append(...childNodes);
this.#childNodes.push(...childNodes);
}
appendChild(childNode) {
const node = asOwnedNode.call(this.#owner, childNode);
if (this.#childNodes.length)
this.#childNodes.at(-1).after(node);
else
super.appendChild(node);
this.#childNodes.push(node);
return node;
}
insertBefore(childNode, ownedNode) {
if (ownedNode) {
if (ownedNodes.get(ownedNode) === this.#owner) {
ownedNode.before(asOwnedNode.call(this.#owner, childNode));
const i = this.#childNodes.indexOf(ownedNode);
this.#childNodes.splice(i, 0, childNode);
}
else illegalOperation();
}
else this.appendChild(childNode);
return childNode;
}
prepend(...childNodes) {
childNodes = childNodes.map(asOwnedNode, this.#owner);
if (this.#childNodes.length)
this.#childNodes.at(0).before(...childNodes);
else
super.prepend(...childNodes);
this.#childNodes.unshift(...childNodes);
}
removeChild(childNode) {
const i = this.#childNodes.indexOf(childNode);
if (i < 0) illegalOperation();
ownedNodes.delete(childNode);
this.#childNodes.splice(i, 1);
childNode.remove();
return childNode;
}
replaceChildren(...childNodes) {
this.#childNodes.forEach(ownedNodes.delete, ownedNodes);
this.#childNodes = childNodes.map(asOwnedNode, this.#owner);
super.replaceChildren(...this.#childNodes);
}
valueOf() {
if (this.#childNodes.at(0)?.parentNode !== this)
super.replaceChildren(...this.#childNodes);
return this;
}
}
function asOwnedNode(childNode) {
const wr = ownedNodes.get(childNode);
if (!wr) ownedNodes.set(childNode, this);
else if (wr !== this) illegalOperation();
return childNode;
}
// patch globals
const [
{ prototype: E },
{ prototype: N },
{ prototype: PF },
{ prototype: CD },
] = [
Element,
Node,
PersistentFragment,
getPrototypeOf(Text),
];
for (const key of Reflect.ownKeys(PF)) {
if (key === 'constructor') continue;
const { [key]: value } = PF;
if (typeof value !== 'function') continue;
for (const proto of [N, E]) augment(proto, key);
}
for (const key of ['after', 'before']) {
for (const proto of [CD, E]) augment(proto, key);
} index.html <!doctype html>
<script type="module">
import PersistentFragment from "./persistent-fragment.js";
const text = value => document.createTextNode(value);
const a = text('a');
const c = text('c');
const pf = new PersistentFragment(a, c);
const hr = document.createElement('hr');
document.body.append(pf, hr);
setTimeout(
() => {
pf.insertBefore(text('b'), c);
setTimeout(
() => {
hr.after(pf);
},
1000
);
},
1000
);
</script> You would see I think this simplification is superior to my original proposal as it answers tons of questions and simplifies everything that needs simplification around this topic and I believe it would be an awesome feature to have for any library author out there. |
@thoughtsunificator my latest idea is that a fragment ownership that transparently operates with its nodes could be a great deal/compromise:
From implementation perspective it's all about adding an extra P.S. the ownership could also be through a WeakMap that relate internally nodes to their owner fragment so that edit I've changed the code to avoid leaks in the wild ... now you either own that fragment and you can operate with it, or it's entirely invisible to the DOM. |
Your polyfill won't work with empty fragments: const fragment = new PersistentFragment();
const textA = document.createTextNode('a');
const textB = document.createTextNode('b');
document.body.append(document.createElement('hr'), fragment);
fragment.append(textA)
document.body.append(document.createElement('hr'));
fragment.append(textB) Even loses where it's as soon as it has no child: const textA = document.createTextNode('a');
const textB = document.createTextNode('b');
const fragment = new PersistentFragment(textA);
document.body.append(document.createElement('hr'), fragment);
textA.remove()
fragment.append(textB)
document.body.append(document.createElement('hr')); Still requires some node in it to follow itself in the DOM: const fragment = new PersistentFragment(document.createComment(''));
const textA = document.createTextNode('a');
const textB = document.createTextNode('b');
document.body.append(document.createElement('hr'), fragment);
fragment.append(textA)
document.body.append(document.createElement('hr'));
fragment.append(textB) Empty fragments are important for conditional rendering with signals. That's why comment nodes are the work around atm. They let frameworks to follow empty fragments. If an author or framework is using persistent fragments they expect it anyway. At this point why complicate things while we can just have normal parent child relationship of nodes? When I insert a fragment inside a And while there;Why not just let authors follow lifecycle of not just custom elements, but any element they want as well as fragment. So frameworks doesn't break when you touch DOM with js or devtools. |
my current code uses those indeed, as lastChild reference, but those can be eagerly removed too, so I am most sure what's your point there but my point was to show easier alternative to something that otherwise would never land on DOM. edit as reminder, I am the author of the polyfill that works the way you describe, so let's remember as that went nowhere, we can discuss alternatives ... if a real persistent fragment can exist as transparent node for the DOM like ShadowDOM somehow is, it'd be +1 to that, but that never happened and nothing is happening in years so take every alternative example I'll propose as desperate as long as something happens, which doesn't seem to be the case, but all variants, have been tested, or are used in production already: pick one! |
edit I wouldn't mind
Node.DOCUMENT_PERSISTENT_FRAGMENT_NODE
name/kind neitherTL;DR
The document fragment is a great primitive to wrap together numerous nodes and append these directly as batch, however a fragment loses all its children as soon as appended, making it's one-off usage limited in those cases where a list of nodes, at a certain position, is meant.
This proposal would like to explore the possibility of a live document fragment that:
<TD>
or<TR>
through such fragment, and keep a reference for future updatesWhy
Both virtual DOM based libraries, such React, as well as direct DOM based one, such as hyperHTML, or lit-html, have been implementing their own version of a persistent fragment in a way or another.
If there was a primitive to directly reference more than a node, through a fragment with a well known position on the DOM, I am pretty sure all libraries would eventually move to adopt such primitive, so that a tag function could handle both
<p>1</p>
and<p>1</p><p>2</p>
without needing to re-invent a similar wheel every single time, and making portability between libraries and frameworks easier than ever: it's just a DOM node!Example
There should be no way to interfere with CSS and/or selectors, the fragment is either referenced somewhere else or it won't exist for the DOM.
How
The way hyper/lit-html are doing this is by abusing comment nodes as boundaries of these virtual fragments. The itchy part of these libraries is mostly represented by these virtual fragments, 'cause it's obvious if the primitive proposed here would exists, these libraries would've used it instead (happy to be corrected, but at least I would never create my own virtual fragment if I could use something else).
The way this could be implemented, is by weakly referencing nodes to such fragment only if this is held in memory.
Possible F.A.Q. Answers
childNodes
as immutable, as it is for regular fragmentsvalueOf()
, thereferences
part can be ignoredThanks in advance for eventually considering this, happy to answer any possible question.
The text was updated successfully, but these errors were encountered: