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

Refactor head_snapshot #832

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
112 changes: 23 additions & 89 deletions src/core/drive/head_snapshot.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,37 @@
import { Snapshot } from "../snapshot"

type ElementDetailMap = { [outerHTML: string]: ElementDetails }

type ElementDetails = {
type?: ElementType
tracked: boolean
elements: Element[]
}

type ElementType = "script" | "stylesheet"

export class HeadSnapshot extends Snapshot<HTMLHeadElement> {
readonly detailsByOuterHTML = this.children
.filter((element) => !elementIsNoscript(element))
.map((element) => elementWithoutNonce(element))
.reduce((result, element) => {
const { outerHTML } = element
const details: ElementDetails =
outerHTML in result
? result[outerHTML]
: {
type: elementType(element),
tracked: elementIsTracked(element),
elements: [],
}
return {
...result,
[outerHTML]: {
...details,
elements: [...details.elements, element],
},
}
}, {} as ElementDetailMap)
elements: { [key: string]: Array<Element> } = {}
stylesheetElements: Array<Element> = []
trackedElemements: Array<Element> = []

get trackedElementSignature(): string {
return Object.keys(this.detailsByOuterHTML)
.filter((outerHTML) => this.detailsByOuterHTML[outerHTML].tracked)
.join("")
constructor(element: HTMLHeadElement) {
super(element)
this.parseDetailsByOuterHTML()
}

getScriptElementsNotInSnapshot(snapshot: HeadSnapshot) {
return this.getElementsMatchingTypeNotInSnapshot<HTMLScriptElement>("script", snapshot)
parseDetailsByOuterHTML() {
for (let element of this.children) {
element = elementWithoutNonce(element)
if (elementIsStylesheet(element)) this.stylesheetElements.push(element)
else if (Object.prototype.hasOwnProperty.call(this.elements, element.localName))
this.elements[element.localName].push(element)
else this.elements[element.localName] = [element]

if (element.getAttribute("data-turbo-track") == "reload") this.trackedElemements.push(element)
}
}

getStylesheetElementsNotInSnapshot(snapshot: HeadSnapshot) {
return this.getElementsMatchingTypeNotInSnapshot<HTMLLinkElement>("stylesheet", snapshot)
get trackedElementSignature(): string {
return this.trackedElemements.map((e) => e.outerHTML).join("")
}

getElementsMatchingTypeNotInSnapshot<T extends Element>(matchedType: ElementType, snapshot: HeadSnapshot): T[] {
return Object.keys(this.detailsByOuterHTML)
.filter((outerHTML) => !(outerHTML in snapshot.detailsByOuterHTML))
.map((outerHTML) => this.detailsByOuterHTML[outerHTML])
.filter(({ type }) => type == matchedType)
.map(({ elements: [element] }) => element) as T[]
get stylesheets(): Array<Element> {
return this.stylesheetElements
}

get provisionalElements(): Element[] {
return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
const { type, tracked, elements } = this.detailsByOuterHTML[outerHTML]
if (type == null && !tracked) {
return [...result, ...elements]
} else if (elements.length > 1) {
return [...result, ...elements.slice(1)]
} else {
return result
}
}, [] as Element[])
getElements(localName: string): Array<Element> {
return this.elements[localName] || []
}

getMetaValue(name: string): string | null {
Expand All @@ -74,47 +40,15 @@ export class HeadSnapshot extends Snapshot<HTMLHeadElement> {
}

findMetaElementByName(name: string) {
return Object.keys(this.detailsByOuterHTML).reduce((result, outerHTML) => {
const {
elements: [element],
} = this.detailsByOuterHTML[outerHTML]
return elementIsMetaElementWithName(element, name) ? element : result
}, undefined as Element | undefined)
return this.getElements("meta").filter((e) => (e as HTMLMetaElement).name == name)[0] as Element | undefined
}
}

function elementType(element: Element) {
if (elementIsScript(element)) {
return "script"
} else if (elementIsStylesheet(element)) {
return "stylesheet"
}
}

function elementIsTracked(element: Element) {
return element.getAttribute("data-turbo-track") == "reload"
}

function elementIsScript(element: Element) {
const tagName = element.localName
return tagName == "script"
}

function elementIsNoscript(element: Element) {
const tagName = element.localName
return tagName == "noscript"
}

function elementIsStylesheet(element: Element) {
const tagName = element.localName
return tagName == "style" || (tagName == "link" && element.getAttribute("rel") == "stylesheet")
}

function elementIsMetaElementWithName(element: Element, name: string) {
const tagName = element.localName
return tagName == "meta" && element.getAttribute("name") == name
}

function elementWithoutNonce(element: Element) {
if (element.hasAttribute("nonce")) {
element.setAttribute("nonce", "")
Expand Down
108 changes: 41 additions & 67 deletions src/core/drive/page_renderer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,11 +60,15 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
}

async mergeHead() {
const mergedHeadElements = this.mergeProvisionalElements()
const newStylesheetElements = this.copyNewHeadStylesheetElements()
this.copyNewHeadScriptElements()
await mergedHeadElements
await newStylesheetElements
// Load new stylesheets and get them to preload before switching out other elements.
const mergeStylesheets = this.copyNewHeadStylesheetElements() //this.mergeElements("link", false, true);

this.mergeNonScriptElements()
this.mergeElements("script", false, (e: Element): Element => {
return activateScriptElement(e as HTMLScriptElement)
})

await mergeStylesheets
}

async replaceBody() {
Expand All @@ -78,73 +82,59 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
return this.currentHeadSnapshot.trackedElementSignature == this.newHeadSnapshot.trackedElementSignature
}

async mergeElements(
localName: string,
removeOld = true,
onAdd = (e: Element): Element => {
return e
}
) {
const currentElements: Array<Element> = this.currentHeadSnapshot.getElements(localName)
const newElements: Array<Element> = [...this.newHeadSnapshot.getElements(localName)]

for (const currentElement of currentElements) {
if (!this.isCurrentElementInElementList(currentElement, newElements)) {
if (removeOld) document.head.removeChild(currentElement)
}
}
for (const element of newElements) {
document.head.appendChild(onAdd(element))
}
}

async copyNewHeadStylesheetElements() {
const loadingElements = []

for (const element of this.newHeadStylesheetElements) {
const currentElements: Array<Element> = this.currentHeadSnapshot.stylesheets
const newElements: Array<Element> = [...this.newHeadSnapshot.stylesheets]
for (const currentElement of currentElements) {
this.isCurrentElementInElementList(currentElement, newElements)
}
for (const element of newElements) {
loadingElements.push(waitForLoad(element as HTMLLinkElement))

document.head.appendChild(element)
}

await Promise.all(loadingElements)
}

copyNewHeadScriptElements() {
for (const element of this.newHeadScriptElements) {
document.head.appendChild(activateScriptElement(element))
}
}

async mergeProvisionalElements() {
const newHeadElements = [...this.newHeadProvisionalElements]

for (const element of this.currentHeadProvisionalElements) {
if (!this.isCurrentElementInElementList(element, newHeadElements)) {
document.head.removeChild(element)
}
}

for (const element of newHeadElements) {
document.head.appendChild(element)
async mergeNonScriptElements() {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { script, ...otherElements } = this.currentSnapshot.headSnapshot.elements
for (const localName of Object.keys(otherElements)) {
this.mergeElements(localName)
}
}

isCurrentElementInElementList(element: Element, elementList: Element[]) {
// removes element from list and returns true if in list
for (const [index, newElement] of elementList.entries()) {
// if title element...
if (element.tagName == "TITLE") {
if (newElement.tagName != "TITLE") {
continue
}
if (element.innerHTML == newElement.innerHTML) {
elementList.splice(index, 1)
return true
}
}

// if any other element...
if (newElement.isEqualNode(element)) {
elementList.splice(index, 1)
return true
}
}

return false
}

removeCurrentHeadProvisionalElements() {
for (const element of this.currentHeadProvisionalElements) {
document.head.removeChild(element)
}
}

copyNewHeadProvisionalElements() {
for (const element of this.newHeadProvisionalElements) {
document.head.appendChild(element)
}
}

activateNewBody() {
document.adoptNode(this.newElement)
this.activateNewBodyScriptElements()
Expand All @@ -161,22 +151,6 @@ export class PageRenderer extends Renderer<HTMLBodyElement, PageSnapshot> {
await this.renderElement(this.currentElement, this.newElement)
}

get newHeadStylesheetElements() {
return this.newHeadSnapshot.getStylesheetElementsNotInSnapshot(this.currentHeadSnapshot)
}

get newHeadScriptElements() {
return this.newHeadSnapshot.getScriptElementsNotInSnapshot(this.currentHeadSnapshot)
}

get currentHeadProvisionalElements() {
return this.currentHeadSnapshot.provisionalElements
}

get newHeadProvisionalElements() {
return this.newHeadSnapshot.provisionalElements
}

get newBodyScriptElements() {
return this.newElement.querySelectorAll("script")
}
Expand Down
55 changes: 55 additions & 0 deletions src/tests/unit/head_snapshot_tests.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { HeadSnapshot } from "../../core/drive/head_snapshot"
import { parseHTMLDocument } from "../../util"
import { DOMTestCase } from "../helpers/dom_test_case"

export class HeadSnapshotTests extends DOMTestCase {
headSnapshot!: HeadSnapshot

async beforeTest() {
this.fixtureHTML = `
<head>
<title>Title 1</title>
<link rel="stylesheet" href="base.css" type="text/css">
<link rel="stylesheet" href="tracked.css" type="text/css" data-turbo-track="reload" nonce="nonce">
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
<style>
.test-style{
font-size:99px;
}
</style>
<meta name="description" content="Meta Description">
<meta name="viewport" content="width=device-width, initial-scale=1.0">

</head>
`
const parsedHTML = parseHTMLDocument(this.fixtureHTML)
this.headSnapshot = new HeadSnapshot(parsedHTML.head)
}

async "test element parsing"() {
// const element = createStreamElement("before", "hello", createTemplateElement(`<h1 id="before">Before Turbo</h1>`))
const titleElement = document.createElement("title")
titleElement.innerHTML = "Title 1"

this.assert.isTrue(this.headSnapshot.getElements("title")[0].isEqualNode(titleElement))

// link elements should only be icon element, stylesheets are stored in stylesheet property
this.assert.equal(this.headSnapshot.getElements("link").length, 1)
this.assert.equal(this.headSnapshot.stylesheets.length, 3)
}

async "test getMetaValue"() {
this.assert.equal(this.headSnapshot.getMetaValue("description"), "Meta Description")
this.assert.equal(this.headSnapshot.getMetaValue("viewport"), "width=device-width, initial-scale=1.0")
}

async "test trackedElementSignature"() {
// ensure tracked elements are in signature, with nonce removed (as nonce will change per page load)
this.assert.equal(
this.headSnapshot.trackedElementSignature,
'<link rel="stylesheet" href="tracked.css" type="text/css" data-turbo-track="reload" nonce="">'
)
}
}

HeadSnapshotTests.registerSuite()