fix: fix signal.aborted logic
This commit is contained in:
parent
546cc9301b
commit
ec880b4ac1
|
@ -21,7 +21,7 @@ import {
|
|||
import { customEmojiIndex } from './customEmojiIndex'
|
||||
import { cleanEmoji } from './utils/cleanEmoji'
|
||||
import { loadDataForFirstTime, checkForUpdates } from './dataLoading'
|
||||
import { isSignalAborted } from './utils/isSignalAborted.js'
|
||||
import { abortOpportunity } from './utils/abortSignalUtils.js'
|
||||
|
||||
export default class Database {
|
||||
constructor ({ dataSource = DEFAULT_DATA_SOURCE, locale = DEFAULT_LOCALE, customEmoji = [] } = {}) {
|
||||
|
@ -41,13 +41,21 @@ export default class Database {
|
|||
const { signal } = controller
|
||||
const db = this._db = await openDatabase(this._dbName)
|
||||
addOnCloseListener(this._dbName, this._clear)
|
||||
if (isSignalAborted(signal)) {
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
await abortOpportunity()
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const dataSource = this.dataSource
|
||||
const empty = await isEmpty(db)
|
||||
if (isSignalAborted(signal)) {
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
await abortOpportunity()
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
|
|
|
@ -1,33 +1,42 @@
|
|||
import { getETag, getETagAndData } from './utils/ajax'
|
||||
import { jsonChecksum } from './utils/jsonChecksum'
|
||||
import { hasData, loadData } from './idbInterface'
|
||||
import { isSignalAborted } from './utils/isSignalAborted.js'
|
||||
import { abortOpportunity } from './utils/abortSignalUtils.js'
|
||||
|
||||
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, signal)
|
||||
if (isSignalAborted(signal)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!eTag) { // work around lack of ETag/Access-Control-Expose-Headers
|
||||
const eTagAndData = await getETagAndData(dataSource, signal)
|
||||
if (isSignalAborted(signal)) {
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
await abortOpportunity()
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
eTag = eTagAndData[0]
|
||||
emojiData = eTagAndData[1]
|
||||
if (!eTag) {
|
||||
eTag = await jsonChecksum(emojiData)
|
||||
if (isSignalAborted(signal)) {
|
||||
eTag = await jsonChecksum(emojiData, signal)
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
await abortOpportunity()
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
const doesHaveData = await hasData(db, dataSource, eTag)
|
||||
if (isSignalAborted(signal)) {
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
await abortOpportunity()
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -37,7 +46,11 @@ export async function checkForUpdates (db, dataSource, signal) {
|
|||
console.log('Database update available')
|
||||
if (!emojiData) {
|
||||
const eTagAndData = await getETagAndData(dataSource, signal)
|
||||
if (isSignalAborted(signal)) {
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
await abortOpportunity()
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -49,15 +62,16 @@ export async function checkForUpdates (db, dataSource, signal) {
|
|||
|
||||
export async function loadDataForFirstTime (db, dataSource, signal) {
|
||||
let [eTag, emojiData] = await getETagAndData(dataSource, signal)
|
||||
if (isSignalAborted(signal)) {
|
||||
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 (isSignalAborted(signal)) {
|
||||
eTag = await jsonChecksum(emojiData, signal)
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
await abortOpportunity()
|
||||
}
|
||||
if (signal.aborted) {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
export const abortOpportunityEventTarget = /* @__PURE__ */ new EventTarget()
|
||||
|
||||
export async function abortOpportunity () {
|
||||
// we need some way to test that someone called signal.aborted in our tests
|
||||
abortOpportunityEventTarget.dispatchEvent(new CustomEvent('called'))
|
||||
// Allow enough microtasks for our test code to handle the event and respond to it asynchronously
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
}
|
|
@ -9,10 +9,6 @@ function assertStatus (response, dataSource) {
|
|||
|
||||
export async function getETag (dataSource, signal) {
|
||||
performance.mark('getETag')
|
||||
/* istanbul ignore if */
|
||||
if (import.meta.env.MODE !== 'production' && !signal) {
|
||||
throw new Error('signal must be defined')
|
||||
}
|
||||
const response = await fetch(dataSource, { method: 'HEAD', signal })
|
||||
assertStatus(response, dataSource)
|
||||
const eTag = response.headers.get('etag')
|
||||
|
@ -23,10 +19,6 @@ export async function getETag (dataSource, signal) {
|
|||
|
||||
export async function getETagAndData (dataSource, signal) {
|
||||
performance.mark('getETagAndData')
|
||||
/* istanbul ignore if */
|
||||
if (import.meta.env.MODE !== 'production' && !signal) {
|
||||
throw new Error('signal must be defined')
|
||||
}
|
||||
const response = await fetch(dataSource, { signal })
|
||||
assertStatus(response, dataSource)
|
||||
const eTag = response.headers.get('etag')
|
||||
|
|
|
@ -1,16 +0,0 @@
|
|||
export let signalAbortedEventTarget
|
||||
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
signalAbortedEventTarget = new EventTarget()
|
||||
}
|
||||
|
||||
export function isSignalAborted (signal) {
|
||||
/* istanbul ignore else */
|
||||
if (import.meta.env.MODE === 'test') {
|
||||
// we need some way to test that someone called signal.aborted in our tests
|
||||
signalAbortedEventTarget.dispatchEvent(new CustomEvent('called'))
|
||||
}
|
||||
|
||||
return signal.aborted
|
||||
}
|
|
@ -161,10 +161,15 @@ describe('database tests', () => {
|
|||
expect((await db1.getEmojiByUnicodeOrName('🐵')).annotation).toBe('monkey face')
|
||||
await tick(40)
|
||||
await db2.close()
|
||||
await tick(40)
|
||||
expect((await db2.getEmojiByUnicodeOrName('🐵')).annotation).toBe('monkey face')
|
||||
await tick(40)
|
||||
const db3 = new Database({ dataSource: ALL_EMOJI })
|
||||
await tick(40)
|
||||
await db3.ready()
|
||||
await tick(40)
|
||||
await db3.delete()
|
||||
await tick(40)
|
||||
})
|
||||
|
||||
test('multiple databases in multiple locales', async () => {
|
||||
|
|
|
@ -7,22 +7,22 @@ import {
|
|||
tick, truncatedFrEmoji
|
||||
} from '../shared.js'
|
||||
import Database from '../../../src/database/Database.js'
|
||||
import { signalAbortedEventTarget } from '../../../src/database/utils/isSignalAborted.js'
|
||||
import { abortOpportunityEventTarget } from '../../../src/database/utils/abortSignalUtils.js'
|
||||
import { mockFetch, mockGetAndHead } from '../mockFetch.js'
|
||||
|
||||
const waitForSignalAbortCalledNTimes = async (n) => {
|
||||
for (let i = 0; i < n; i++) {
|
||||
await Promise.race([
|
||||
new Promise(resolve => {
|
||||
signalAbortedEventTarget.addEventListener('called', resolve, { once: true })
|
||||
abortOpportunityEventTarget.addEventListener('called', resolve, { once: true })
|
||||
}),
|
||||
new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out waiting for signal.aborted call')), 1000))
|
||||
new Promise((resolve, reject) => setTimeout(() => reject(new Error('timed out waiting for abort opportunity')), 500))
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
function runTest ({ secondLoad, dataChanged, dataSource, signalAbortedCallCount }) {
|
||||
test(`throws no errors when DB is closed after ${signalAbortedCallCount} signal.aborted calls`, async () => {
|
||||
function runTest ({ secondLoad, dataChanged, dataSource, abortOpportunityCount }) {
|
||||
test(`throws no errors when DB is closed after ${abortOpportunityCount} signal.aborted calls`, async () => {
|
||||
if (secondLoad) {
|
||||
// do a throwaway first load
|
||||
const db = new Database({ dataSource })
|
||||
|
@ -37,24 +37,19 @@ function runTest ({ secondLoad, dataChanged, dataSource, signalAbortedCallCount
|
|||
if (dataSource === ALL_EMOJI_NO_ETAG) {
|
||||
mockGetAndHead(dataSource, truncatedFrEmoji)
|
||||
} else if (dataSource === ALL_EMOJI_MISCONFIGURED_ETAG) {
|
||||
mockFetch('get', dataSource, truncatedFrEmoji, { headers: { ETag: 'W/yyy' } })
|
||||
mockFetch('get', dataSource, truncatedFrEmoji, { headers: { ETag: 'W/updated' } })
|
||||
mockFetch('head', dataSource, null)
|
||||
} else {
|
||||
mockGetAndHead(dataSource, truncatedFrEmoji, { headers: { ETag: 'W/yyy' } })
|
||||
mockGetAndHead(dataSource, truncatedFrEmoji, { headers: { ETag: 'W/updated' } })
|
||||
}
|
||||
}
|
||||
|
||||
const db2 = new Database({ dataSource })
|
||||
await waitForSignalAbortCalledNTimes(signalAbortedCallCount)
|
||||
const db = new Database({ dataSource })
|
||||
await waitForSignalAbortCalledNTimes(abortOpportunityCount)
|
||||
const doClose = async () => {
|
||||
await db2.close()
|
||||
}
|
||||
if (!secondLoad && signalAbortedCallCount === 2) {
|
||||
// this happens to cancel an inflight fetch request
|
||||
await expect(doClose).rejects.toThrow(/The operation was aborted/)
|
||||
} else {
|
||||
await doClose()
|
||||
await db.close()
|
||||
}
|
||||
await doClose()
|
||||
await tick(40)
|
||||
})
|
||||
}
|
||||
|
@ -73,26 +68,26 @@ describe('database timing tests', () => {
|
|||
{
|
||||
testName: 'basic',
|
||||
dataSource: ALL_EMOJI,
|
||||
maxExpectedSignalAbortedCallCount: secondLoad ? (dataChanged ? 6 : 5) : 4
|
||||
maxExpectedAbortOpportunityCount: secondLoad ? 4 : 3
|
||||
},
|
||||
{
|
||||
testName: 'misconfigured etag',
|
||||
dataSource: ALL_EMOJI_MISCONFIGURED_ETAG,
|
||||
maxExpectedSignalAbortedCallCount: secondLoad ? 6 : 4
|
||||
maxExpectedAbortOpportunityCount: secondLoad ? 4 : 3
|
||||
},
|
||||
{
|
||||
testName: 'no etag',
|
||||
dataSource: ALL_EMOJI_NO_ETAG,
|
||||
maxExpectedSignalAbortedCallCount: secondLoad ? 7 : 5
|
||||
maxExpectedAbortOpportunityCount: secondLoad ? 5 : 3
|
||||
}
|
||||
]
|
||||
scenarios.forEach(({ testName, dataSource, maxExpectedSignalAbortedCallCount }) => {
|
||||
scenarios.forEach(({ testName, dataSource, maxExpectedAbortOpportunityCount }) => {
|
||||
describe(testName, () => {
|
||||
// Number of times somebody called the getter on `signal.aborted` which
|
||||
// we are using as an easy way to get full code coverage here
|
||||
const signalAbortedCallCounts = new Array(maxExpectedSignalAbortedCallCount).fill().map((_, i) => i)
|
||||
signalAbortedCallCounts.forEach(signalAbortedCallCount => {
|
||||
runTest({ secondLoad, dataChanged, dataSource, signalAbortedCallCount })
|
||||
const abortOpportunityCounts = new Array(maxExpectedAbortOpportunityCount).fill().map((_, i) => i)
|
||||
abortOpportunityCounts.forEach(abortOpportunityCount => {
|
||||
runTest({ secondLoad, dataChanged, dataSource, abortOpportunityCount })
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
Loading…
Reference in New Issue