Last active
May 3, 2022 18:03
-
-
Save eric-hemasystems/71601d306ffd9f64ff7445d44f88fb84 to your computer and use it in GitHub Desktop.
addEventListener for submit with async cancel
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
/* | |
* 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 }) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | |
}) | |
}) | |
}) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// 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 }) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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