Skip to content
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
10 changes: 3 additions & 7 deletions src/Elastic.Documentation.Site/Assets/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
ATTR_URL_FULL,
} from './telemetry/semconv'
import { initTocNav } from './toc-nav'
import { loadWebComponents } from './web-components/loadWebComponents'
import {
getPathFromUrl,
isExternalDocsUrl,
Expand All @@ -47,13 +48,6 @@ if (config.telemetryEnabled) {
})
}

// Dynamically import web components after telemetry is initialized.
// Parcel code-splits these into separate chunks loaded on demand.
import('./web-components/VersionDropdown')
import('./web-components/AppliesToPopover')
import('./web-components/Diagnostics/DiagnosticsComponent')
import('./web-components/StorybookStory/StorybookStoryComponent')

if (config.buildType === 'isolated' || config.airGapped) {
import('./isolated')
} else if (config.buildType === 'codex') {
Expand Down Expand Up @@ -189,6 +183,7 @@ function initCtaImpressions() {
// Initialize on initial page load
document.addEventListener('DOMContentLoaded', function () {
runInitSteps([
['loadWebComponents', loadWebComponents],
['initMath', initMath],
['initMermaid', initMermaid],
['initCtaImpressions', initCtaImpressions],
Expand All @@ -197,6 +192,7 @@ document.addEventListener('DOMContentLoaded', function () {

document.addEventListener('htmx:load', function () {
runInitSteps([
['loadWebComponents', loadWebComponents],
['initTocNav', initTocNav],
['initHighlight', initHighlight],
['initCopyButton', initCopyButton],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { createWebComponentLoader } from './loadWebComponents'

describe('web component loader', () => {
beforeEach(() => {
document.body.innerHTML = ''
})

it('does not load a component when its host is absent', async () => {
const load = jest.fn().mockResolvedValue(undefined)
const loadWebComponents = createWebComponentLoader({
'absent-component': load,
})

await loadWebComponents()

expect(load).not.toHaveBeenCalled()
})

it('loads a component when its host is present', async () => {
const load = jest.fn().mockImplementation(async () => {
customElements.define(
'present-component',
class extends HTMLElement {}
)
})
const loadWebComponents = createWebComponentLoader({
'present-component': load,
})
document.body.innerHTML = '<present-component></present-component>'

await loadWebComponents()

expect(load).toHaveBeenCalledTimes(1)
expect(customElements.get('present-component')).toBeDefined()
})

it('loads a component introduced before a subsequent scan', async () => {
const load = jest.fn().mockResolvedValue(undefined)
const loadWebComponents = createWebComponentLoader({
'swapped-component': load,
})

await loadWebComponents()
document.body.innerHTML = '<swapped-component></swapped-component>'
await loadWebComponents()

expect(load).toHaveBeenCalledTimes(1)
})

it('deduplicates concurrent and repeated loads', async () => {
let resolveLoad: () => void
const pendingLoad = new Promise<void>((resolve) => {
resolveLoad = resolve
})
const load = jest.fn(() => pendingLoad)
const loadWebComponents = createWebComponentLoader({
'deduplicated-component': load,
})
document.body.innerHTML =
'<deduplicated-component></deduplicated-component>'

const firstLoad = loadWebComponents()
const secondLoad = loadWebComponents()
resolveLoad!()
await Promise.all([firstLoad, secondLoad])
await loadWebComponents()

expect(load).toHaveBeenCalledTimes(1)
})

it('skips a component that is already registered', async () => {
customElements.define(
'registered-component',
class extends HTMLElement {}
)
const load = jest.fn().mockResolvedValue(undefined)
const loadWebComponents = createWebComponentLoader({
'registered-component': load,
})
document.body.innerHTML =
'<registered-component></registered-component>'

await loadWebComponents()

expect(load).not.toHaveBeenCalled()
})

it('retries a component after its load fails', async () => {
const load = jest
.fn()
.mockRejectedValueOnce(new Error('load failed'))
.mockResolvedValueOnce(undefined)
const loadWebComponents = createWebComponentLoader({
'retry-component': load,
})
document.body.innerHTML = '<retry-component></retry-component>'

await expect(loadWebComponents()).rejects.toThrow('load failed')
await expect(loadWebComponents()).resolves.toBeUndefined()

expect(load).toHaveBeenCalledTimes(2)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
type ComponentLoader = () => Promise<unknown>
type ComponentLoaders = Readonly<Record<string, ComponentLoader>>

export function createWebComponentLoader(componentLoaders: ComponentLoaders) {
const loadingComponents = new Map<string, Promise<unknown>>()

return async function loadWebComponents(
root: ParentNode = document
): Promise<void> {
const loads: Promise<unknown>[] = []

for (const [tagName, load] of Object.entries(componentLoaders)) {
if (!root.querySelector(tagName)) continue
if (customElements.get(tagName)) continue

const existingLoad = loadingComponents.get(tagName)
if (existingLoad) {
loads.push(existingLoad)
continue
}

const componentLoad = load().catch((error) => {
loadingComponents.delete(tagName)
throw error
})
loadingComponents.set(tagName, componentLoad)
loads.push(componentLoad)
}

await Promise.all(loads)
}
}

export const loadWebComponents = createWebComponentLoader({
'version-dropdown': () => import('./VersionDropdown'),
'applies-to-popover': () => import('./AppliesToPopover'),
'diagnostics-panel': () => import('./Diagnostics/DiagnosticsComponent'),
'storybook-story': () => import('./StorybookStory/StorybookStoryComponent'),
})
Loading