Skip to content

Refactor private functions into the component #78

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

Merged
merged 3 commits into from
Mar 30, 2022
Merged
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
218 changes: 109 additions & 109 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,113 +1,10 @@
const privateData = new WeakMap()

const observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
const {target} = entry
observer.unobserve(target)
if (!(target instanceof IncludeFragmentElement)) return
if (target.loading === 'lazy') {
handleData(target)
}
}
}
},
{
// Currently the threshold is set to 256px from the bottom of the viewport
// with a threshold of 0.1. This means the element will not load until about
// 2 keyboard-down-arrow presses away from being visible in the viewport,
// giving us some time to fetch it before the contents are made visible
rootMargin: '0px 0px 256px 0px',
threshold: 0.01
}
)

// Functional stand in for the W3 spec "queue a task" paradigm
function task(): Promise<void> {
return new Promise(resolve => setTimeout(resolve, 0))
}

async function handleData(el: IncludeFragmentElement) {
observer.unobserve(el)
return getData(el).then(
function (html: string) {
const template = document.createElement('template')
// eslint-disable-next-line github/no-inner-html
template.innerHTML = html
const fragment = document.importNode(template.content, true)
const canceled = !el.dispatchEvent(
new CustomEvent('include-fragment-replace', {cancelable: true, detail: {fragment}})
)
if (canceled) return
el.replaceWith(fragment)
el.dispatchEvent(new CustomEvent('include-fragment-replaced'))
},
function () {
el.classList.add('is-error')
}
)
}

function getData(el: IncludeFragmentElement) {
const src = el.src
let data = privateData.get(el)
if (data && data.src === src) {
return data.data
} else {
if (src) {
data = fetchDataWithEvents(el)
} else {
data = Promise.reject(new Error('missing src'))
}
privateData.set(el, {src, data})
return data
}
}

function fetchDataWithEvents(el: IncludeFragmentElement) {
// We mimic the same event order as <img>, including the spec
// which states events must be dispatched after "queue a task".
// https://www.w3.org/TR/html52/semantics-embedded-content.html#the-img-element
return task()
.then(() => {
el.dispatchEvent(new Event('loadstart'))
return el.fetch(el.request())
})
.then(response => {
if (response.status !== 200) {
throw new Error(`Failed to load resource: the server responded with a status of ${response.status}`)
}
const ct = response.headers.get('Content-Type')
if (!isWildcard(el.accept) && (!ct || !ct.includes(el.accept ? el.accept : 'text/html'))) {
throw new Error(`Failed to load resource: expected ${el.accept || 'text/html'} but was ${ct}`)
}
return response.text()
})
.then(
data => {
// Dispatch `load` and `loadend` async to allow
// the `load()` promise to resolve _before_ these
// events are fired.
task().then(() => {
el.dispatchEvent(new Event('load'))
el.dispatchEvent(new Event('loadend'))
})
return data
},
error => {
// Dispatch `error` and `loadend` async to allow
// the `load()` promise to resolve _before_ these
// events are fired.
task().then(() => {
el.dispatchEvent(new Event('error'))
el.dispatchEvent(new Event('loadend'))
})
throw error
}
)
}

function isWildcard(accept: string | null) {
return accept && !!accept.split(',').find(x => x.match(/^\s*\*\/\*/))
}
Expand Down Expand Up @@ -150,19 +47,19 @@ export default class IncludeFragmentElement extends HTMLElement {
}

get data(): Promise<string> {
return getData(this)
return this.#getData()
}

attributeChangedCallback(attribute: string, oldVal: string | null): void {
if (attribute === 'src') {
// Source changed after attached so replace element.
if (this.isConnected && this.loading === 'eager') {
handleData(this)
this.#handleData()
}
} else if (attribute === 'loading') {
// Loading mode changed to Eager after attached so replace element.
if (this.isConnected && oldVal !== 'eager' && this.loading === 'eager') {
handleData(this)
this.#handleData()
}
}
}
Expand All @@ -181,10 +78,10 @@ export default class IncludeFragmentElement extends HTMLElement {

connectedCallback(): void {
if (this.src && this.loading === 'eager') {
handleData(this)
this.#handleData()
}
if (this.loading === 'lazy') {
observer.observe(this)
this.#observer.observe(this)
}
}

Expand All @@ -204,12 +101,115 @@ export default class IncludeFragmentElement extends HTMLElement {
}

load(): Promise<string> {
return getData(this)
return this.#getData()
}

fetch(request: RequestInfo): Promise<Response> {
return fetch(request)
}

#observer = new IntersectionObserver(
entries => {
for (const entry of entries) {
if (entry.isIntersecting) {
const {target} = entry
this.#observer.unobserve(target)
if (!(target instanceof IncludeFragmentElement)) return
if (target.loading === 'lazy') {
this.#handleData()
}
}
}
},
{
// Currently the threshold is set to 256px from the bottom of the viewport
// with a threshold of 0.1. This means the element will not load until about
// 2 keyboard-down-arrow presses away from being visible in the viewport,
// giving us some time to fetch it before the contents are made visible
rootMargin: '0px 0px 256px 0px',
threshold: 0.01
}
)

#handleData(): Promise<void> {
this.#observer.unobserve(this)
return this.#getData().then(
(html: string) => {
const template = document.createElement('template')
// eslint-disable-next-line github/no-inner-html
template.innerHTML = html
const fragment = document.importNode(template.content, true)
const canceled = !this.dispatchEvent(
new CustomEvent('include-fragment-replace', {cancelable: true, detail: {fragment}})
)
if (canceled) return
this.replaceWith(fragment)
this.dispatchEvent(new CustomEvent('include-fragment-replaced'))
},
() => {
this.classList.add('is-error')
}
)
}

#getData(): Promise<string> {
const src = this.src
let data = privateData.get(this)
if (data && data.src === src) {
return data.data
} else {
if (src) {
data = this.#fetchDataWithEvents()
} else {
data = Promise.reject(new Error('missing src'))
}
privateData.set(this, {src, data})
return data
}
}

#fetchDataWithEvents(): Promise<string> {
// We mimic the same event order as <img>, including the spec
// which states events must be dispatched after "queue a task".
// https://www.w3.org/TR/html52/semantics-embedded-content.html#the-img-element
return task()
.then(() => {
this.dispatchEvent(new Event('loadstart'))
return this.fetch(this.request())
})
.then(response => {
if (response.status !== 200) {
throw new Error(`Failed to load resource: the server responded with a status of ${response.status}`)
}
const ct = response.headers.get('Content-Type')
if (!isWildcard(this.accept) && (!ct || !ct.includes(this.accept ? this.accept : 'text/html'))) {
throw new Error(`Failed to load resource: expected ${this.accept || 'text/html'} but was ${ct}`)
}
return response.text()
})
.then(
data => {
// Dispatch `load` and `loadend` async to allow
// the `load()` promise to resolve _before_ these
// events are fired.
task().then(() => {
this.dispatchEvent(new Event('load'))
this.dispatchEvent(new Event('loadend'))
})
return data
},
error => {
// Dispatch `error` and `loadend` async to allow
// the `load()` promise to resolve _before_ these
// events are fired.
task().then(() => {
this.dispatchEvent(new Event('error'))
this.dispatchEvent(new Event('loadend'))
})
throw error
}
)
}
}

declare global {
Expand Down