Skip to content

Commit

Permalink
Add observeReadyElements method (#36)
Browse files Browse the repository at this point in the history
  • Loading branch information
Richienb authored Sep 10, 2021
1 parent 32360a0 commit 9545514
Show file tree
Hide file tree
Showing 7 changed files with 182 additions and 23 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,7 @@ jobs:
fail-fast: false
matrix:
node-version:
- 14
- 12
- 16
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
Expand Down
32 changes: 31 additions & 1 deletion index.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
/* eslint-disable import/export */
import type {ParseSelector} from 'typed-query-selector/parser';

export interface Options {
Expand Down Expand Up @@ -68,3 +67,34 @@ export default function elementReady<ElementName extends Element = HTMLElement>(
selector: string,
options?: Options
): StoppablePromise<ElementName | undefined>;

/**
Detect when elements are ready in the DOM.
Useful for user-scripts that modify elements when they are added.
@param selector - [CSS selector.](https://developer.mozilla.org/en-US/docs/Web/Guide/CSS/Getting_Started/Selectors) Prefix the element type to get a better return type. For example, `button.my-btn` instead of `.my-btn`.
@returns An async iterable which yields with each new matching element.
@example
```
import {observeReadyElements} from 'element-ready';
for await (const element of observeReadyElements('#unicorn')) {
console.log(element.id);
//=> 'unicorn'
if (element.id === 'elephant') {
break;
}
}
```
*/
export function observeReadyElements<Selector extends string, ElementName extends Element = ParseSelector<Selector, HTMLElement>>(
selector: Selector,
options?: Options
): AsyncIterable<ElementName>;
export function observeReadyElements<ElementName extends Element = HTMLElement>(
selector: string,
options?: Options
): AsyncIterable<ElementName>;
68 changes: 67 additions & 1 deletion index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import ManyKeysMap from 'many-keys-map';
import pDefer from 'p-defer';
import createDeferredAsyncIterator from 'deferred-async-iterator';

const cache = new ManyKeysMap();

Expand All @@ -10,7 +11,7 @@ export default function elementReady(selector, {
target = document,
stopOnDomReady = true,
waitForChildren = true,
timeout = Number.POSITIVE_INFINITY
timeout = Number.POSITIVE_INFINITY,
} = {}) {
const cacheKeys = [selector, stopOnDomReady, timeout, waitForChildren, target];
const cachedPromise = cache.get(cacheKeys);
Expand Down Expand Up @@ -59,3 +60,68 @@ export default function elementReady(selector, {

return Object.assign(promise, {stop: () => stop()});
}

export function observeReadyElements(selector, {
target = document,
stopOnDomReady = true,
waitForChildren = true,
timeout = Number.POSITIVE_INFINITY,
} = {}) {
return {
[Symbol.asyncIterator]() {
const {next, complete, onCleanup, iterator} = createDeferredAsyncIterator();

function handleMutations(mutations) {
for (const {addedNodes} of mutations) {
for (const element of addedNodes) {
if (element.nodeType !== 1 || !element.matches(selector)) {
continue;
}

// When it's ready, only stop if requested or found
if (isDomReady(target) && element) {
next(element);
continue;
}

let current = element;
while (current) {
if (!waitForChildren || current.nextSibling) {
next(element);
continue;
}

current = current.parentElement;
}
}
}
}

const observer = new MutationObserver(handleMutations);

observer.observe(target, {
childList: true,
subtree: true,
});

function stop() {
handleMutations(observer.takeRecords());
complete();
}

onCleanup(() => {
observer.disconnect();
});

if (stopOnDomReady) {
target.addEventListener('DOMContentLoaded', stop, {once: true});
}

if (timeout !== Number.POSITIVE_INFINITY) {
setTimeout(stop, timeout);
}

return iterator;
},
};
}
8 changes: 6 additions & 2 deletions index.test-d.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
/* eslint-disable @typescript-eslint/no-floating-promises */
import {expectType} from 'tsd';
import elementReady, {StoppablePromise} from './index.js';
import elementReady, {StoppablePromise, observeReadyElements} from './index.js';

const promise = elementReady('#unicorn');
elementReady('#unicorn', {target: document});
elementReady('#unicorn', {target: document.documentElement});
elementReady('#unicorn', {timeout: 1000000});
elementReady('#unicorn', {timeout: 1_000_000});

elementReady('#unicorn', {stopOnDomReady: false});

Expand All @@ -20,3 +20,7 @@ expectType<StoppablePromise<HTMLInputElement | undefined>>(elementReady('input[t
expectType<StoppablePromise<HTMLButtonElement | undefined>>(elementReady(':root > button'));

promise.stop();

const readyElements = observeReadyElements('#unicorn');

expectType<AsyncIterable<HTMLElement>>(readyElements);
7 changes: 4 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"domready"
],
"dependencies": {
"deferred-async-iterator": "^1.0.1",
"many-keys-map": "^1.0.3",
"p-defer": "^4.0.0",
"typed-query-selector": "^2.4.1"
Expand All @@ -45,9 +46,9 @@
"ava": "^3.15.0",
"delay": "^5.0.0",
"jsdom": "^16.5.2",
"p-state": "^0.2.0",
"tsd": "^0.14.0",
"xo": "^0.38.2"
"p-state": "^0.2.1",
"tsd": "^0.16.0",
"xo": "^0.44.0"
},
"xo": {
"envs": [
Expand Down
17 changes: 17 additions & 0 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,23 @@ console.log(element.id);

Returns a promise for a matching element.

### observeReadyElements(selector, options?)

Returns an async iterable which yields with each new matching element. Useful for user-scripts that modify elements when they are added.

```js
import {observeReadyElements} from 'element-ready';

for await (const element of observeReadyElements('#unicorn')) {
console.log(element.id);
//=> 'unicorn'

if (element.id === 'elephant') {
break;
}
}
```

#### selector

Type: `string`
Expand Down
70 changes: 56 additions & 14 deletions test.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import test from 'ava';
import delay from 'delay';
import {JSDOM} from 'jsdom';
import {promiseStateSync} from 'p-state';
import elementReady from './index.js';
import elementReady, {observeReadyElements} from './index.js';

const {window} = new JSDOM();
global.window = window;
global.document = window.document;
global.requestAnimationFrame = fn => setTimeout(fn, 16);
global.cancelAnimationFrame = id => clearTimeout(id);
global.MutationObserver = window.MutationObserver;

test('check if element ready', async t => {
const elementCheck = elementReady('#unicorn', {stopOnDomReady: false});
Expand All @@ -28,7 +29,7 @@ test('check if element ready inside target', async t => {
const target = document.createElement('p');
const elementCheck = elementReady('#unicorn', {
target,
stopOnDomReady: false
stopOnDomReady: false,
});

(async () => {
Expand All @@ -46,12 +47,12 @@ test('check if different elements ready inside different targets with same selec
const target1 = document.createElement('p');
const elementCheck1 = elementReady('.unicorn', {
target: target1,
stopOnDomReady: false
stopOnDomReady: false,
});
const target2 = document.createElement('span');
const elementCheck2 = elementReady('.unicorn', {
target: target2,
stopOnDomReady: false
stopOnDomReady: false,
});

(async () => {
Expand All @@ -76,15 +77,15 @@ test('check if different elements ready inside different targets with same selec

test('check if element ready after dom loaded', async t => {
const elementCheck = elementReady('#bio', {
stopOnDomReady: true
stopOnDomReady: true,
});

// The element will be added eventually, but we're not around to wait for it
setTimeout(() => {
const element = document.createElement('p');
element.id = 'bio';
document.body.append(element);
}, 50000);
}, 50_000);

const element = await elementCheck;
t.is(element, undefined);
Expand All @@ -96,7 +97,7 @@ test('check if element ready before dom loaded', async t => {
document.body.append(element);

const elementCheck = elementReady('#history', {
stopOnDomReady: true
stopOnDomReady: true,
});

t.is(await elementCheck, element);
Expand All @@ -105,18 +106,19 @@ test('check if element ready before dom loaded', async t => {
test('check if element ready after timeout', async t => {
const elementCheck = elementReady('#cheezburger', {
stopOnDomReady: false,
timeout: 1000
timeout: 1000,
});

// The element will be added eventually, but we're not around to wait for it
setTimeout(() => {
const timeoutId = setTimeout(() => {
const element = document.createElement('p');
element.id = 'cheezburger';
document.body.append(element);
}, 50000);
}, 50_000);

const element = await elementCheck;
t.is(element, undefined);
clearTimeout(timeoutId);
});

test('check if element ready before timeout', async t => {
Expand All @@ -126,7 +128,7 @@ test('check if element ready before timeout', async t => {

const elementCheck = elementReady('#thunders', {
stopOnDomReady: false,
timeout: 10
timeout: 10,
});

t.is(await elementCheck, element);
Expand Down Expand Up @@ -193,18 +195,18 @@ test('ensure that the whole element has loaded', async t => {

// Fake the pre-DOM-ready state
Object.defineProperty(document, 'readyState', {
get: () => 'loading'
get: () => 'loading',
});

const nav = document.querySelector('nav');
const partialCheck = elementReady('nav', {
target: document,
waitForChildren: false
waitForChildren: false,
});

const entireCheck = elementReady('nav', {
target: document,
waitForChildren: true
waitForChildren: true,
});

t.is(await partialCheck, nav, '<nav> appears in the loading document, so it should be found whether it’s loaded fully or not');
Expand All @@ -220,3 +222,43 @@ test('ensure that the whole element has loaded', async t => {
nav.after('Some other part of the page, even a text node');
t.is(await entireCheck, await partialCheck, 'Something appears after <nav>, so it’s guaranteed that it loaded in full');
});

test('subscribe to newly added elements that match a selector', async t => {
(async () => {
await delay(500);
const element = document.createElement('p');
element.id = 'unicorn';
document.body.append(element);

const element2 = document.createElement('p');
element2.id = 'unicorn';
document.body.append(element2);

await delay(500);

const element3 = document.createElement('p');
element3.id = 'unicorn3';
document.body.append(element3);
})();

const readyElements = observeReadyElements('#unicorn, #unicorn3');
let readyElementsCount = 0;

for await (const element of readyElements) {
readyElementsCount++;
t.is(element.id, 'unicorn');

if (readyElementsCount === 2) {
break;
}
}

for await (const element of readyElements) {
readyElementsCount++;
t.is(element.id, 'unicorn3');

if (readyElementsCount === 3) {
break;
}
}
});

0 comments on commit 9545514

Please sign in to comment.