Promise doesn't resolve a DOM element what I expected - javascript

I implemented a kind of logic using MutationObserver and Promise to find a DOM element for the reason of DOM changes.
const waitForElement = async (
selector
) => {
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
console.log('found element=', element);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(document, {
childList: true,
subtree: true,
});
});
});
waitForElement('button[data-test="node"]').then(el => {
console.log('button element', el);
});
I think the page what I am on now has several iframes and has lazy load.
But I noticed that this works for only the first time specifically whenever I refresh a page.
If I check the log found element= inside the MutationObserver, then I can see the found element and logged that element several times, but it doesn't resolve that element as a result.
I am expecting that the waitForElement function should find the button element whenever the DOM changes every time.

As mentioned in the comments, a promise only resolves once. You can create a new promise each time it resolves, to wait for the next one. With your code structure, you can await the promise in a loop.
const container = document.getElementById("container");
const waitForElement = async(
selector
) => {
return new Promise((resolve) => {
const observer = new MutationObserver(() => {
const element = document.querySelector(selector);
console.log('found element=', element.nodeName);
if (element) {
observer.disconnect();
resolve(element);
}
});
observer.observe(container, {
childList: true
});
});
}
(async() => {
while (true) {
let el = await waitForElement('button[data-test="node"]');
console.log('button element', el.nodeName);
}
})();
setInterval(() => {
container.innerHTML = '<button data-test="node">X</button>';
}, 2000);
<div id="container">
</div>

Related

How to avoid the creation of a new promise for each click?

I have a click that calls the method:
public clickEvent() {
this.createIframe().then((iframe) => { // Return iframe or create if is not before needed inside
// Async hard logic here
})
}
Problem is when user clicks a lot of times clickEvent() it fires promise and then fires a hard logic inside.
How to avoid click until logic inside is not finished?
Or disable to call logic inside if it is done?
If you're using Angular, I think you can convert the click event into an observable and then use the variety of operators such as exhaustMap to achieve this.
import { exhaustMap, fromEvent } from 'rxjs';
....
#ViewChild('btnId') btnElementRef!: ElementRef<HTMLButtonElement>;
ngAfterViewInit(): void {
fromEvent(this.btnElementRef.nativeElement, 'click')
.pipe(
exhaustMap(() => this.createIframe())
)
.subscribe((iframe) => {
// hard coded async logic here
});
}
);
This will ignore sub-sequent click until the Promise resolve first.
Further more, if you want to disable the button and display somekind of loading indicator, you can also add a variable to track that inside the stream using tap
fromEvent(this.btnElementRef.nativeElement, 'click')
.pipe(
tap(() => isProcessing = true),
exhaustMap(() => this.createIframe())
)
.subscribe((iframe) => {
isProcessing = false;
// hard coded async logic here
});
Make createIframe cache its Promise (like as an instance property), and return that first if it exists, instead of starting another. For example:
// example function that creates the Promise
const createPromise = () => {
console.log('creating Promise');
return new Promise(resolve => setTimeout(resolve, 3000));
}
class SomeClass {
createIframe() {
if (this.iframePromise) return this.iframePromise;
this.iframePromise = createPromise();
return this.iframePromise;
}
clickEvent() {
this.createIframe().then((iframe) => {
console.log('clickEvent has received the Promise and is now running more code');
})
}
}
const s = new SomeClass();
button.onclick = () => s.clickEvent();
<button id="button">click to call clickEvent</button>
If you also want to prevent // Async hard logic here from running multiple times after multiple clicks, assign something to the instance inside clickEvent instead.
// example function that creates the Promise
const createPromise = () => {
console.log('creating Promise');
return new Promise(resolve => setTimeout(resolve, 3000));
}
class SomeClass {
createIframe() {
return createPromise();
}
clickEvent() {
if (this.hasClicked) return;
this.hasClicked = true;
this.createIframe().then((iframe) => {
console.log('clickEvent has received the Promise and is now running more code');
})
}
}
const s = new SomeClass();
button.onclick = () => s.clickEvent();
<button id="button">click to call clickEvent</button>

How do I mock the IntersectionObserver API in Jest?

