From 6698b4be882a534089d7ce80f60084a4e788a80f Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 14:32:12 -0600 Subject: [PATCH 01/10] Abort in-flight requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Announce a single set of logical network events—loadstart, load, loadend—while aborting all but the final request as an internal optimization. Closes #14 Co-authored-by: Mu-An Chiou --- src/index.ts | 51 +++++++++++++++++++------ test/test.js | 106 ++++++++++++++++++++++++++++----------------------- 2 files changed, 97 insertions(+), 60 deletions(-) diff --git a/src/index.ts b/src/index.ts index 15d7132..a946b82 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,7 +4,7 @@ class RemoteInputElement extends HTMLElement { constructor() { super() const fetch = fetchResults.bind(null, this, true) - const state = {currentQuery: null, oninput: debounce(fetch), fetch} + const state = {currentQuery: null, oninput: debounce(fetch), fetch, controller: null} states.set(this, state) } @@ -59,6 +59,13 @@ class RemoteInputElement extends HTMLElement { } } +function makeAbortController() { + if ('AbortController' in window) { + return new AbortController() + } + return {signal: null, abort() {}} +} + async function fetchResults(remoteInput: RemoteInputElement, checkCurrentQuery: boolean) { const input = remoteInput.input if (!input) return @@ -82,33 +89,53 @@ async function fetchResults(remoteInput: RemoteInputElement, checkCurrentQuery: params.append(remoteInput.getAttribute('param') || 'q', query) url.search = params.toString() - remoteInput.dispatchEvent(new CustomEvent('loadstart')) + if (state.controller) { + state.controller.abort() + } else { + remoteInput.dispatchEvent(new CustomEvent('loadstart')) + } + + state.controller = makeAbortController() + remoteInput.setAttribute('loading', '') let response - let errored = false let html = '' try { - response = await fetch(url.toString(), { + response = await fetchWithNetworkEvents(remoteInput, url.toString(), { + signal: state.controller.signal, credentials: 'same-origin', headers: {accept: 'text/html; fragment'} }) html = await response.text() - remoteInput.dispatchEvent(new CustomEvent('load')) - } catch { - errored = true - remoteInput.dispatchEvent(new CustomEvent('error')) + remoteInput.removeAttribute('loading') + } catch (error) { + if (error.name !== 'AbortError') { + remoteInput.removeAttribute('loading') + } + return } - remoteInput.removeAttribute('loading') - if (errored) return if (response && response.ok) { - remoteInput.dispatchEvent(new CustomEvent('remote-input-success', {bubbles: true})) resultsContainer.innerHTML = html + remoteInput.dispatchEvent(new CustomEvent('remote-input-success', {bubbles: true})) } else { remoteInput.dispatchEvent(new CustomEvent('remote-input-error', {bubbles: true})) } +} - remoteInput.dispatchEvent(new CustomEvent('loadend')) +async function fetchWithNetworkEvents(el: Element, url: string, options: RequestInit): Promise { + try { + const response = await fetch(url, options) + el.dispatchEvent(new CustomEvent('load')) + el.dispatchEvent(new CustomEvent('loadend')) + return response + } catch (error) { + if (error.name !== 'AbortError') { + el.dispatchEvent(new CustomEvent('error')) + el.dispatchEvent(new CustomEvent('loadend')) + } + throw error + } } function debounce(callback: () => void) { diff --git a/test/test.js b/test/test.js index 81b2e79..9ce0a4f 100644 --- a/test/test.js +++ b/test/test.js @@ -25,94 +25,104 @@ describe('remote-input', function() { document.body.innerHTML = '' }) - it('loads content', function(done) { + it('loads content', async function() { const remoteInput = document.querySelector('remote-input') const input = document.querySelector('input') const results = document.querySelector('#results') assert.equal(results.innerHTML, '') - let successEvent = false - remoteInput.addEventListener('remote-input-success', function() { - successEvent = true - }) - remoteInput.addEventListener('loadend', function() { - assert.ok(successEvent, 'success event happened') - assert.equal(results.querySelector('ol').getAttribute('data-src'), '/results?q=test') - done() - }) + + const success = once(remoteInput, 'remote-input-success') + const loadend = once(remoteInput, 'loadend') + input.value = 'test' input.focus() + + await success + await loadend + assert.equal(results.querySelector('ol').getAttribute('data-src'), '/results?q=test') }) - it('handles not ok responses', function(done) { + it('handles not ok responses', async function() { const remoteInput = document.querySelector('remote-input') const input = document.querySelector('input') const results = document.querySelector('#results') remoteInput.src = '/500' assert.equal(results.innerHTML, '') - let errorEvent = false - remoteInput.addEventListener('remote-input-error', function() { - errorEvent = true - }) - remoteInput.addEventListener('loadend', function() { - assert.ok(errorEvent, 'error event happened') - assert.equal(results.innerHTML, '', 'nothing was appended') - done() - }) + + const error = once(remoteInput, 'remote-input-error') + const loadend = once(remoteInput, 'loadend') + input.value = 'test' input.focus() + + await loadend + await error + + assert.equal(results.innerHTML, '', 'nothing was appended') }) - it('handles network error', function(done) { + it('handles network error', async function() { const remoteInput = document.querySelector('remote-input') - const input = document.querySelector('input') + const input = remoteInput.querySelector('input') const results = document.querySelector('#results') remoteInput.src = '/network-error' assert.equal(results.innerHTML, '') - remoteInput.addEventListener('error', async function() { - await Promise.resolve() - assert.equal(results.innerHTML, '', 'nothing was appended') - assert.notOk(remoteInput.hasAttribute('loading'), 'loading attribute was removed') - done() - }) + + const result = once(remoteInput, 'error') + input.value = 'test' input.focus() - assert.ok(remoteInput.hasAttribute('loading'), 'loading attribute was added') + assert.ok(remoteInput.hasAttribute('loading'), 'loading attribute should have been added') + + await result + await nextTick() + assert.equal(results.innerHTML, '', 'nothing was appended') + assert.notOk(remoteInput.hasAttribute('loading'), 'loading attribute should have been removed') }) - it('repects param attribute', function(done) { + it('repects param attribute', async function() { const remoteInput = document.querySelector('remote-input') const input = document.querySelector('input') const results = document.querySelector('#results') remoteInput.setAttribute('param', 'robot') assert.equal(results.innerHTML, '') - remoteInput.addEventListener('loadend', function() { - assert.equal(results.querySelector('ol').getAttribute('data-src'), '/results?robot=test') - done() - }) + + const result = once(remoteInput, 'remote-input-success') + input.value = 'test' input.focus() + + await result + assert.equal(results.querySelector('ol').getAttribute('data-src'), '/results?robot=test') }) - it('loads content again after src is changed', function(done) { + it('loads content again after src is changed', async function() { const remoteInput = document.querySelector('remote-input') const input = document.querySelector('input') const results = document.querySelector('#results') - function listenOnce(cb) { - remoteInput.addEventListener('loadend', cb, {once: true}) - } - listenOnce(function() { - assert.equal(results.querySelector('ol').getAttribute('data-src'), '/results?q=test') - - listenOnce(function() { - assert.equal(results.querySelector('ol').getAttribute('data-src'), '/srcChanged?q=test') - done() - }) - - remoteInput.src = '/srcChanged' - }) + const result1 = once(remoteInput, 'remote-input-success') input.value = 'test' input.focus() + + await result1 + assert.equal(results.querySelector('ol').getAttribute('data-src'), '/results?q=test') + + const result2 = once(remoteInput, 'remote-input-success') + remoteInput.src = '/srcChanged' + + await result2 + assert.equal(results.querySelector('ol').getAttribute('data-src'), '/srcChanged?q=test') }) }) }) + +function nextTick() { + return Promise.resolve() +} + +function once(element, eventName) { + return new Promise(resolve => { + element.addEventListener(eventName, resolve, {once: true}) + }) +} From b937a7eedd526983c5af1db57ab3dc1f2bcd5eed Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 14:34:18 -0600 Subject: [PATCH 02/10] Test event dispatch order Co-authored-by: Mu-An Chiou --- test/test.js | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/test/test.js b/test/test.js index 9ce0a4f..f74ca4f 100644 --- a/test/test.js +++ b/test/test.js @@ -25,6 +25,29 @@ describe('remote-input', function() { document.body.innerHTML = '' }) + it('emits network events in order', async function() { + const remoteInput = document.querySelector('remote-input') + const input = document.querySelector('input') + + const events = [] + const track = event => events.push(event.type) + + remoteInput.addEventListener('loadstart', track) + remoteInput.addEventListener('load', track) + remoteInput.addEventListener('loadend', track) + + const completed = Promise.all([ + once(remoteInput, 'loadstart'), + once(remoteInput, 'load'), + once(remoteInput, 'loadend') + ]) + input.value = 'test' + input.focus() + await completed + + assert.deepEqual(['loadstart', 'load', 'loadend'], events) + }) + it('loads content', async function() { const remoteInput = document.querySelector('remote-input') const input = document.querySelector('input') From c4daadfed2b3abc50dde7c7e2e6929194a2aa60c Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 14:36:32 -0600 Subject: [PATCH 03/10] Query test element fixtures once Co-authored-by: Mu-An Chiou --- test/test.js | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/test/test.js b/test/test.js index f74ca4f..ec53b5d 100644 --- a/test/test.js +++ b/test/test.js @@ -12,6 +12,10 @@ describe('remote-input', function() { }) describe('after tree insertion', function() { + let remoteInput + let input + let results + beforeEach(function() { document.body.innerHTML = ` @@ -19,16 +23,19 @@ describe('remote-input', function() {
` + remoteInput = document.querySelector('remote-input') + input = remoteInput.querySelector('input') + results = document.querySelector('#results') }) afterEach(function() { document.body.innerHTML = '' + remoteInput = null + input = null + results = null }) it('emits network events in order', async function() { - const remoteInput = document.querySelector('remote-input') - const input = document.querySelector('input') - const events = [] const track = event => events.push(event.type) @@ -49,9 +56,6 @@ describe('remote-input', function() { }) it('loads content', async function() { - const remoteInput = document.querySelector('remote-input') - const input = document.querySelector('input') - const results = document.querySelector('#results') assert.equal(results.innerHTML, '') const success = once(remoteInput, 'remote-input-success') @@ -66,9 +70,6 @@ describe('remote-input', function() { }) it('handles not ok responses', async function() { - const remoteInput = document.querySelector('remote-input') - const input = document.querySelector('input') - const results = document.querySelector('#results') remoteInput.src = '/500' assert.equal(results.innerHTML, '') @@ -85,9 +86,6 @@ describe('remote-input', function() { }) it('handles network error', async function() { - const remoteInput = document.querySelector('remote-input') - const input = remoteInput.querySelector('input') - const results = document.querySelector('#results') remoteInput.src = '/network-error' assert.equal(results.innerHTML, '') @@ -104,9 +102,6 @@ describe('remote-input', function() { }) it('repects param attribute', async function() { - const remoteInput = document.querySelector('remote-input') - const input = document.querySelector('input') - const results = document.querySelector('#results') remoteInput.setAttribute('param', 'robot') assert.equal(results.innerHTML, '') @@ -120,10 +115,6 @@ describe('remote-input', function() { }) it('loads content again after src is changed', async function() { - const remoteInput = document.querySelector('remote-input') - const input = document.querySelector('input') - const results = document.querySelector('#results') - const result1 = once(remoteInput, 'remote-input-success') input.value = 'test' input.focus() From 0e04041cdd85687adff9e15d68788cdb93768287 Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 14:40:39 -0600 Subject: [PATCH 04/10] Extract changeValue function Co-authored-by: Mu-An Chiou --- test/test.js | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/test/test.js b/test/test.js index ec53b5d..80a1930 100644 --- a/test/test.js +++ b/test/test.js @@ -48,8 +48,7 @@ describe('remote-input', function() { once(remoteInput, 'load'), once(remoteInput, 'loadend') ]) - input.value = 'test' - input.focus() + changeValue(input, 'test') await completed assert.deepEqual(['loadstart', 'load', 'loadend'], events) @@ -61,8 +60,7 @@ describe('remote-input', function() { const success = once(remoteInput, 'remote-input-success') const loadend = once(remoteInput, 'loadend') - input.value = 'test' - input.focus() + changeValue(input, 'test') await success await loadend @@ -76,8 +74,7 @@ describe('remote-input', function() { const error = once(remoteInput, 'remote-input-error') const loadend = once(remoteInput, 'loadend') - input.value = 'test' - input.focus() + changeValue(input, 'test') await loadend await error @@ -91,8 +88,7 @@ describe('remote-input', function() { const result = once(remoteInput, 'error') - input.value = 'test' - input.focus() + changeValue(input, 'test') assert.ok(remoteInput.hasAttribute('loading'), 'loading attribute should have been added') await result @@ -107,8 +103,7 @@ describe('remote-input', function() { const result = once(remoteInput, 'remote-input-success') - input.value = 'test' - input.focus() + changeValue(input, 'test') await result assert.equal(results.querySelector('ol').getAttribute('data-src'), '/results?robot=test') @@ -116,8 +111,7 @@ describe('remote-input', function() { it('loads content again after src is changed', async function() { const result1 = once(remoteInput, 'remote-input-success') - input.value = 'test' - input.focus() + changeValue(input, 'test') await result1 assert.equal(results.querySelector('ol').getAttribute('data-src'), '/results?q=test') @@ -131,6 +125,11 @@ describe('remote-input', function() { }) }) +function changeValue(input, value) { + input.value = value + input.dispatchEvent(new Event('change')) +} + function nextTick() { return Promise.resolve() } From f00c994401d838417b7afbf7f12c864e6b055cc1 Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 14:56:53 -0600 Subject: [PATCH 05/10] Toggle loading attribute once Co-authored-by: Mu-An Chiou --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index a946b82..9ea0889 100644 --- a/src/index.ts +++ b/src/index.ts @@ -93,11 +93,11 @@ async function fetchResults(remoteInput: RemoteInputElement, checkCurrentQuery: state.controller.abort() } else { remoteInput.dispatchEvent(new CustomEvent('loadstart')) + remoteInput.setAttribute('loading', '') } state.controller = makeAbortController() - remoteInput.setAttribute('loading', '') let response let html = '' try { From 480d5664e3d4b97352cd5ce7d992e867e2805310 Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 16:44:17 -0600 Subject: [PATCH 06/10] Fix example fetch URL handling --- examples/index.html | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/examples/index.html b/examples/index.html index 5c8fff1..9cd4d55 100644 --- a/examples/index.html +++ b/examples/index.html @@ -11,16 +11,16 @@ function fakeFetch(url) { const urlObj = new URL(url) let html = '' - if (url.pathname === '/results') { + if (urlObj.pathname === '/results') { const doc = document.createElement('div') doc.innerHTML = `
  • Hubot
  • BB-8
  • Wall-E
  • Bender
  • ` - const q = url.searchParams.get('q') + const q = urlObj.searchParams.get('q') for (const el of doc.querySelectorAll('li')) { if (q !== '' && !el.textContent.toLowerCase().match(q.toLowerCase())) el.remove() } html = doc.innerHTML - } else if (url.pathname === '/marquee') { - html = `${url.searchParams.get('q') || '🐈 Nothing to preview 🐈'}` + } else if (urlObj.pathname === '/marquee') { + html = `${urlObj.searchParams.get('q') || '🐈 Nothing to preview 🐈'}` } const promiseHTML = new Promise(resolve => resolve(html)) return new Promise(resolve => resolve({ok: true, text: () => promiseHTML})) From 146f11c29dbddc50a4f1e7157797b7a82f4ec64c Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 16:44:54 -0600 Subject: [PATCH 07/10] Use Promise.resolve factory function --- examples/index.html | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/index.html b/examples/index.html index 9cd4d55..3e1fabc 100644 --- a/examples/index.html +++ b/examples/index.html @@ -22,8 +22,7 @@ } else if (urlObj.pathname === '/marquee') { html = `${urlObj.searchParams.get('q') || '🐈 Nothing to preview 🐈'}` } - const promiseHTML = new Promise(resolve => resolve(html)) - return new Promise(resolve => resolve({ok: true, text: () => promiseHTML})) + return Promise.resolve({ok: true, text: () => Promise.resolve(html)}) } window.fetch = fakeFetch From c72ac7b945227f42d037f6d8fa9ec3c9d5498138 Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 16:50:30 -0600 Subject: [PATCH 08/10] Load compiled module --- examples/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/index.html b/examples/index.html index 3e1fabc..197045b 100644 --- a/examples/index.html +++ b/examples/index.html @@ -39,7 +39,7 @@
      - + From 02358697d71be3406e972fd46f1eb499aaf535d8 Mon Sep 17 00:00:00 2001 From: David Graham Date: Fri, 18 Oct 2019 16:51:35 -0600 Subject: [PATCH 09/10] Load published module file in example --- examples/index.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/index.html b/examples/index.html index 197045b..22dccea 100644 --- a/examples/index.html +++ b/examples/index.html @@ -42,6 +42,6 @@ - + From a4662337659fe34973eef1c729ba9188d1039393 Mon Sep 17 00:00:00 2001 From: David Graham Date: Tue, 22 Oct 2019 11:33:42 -0600 Subject: [PATCH 10/10] Clear in-flight state after final request --- src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/index.ts b/src/index.ts index 9ea0889..a7e770a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -108,9 +108,11 @@ async function fetchResults(remoteInput: RemoteInputElement, checkCurrentQuery: }) html = await response.text() remoteInput.removeAttribute('loading') + state.controller = null } catch (error) { if (error.name !== 'AbortError') { remoteInput.removeAttribute('loading') + state.controller = null } return }