feat: add promise based loader for FB script (#9780)

![CleanShot 2024-07-16 at 11 10
40@2x](https://github.com/user-attachments/assets/8b938968-5f80-4a19-95fb-e00e1dbd7526)

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
This commit is contained in:
Shivam Mishra
2024-07-22 11:32:05 +05:30
committed by GitHub
parent 23e30fcb1a
commit cb0642564c
5 changed files with 296 additions and 78 deletions

View File

@@ -0,0 +1,124 @@
const SCRIPT_TYPE = 'text/javascript';
const DATA_LOADED_ATTR = 'data-loaded';
const SCRIPT_PROPERTIES = [
'defer',
'crossOrigin',
'noModule',
'referrerPolicy',
'id',
];
/**
* Custom error class for script loading failures.
* @extends Error
*/
class ScriptLoaderError extends Error {
/**
* Creates a new ScriptLoaderError.
* @param {string} src - The source URL of the script that failed to load.
* @param {string} message - The error message.
*/
constructor(src, message = 'Failed to load script') {
super(message);
this.name = 'ScriptLoaderError';
this.src = src;
}
/**
* Gets detailed error information.
* @returns {string} A string containing the error details.
*/
getErrorDetails() {
return `Failed to load script from source: ${this.src}`;
}
}
/**
* Creates a new script element with the specified attributes.
* @param {string} src - The source URL of the script.
* @param {Object} options - Options for configuring the script element.
* @param {string} [options.type='text/javascript'] - The type of the script.
* @param {boolean} [options.async=true] - Whether the script should load asynchronously.
* @param {boolean} [options.defer] - Whether the script execution should be deferred.
* @param {string} [options.crossOrigin] - The CORS setting for the script.
* @param {boolean} [options.noModule] - Whether the script should not be treated as a JavaScript module.
* @param {string} [options.referrerPolicy] - The referrer policy for the script.
* @param {string} [options.id] - The id attribute for the script element.
* @param {Object} [options.attrs] - Additional attributes to set on the script element.
* @returns {HTMLScriptElement} The created script element.
*/
const createScriptElement = (src, options) => {
const el = document.createElement('script');
el.type = options.type || SCRIPT_TYPE;
el.async = options.async !== false;
el.src = src;
SCRIPT_PROPERTIES.forEach(property => {
if (property in options) {
el[property] = options[property];
}
});
Object.entries(options.attrs || {}).forEach(([name, value]) =>
el.setAttribute(name, value)
);
return el;
};
/**
* Finds an existing script element with the specified source URL.
* @param {string} src - The source URL to search for.
* @returns {HTMLScriptElement|null} The found script element, or null if not found.
*/
const findExistingScript = src => {
return document.querySelector(`script[src="${src}"]`);
};
/**
* Loads a script asynchronously and returns a promise.
* @param {string} src - The source URL of the script to load.
* @param {Object} options - Options for configuring the script element.
* @param {string} [options.type='text/javascript'] - The type of the script.
* @param {boolean} [options.async=true] - Whether the script should load asynchronously.
* @param {boolean} [options.defer] - Whether the script execution should be deferred.
* @param {string} [options.crossOrigin] - The CORS setting for the script.
* @param {boolean} [options.noModule] - Whether the script should not be treated as a JavaScript module.
* @param {string} [options.referrerPolicy] - The referrer policy for the script.
* @param {string} [options.id] - The id attribute for the script element.
* @param {Object} [options.attrs] - Additional attributes to set on the script element.
* @returns {Promise<HTMLScriptElement|boolean>} A promise that resolves with the loaded script element,
* or false if the script couldn't be loaded.
* @throws {ScriptLoaderError} If the script fails to load.
*/
export async function loadScript(src, options) {
if (typeof window === 'undefined' || !window.document) {
return Promise.resolve(false);
}
return new Promise((resolve, reject) => {
if (typeof src !== 'string' || src.trim() === '') {
reject(new Error('Invalid source URL provided'));
return;
}
let el = findExistingScript(src);
if (!el) {
el = createScriptElement(src, options);
document.head.appendChild(el);
} else if (el.hasAttribute(DATA_LOADED_ATTR)) {
resolve(el);
return;
}
const handleError = () => reject(new ScriptLoaderError(src));
el.addEventListener('error', handleError);
el.addEventListener('abort', handleError);
el.addEventListener('load', () => {
el.setAttribute(DATA_LOADED_ATTR, 'true');
resolve(el);
});
});
}

View File

@@ -0,0 +1,95 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { loadScript } from '../DOMHelpers';
import { JSDOM } from 'jsdom';
describe('loadScript', () => {
let dom;
let window;
let document;
beforeEach(() => {
dom = new JSDOM('<!DOCTYPE html><html><head></head><body></body></html>', {
url: 'http://localhost',
});
window = dom.window;
document = window.document;
global.document = document;
});
afterEach(() => {
vi.restoreAllMocks();
delete global.document;
});
it('should load a script successfully', async () => {
const src = 'https://example.com/script.js';
const loadPromise = loadScript(src, {});
// Simulate successful script load
setTimeout(() => {
const script = document.querySelector(`script[src="${src}"]`);
if (script) {
script.dispatchEvent(new window.Event('load'));
}
}, 0);
const script = await loadPromise;
expect(script).toBeTruthy();
expect(script.getAttribute('src')).toBe(src);
expect(script.getAttribute('data-loaded')).toBe('true');
});
it('should not load a script if document is not available', async () => {
delete global.document;
const result = await loadScript('https://example.com/script.js', {});
expect(result).toBe(false);
});
it('should use an existing script if already present', async () => {
const src = 'https://example.com/existing-script.js';
const existingScript = document.createElement('script');
existingScript.src = src;
existingScript.setAttribute('data-loaded', 'true');
document.head.appendChild(existingScript);
const script = await loadScript(src, {});
expect(script).toBe(existingScript);
});
it('should set custom attributes on the script element', async () => {
const src = 'https://example.com/custom-script.js';
const options = {
type: 'module',
async: false,
defer: true,
crossOrigin: 'anonymous',
noModule: true,
referrerPolicy: 'origin',
id: 'custom-script',
attrs: { 'data-custom': 'value' },
};
const loadPromise = loadScript(src, options);
// Simulate successful script load
setTimeout(() => {
const script = document.querySelector(`script[src="${src}"]`);
if (script) {
script.dispatchEvent(new window.Event('load'));
}
}, 0);
const script = await loadPromise;
expect(script.type).toBe('module');
expect(script.async).toBe(false);
expect(script.defer).toBe(true);
expect(script.crossOrigin).toBe('anonymous');
expect(script.noModule).toBe(true);
expect(script.referrerPolicy).toBe('origin');
expect(script.id).toBe('custom-script');
expect(script.getAttribute('data-custom')).toBe('value');
});
});

View File

@@ -379,6 +379,7 @@
},
"DETAILS": {
"LOADING_FB": "Authenticating you with Facebook...",
"ERROR_FB_LOADING": "Error loading Facebook SDK. Please disable any ad-blockers and try again from a different browser.",
"ERROR_FB_AUTH": "Something went wrong, Please refresh page...",
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",

View File

@@ -2,14 +2,15 @@
<div
class="border border-slate-25 dark:border-slate-800/60 bg-white dark:bg-slate-900 h-full p-6 w-full max-w-full md:w-3/4 md:max-w-[75%] flex-shrink-0 flex-grow-0"
>
<div v-if="!hasLoginStarted" class="login-init h-full">
<div v-if="!hasLoginStarted" class="pt-[30%] h-full">
<a href="#" @click="startLogin()">
<img
class="h-10 w-auto"
src="~dashboard/assets/images/channels/facebook_login.png"
alt="Facebook-logo"
/>
</a>
<p>
<p class="py-6">
{{
useInstallationName(
$t('INBOX_MGMT.ADD.FB.HELP'),
@@ -99,12 +100,16 @@ import router from '../../../../index';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import accountMixin from '../../../../../mixins/account';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import alertMixin from 'shared/mixins/alertMixin';
import * as Sentry from '@sentry/browser';
export default {
components: {
LoadingState,
PageHeader,
},
mixins: [globalConfigMixin, accountMixin],
mixins: [globalConfigMixin, accountMixin, alertMixin],
data() {
return {
isCreating: false,
@@ -147,19 +152,29 @@ export default {
}),
},
created() {
this.initFB();
this.loadFBsdk();
},
mounted() {
this.initFB();
window.fbAsyncInit = this.runFBInit;
},
methods: {
startLogin() {
async startLogin() {
this.hasLoginStarted = true;
this.tryFBlogin();
try {
// this will load the SDK in a promise, and resolve it when the sdk is loaded
// in case the SDK is already present, it will resolve immediately
await this.loadFBsdk();
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
this.tryFBlogin(); // make an attempt to login
} catch (error) {
if (error.name === 'ScriptLoaderError') {
// if the error was related to script loading, we show a toast
this.showAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
} else {
// if the error was anything else, we capture it and show a toast
Sentry.captureException(error);
this.showAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
}
}
},
setPageName({ name }) {
@@ -173,34 +188,21 @@ export default {
}
},
initFB() {
if (window.fbSDKLoaded === undefined) {
window.fbAsyncInit = () => {
FB.init({
appId: window.chatwootConfig.fbAppId,
xfbml: true,
version: window.chatwootConfig.fbApiVersion,
status: true,
});
window.fbSDKLoaded = true;
FB.AppEvents.logPageView();
};
}
runFBInit() {
FB.init({
appId: window.chatwootConfig.fbAppId,
xfbml: true,
version: window.chatwootConfig.fbApiVersion,
status: true,
});
window.fbSDKLoaded = true;
FB.AppEvents.logPageView();
},
loadFBsdk() {
((d, s, id) => {
let js;
// eslint-disable-next-line
const fjs = (js = d.getElementsByTagName(s)[0]);
if (d.getElementById(id)) {
return;
}
js = d.createElement(s);
js.id = id;
js.src = '//connect.facebook.net/en_US/sdk.js';
fjs.parentNode.insertBefore(js, fjs);
})(document, 'script', 'facebook-jssdk');
async loadFBsdk() {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
id: 'facebook-jssdk',
});
},
tryFBlogin() {
@@ -285,16 +287,3 @@ export default {
},
};
</script>
<style scoped lang="scss">
.login-init {
@apply pt-[30%] text-center;
p {
@apply p-6;
}
> a > img {
@apply w-60;
}
}
</style>

View File

@@ -1,11 +1,14 @@
<template>
<inbox-reconnection-required class="mx-8 mt-5" @reauthorize="tryFBlogin" />
<inbox-reconnection-required class="mx-8 mt-5" @reauthorize="startLogin" />
</template>
<script>
/* global FB */
import InboxReconnectionRequired from '../components/InboxReconnectionRequired';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import alertMixin from 'shared/mixins/alertMixin';
import * as Sentry from '@sentry/browser';
export default {
components: {
@@ -24,38 +27,44 @@ export default {
},
},
mounted() {
this.initFB();
this.loadFBsdk();
window.fbAsyncInit = this.runFBInit;
},
methods: {
initFB() {
if (window.fbSDKLoaded === undefined) {
window.fbAsyncInit = () => {
FB.init({
appId: window.chatwootConfig.fbAppId,
xfbml: true,
version: window.chatwootConfig.fbApiVersion,
status: true,
});
window.fbSDKLoaded = true;
FB.AppEvents.logPageView();
};
}
runFBInit() {
FB.init({
appId: window.chatwootConfig.fbAppId,
xfbml: true,
version: window.chatwootConfig.fbApiVersion,
status: true,
});
window.fbSDKLoaded = true;
FB.AppEvents.logPageView();
},
loadFBsdk() {
((d, s, id) => {
let js;
// eslint-disable-next-line
const fjs = (js = d.getElementsByTagName(s)[0]);
if (d.getElementById(id)) {
return;
async loadFBsdk() {
return loadScript('https://connect.facebook.net/en_US/sdk.js', {
id: 'facebook-jssdk',
});
},
async startLogin() {
this.hasLoginStarted = true;
try {
// this will load the SDK in a promise, and resolve it when the sdk is loaded
// in case the SDK is already present, it will resolve immediately
await this.loadFBsdk();
this.runFBInit(); // run init anyway, `tryFBlogin` won't wait for `fbAsyncInit` otherwise.
this.tryFBlogin(); // make an attempt to login
} catch (error) {
if (error.name === 'ScriptLoaderError') {
// if the error was related to script loading, we show a toast
this.showAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_LOADING'));
} else {
// if the error was anything else, we capture it and show a toast
Sentry.captureException(error);
this.showAlert(this.$t('INBOX_MGMT.DETAILS.ERROR_FB_AUTH'));
}
js = d.createElement(s);
js.id = id;
js.src = 'https://connect.facebook.net/en_US/sdk.js';
fjs.parentNode.insertBefore(js, fjs);
})(document, 'script', 'facebook-jssdk');
}
},
tryFBlogin() {