diff --git a/src/database/Database.js b/src/database/Database.js index 2889f78..d0769b9 100644 --- a/src/database/Database.js +++ b/src/database/Database.js @@ -9,73 +9,60 @@ import { import { uniqEmoji } from './utils/uniqEmoji' import { closeDatabase, - deleteDatabase, - addOnCloseListener, - openDatabase + deleteDatabase } from './databaseLifecycle' import { - isEmpty, getEmojiByGroup, + getEmojiByGroup, getEmojiBySearchQuery, getEmojiByShortcode, getEmojiByUnicode, get, set, getTopFavoriteEmoji, incrementFavoriteEmojiCount } from './idbInterface' import { customEmojiIndex } from './customEmojiIndex' import { cleanEmoji } from './utils/cleanEmoji' -import { loadDataForFirstTime, checkForUpdates } from './dataLoading' +import { initDatabase } from './initDatabase.js' export default class Database { constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) { this.dataSource = dataSource this.locale = locale this._dbName = `emoji-picker-element-${this.locale}` - this._db = undefined - this._lazyUpdate = undefined this._custom = customEmojiIndex(customEmoji) this._clear = this._clear.bind(this) - this._ready = this._init() + /* no await */ this._init() } async _init () { - const db = this._db = await openDatabase(this._dbName) - - addOnCloseListener(this._dbName, this._clear) - const dataSource = this.dataSource - const empty = await isEmpty(db) - - if (empty) { - await loadDataForFirstTime(db, dataSource) - } else { // offline-first - do an update asynchronously - this._lazyUpdate = checkForUpdates(db, dataSource) + if (this._controller) { + this._controller.abort() } + this._controller = new AbortController() + this._dbPromise = initDatabase(this._dbName, this.dataSource, this._clear, this._controller.signal) } async ready () { - const checkReady = async () => { - if (!this._ready) { - this._ready = this._init() - } - return this._ready - } - await checkReady() - // There's a possibility of a race condition where the element gets added, removed, and then added again - // with a particular timing, which would set the _db to undefined. - // We *could* do a while loop here, but that seems excessive and could lead to an infinite loop. - if (!this._db) { - await checkReady() + await this._recreateIfNecessary() + await this._dbPromise + } + + async _recreateIfNecessary () { + if (!this._controller) { + await this._init() } + const db = await this._dbPromise + return db } async getEmojiByGroup (group) { assertNumber(group) - await this.ready() - return uniqEmoji(await getEmojiByGroup(this._db, group)).map(cleanEmoji) + const db = await this._recreateIfNecessary() + return uniqEmoji(await getEmojiByGroup(db, group)).map(cleanEmoji) } async getEmojiBySearchQuery (query) { assertNonEmptyString(query) - await this.ready() + const db = await this._recreateIfNecessary() const customs = this._custom.search(query) - const natives = uniqEmoji(await getEmojiBySearchQuery(this._db, query)).map(cleanEmoji) + const natives = uniqEmoji(await getEmojiBySearchQuery(db, query)).map(cleanEmoji) return [ ...customs, ...natives @@ -84,45 +71,45 @@ export default class Database { async getEmojiByShortcode (shortcode) { assertNonEmptyString(shortcode) - await this.ready() + const db = await this._recreateIfNecessary() const custom = this._custom.byShortcode(shortcode) if (custom) { return custom } - return cleanEmoji(await getEmojiByShortcode(this._db, shortcode)) + return cleanEmoji(await getEmojiByShortcode(db, shortcode)) } async getEmojiByUnicodeOrName (unicodeOrName) { assertNonEmptyString(unicodeOrName) - await this.ready() + const db = await this._recreateIfNecessary() const custom = this._custom.byName(unicodeOrName) if (custom) { return custom } - return cleanEmoji(await getEmojiByUnicode(this._db, unicodeOrName)) + return cleanEmoji(await getEmojiByUnicode(db, unicodeOrName)) } async getPreferredSkinTone () { - await this.ready() - return (await get(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0 + const db = await this._recreateIfNecessary() + return (await get(db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE)) || 0 } async setPreferredSkinTone (skinTone) { assertNumber(skinTone) - await this.ready() - return set(this._db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone) + const db = await this._recreateIfNecessary() + return set(db, STORE_KEYVALUE, KEY_PREFERRED_SKINTONE, skinTone) } async incrementFavoriteEmojiCount (unicodeOrName) { assertNonEmptyString(unicodeOrName) - await this.ready() - return incrementFavoriteEmojiCount(this._db, unicodeOrName) + const db = await this._recreateIfNecessary() + return incrementFavoriteEmojiCount(db, unicodeOrName) } async getTopFavoriteEmoji (limit) { assertNumber(limit) - await this.ready() - return (await getTopFavoriteEmoji(this._db, this._custom, limit)).map(cleanEmoji) + const db = await this._recreateIfNecessary() + return (await getTopFavoriteEmoji(db, this._custom, limit)).map(cleanEmoji) } set customEmoji (customEmojis) { @@ -133,11 +120,12 @@ export default class Database { return this._custom.all } - async _shutdown () { - await this.ready() // reopen if we've already been closed/deleted - try { - await this._lazyUpdate // allow any lazy updates to process before closing/deleting - } catch (err) { /* ignore network errors (offline-first) */ } + _shutdown () { + if (this._controller) { + this._controller.abort() + this._controller = undefined + this._dbPromise = undefined + } } // clear references to IDB, e.g. during a close event @@ -147,16 +135,16 @@ export default class Database { // The memory leak tests prove this is unnecessary. It's because: // 1) IDBDatabases that can no longer fire "close" automatically have listeners GCed // 2) we clear the manual close listeners in databaseLifecycle.js. - this._db = this._ready = this._lazyUpdate = undefined + this._shutdown() } async close () { - await this._shutdown() + this._shutdown() await closeDatabase(this._dbName) } async delete () { - await this._shutdown() + this._shutdown() await deleteDatabase(this._dbName) } } diff --git a/src/database/dataLoading.js b/src/database/dataLoading.js index 579911a..3ba74b5 100644 --- a/src/database/dataLoading.js +++ b/src/database/dataLoading.js @@ -2,37 +2,71 @@ import { getETag, getETagAndData } from './utils/ajax' import { jsonChecksum } from './utils/jsonChecksum' import { hasData, loadData } from './idbInterface' -export async function checkForUpdates (db, dataSource) { +export async function checkForUpdates (db, dataSource, signal) { // just do a simple HEAD request first to see if the eTags match let emojiData let eTag = await getETag(dataSource) + + if (signal.aborted) { + return + } + if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers const eTagAndData = await getETagAndData(dataSource) eTag = eTagAndData[0] emojiData = eTagAndData[1] + + if (signal.aborted) { + return + } + if (!eTag) { eTag = await jsonChecksum(emojiData) } } + + if (signal.aborted) { + return + } + if (await hasData(db, dataSource, eTag)) { console.log('Database already populated') } else { console.log('Database update available') + + if (signal.aborted) { + return + } + if (!emojiData) { const eTagAndData = await getETagAndData(dataSource) emojiData = eTagAndData[1] } + + if (signal.aborted) { + return + } + await loadData(db, emojiData, dataSource, eTag) } } -export async function loadDataForFirstTime (db, dataSource) { +export async function loadDataForFirstTime (db, dataSource, signal) { let [eTag, emojiData] = await getETagAndData(dataSource) + + if (signal.aborted) { + return + } + if (!eTag) { // Handle lack of support for ETag or Access-Control-Expose-Headers // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Expose-Headers#Browser_compatibility eTag = await jsonChecksum(emojiData) } + if (signal.aborted) { + return + } + await loadData(db, emojiData, dataSource, eTag) } diff --git a/src/database/initDatabase.js b/src/database/initDatabase.js new file mode 100644 index 0000000..e278850 --- /dev/null +++ b/src/database/initDatabase.js @@ -0,0 +1,28 @@ +import { addOnCloseListener, openDatabase } from './databaseLifecycle.js' +import { isEmpty } from './idbInterface.js' +import { checkForUpdates, loadDataForFirstTime } from './dataLoading.js' + +export async function initDatabase (dbName, dataSource, onClear, signal) { + // signal.addEventListener('abort', () => { + // closeDatabase(dbName) + // }) + const db = await openDatabase(dbName) + addOnCloseListener(dbName, onClear) + + if (signal.aborted) { + return + } + + const empty = await isEmpty(db) + + if (signal.aborted) { + return + } + + if (empty) { + await loadDataForFirstTime(db, dataSource, signal) + } else { // offline-first - do an update asynchronously + await checkForUpdates(db, dataSource, signal) + } + return db +} diff --git a/src/picker/PickerElement.js b/src/picker/PickerElement.js index eaea0e2..7ee0984 100644 --- a/src/picker/PickerElement.js +++ b/src/picker/PickerElement.js @@ -46,7 +46,7 @@ export default class PickerElement extends HTMLElement { delete this[prop] } } - this._dbFlush() // wait for a flush before creating the db, in case the user calls e.g. a setter or setAttribute + this._dbCreate() } connectedCallback () { @@ -94,7 +94,7 @@ export default class PickerElement extends HTMLElement { this._cmp.$set({ [prop]: newValue }) } if (['locale', 'dataSource'].includes(prop)) { - this._dbFlush() + this._dbCreate() } } @@ -102,17 +102,12 @@ export default class PickerElement extends HTMLElement { const { locale, dataSource, database } = this._ctx // only create a new database if we really need to if (!database || database.locale !== locale || database.dataSource !== dataSource) { + if (database) { + database.close() + } this._set('database', new Database({ locale, dataSource })) } } - - // Update the Database in one microtask if the locale/dataSource change. We do one microtask - // so we don't create two Databases if e.g. both the locale and the dataSource change - _dbFlush () { - queueMicrotask(() => ( - this._dbCreate() - )) - } } const definitions = {}