I've read all of the relevant questions on this topic, and I realize this will probably be marked as a duplicate, but I simply cannot for the life of me figure out how to get this working.
I have this simple function that lazily loads elements:
export default function lazyLoad(targets, onIntersection) {
const observer = new IntersectionObserver((entries, self) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
onIntersection(entry.target);
self.unobserve(entry.target);
}
});
});
document.querySelectorAll(targets).forEach((target) => observer.observe(target));
return observer;
}
Example usage:
lazyLoad('.lazy-img', (img) => {
const pictureElement = img.parentElement;
const source = pictureElement.querySelector('.lazy-source');
source.srcset = source.getAttribute('data-srcset');
img.src = img.getAttribute('data-src');
});
Now, I'm trying to test the lazyLoad function using jest, but I obviously need to mock IntersectionObserver since it's a browser API, not a native JavaScript one.
The following works for testing the observe method:
let observe;
let unobserve;
beforeEach(() => {
observe = jest.fn();
unobserve = jest.fn();
window.IntersectionObserver = jest.fn(() => ({
observe,
unobserve,
}));
});
describe('lazyLoad utility', () => {
it('calls observe on each target', () => {
for (let i = 0; i < 3; i++) {
const target = document.createElement('img');
target.className = 'lazy-img';
document.body.appendChild(target);
}
lazyLoad(
'.lazy-img',
jest.fn(() => {})
);
expect(observe).toHaveBeenCalledTimes(3);
});
});
But I also want to test the .isIntersecting logic, where the callback fires... Except I don't know how to do that. How can I test intersections with jest?
Mocking stuff is so easy when you pass it as an argument:
export default function lazyLoad(targets, onIntersection, observerClass = IntersectionObserver) {
const observer = new observerClass(...)
...
}
// test file
let entries = [];
const observeFn = jest.fn();
const unobserveFn = jest.fn()
class MockObserver {
constructor(fn) {
fn(entries,this);
}
observe() { observeFn() }
unobserve() { unobserveFn() }
}
test('...',() => {
// set `entries` to be something so you can mock it
entries = ...something
lazyLoad('something',jest.fn(),MockObserver);
});
Another option is to use mockObserver mentioned above and mock in window.
window.IntersetionObserver = mockObserver
And you don't necessary need to pass observer in component props.
The important point to test if entries isIntesecting is to mock IntersectionObserver as class like mentioned above.

How to check if HTMLElement has already been generated?

I have a component from 3rd party which emits "onCellEdit" event and passes a cell element as parameter.
In my event handler I want to automatically select the whole text in the input element that is generated inside of this cell.
The problem I'm having is that when my handler is triggered the input element is not yet loaded.
(cellElement as HTMLTableCellElement).querySelector('input') returns nothing since the 3rd party component needs some time I guess.
My solution now looks like this:
selectTextOnEdit(cell: HTMLTableCellElement) {
const repeater = (element: HTMLTableCellElement) => {
const inputElement = element.querySelector('input');
if (inputElement) {
inputElement.select();
} else {
setTimeout(() => { repeater(element); }, 50);
}
};
repeater(cell);
}
this function then triggers the repeater function which goes around until the input element is found. I know I'm missing some kind of a check in case the input element is never generated.. but it's not important for this question.
I highly dislike this solution and I'm sure there are better ones.
Update:
After some research I found out about "MutationObserver".
Here is my new solution:
selectTextOnEdit(cell: HTMLTableCellElement) {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.addedNodes && mutation.addedNodes.length > 0) {
const inputElement = cell.querySelector('input');
if (inputElement) {
inputElement.select();
observer.disconnect();
}
}
});
});
observer.observe(cell, {childList: true});
}
For these kind of scenarios, I like to use a utility function of waitUntil.
import { interval } from 'rxjs';
import { take } from 'rxjs/operators';
...
export const waitUntil = async (untilTruthy: Function): Promise<boolean> => {
while (!untilTruthy()) {
await interval(25).pipe(
take(1),
).toPromise();
}
return Promise.resolve(true);
}
Then in your function, it would be:
async selectTextOnEdit(cell: HTMLTableCellElement) {
await waitUntil(() => !!cell.querySelector('input'));
const inputElement = element.querySelector('input');
inputElement.select();
}
This is the same thing but slightly cleaner in my opinion. Why is it an issue that the input was never created, shouldn't always be created if the callback of selectTextOnEdit is called?
After some research I found out about "MutationObserver".
Here is my new solution:
selectTextOnEdit(cell: HTMLTableCellElement) {
const observer = new MutationObserver(mutations => {
mutations.forEach(mutation => {
if (mutation.addedNodes && mutation.addedNodes.length > 0) {
const inputElement = cell.querySelector('input');
if (inputElement) {
inputElement.select();
observer.disconnect();
}
}
});
});
observer.observe(cell, {childList: true});
}

