Skip to content

Ensure Click to Load events are dispatched at the right times #411

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

Closed
Closed
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
166 changes: 117 additions & 49 deletions src/features/click-to-load.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,20 @@ const entityData = {}
// Used to avoid displaying placeholders for the same tracking element twice.
const knownTrackingElements = new WeakSet()

let readyResolver
const ready = new Promise(resolve => { readyResolver = resolve })
// Used to keep track of elements that have been hidden, but not yet replaced.
// Contains Promises, that resolve when those elements have finished being
// replaced.
const inProgressElementReplacements = new Set()

// Promise that is resolved when the Click to Load feature init() function has
// finished its work. Wait for this before replacing elements with placeholders.
let afterInitResolver
const afterInit = new Promise(resolve => { afterInitResolver = resolve })

// Promise that is resolved when the page has finished loading. Wait for this
// before sending essential messages to surrogate scripts.
let afterPageLoadResolver
const afterPageLoad = new Promise(resolve => { afterPageLoadResolver = resolve })

/*********************************************************
* Widget Replacement logic
Expand Down Expand Up @@ -372,15 +384,80 @@ class DuckWidget {
}
}

function replaceTrackingElement (widget, trackingElement, placeholderElement, currentPlaceholder = null) {
/**
* Replace the given tracking element with the given placeholder.
* Notes:
* 1. This function also dispatches events targetting the original and
* placeholder elements. That way, the surrogate scripts can use the event
* targets to keep track of which placeholder corresponds to which tracking
* element.
* 2. To achieve that, the original and placeholder elements must be in the DOM
* at the time the events are dispatched. Otherwise, the events will not
* bubble up and the surrogate script will miss them.
* 3. Placeholder must be shown immediately (to avoid a flicker for the user),
* but the events must only be sent once the document (and therefore
* surrogate scripts) have loaded.
* 4. Therefore, we hide the element until the page has loaded, then dispatch
* the events after page load, and then remove the element from the DOM.
* 5. The "ddg-ctp-ready" message needs to be sent _after_ the element
* replacement events have fired. A Set of in-progress replacements is
* maintained to ensure that.
*
* Also note, this all assumes that the surrogate script that needs these
* events will not be loaded asynchronously after the page has finished
* loading.
*
* @param {DuckWidget} widget
* The DuckWidget associated with the tracking element.
* @param {Element} trackingElement
* The tracking element on the page to replace.
* @param {Element} placeholderElement
* The placeholder element that should be shown instead.
*/
function replaceTrackingElement (widget, trackingElement, placeholderElement) {
// In some situations (e.g. YouTube Click to Load previews are
// enabled/disabled), a second placeholder will be shown for a tracking
// element.
const elementToReplace = widget.placeholderElement || trackingElement

// Note the placeholder element, so that it can also be replaced later if
// necessary.
widget.placeholderElement = placeholderElement

widget.dispatchEvent(trackingElement, 'ddg-ctp-tracking-element')
// First hide the element, since we need to keep it in the DOM until the
// events have been dispatched.
const originalDisplay = [
elementToReplace.style.getPropertyValue('display'),
elementToReplace.style.getPropertyPriority('display')
]
elementToReplace.style.setProperty('display', 'none', 'important')

// Add the placeholder element to the page.
elementToReplace.parentElement.insertBefore(
placeholderElement, elementToReplace
)

const elementToReplace = currentPlaceholder || trackingElement
elementToReplace.replaceWith(placeholderElement)
// While the placeholder is shown (and original element hidden)
// synchronously, the events are dispatched (and original element removed
// from the DOM) asynchronously after the page has finished loading.
// eslint-disable-next-line promise/prefer-await-to-then
const finishedReplacing = afterPageLoad.then(() => {
// With page load complete, and both elements in the DOM, the events can
// be dispatched.
widget.dispatchEvent(trackingElement, 'ddg-ctp-tracking-element')
widget.dispatchEvent(placeholderElement, 'ddg-ctp-placeholder-element')

// Once the events are sent, the tracking element (or previous
// placeholder) can finally be removed from the DOM.
elementToReplace.remove()
elementToReplace.style.setProperty('display', ...originalDisplay)

inProgressElementReplacements.delete(finishedReplacing)
})

widget.dispatchEvent(placeholderElement, 'ddg-ctp-placeholder-element')
// Avoid the"ddg-ctp-ready" event being fired in-between now and when the
// "ddg-ctp-*-element" events are fired.
inProgressElementReplacements.add(finishedReplacing)
}

