Skip to content
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

Merged
merged 4 commits into from
Oct 3, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 65 additions & 46 deletions assets/js/phoenix_live_view/rendered.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,66 @@ import {
isCid,
} from "./utils"

export let modifyRoot = (html, attrs, innerHTML) => {
html = html.trimStart()
Copy link
Member

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().

let tagStartsAt = null
let pos = 0
while(pos < html.length){
let maybeStart = html.indexOf("<", pos)
if(maybeStart === -1){ break }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should you throw instead? Otherwise html.slice() later will fail in confusing ways.

if(maybeStart >= 0 && html.charAt(maybeStart + 1) !== "!"){
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can remove maybeStart >= 0, right?

tagStartsAt = maybeStart
break
}
pos += html.indexOf("-->", pos)
}

let commentBefore = tagStartsAt === 0 ? null : html.slice(0, tagStartsAt).trim()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  let commentBefore = html.slice(0, tagStartsAt).trim()

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) === ">")){
Copy link
Member

@josevalim josevalim Oct 3, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is char === "!" about?

tagNamesEndsAt = i
break
}
}

let tag = html.slice(1, tagNamesEndsAt)
let tagOpenEndsAt = html.indexOf(">")
Copy link
Member

Choose a reason for hiding this comment

The 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}>`
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This also does not work, because you may have this: <div>Foo<div></div>Baz</div> and now you will get the closing to the wrong div.

The approach I took in the gist was to check if the html ends with -->, if so, you need to remove the comments at the end. Once comments are stripped, the closing tag will necessarily be at the end of the document.

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]]) }
Copy link
Member

Choose a reason for hiding this comment

The 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
Expand Down Expand Up @@ -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}`}
Copy link
Member

Choose a reason for hiding this comment

The 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 modifyRoot.

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){
Expand Down
73 changes: 73 additions & 0 deletions assets/test/modify_root_test.js
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=""/>`)
})
})
4 changes: 2 additions & 2 deletions assets/test/rendered_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -234,12 +234,12 @@ describe("Rendered", () => {
`<div>
<p>
foo
<span>0: <b data-phx-component="1" id="123-1-0">FROM index_1 world</b></span><span>1: <b data-phx-component="2" id="123-2-0">FROM index_2 world</b></span>
<span>0: <b data-phx-component="1" id="123-1">FROM index_1 world</b></span><span>1: <b data-phx-component="2" id="123-2">FROM index_2 world</b></span>
</p>

<p>
bar
<span>0: <b data-phx-component="3" id="123-3-0">FROM index_1 world</b></span><span>1: <b data-phx-component="4" id="123-4-0">FROM index_2 world</b></span>
<span>0: <b data-phx-component="3" id="123-3">FROM index_1 world</b></span><span>1: <b data-phx-component="4" id="123-4">FROM index_2 world</b></span>
</p>
</div>`.trim())
})
Expand Down
42 changes: 3 additions & 39 deletions assets/test/view_test.js
Original file line number Diff line number Diff line change
Expand Up @@ -1128,10 +1128,10 @@ describe("View + Component", function(){
}

view.onJoin({rendered: joinDiff})
expect(view.el.innerHTML.trim()).toBe("<div phx-click=\"show-rect\" data-phx-component=\"0\" id=\"container-0-0\">Menu</div><h2>2</h2>")
expect(view.el.innerHTML.trim()).toBe("<div phx-click=\"show-rect\" data-phx-component=\"0\" id=\"container-0\">Menu</div><h2>2</h2>")

view.update(updateDiff, [])
expect(view.el.innerHTML.trim().replace("\n", "")).toBe("<h1>1</h1><div phx-click=\"show-rect\" data-phx-component=\"0\" id=\"container-0-0\">Menu</div><h2>2</h2>")
expect(view.el.innerHTML.trim().replace("\n", "")).toBe("<h1>1</h1><div phx-click=\"show-rect\" data-phx-component=\"0\" id=\"container-0\">Menu</div><h2>2</h2>")
})

test("respects nested components", () => {
Expand All @@ -1151,43 +1151,7 @@ describe("View + Component", function(){
}

view.onJoin({rendered: joinDiff})
expect(view.el.innerHTML.trim()).toBe("<div data-phx-component=\"0\" id=\"container-0-0\">Hello</div><div data-phx-component=\"1\" id=\"container-1-0\">World</div>")
})

test("wraps non-empty text nodes in span tags", () => {
let liveSocket = new LiveSocket("/live", Socket)
let el = liveViewDOM()
let view = simulateJoinedView(el, liveSocket)

stubChannel(view)

let joinDiff = {
"0": 0,
"c": {"0": {"s": ["Hello<div>World</div>\n"]}},
"s": ["", ""]
}

jest.spyOn(console, "error").mockImplementation(() => { })
view.onJoin({rendered: joinDiff})
expect(view.el.innerHTML.trim()).toBe("<span data-phx-component=\"0\"></span><div data-phx-component=\"0\" id=\"container-0-1\">World</div>")
})

test("wraps empty component in a single span tag", () => {
let liveSocket = new LiveSocket("/live", Socket)
let el = liveViewDOM()
let view = simulateJoinedView(el, liveSocket)

stubChannel(view)

let joinDiff = {
"0": 0,
"c": {"0": {"s": ["\n"]}},
"s": ["", ""]
}

jest.spyOn(console, "error").mockImplementation(() => { })
view.onJoin({rendered: joinDiff})
expect(view.el.innerHTML.trim()).toBe("<span data-phx-component=\"0\"></span>")
expect(view.el.innerHTML.trim()).toBe("<div data-phx-component=\"0\" id=\"container-0\">Hello</div><div data-phx-component=\"1\" id=\"container-1\">World</div>")
})

test("destroys children when they are removed by an update", () => {
Expand Down