Issues with nodes returned by querySelectorAll

I am implementing an outside click hooks by class name
const useClickOutside = (className, f) => {
function handleClickOutside(event) {
if(event.which !== 1) return
const nodes = document.querySelectorAll(className)
console.log(nodes.length) // display the right length
console.log(nodes) // display the right elements
nodes.some((node) => { // falls
let outside = !node.contains(event.target)
if(outside) { f(); }
return outside
})
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
}
i call the hooks like that
useClickOutside(".foo",
() => {
// some code ...
},
);
i got this error TypeError: nodes.some is not a function even if just before the some function i got everything working on the nodes array !!
SOLUTION
thanks to #enapupe answer we can use also ES6
const nodes = [ ...document.querySelectorAll(className) ]
The Document method querySelectorAll() returns a static (not live) NodeList representing a list of the document's elements that match the specified group of selectors.
https://developer.mozilla.org/en-US/docs/Web/API/Document/querySelectorAll
The NodeList type is not compatible with an array type, which has some.
You can do Array.from(selector) in order to use regular array prototypes on top of it.
const useClickOutside = (className, f) => {
function handleClickOutside(event) {
if (event.which !== 1) return
const nodes = Array.from(document.querySelectorAll(className))
nodes.some((node) => {
let outside = !node.contains(event.target)
if (outside) {
f()
}
return outside
})
}
useEffect(() => {
document.addEventListener('mousedown', handleClickOutside)
return () => document.removeEventListener('mousedown', handleClickOutside)
}, [])
}

In a Chrome extension, how to ensure previous promise resolves before the next one using chrome-promise?

I've been using the chrome-promise library to wrap the Chrome extension API with a facade that returns promises instead of using callbacks. This has generally worked quite well, but I seem to be running into an issue with chrome.storage.local APIs.
My extension's event page listens for the chrome.tabs.onActivated and chrome.tabs.onRemoved events. When it gets the onActivated event, it adds the tab info to an array and calls chrome.storage.local.set(data) to store the updated array in local storage.
When it gets the onRemoved event, it calls chromepromise.storage.local.get(null).then(...) to get the list of tabs via a promise, removes the tab info from the array, and then calls chrome.storage.local.set() again to save the updated array.
The issue is that the onActivated event seems to trigger before the promise flow from the onRemoved event resolves. So the onActivated handler retrieves the old stored array, with the closed tab still in it, and then pushes the newly activated tab. So the stored tab data now includes a tab that's already been closed.
I'm assuming this is an issue with using promises instead of callbacks, but I'm wondering if anyone else has run into this problem with this library and worked around it.
Update
As wOxxOm points out, this is a generic problem with "arbitrating unpredictable asynchronous access to a single resource such as chrome.storage" and not unique to the chrome-promise library.
After researching a bit, I came up with a couple solutions, added as answers below. One uses a mutex to ensure (I think) that one promise chain's getting and setting data in chrome.storage completes before the next one starts. The other queues the whole promise chain that's created from an event and doesn't start the next one until the current one has fully completed. I'm not sure which is better, though I suppose locking for a shorter period of time is better.
Any suggestions or better answers are welcome.
Queue
This solution uses a very simple queuing mechanism. The event handlers call queue() with a function that kicks off the promise chain to handle that event. If there isn't already a promise in the queue, then the function is called immediately. Otherwise, it's pushed on the queue and will be triggered when the current promise chain finishes. This means only one event can be processed at a time, which might not be as efficient.
var taskQueue = [];
function queue(
fn)
{
taskQueue.push(fn);
processQueue();
}
function processQueue()
{
const nextTask = taskQueue[0];
if (nextTask && !(nextTask instanceof Promise)) {
taskQueue[0] = nextTask()
.then((result) => {
console.log("RESULT", result);
taskQueue.shift();
processQueue();
});
}
}
function onActivated(tabID) {
console.log("EVENT onActivated", tabID);
queue(() => Promise.resolve(tabID).then(tab => addTab(tab)));
}
function onRemoved(tabID) {
console.log("EVENT onRemoved", tabID);
queue(() => removeTab(tabID));
}
var localData = {
tabs: []
};
function delay(time) {
return new Promise(resolve => setTimeout(resolve, time));
}
function getData()
{
return delay(0).then(() => JSON.parse(JSON.stringify(localData)));
}
function saveData(data, source)
{
return delay(0)
.then(() => {
localData = data;
console.log("save from:", source, "localData:", localData);
return Promise.resolve(localData);
});
}
function addTab(tabID)
{
return getData().then((data) => {
console.log("addTab", tabID, "data:", data);
data.tabs = data.tabs.filter(tab => tab != tabID);
data.tabs.push(tabID);
return saveData(data, "addTab");
});
}
function removeTab(tabID)
{
return getData().then((data) => {
console.log("removeTab", tabID, "data:", data);
data.tabs = data.tabs.filter(tab => tab != tabID);
return saveData(data, "removeTab");
});
}
const events = [
() => onActivated(1),
() => onActivated(2),
() => onActivated(3),
() => onActivated(4),
() => onActivated(2),
() => { onRemoved(2); onActivated(3) }
];
function playNextEvent()
{
var event = events.shift();
if (event) {
delay(0).then(() => { event(); delay(0).then(playNextEvent) });
}
}
playNextEvent();
Mutex
Update: I ended up using the approach below to create a module that uses a mutex to ensure gets and sets of the Chrome extension storage maintain their order. It seems to be working well so far.
This solution uses the mutex implementation from this article. addTab() and removeTab() call storageMutex.synchronize() with a function that does all the storage getting and setting. This should prevent later events from affecting the storage of earlier events.
The code below is a very simplified version of the extension, but it does run. The playNextEvent() calls at the bottom simulate opening 4 tabs, switching back to tab 2 and closing it, which then causes tab 3 to activate. setTimeout()s are used so that everything doesn't run as one long call stack.
function Mutex() {
this._busy = false;
this._queue = [];
}
Object.assign(Mutex.prototype, {
synchronize: function(task) {
var self = this;
return new Promise(function(resolve, reject) {
self._queue.push([task, resolve, reject]);
if (!self._busy) {
self._dequeue();
}
});
},
_dequeue: function() {
var next = this._queue.shift();
if (next) {
this._busy = true;
this._execute(next);
} else {
this._busy = false;
}
},
_execute: function(record) {
var task = record[0],
resolve = record[1],
reject = record[2],
self = this;
task().then(resolve, reject).then(function() {
self._dequeue();
});
}
});
const storageMutex = new Mutex();
function onActivated(tabID) {
console.log("EVENT onActivated", tabID);
return Promise.resolve(tabID).then(tab => addTab(tab));
}
function onRemoved(tabID) {
console.log("EVENT onRemoved", tabID);
return removeTab(tabID);
}
var localData = {
tabs: []
};
function delay(time) {
return new Promise(resolve => setTimeout(resolve, time));
}
function getData()
{
return delay(0).then(() => JSON.parse(JSON.stringify(localData)));
}
function saveData(data, source)
{
return delay(0)
.then(() => {
localData = data;
console.log("save from:", source, "localData:", localData);
return Promise.resolve(localData);
});
}
function addTab(tabID)
{
return storageMutex.synchronize(() => getData().then((data) => {
console.log("addTab", tabID, "data:", data);
data.tabs = data.tabs.filter(tab => tab != tabID);
data.tabs.push(tabID);
return saveData(data, "addTab");
}));
}
function removeTab(tabID)
{
return storageMutex.synchronize(() => getData().then((data) => {
console.log("removeTab", tabID, "data:", data);
data.tabs = data.tabs.filter(tab => tab != tabID);
return saveData(data, "removeTab");
}));
}
const events = [
() => onActivated(1),
() => onActivated(2),
() => onActivated(3),
() => onActivated(4),
() => onActivated(2),
() => { onRemoved(2); onActivated(3) }
];
function playNextEvent()
{
var event = events.shift();
if (event) {
delay(0).then(() => { event(); delay(0).then(playNextEvent) });
}
}
playNextEvent();

Categories

Resources