/**
Expand Down Expand Up @@ -419,10 +496,7 @@ function createPlaceholderElementAndReplace (widget, trackingElement) {
button.addEventListener('click', widget.clickFunction(trackingElement, contentBlock))
textButton.addEventListener('click', widget.clickFunction(trackingElement, contentBlock))

replaceTrackingElement(
widget, trackingElement, contentBlock
)
// @ts-expect-error inital fix of click-to-load (please remove)
replaceTrackingElement(widget, trackingElement, contentBlock)
showExtraUnblockIfShortPlaceholder(shadowRoot, contentBlock)
}

Expand All @@ -436,7 +510,7 @@ function createPlaceholderElementAndReplace (widget, trackingElement) {
// @ts-expect-error inital fix of click-to-load (please remove)
window.addEventListener('ddg-settings-youtubePreviewsEnabled', ({ detail: value }) => {
isYoutubePreviewsEnabled = value
replaceYouTubeCTL(trackingElement, widget, true)
replaceYouTubeCTL(trackingElement, widget)
})
}
}
Expand All @@ -446,37 +520,29 @@ function createPlaceholderElementAndReplace (widget, trackingElement) {
* The original tracking element (YouTube video iframe)
* @param {DuckWidget} widget
* The CTP 'widget' associated with the tracking element.
* @param {boolean} togglePlaceholder
* Boolean indicating if this function should toggle between placeholders,
* because tracking element has already been replaced
*/
function replaceYouTubeCTL (trackingElement, widget, togglePlaceholder = false) {
function replaceYouTubeCTL (trackingElement, widget) {
// Skip replacing tracking element if it has already been unblocked
if (widget.isUnblocked) {
return
}

// Show YouTube Preview for embedded video
if (isYoutubePreviewsEnabled === true) {
const oldPlaceholder = widget.placeholderElement
const { youTubePreview, shadowRoot } = createYouTubePreview(trackingElement, widget)
const currentPlaceholder = togglePlaceholder ? widget.placeholderElement : null
resizeElementToMatch(currentPlaceholder || trackingElement, youTubePreview)
replaceTrackingElement(
widget, trackingElement, youTubePreview, currentPlaceholder
)
resizeElementToMatch(oldPlaceholder || trackingElement, youTubePreview)
replaceTrackingElement(widget, trackingElement, youTubePreview)
showExtraUnblockIfShortPlaceholder(shadowRoot, youTubePreview)

// Block YouTube embedded video and display blocking dialog
} else {
// @ts-expect-error inital fix of click-to-load (please remove)
widget.autoplay = false
const oldPlaceholder = widget.placeholderElement
const { blockingDialog, shadowRoot } = createYouTubeBlockingDialog(trackingElement, widget)
const currentPlaceholder = togglePlaceholder ? widget.placeholderElement : null
resizeElementToMatch(currentPlaceholder || trackingElement, blockingDialog)
replaceTrackingElement(
widget, trackingElement, blockingDialog, currentPlaceholder
)
// @ts-expect-error inital fix of click-to-load (please remove)
resizeElementToMatch(oldPlaceholder || trackingElement, blockingDialog)
replaceTrackingElement(widget, trackingElement, blockingDialog)
showExtraUnblockIfShortPlaceholder(shadowRoot, blockingDialog)
}
}
Expand All @@ -486,7 +552,7 @@ function replaceYouTubeCTL (trackingElement, widget, togglePlaceholder = false)
* its parent is too short for the normal unblock button to be visible.
* Note: This does not take into account the placeholder's vertical
* position in the parent element.
* @param {Element} shadowRoot
* @param {ShadowRoot} shadowRoot
* @param {Element} placeholder Placeholder for tracking element
*/
function showExtraUnblockIfShortPlaceholder (shadowRoot, placeholder) {
Expand Down Expand Up @@ -521,7 +587,7 @@ function showExtraUnblockIfShortPlaceholder (shadowRoot, placeholder) {
* in the document will be replaced instead.
*/
async function replaceClickToLoadElements (targetElement) {
await ready
await afterInit

for (const entity of Object.keys(config)) {
for (const widgetData of Object.values(config[entity].elementData)) {
Expand Down Expand Up @@ -1150,7 +1216,7 @@ function createYouTubeBlockingDialog (trackingElement, widget) {
* The YouTube video iframe element.
* @param {DuckWidget} widget
* The widget Object. We mutate this to set the autoplay property.
* @returns {{ youTubePreview: Element, shadowRoot: Element }}
* @returns {{ youTubePreview: Element, shadowRoot: ShadowRoot }}
* Object containing the YouTube Preview element and its shadowRoot.
*/
function createYouTubePreview (originalElement, widget) {
Expand Down Expand Up @@ -1285,7 +1351,6 @@ function createYouTubePreview (originalElement, widget) {
const feedbackRow = makeShareFeedbackRow()
shadowRoot.appendChild(feedbackRow)

// @ts-expect-error inital fix of click-to-load (please remove)
return { youTubePreview, shadowRoot }
}

Expand All @@ -1297,21 +1362,9 @@ const messageResponseHandlers = {
devMode = response.devMode
isYoutubePreviewsEnabled = response.youtubePreviewsEnabled

// TODO: Move the below init logic to the exported init() function,
// somehow waiting for this response handler to have been called
// first.

// Start Click to Load
window.addEventListener('ddg-ctp-replace-element', ({ target }) => {
// @ts-expect-error inital fix of click-to-load (please remove)
replaceClickToLoadElements(target)
}, { capture: true })

// Inform surrogate scripts that CTP is ready
originalWindowDispatchEvent(createCustomEvent('ddg-ctp-ready'))

// Mark the feature as ready, to allow placeholder replacements.
readyResolver()
// Mark the feature as ready, to allow placeholder replacements to
// start.
afterInitResolver()
},
setYoutubePreviewsEnabled: function (resp) {
if (resp?.messageType && typeof resp?.value === 'boolean') {
Expand All @@ -1331,7 +1384,7 @@ const messageResponseHandlers = {
const knownMessageResponseType = Object.prototype.hasOwnProperty.bind(messageResponseHandlers)

export default class ClickToLoad extends ContentFeature {
init (args) {
async init (args) {
const websiteOwner = args?.site?.parentEntity
const settings = args?.featureSettings?.clickToLoad || {}
const locale = args?.locale || 'en'
Expand Down Expand Up @@ -1396,9 +1449,24 @@ export default class ClickToLoad extends ContentFeature {
})

// Request the current state of Click to Load from the platform.
// Note: When the response is received, the response handler finishes
// starting up the feature.
// Note: When the response is received, the response handler resolves
// the afterInit Promise.
sendMessage('getClickToLoadState')
await afterInit

// Then wait for the page to finish loading, and resolve the
// afterPageLoad Promise.
if (document.readyState === 'complete') {
afterPageLoadResolver()
} else {
window.addEventListener('load', afterPageLoadResolver, { once: true })
}
await afterPageLoad

// Then wait for any in-progress element replacements, before letting
// the surrogate scripts know to start.
await Promise.all(Array.from(inProgressElementReplacements))
originalWindowDispatchEvent(createCustomEvent('ddg-ctp-ready'))
}

update (message) {
Expand Down