From 77b9977785663e6182d32ba4f84a43688e0b0855 Mon Sep 17 00:00:00 2001 From: Gabor Babicz Date: Thu, 10 Feb 2022 10:59:44 +0100 Subject: [PATCH] Add resilience for when intl-tel-input fails to load --- addon/components/phone-input.js | 46 ++++- .../app/templates/docs/action-handling.md | 7 + .../components/phone-input-test.js | 176 +++++++++++++++++- 3 files changed, 217 insertions(+), 12 deletions(-) diff --git a/addon/components/phone-input.js b/addon/components/phone-input.js index 47d3c927..40393b2b 100644 --- a/addon/components/phone-input.js +++ b/addon/components/phone-input.js @@ -28,7 +28,13 @@ import { isPresent } from '@ember/utils'; export default Component.extend({ tagName: 'input', - attributeBindings: ['type', 'disabled', 'required', 'autocomplete'], + attributeBindings: [ + 'type', + 'disabled', + 'required', + 'autocomplete', + 'isLoadingIti:data-test-loading-iti' + ], type: 'tel', phoneInput: service(), @@ -160,7 +166,8 @@ export default Component.extend({ }, input() { - const internationalPhoneNumber = this._iti.getNumber(); + const internationalPhoneNumber = + this._iti?.getNumber() ?? this.element.value; var meta = this._metaData(this._iti); this.update(internationalPhoneNumber, meta); @@ -182,23 +189,37 @@ export default Component.extend({ }, willDestroyElement() { - this._iti.destroy(); + this._iti?.destroy(); this.element.removeEventListener('countrychange', this._onCountryChange); this._super(...arguments); }, async _loadAndSetup() { - await this.phoneInput.load(); + try { + this.set('isLoadingIti', true); - this._setupLibrary(); + await this.phoneInput.load(); - this._formatNumber(); + // Even if the above promise resolves, it might be at the end of the + // component lifecycle + if (this.isDestroying || this.isDestroyed) { + return; + } - this.element.addEventListener( - 'countrychange', - this._onCountryChange.bind(this) - ); + this._setupLibrary(); + + this._formatNumber(); + + this.element.addEventListener( + 'countrychange', + this._onCountryChange.bind(this) + ); + } finally { + if (!this.isDestroying && !this.isDestroyed) { + this.set('isLoadingIti', false); + } + } }, _setupLibrary() { @@ -259,6 +280,11 @@ export default Component.extend({ }, _metaData(iti) { + if (!iti) { + // Libraries may rely on always receiving an object + return {}; + } + const extension = iti.getExtension(); const selectedCountryData = iti.getSelectedCountryData(); const isValidNumber = iti.isValidNumber(); diff --git a/tests/dummy/app/templates/docs/action-handling.md b/tests/dummy/app/templates/docs/action-handling.md index abaad7c8..53a060ed 100644 --- a/tests/dummy/app/templates/docs/action-handling.md +++ b/tests/dummy/app/templates/docs/action-handling.md @@ -4,6 +4,13 @@ Data changes that occur in the component are not propagated to the outside using two-way bindings, but rather they are communicated via the update action. +Note that `ember-phone-input` can function without the `intl-tel-input` & +`libphonenumber` scripts being loaded, in case if they load slowly or fail +completely, but the user began interacting with the component. In both cases the +`` field's value as `number` and an empty `metaData` object is sent as +`update` action arguments. If the scripts are loaded later, the component will +proceed to initializing the scripts, keeping the value already entered. + ## `update` action {{docs/components/phone-input/action-handling}} diff --git a/tests/integration/components/phone-input-test.js b/tests/integration/components/phone-input-test.js index a096ba82..fbd9148b 100644 --- a/tests/integration/components/phone-input-test.js +++ b/tests/integration/components/phone-input-test.js @@ -1,6 +1,6 @@ -import { module, test } from 'qunit'; +import QUnit, { module, test } from 'qunit'; import { setupRenderingTest } from 'ember-qunit'; -import { fillIn, render, find, typeIn } from '@ember/test-helpers'; +import { fillIn, render, find, typeIn, waitUntil } from '@ember/test-helpers'; import { hbs } from 'ember-cli-htmlbars'; module('Integration | Component | phone-input', function (hooks) { @@ -179,4 +179,176 @@ module('Integration | Component | phone-input', function (hooks) { assert.dom('.iti__flag').hasClass('iti__my'); }); + + module('resilience', function (hooks) { + let originalOnUncaughtException; + + hooks.before(function () { + originalOnUncaughtException = QUnit.onUncaughtException; + QUnit.onUncaughtException = () => {}; + }); + + hooks.after(function () { + QUnit.onUncaughtException = originalOnUncaughtException; + }); + + test('intl-tel-input is loaded after user interaction', async function (assert) { + let service = this.owner.lookup('service:phone-input'); + let load = service.load; + let resolveLoading; + + service.load = () => new Promise((resolve) => (resolveLoading = resolve)); + + this.number = null; + this.metaData = null; + + this.set('update', (value, metaData) => { + this.set('number', value); + this.set('metaData', metaData); + }); + + await render( + hbs`` + ); + + assert.dom('input').doesNotHaveAttribute('data-intl-tel-input-id'); + + assert.strictEqual( + this.number, + null, + 'number is null when rendered but intl-tel-input is not loaded yet' + ); + assert.strictEqual( + this.metaData, + null, + 'metaData is null when rendered but intl-tel-input is not loaded yet' + ); + + await fillIn('input', '9'); + + assert.strictEqual( + this.number, + '9', + 'number is correct after input without intl-tel-input' + ); + assert.deepEqual( + this.metaData, + {}, + 'metaData is an empty object after input without intl-tel-input' + ); + + resolveLoading(load.call(service)); + + await waitUntil(() => find('input:not([data-test-loading-iti])')); + + assert.dom('input').hasAttribute('data-intl-tel-input-id'); + + assert.strictEqual( + this.number, + '9', + 'number is correct after intl-tel-input is loaded' + ); + + assert.deepEqual( + this.metaData, + { + extension: '', + isValidNumber: false, + numberFormat: null, + selectedCountryData: {} + }, + 'metaData is correct after intl-tel-input is loaded' + ); + + await fillIn('input', '8'); + + assert.strictEqual( + this.number, + '8', + 'number is correct after input when the intl-tel-input is loaded' + ); + + assert.deepEqual( + this.metaData, + { + extension: '', + isValidNumber: false, + numberFormat: null, + selectedCountryData: {} + }, + 'metaData is correct after input when the intl-tel-input is loaded' + ); + }); + + test('intl-tel-input fails to load', async function (assert) { + let tmp = QUnit.onUncaughtException; + QUnit.onUncaughtException = () => {}; + + let service = this.owner.lookup('service:phone-input'); + let rejectLoading; + + service.load = () => + new Promise((_resolve, reject) => (rejectLoading = reject)); + + this.number = null; + this.metaData = null; + + this.set('update', (value, metaData) => { + this.set('number', value); + this.set('metaData', metaData); + }); + + await render( + hbs`` + ); + + assert.dom('input').doesNotHaveAttribute('data-intl-tel-input-id'); + + assert.strictEqual( + this.number, + null, + 'number is null when rendered but intl-tel-input is not loaded yet' + ); + assert.strictEqual( + this.metaData, + null, + 'metaData is null when rendered but intl-tel-input is not loaded yet' + ); + + await fillIn('input', '9'); + + assert.strictEqual( + this.number, + '9', + 'number is correct after input without intl-tel-input' + ); + assert.deepEqual( + this.metaData, + {}, + 'metaData is an empty object after input without intl-tel-input' + ); + + rejectLoading(); + + await waitUntil(() => find('input:not([data-test-loading-iti])')); + + assert.dom('input').doesNotHaveAttribute('data-intl-tel-input-id'); + + await fillIn('input', '8'); + + assert.strictEqual( + this.number, + '8', + 'number is correct when intl-tel-input is loaded' + ); + + assert.deepEqual( + this.metaData, + {}, + 'metaData is correct when intl-tel-input is loaded' + ); + + QUnit.onUncaughtException = tmp; + }); + }); });