Skip to content

Instantly share code, notes, and snippets.

@eric-hemasystems
Last active May 3, 2022 18:03
Show Gist options
  • Save eric-hemasystems/71601d306ffd9f64ff7445d44f88fb84 to your computer and use it in GitHub Desktop.
Save eric-hemasystems/71601d306ffd9f64ff7445d44f88fb84 to your computer and use it in GitHub Desktop.
addEventListener for submit with async cancel
/*
* My ideal would be something like this:
*
* form.addEventListener('submit', async function(event) {
* const shouldCancel = await someAsyncActivity()
* if( shouldCancel ) event.preventDefault()
* })
*
* Unfortuantly calling `preventDefault` AFTER any async activity will not work
* AFAIK. This function provides an abstraction to work around this. Usage
* should look like:
*
* addAsyncSubmitListener(form, async function(event) {
* const shouldCancel = await someAsyncActivity()
* if( shouldCancel ) event.preventDefault()
* })
*
* As you can see this is very close to my ideal.
*
* Small caviot. IF there is a regular event listener installed BEFORE this
* event listener DURING thhe capture phase AND that event listener does not
* call `preventDefault` consistently then unexpected behavior could occur.
*
* Also note you don't have control regarding if your event handler runs during
* the capture or bubble phase. It is always the capture phase.
*/
import onNodeRemove from 'on_node_remove'
export default function(form, listener) {
if( !listeners.has(form) ) {
listeners.set(form, [])
onNodeRemove(form, ()=> listeners.delete(form))
}
listeners.get(form).push(listener)
}
// All the listeners that have been registered keyed by the related form
const listeners = new Map()
document.addEventListener('submit', async (event) => {
const form = event.target.closest('form')
// Form is not using async submit listeners so return early
if( !listeners.has(form) ) return
// Form has already run async listeners
if( form.dataset.asyncSubmitExecuted == 'true' ) {
delete form.dataset.asyncSubmitExecuted
if( form.dataset.preventDefault == 'true' ) {
event.preventDefault()
delete form.dataset.preventDefault
}
if( form.dataset.propagationStopped == 'true' ) {
event.stopPropagation()
delete form.dataset.propagationStopped
}
if( form.dataset.immediatePropagationStopped == 'true' ) {
event.stopImmediatePropagation()
delete form.dataset.immediatePropagationStopped
}
// Return early to hand off to non-async
return
}
// Stop everything to run async callbacks so we can wait for them to complete
event.preventDefault()
event.stopImmediatePropagation()
// Feels like a real event but sort of a stub just to communicate to use
// using familiar API what should be done.
const asyncEvent = new CustomEvent('submit:async', { cancelable: true })
propagationStopped = false
asyncEvent.stopPropagation = () => propagationStopped = true
immediatePropagationStopped = false
asyncEvent.stopImmediatePropagation = () => immediatePropagationStopped = true
const formListeners = listeners.get(form)
const listenerCount = formListeners.length
for( i = 0; i < listenerCount; i++ )
await formListeners[i](asyncEvent)
if( asyncEvent.defaultPrevented ) form.dataset.preventDefault = 'true'
if( propagationStopped ) form.dataset.propagationStopped = 'true'
if( immediatePropagationStopped ) form.dataset.immediatePropagationStopped = 'true'
// Mark async submit listeners as having been run and restart the process
form.dataset.asyncSubmitExecuted = 'true'
form.requestSubmit(event.submitter)
}, { capture: true })
import addAsyncSubmitListener from 'add_async_submit_listener'
import { sleep } from './support/delay'
describe('addAsyncSubmitListener', ()=> {
let submitted, form, submitButton
beforeEach(()=> {
fixture.set(`
<form action="/does-not-matter" method="POST">
<button type="submit">Save</button>
</form>
`)
form = fixture.el.querySelector('form')
submitButton = form.querySelector('button')
})
afterEach(()=> fixture.cleanup())
function submit(delay=0.2) {
// Going to prevent default in extra listener so we don't change the page
// and just track a variable to determine if the submit would have happened
submitted = false
form.addEventListener('submit', (event)=> {
if( !event.defaultPrevented ) submitted = true
event.preventDefault()
}, { once: true })
submitButton.click()
return sleep(delay)
}
it('executes async callback before submit', async ()=> {
let executesAsyncBeforeSubmit = false
addAsyncSubmitListener(form, async function(_event) {
await sleep(0.1)
if( !submitted ) executesAsyncBeforeSubmit = true
})
await submit()
expect( submitted ).toEqual(true)
expect( executesAsyncBeforeSubmit ).toEqual(true)
})
it('allows submit to be prevented', async ()=> {
addAsyncSubmitListener(form, async function(event) {
await sleep(0.1)
event.preventDefault()
})
await submit()
expect( submitted ).toEqual(false)
})
it('allows propagation to stop', async ()=> {
anotherCallbackRan = false
form.addEventListener('submit', (event)=> anotherCallbackRan = true)
addAsyncSubmitListener(form, async function(event) {
await sleep(0.1)
event.stopPropagation()
// It would normally submit if we just stopped propagation. We stop it
// to prevent the test from changing but our general impl of that is
// stopped so adding it here also.
event.preventDefault()
})
await submit()
expect( anotherCallbackRan ).toEqual(false)
})
it('allows immediate propagation to stop', async ()=> {
// Immediate propagation would be an event on the document element during
// the capture phase
anotherCallbackRan = false
form.addEventListener('submit', ((event)=> anotherCallbackRan = true), true)
addAsyncSubmitListener(form, async function(event) {
await sleep(0.1)
event.stopImmediatePropagation()
// It would normally submit if we just stopped propagation. We stop it
// to prevent the test from changing but our general impl of that is
// stopped so adding it here also.
event.preventDefault()
})
await submit()
expect( anotherCallbackRan ).toEqual(false)
})
describe('multiple forms', ()=> {
let anotherForm, formCalled, anotherFormCalled
beforeEach(()=> {
fixture.set(`
<form id="another" action="does-not-matter" method="POST">
<button type="submit">Save</button>
</form>
`, true)
anotherForm = document.getElementById('another')
formCalled = false
addAsyncSubmitListener(form, function(_event) { formCalled = true })
anotherFormCalled = false
addAsyncSubmitListener(anotherForm, function(_event) { anotherFormCalled = true })
})
it('runs correct callbacks and submits correct form', async ()=> {
await submit(0)
expect( submitted ).toEqual(true)
expect( formCalled ).toEqual(true)
// If this form gets submitted it will change the page which will cause
// an error.
expect( anotherFormCalled ).toEqual(false)
})
})
})
// Based on https://stackoverflow.com/a/62976113
export default function(node, callback) {
new MutationObserver((_mutations, observer) => {
if( document.body.contains(node) ) return
observer.disconnect()
callback()
}).observe(document.body, { subtree: true, childList: true })
}
import onNodeRemove from 'on_node_remove'
import { nextTick } from './support/delay'
describe('node removal mutation observer', ()=> {
let grandparent, parent, target, child, notified
beforeEach(()=> {
fixture.set(`
<div id="grandparent">
<div id="parent">
<div id="target">
<div id="child"></div>
</div>
</div>
</div>
`)
grandparent = document.getElementById('grandparent')
parent = document.getElementById('parent')
target = document.getElementById('target')
child = document.getElementById('child')
notified = false
onNodeRemove(target, ()=> notified = true)
})
describe('removed via DOM operation', ()=> {
describe('node itself', ()=> {
it('notifies of removal', async ()=> {
target.remove()
await nextTick()
expect( notified ).toEqual(true)
})
})
describe('parent', ()=> {
it('notifies of removal', async ()=> {
parent.remove()
await nextTick()
expect( notified ).toEqual(true)
})
})
describe('node changed but kept', ()=> {
it('does not notify', async ()=> {
target.setAttribute('data-test', 'testing')
await nextTick()
expect( notified ).toEqual(false)
// Make sure it continues to observe by doing a real remove
target.remove()
await nextTick()
expect( notified ).toEqual(true)
})
})
describe('child node removed', ()=> {
it('does not notify', async ()=> {
child.remove()
await nextTick()
expect( notified ).toEqual(false)
// Make sure it continues to observe by doing a real remove
target.remove()
await nextTick()
expect( notified ).toEqual(true)
})
})
})
describe('removed via markup change', ()=> {
describe('node itself', ()=> {
it('notifies of removal', async ()=> {
parent.innerHTML = ''
await nextTick()
expect( notified ).toEqual(true)
})
})
describe('parent', ()=> {
it('notifies of removal', async ()=> {
grandparent.innerHTML = ''
await nextTick()
expect( notified ).toEqual(true)
})
})
describe('child node removed', ()=> {
it('does not notify', async ()=> {
target.innerHTML = ''
await nextTick()
expect( notified ).toEqual(false)
// Make sure it continues to observe by doing a real remove
target.remove()
await nextTick()
expect( notified ).toEqual(true)
})
})
})
})
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment