-
Notifications
You must be signed in to change notification settings - Fork 950
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
Avoid parsing into DOM when rendering live components #2839
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -17,6 +17,66 @@ import { | |
isCid, | ||
} from "./utils" | ||
|
||
export let modifyRoot = (html, attrs, innerHTML) => { | ||
html = html.trimStart() | ||
let tagStartsAt = null | ||
let pos = 0 | ||
while(pos < html.length){ | ||
let maybeStart = html.indexOf("<", pos) | ||
if(maybeStart === -1){ break } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should you throw instead? Otherwise |
||
if(maybeStart >= 0 && html.charAt(maybeStart + 1) !== "!"){ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think we can remove |
||
tagStartsAt = maybeStart | ||
break | ||
} | ||
pos += html.indexOf("-->", pos) | ||
} | ||
|
||
let commentBefore = tagStartsAt === 0 ? null : html.slice(0, tagStartsAt).trim() | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
then you don't need to deal with nulls later. |
||
let contentAfter = null | ||
html = html.slice(tagStartsAt).trimStart() | ||
let tagNamesEndsAt | ||
for(let i = 1; i < html.length; i++){ | ||
let char = html.charAt(i) | ||
if([">", " ", "\n", "\t", "\r"].indexOf(char) >= 0 || (char === "!" && html.charAt(i + 1) === ">")){ | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What is |
||
tagNamesEndsAt = i | ||
break | ||
} | ||
} | ||
|
||
let tag = html.slice(1, tagNamesEndsAt) | ||
let tagOpenEndsAt = html.indexOf(">") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This does not work because you may have ">" inside an attribute. :D That's why my original suggestion in the gist was to have a list of void tags. Because if you want to check if the tag is actually void, you will get pretty close to having to parse all HTML! |
||
let tagInnerHTML | ||
let closingTag | ||
let isVoid = html.charAt(tagOpenEndsAt - 1) === "/" | ||
let tagOpenContent = isVoid ? html.slice(0, tagOpenEndsAt - 1) : html.slice(0, tagOpenEndsAt) | ||
if(isVoid){ | ||
contentAfter = html.slice(tagOpenEndsAt + 1) || null | ||
} else { | ||
closingTag = `</${tag}>` | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This also does not work, because you may have this: The approach I took in the gist was to check if the html ends with |
||
let tagInnerEndsAt = html.lastIndexOf(closingTag) | ||
tagInnerHTML = html.slice(tagOpenEndsAt + 1, tagInnerEndsAt) | ||
contentAfter = html.slice(tagInnerEndsAt + closingTag.length) | ||
} | ||
|
||
let newAttrs = [] | ||
Object.keys(attrs).forEach(attr => { | ||
if(!tagOpenContent.includes(`${attr}="`)){ newAttrs.push([attr, attrs[attr]]) } | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This can also have false positives, albeit more unlikely. However, my suggestion is to not use the ID at all, let's just inject new/private attributes. |
||
}) | ||
|
||
if(newAttrs.length > 0){ | ||
tagOpenContent = `${tagOpenContent} ${newAttrs.map(([attr, val]) => `${attr}="${val}"`).join(" ")}` | ||
} | ||
let closingContent | ||
if(isVoid){ | ||
closingContent = "/>" | ||
} else { | ||
closingContent = `>${typeof(innerHTML) === "string" ? innerHTML : tagInnerHTML}${closingTag}` | ||
} | ||
let newHTML = tagOpenContent + closingContent | ||
let commentAfter = contentAfter && contentAfter.indexOf("<!--") >= 0 ? contentAfter.trim() : null | ||
return [newHTML, commentBefore, commentAfter] | ||
} | ||
|
||
export default class Rendered { | ||
static extract(diff){ | ||
let {[REPLY]: reply, [EVENTS]: events, [TITLE]: title} = diff | ||
|
@@ -205,55 +265,14 @@ export default class Rendered { | |
|
||
recursiveCIDToString(components, cid, onlyCids, allowRootComments = true){ | ||
let component = components[cid] || logError(`no component for CID ${cid}`, components) | ||
let template = document.createElement("template") | ||
let [html, streams] = this.recursiveToString(component, components, onlyCids) | ||
template.innerHTML = html | ||
let container = template.content | ||
let skip = onlyCids && !onlyCids.has(cid) | ||
let attrs = {[PHX_COMPONENT]: cid, id: `${this.parentViewId()}-${cid}`} | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Could we use the new phx-magic-id already and battle test it within components? This way we don't need to mangle the ID either in |
||
if(skip){ attrs[PHX_SKIP] = ""} | ||
let [newHTML, commentBefore, commentAfter] = modifyRoot(html, attrs, skip ? "" : null) | ||
if(allowRootComments){ newHTML = `${commentBefore || ""}${newHTML}${commentAfter || ""}` } | ||
|
||
let [hasChildNodes, hasChildComponents] = | ||
Array.from(container.childNodes).reduce(([hasNodes, hasComponents], child, i) => { | ||
if(child.nodeType === Node.ELEMENT_NODE){ | ||
if(child.getAttribute(PHX_COMPONENT)){ | ||
return [hasNodes, true] | ||
} | ||
child.setAttribute(PHX_COMPONENT, cid) | ||
if(!child.id){ child.id = `${this.parentViewId()}-${cid}-${i}` } | ||
if(skip){ | ||
child.setAttribute(PHX_SKIP, "") | ||
child.innerHTML = "" | ||
} | ||
return [true, hasComponents] | ||
} else if(child.nodeType === Node.COMMENT_NODE){ | ||
// we have to strip root comments when rendering a component directly | ||
// for patching because the morphdom target must be exactly the root entrypoint | ||
if(!allowRootComments){ child.remove() } | ||
return [hasNodes, hasComponents] | ||
} else { | ||
if(child.nodeValue.trim() !== ""){ | ||
logError("only HTML element tags are allowed at the root of components.\n\n" + | ||
`got: "${child.nodeValue.trim()}"\n\n` + | ||
"within:\n", template.innerHTML.trim()) | ||
child.replaceWith(this.createSpan(child.nodeValue, cid)) | ||
return [true, hasComponents] | ||
} else { | ||
child.remove() | ||
return [hasNodes, hasComponents] | ||
} | ||
} | ||
}, [false, false]) | ||
|
||
if(!hasChildNodes && !hasChildComponents){ | ||
logError("expected at least one HTML element tag inside a component, but the component is empty:\n", | ||
template.innerHTML.trim()) | ||
return [this.createSpan("", cid).outerHTML, streams] | ||
} else if(!hasChildNodes && hasChildComponents){ | ||
logError("expected at least one HTML element tag directly inside a component, but only subcomponents were found. A component must render at least one HTML tag directly inside itself.", | ||
template.innerHTML.trim()) | ||
return [template.innerHTML, streams] | ||
} else { | ||
return [template.innerHTML, streams] | ||
} | ||
return [newHTML, streams] | ||
} | ||
|
||
createSpan(text, cid){ | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,73 @@ | ||
import {modifyRoot} from "phoenix_live_view/rendered" | ||
|
||
describe("modifyRoot stripping comments", () => { | ||
test("starting comments", () => { | ||
// starting comments | ||
let html = ` | ||
<!-- start --> | ||
<!-- start2 --> | ||
<div class="px-51"><!-- MENU --><div id="menu">MENU</div></div> | ||
` | ||
let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) | ||
expect(strippedHTML).toEqual("<div class=\"px-51\"><!-- MENU --><div id=\"menu\">MENU</div></div>") | ||
expect(commentBefore).toEqual(`<!-- start --> | ||
<!-- start2 -->`) | ||
expect(commentAfter).toEqual(null) | ||
}) | ||
|
||
test("ending comments", () => { | ||
let html = ` | ||
<div class="px-52"><!-- MENU --><div id="menu">MENU</div></div> | ||
<!-- ending --> | ||
` | ||
let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) | ||
expect(strippedHTML).toEqual("<div class=\"px-52\"><!-- MENU --><div id=\"menu\">MENU</div></div>") | ||
expect(commentBefore).toEqual(null) | ||
expect(commentAfter).toEqual("<!-- ending -->") | ||
}) | ||
|
||
test("staring and ending comments", () => { | ||
let html = ` | ||
<!-- starting --> | ||
<div class="px-53"><!-- MENU --><div id="menu">MENU</div></div> | ||
<!-- ending --> | ||
` | ||
let [strippedHTML, commentBefore, commentAfter] = modifyRoot(html, {}) | ||
expect(strippedHTML).toEqual("<div class=\"px-53\"><!-- MENU --><div id=\"menu\">MENU</div></div>") | ||
expect(commentBefore).toEqual(`<!-- starting -->`) | ||
expect(commentAfter).toEqual(`<!-- ending -->`) | ||
}) | ||
test("merges new attrs", () => { | ||
let html = ` | ||
<div class="px-5"><div id="menu">MENU</div></div> | ||
` | ||
expect(modifyRoot(html, {id: 123})[0]).toEqual(`<div class="px-5" id="123"><div id="menu">MENU</div></div>`) | ||
expect(modifyRoot(html, {id: 123, class: ""})[0]).toEqual(`<div class="px-5" id="123"><div id="menu">MENU</div></div>`) | ||
expect(modifyRoot(html, {id: 123, another: ""})[0]).toEqual(`<div class="px-5" id="123" another=""><div id="menu">MENU</div></div>`) | ||
// clearing innerHTML | ||
expect(modifyRoot(html, {id: 123, another: ""}, "")[0]).toEqual(`<div class="px-5" id="123" another=""></div>`) | ||
// self closing | ||
let selfClose = ` | ||
<input class="px-5"/> | ||
` | ||
expect(modifyRoot(selfClose, {id: 123, another: ""})[0]).toEqual(`<input class="px-5" id="123" another=""/>`) | ||
}) | ||
test("mixed whitespace", () => { | ||
let html = ` | ||
<div | ||
${"\t"}class="px-5"><div id="menu">MENU</div></div> | ||
` | ||
expect(modifyRoot(html, {id: 123})[0]).toEqual(`<div | ||
${"\t"}class="px-5" id="123"><div id="menu">MENU</div></div>`) | ||
expect(modifyRoot(html, {id: 123, class: ""})[0]).toEqual(`<div | ||
${"\t"}class="px-5" id="123"><div id="menu">MENU</div></div>`) | ||
expect(modifyRoot(html, {id: 123, another: ""})[0]).toEqual(`<div | ||
${"\t"}class="px-5" id="123" another=""><div id="menu">MENU</div></div>`) | ||
// clearing innerHTML | ||
expect(modifyRoot(html, {id: 123, another: ""}, "")[0]).toEqual(`<div | ||
${"\t"}class="px-5" id="123" another=""></div>`) | ||
// self closing | ||
let selfClose = `<input${"\t\r\n"}class="px-5"/>` | ||
expect(modifyRoot(selfClose, {id: 123, another: ""})[0]).toEqual(`<input${"\t\r\n"}class="px-5" id="123" another=""/>`) | ||
}) | ||
}) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
You shouldn't need
trimStart()
.