Skip to content

Commit

Permalink
Merge pull request #21 from hlxsites/fix-page-render
Browse files Browse the repository at this point in the history
Prevent Component init errors from blocking page render
  • Loading branch information
FelipeSimoes authored May 9, 2024
2 parents b76b1be + 0a7eb7d commit 5f27dee
Show file tree
Hide file tree
Showing 9 changed files with 224 additions and 100 deletions.
3 changes: 2 additions & 1 deletion blocks/breadcrumbs/breadcrumbs.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ export default class Breadcrumbs extends ComponentBase {
connected() {
this.classList.add('full-width');
this.classList.add('breadcrumbs');
this.path = window.location.href.split(getBaseUrl()).join('/').split('/');
const { origin, pathname } = window.location;
this.path = `${origin}${pathname}`.split(getBaseUrl()).join('/').split('/');
this.innerHTML = `
<ul>
${this.path
Expand Down
2 changes: 1 addition & 1 deletion blocks/header/header.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const metaFragment = !!metaHeader && `${metaHeader}.plain.html`;
export default class Header extends ComponentBase {
fragment = metaFragment || 'header.plain.html';

dependencies = ['navigation'];
dependencies = ['navigation', 'image'];

extendConfig() {
return [
Expand Down
9 changes: 9 additions & 0 deletions blocks/icon/icon.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,15 @@ export default class Icon extends ComponentBase {

nestedComponentsConfig = {};

extendConfig() {
return [
...super.extendConfig(),
{
contentFromTargets: false,
},
];
}

constructor() {
super();
this.setupSprite();
Expand Down
3 changes: 1 addition & 2 deletions blocks/navigation/navigation.js
Original file line number Diff line number Diff line change
Expand Up @@ -67,8 +67,7 @@ export default class Navigation extends ComponentBase {
async setupCompactedNav() {
if (!this.navCompactedContentInit) {
this.navCompactedContentInit = true;
await Promise.all([component.loadAndDefine('accordion'), component.loadAndDefine('icon')]);

await component.multiLoadAndDefine(['accordion', 'icon']);
this.setupClasses(this.navCompactedContent, true);
this.navCompactedContent.addEventListener('click', (e) => this.activate(e));
}
Expand Down
101 changes: 65 additions & 36 deletions scripts/component-base.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,13 @@ export default class ComponentBase extends HTMLElement {

constructor() {
super();
this.uuid = `gen${crypto.randomUUID().split('-')[0]}`;
this.componentName = null; // set by component loader
this.webComponentName = null; // set by component loader
this.fragment = false;
this.dependencies = [];
this.breakpoints = getBreakPoints();
this.uuid = `gen${crypto.randomUUID().split('-')[0]}`;
this.initError = null;
this.attributesValues = {}; // the values are set by the component loader
this.setConfig('config', 'extendConfig');
this.setConfig('nestedComponentsConfig', 'extendNestedConfig');
Expand All @@ -36,13 +37,16 @@ export default class ComponentBase extends HTMLElement {
attributesValues = {}; // the values are set by the component loader

config = {
hideOnInitError: true,
hideOnNestedError: false,
addToTargetMethod: 'replaceWith',
contentFromTargets: true,
targetsAsContainers: {
addToTargetMethod: 'replaceWith',
},
};

// Default values are set by component loader
nestedComponentsConfig = {
image: {
componentName: 'image',
Expand Down Expand Up @@ -114,12 +118,17 @@ export default class ComponentBase extends HTMLElement {
* In some cases a check for `this.initialized` inside `onAttribute${capitalizedAttr}Changed` might be required
*/
attributeChangedCallback(name, oldValue, newValue) {
const camelAttr = camelCaseAttr(name);
const capitalizedAttr = capitalizeCaseAttr(name);
// handle case when attribute is removed from the element
// default to attribute breakpoint value
const defaultNewVal = newValue === null ? this.getBreakpointAttrVal(camelAttr) ?? null : newValue;
this[`onAttribute${capitalizedAttr}Changed`]?.({ oldValue, newValue: defaultNewVal });
try {
const camelAttr = camelCaseAttr(name);
const capitalizedAttr = capitalizeCaseAttr(name);
// handle case when attribute is removed from the element
// default to attribute breakpoint value
const defaultNewVal = newValue === null ? this.getBreakpointAttrVal(camelAttr) ?? null : newValue;
this[`onAttribute${capitalizedAttr}Changed`]?.({ oldValue, newValue: defaultNewVal });
} catch (error) {
// eslint-disable-next-line no-console
console.error(`There was an error while processing the '${name}' attribute change:`, this, error);
}
}

getBreakpointAttrVal(attr) {
Expand All @@ -134,42 +143,51 @@ export default class ComponentBase extends HTMLElement {
}

async connectedCallback() {
this.initialized = this.getAttribute('initialized');
this.initSubscriptions(); // must subscribe each time the element is added to the document
if (!this.initialized) {
this.setAttribute('id', this.uuid);
await Promise.all([this.loadFragment(this.fragment), this.loadDependencies()]);
await this.connected(); // manipulate/create the html
await this.initNestedComponents();
this.addListeners(); // html is ready add listeners
await this.ready(); // add extra functionality
this.setAttribute('initialized', true);
this.initialized = true;
this.dispatchEvent(new CustomEvent('initialized', { detail: { element: this } }));
try {
this.initialized = this.getAttribute('initialized');
this.initSubscriptions(); // must subscribe each time the element is added to the document
if (!this.initialized) {
this.setAttribute('id', this.uuid);
this.loadDependencies(); // do not wait for dependencies;
await this.loadFragment(this.fragment);
await this.connected(); // manipulate/create the html
await this.initNestedComponents();
this.addListeners(); // html is ready add listeners
await this.ready(); // add extra functionality
this.setAttribute('initialized', true);
this.initialized = true;
this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { element: this } }));
}
} catch (error) {
this.dispatchEvent(new CustomEvent(`initialized:${this.uuid}`, { detail: { error } }));
this.initError = error;
this.hideWithError(this.config.hideOnInitError, 'has-nested-error');
}
}

async initNestedComponents() {
const nested = await Promise.all(
Object.values(this.nestedComponentsConfig).flatMap(async (setting) => {
if (!setting.active) return [];
const s = this.fragment
? deepMerge({}, setting, {
// Content can contain blocks which are going to init their own nestedComponents.
loaderConfig: {
targetsSelectorsPrefix: ':scope > div >', // Limit only to default content, exclude blocks.
},
})
: setting;
return component.init(s);
}),
);
this.nestedElements = nested.flat();
const settings = Object.values(this.nestedComponentsConfig).flatMap((setting) => {
if (!setting.active) return [];
return this.fragment
? deepMerge({}, setting, {
// Content can contain blocks which are going to init their own nestedComponents.
loaderConfig: {
targetsSelectorsPrefix: ':scope > div >', // Limit only to default content, exclude blocks.
},
})
: setting;
});
this.nestedComponents = await component.multiInit(settings);
const {
nestedComponents: { allInitialized },
config: { hideOnNestedError },
} = this;
this.hideWithError(!allInitialized && hideOnNestedError, 'has-nested-error');
}

async loadDependencies() {
if (!this.dependencies.length) return;
await Promise.all(this.dependencies.map((dep) => component.loadAndDefine(dep)));
component.multiLoadAndDefine(this.dependencies);
}

async loadFragment(path) {
Expand All @@ -186,7 +204,18 @@ export default class ComponentBase extends HTMLElement {
if (response.ok) {
const html = await response.text();
this.innerHTML = html;
await Promise.all([...this.querySelectorAll('div[class]')].map((block) => component.init({ targets: [block] })));
const fragmentNested = await component.multiInit(
[...this.querySelectorAll('div[class]')].map((block) => ({ targets: [block] })),
);
const { allInitialized } = fragmentNested;
this.hideWithError(!allInitialized && this.config.hideOnNestedError, 'has-nested-error');
}
}

hideWithError(check, statusAttr) {
if (check) {
this.classList.add('hide-with-error');
this.setAttribute(statusAttr, '');
}
}

Expand Down
98 changes: 67 additions & 31 deletions scripts/component-loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,7 @@ export default class ComponentLoader {
constructor({ componentName, targets = [], loaderConfig, rawClasses, config, nestedComponentsConfig, active }) {
window.raqnComponents ??= {};
if (!componentName) {
// eslint-disable-next-line no-console
console.error('`componentName` is required');
return;
throw new Error('`componentName` is required');
}
this.componentName = componentName;
this.targets = targets.map((target) => ({ target }));
Expand Down Expand Up @@ -40,41 +38,76 @@ export default class ComponentLoader {
}

async init() {
if (this.active === false) return null;
if (!this.componentName) return null;
const loaded = await this.loadAndDefine();
if (!loaded) return null;
if (this.active === false) return [];
if (!this.componentName) return [];
const {loaded, error} = await this.loadAndDefine();
if (!loaded) throw new Error(error);
this.setHandlerType();
if (await this.Handler?.earlyStopRender?.()) return this.Handler;
if (!this.targets?.length) return this.Handler;
if (await this.Handler?.earlyStopRender?.()) return [];
if (!this.targets?.length) return [];

this.setTargets();
return Promise.all(
return Promise.allSettled(
this.targets.map(async (target) => {
let returnVal = null;
const data = this.getTargetData(target);
if (this.isWebComponent) {
const elem = await this.createElementAndConfigure(data);
data.componentElem = elem;
this.addContentFromTarget(data);
await this.connectComponent(data);
return elem;
returnVal = this.initWebComponent(data);
}

if (this.isClass) {
return new this.Handler({
componentName: this.componentName,
...data,
});
returnVal = this.initClass(data);
}

if (this.isFn) {
return this.Handler(data);
returnVal = this.initFn(data);
}
return null;
return returnVal;
}),
);
}

async initWebComponent(data) {
let returnVal = null;
try {
const elem = await this.createElementAndConfigure(data);
data.componentElem = elem;
returnVal = elem;
this.addContentFromTarget(data);
await this.connectComponent(data);
} catch (error) {
const err = new Error(error);
err.elem = returnVal;
// eslint-disable-next-line no-console
console.error(`There was an error while initializing the '${this.componentName}' webComponent:`, returnVal, error);
throw err;
}
return returnVal;
}

async initClass(data) {
try {
return new this.Handler({
componentName: this.componentName,
...data,
});
} catch (error) {
// eslint-disable-next-line no-console
console.error(`There was an error while initializing the '${this.componentName}' class:`, data.target, error);
throw error;
}
}

async initFn(data) {
try {
return this.Handler(data);
} catch (error) {
// eslint-disable-next-line no-console
console.error(`There was an error while initializing the '${this.componentName}' function:`, data.target, error);
throw error;
}
}

getTargetData({ target, container }) {
return {
target,
Expand Down Expand Up @@ -158,21 +191,24 @@ export default class ComponentLoader {
const { contentFromTargets } = componentElem.config;
if (!contentFromTargets) return;

componentElem.append(...target.children);
componentElem.append(...target.childNodes);
}

async connectComponent(data) {
const { componentElem } = data;
const { uuid } = componentElem;
componentElem.setAttribute('isloading', '');
const initialized = new Promise((resolve) => {
const initialized = new Promise((resolve, reject) => {
const initListener = async (event) => {
if (event.detail.element === componentElem) {
componentElem.removeEventListener('initialized', initListener);
componentElem.removeAttribute('isloading');
resolve(componentElem);
const { error } = event.detail;
componentElem.removeEventListener(`initialized:${uuid}`, initListener);
componentElem.removeAttribute('isloading');
if (error) {
reject(error);
}
resolve(componentElem);
};
componentElem.addEventListener('initialized', initListener);
componentElem.addEventListener(`initialized:${uuid}`, initListener);
});
const { targetsAsContainers } = this.loaderConfig;
const conf = componentElem.config;
Expand All @@ -198,11 +234,11 @@ export default class ComponentLoader {
}
this.Handler = await this.Handler;
await cssLoaded;
return true;
return { loaded: true };
} catch (error) {
// eslint-disable-next-line no-console
console.error(`failed to load module for ${this.componentName}`, error);
return false;
console.error(`Failed to load module for ${this.componentName}:`, error);
return { loaded: false, error };
}
}
}
Loading

0 comments on commit 5f27dee

Please sign in to comment.