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

Speed up patching 5-10x #2845

Merged
merged 20 commits into from
Oct 13, 2023
Merged
Show file tree
Hide file tree
Changes from 3 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
4 changes: 3 additions & 1 deletion assets/js/phoenix_live_view/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export const PHX_DROP_TARGET = "drop-target"
export const PHX_ACTIVE_ENTRY_REFS = "data-phx-active-refs"
export const PHX_LIVE_FILE_UPDATED = "phx:live-file:updated"
export const PHX_SKIP = "data-phx-skip"
export const PHX_MAGIC_ID = "data-phx-id"
export const PHX_PRUNE = "data-phx-prune"
export const PHX_PAGE_LOADING = "page-loading"
export const PHX_CONNECTED_CLASS = "phx-connected"
Expand Down Expand Up @@ -79,9 +80,10 @@ export const DEFAULTS = {
// Rendered
export const DYNAMICS = "d"
export const STATIC = "s"
export const ROOT = "r"
export const COMPONENTS = "c"
export const EVENTS = "e"
export const REPLY = "r"
export const TITLE = "t"
export const TEMPLATES = "p"
export const STREAM = "stream"
export const STREAM = "stream"
15 changes: 10 additions & 5 deletions assets/js/phoenix_live_view/dom_patch.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
PHX_ROOT_ID,
PHX_SESSION,
PHX_SKIP,
PHX_MAGIC_ID,
PHX_STATIC,
PHX_TRIGGER_ACTION,
PHX_UPDATE,
Expand Down Expand Up @@ -124,10 +125,11 @@ export default class DOMPatch {
morphdom(targetContainer, diffHTML, {
childrenOnly: targetContainer.getAttribute(PHX_COMPONENT) === null,
getNodeKey: (node) => {
return DOM.isPhxDestroyed(node) ? null : node.id
if(DOM.isPhxDestroyed(node)){ return null }
return (node.getAttribute && node.getAttribute(PHX_MAGIC_ID)) || node.id
},
// skip indexing from children when container is stream
skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM },
skipFromChildren: (from) => { return from.getAttribute(phxUpdate) === PHX_STREAM},
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
// tell morphdom how to add a child
addChild: (parent, child) => {
let {ref, streamAt, limit} = this.getStreamInsert(child)
Expand Down Expand Up @@ -363,7 +365,7 @@ export default class DOMPatch {
isCIDPatch(){ return this.cidPatch }

skipCIDSibling(el){
return el.nodeType === Node.ELEMENT_NODE && el.getAttribute(PHX_SKIP) !== null
return el.nodeType === Node.ELEMENT_NODE && el.hasAttribute(PHX_SKIP)
}

targetCIDContainer(html){
Expand Down Expand Up @@ -397,7 +399,10 @@ export default class DOMPatch {
rest.forEach(el => el.remove())
Array.from(diffContainer.childNodes).forEach(child => {
// we can only skip trackable nodes with an ID
if(child.id && child.nodeType === Node.ELEMENT_NODE && child.getAttribute(PHX_COMPONENT) !== this.targetCID.toString()){
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
if(child.nodeType === Node.ELEMENT_NODE &&
child.hasAttribute(PHX_MAGIC_ID) &&
child.getAttribute(PHX_COMPONENT) !== this.targetCID.toString()){

child.setAttribute(PHX_SKIP, "")
child.innerHTML = ""
}
Expand All @@ -409,4 +414,4 @@ export default class DOMPatch {
}

indexOf(parent, child){ return Array.from(parent.children).indexOf(child) }
}
}
189 changes: 136 additions & 53 deletions assets/js/phoenix_live_view/rendered.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import {
EVENTS,
PHX_COMPONENT,
PHX_SKIP,
PHX_MAGIC_ID,
REPLY,
STATIC,
TITLE,
STREAM,
ROOT,
} from "./constants"

import {
Expand All @@ -17,6 +19,90 @@ import {
isCid,
} from "./utils"

const VOID_TAGS = [
"area",
"base",
"br",
"col",
"command",
"embed",
"hr",
"img",
"input",
"keygen",
"link",
"meta",
"param",
"source",
"track",
"wbr"
]

export let modifyRoot = (html, attrs, clearInnerHTML) => {
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
let i =0
let insideComment = false
let insideTag = false
let tag
let beforeTagBuff = []
while(i < html.length){
let char = html.charAt(i)
if(insideComment){
if(char === "-" && html.slice(i, i + 3) === "-->"){
insideComment = false
beforeTagBuff.push("-->")
i += 3
} else {
beforeTagBuff.push(char)
i++
}
} else if(char === "<" && html.slice(i, i + 4) === "<!--"){
insideComment = true
beforeTagBuff.push("<!--")
i += 4
} else if(char === "<"){
insideTag = true
let iAtOpen = i
for(i; i < html.length; i++){
if([">", " ", "\n", "\t", "\r"].indexOf(html.charAt(i)) >= 0){ break }
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
}
tag = html.slice(iAtOpen + 1, i)
break
} else if(!insideComment && !insideTag){
beforeTagBuff.push(char)
i++
}
}
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
if(!tag){ throw new Error(`malformed html ${html}`) }

let attrsStr =
Object.keys(attrs)
.map(attr => attrs[attr] === true ? attr : `${attr}="${attrs[attr]}"`)
.join(" ")

let isVoid = VOID_TAGS.indexOf(tag) >= 0
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
let closeTag = `</${tag}>`
let newHTML
let beforeTag = beforeTagBuff.join("")
let afterTag
if(isVoid){
afterTag = html.slice(html.lastIndexOf(`/>`) + 2)
} else {
afterTag = html.slice(html.lastIndexOf(closeTag) + closeTag.length)
}
if(clearInnerHTML){
if(isVoid){
newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}/>`
} else {
newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}>${closeTag}`
}
} else {
let rest = html.slice(i, html.length - afterTag.length)
newHTML = `<${tag}${attrsStr === "" ? "" : " "}${attrsStr}${rest}`
}

return [newHTML, beforeTag, afterTag]
}

export default class Rendered {
static extract(diff){
let {[REPLY]: reply, [EVENTS]: events, [TITLE]: title} = diff
Expand All @@ -29,19 +115,20 @@ export default class Rendered {
constructor(viewId, rendered){
this.viewId = viewId
this.rendered = {}
this.magicId = 0
this.mergeDiff(rendered)
}

parentViewId(){ return this.viewId }

toString(onlyCids){
let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids)
let [str, streams] = this.recursiveToString(this.rendered, this.rendered[COMPONENTS], onlyCids, false)
chrismccord marked this conversation as resolved.
Show resolved Hide resolved
return [str, streams]
}

recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids){
recursiveToString(rendered, components = rendered[COMPONENTS], onlyCids, insideComponent){
onlyCids = onlyCids ? new Set(onlyCids) : null
let output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set()}
let output = {buffer: "", components: components, onlyCids: onlyCids, streams: new Set(), insideComponent}
this.toOutputBuffer(rendered, null, output)
return [output.buffer, output.streams]
}
Expand Down Expand Up @@ -121,6 +208,9 @@ export default class Rendered {
target[key] = val
}
}
if(target[ROOT]){
target.changed = true
}
}

cloneMerge(target, source){
Expand Down Expand Up @@ -158,16 +248,50 @@ export default class Rendered {
}
}

nextMagicID(){
this.magicId++
return `phx-${this.magicId}`
}

toOutputBuffer(rendered, templates, output){
if(rendered[DYNAMICS]){ return this.comprehensionToBuffer(rendered, templates, output) }
let {[STATIC]: statics} = rendered
statics = this.templateStatic(statics, templates)
let currentOut = {
buffer: "",
components: output.components,
onlyCids: output.onlyCids,
streams: output.streams,
insideComponent: output.insideComponent
}
let firstRootRender = false
let isRoot = rendered[ROOT] && !output.insideComponent

output.buffer += statics[0]
if(isRoot && !rendered.magicId){
firstRootRender = true
rendered.magicId = this.nextMagicID()
}

currentOut.buffer += statics[0]
for(let i = 1; i < statics.length; i++){
this.dynamicToBuffer(rendered[i - 1], templates, output)
output.buffer += statics[i]
this.dynamicToBuffer(rendered[i - 1], templates, currentOut)
currentOut.buffer += statics[i]
}

if(isRoot){
let skip = !rendered.changed && !firstRootRender && currentOut.streams.size === output.streams.size
let attrs = {[PHX_MAGIC_ID]: rendered.magicId}
if(skip){ attrs[PHX_SKIP] = true }
let [newRoot, commentBefore, commentAfter] = modifyRoot(currentOut.buffer, attrs, skip)
rendered.changed = false
currentOut.buffer = `${commentBefore}${newRoot}${commentAfter}`
}

output.buffer += currentOut.buffer
output.components = currentOut.components
output.insideComponent = currentOut.insideComponent
output.onlyCids = currentOut.onlyCids
output.streams = currentOut.streams
}

comprehensionToBuffer(rendered, templates, output){
Expand Down Expand Up @@ -205,55 +329,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 [html, streams] = this.recursiveToString(component, components, onlyCids, true)
let skip = onlyCids && !onlyCids.has(cid)
let attrs = {[PHX_COMPONENT]: cid, [PHX_MAGIC_ID]: `${this.parentViewId()}-${cid}`}
if(skip){ attrs[PHX_SKIP] = true }
let [newHTML, commentBefore, commentAfter] = modifyRoot(html, attrs, skip)
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
14 changes: 7 additions & 7 deletions assets/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion assets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"test.watch": "jest --watch"
},
"dependencies": {
"morphdom": "2.7.0"
"morphdom": "2.7.1"
},
"devDependencies": {
"@babel/cli": "7.14.3",
Expand Down
Loading
Loading