@mach25/find-elements
v2.1.0
Published
Utility to find elements on a web page using selector functions and a timeout
Downloads
42
Readme
@mach25/find-elements
Find elements on a web page that may not exist yet.
Elements injected by a third-party script, a slow-loading widget or a framework that renders after your code runs are not in the DOM when you go looking for them. findElements returns a promise that polls for the element on every animation frame and resolves as soon as it appears, or rejects once a timeout elapses.
Install
npm install @mach25/find-elementsUsage
import { findElements } from '@mach25/find-elements';
const banner = await findElements('cookie-banner');
banner.classList.add('ready');The common case is getting a handle on a mount point that something else renders — a CMS, a template, another app — so you can attach to it:
import { createRoot } from 'react-dom/client';
import { findElements } from '@mach25/find-elements';
import App from './App';
const container = await findElements('app-root');
createRoot(container).render(<App />);No DOMContentLoaded listener, no setTimeout guess, no MutationObserver to tear down — the mount waits exactly as long as it needs to, and throws if the container never shows up.
By default it looks the element up by id. Pass a different selector function as the second argument:
import { findElements, bySelector, getFirst } from '@mach25/find-elements';
// All matches for a CSS selector, once at least one exists
const rows = await findElements('.data-row', bySelector);
// Just the first match
const firstRow = await findElements('.data-row', getFirst(bySelector));Failures reject, so handle them:
import { byId, findElements } from '@mach25/find-elements';
try {
const el = await findElements('late-widget', byId, 3000);
} catch (err) {
// Error: late-widget not found in 3012 milliseconds
}API
findElements(selector, findFnc?, timeout?)
| Parameter | Type | Default | Description |
| ---------- | -------------------------------------------- | ------- | ------------------------------------------------------------------ |
| selector | string | — | Passed through to findFnc. |
| findFnc | (selector: string) => HTMLElements \| null | byId | How to look the element up. |
| timeout | number | 10000 | Milliseconds before giving up. A floor, not a ceiling — see below. |
Returns a Promise that resolves with whatever findFnc returned. It calls findFnc once immediately; if that comes back falsy it re-checks on each requestAnimationFrame until something is found or timeout is exceeded, then rejects with an Error.
In TypeScript the resolved type follows the lookup, so no cast is needed: the default gives you an HTMLElement, bySelector gives you a NodeListOf<Element>, and a custom document.querySelector<HTMLInputElement> lookup gives you an HTMLInputElement.
The timeout is a backstop so the promise cannot hang forever, not a precise deadline. It measures time the page was actually being rendered, not wall-clock time. Polling runs on requestAnimationFrame, which browsers stop firing in a backgrounded or hidden tab, so a tab left in the background suspends the wait rather than consuming it — the budget is spent only on frames that ran. Switch back after ten minutes away and an element with a ten second timeout still gets its full ten seconds to appear.
In practice this means a wall clock can read far more than timeout by the time the promise settles, which is the intended behaviour: an element is unlikely to render in a hidden tab, so time spent there should not count against it. The elapsed figure in the rejection message reports the time waited, not the wall clock.
setLogger(log)
Nothing is logged by default. Pass a function to hear how long each successful lookup took, which is useful when you are trying to work out whether an element is slow to render or never renders at all:
import { setLogger } from '@mach25/find-elements';
setLogger(console.info); // "app-root found in 812 milliseconds"
setLogger(null); // quiet againOnly successful lookups are reported; a timeout comes through as the rejected promise instead.
Selector functions
The lookup is a parameter rather than a fixed strategy, so a call states what it is waiting for and how to find it — findElements('.data-row', bySelector, 2000) reads as a sentence. The two built-ins cover the common cases; supplying your own is a one-liner. Each returns null on a miss, which is what lets findElements keep polling.
| Function | Looks up with | Returns |
| ---------------------- | --------------------------- | ------------------------------------------- |
| byId(id) | document.getElementById | The element, or null. The default. |
| bySelector(selector) | document.querySelectorAll | The NodeList, or null when it is empty. |
Picking elements out of a list
bySelector matches everything, which is rarely what you want. These wrappers take a lookup that returns a NodeList and give back one that narrows it — so they slot straight into findElements:
findElements('.data-row', getFirst(bySelector)); // the first row, once one exists
findElements('.data-row', take(bySelector, 3)); // the first three, once three exist| Wrapper | Yields |
| ---------------------------- | -------------------------------------------------------- |
| getFirst(findFnc) | The first match. |
| getLast(findFnc) | The last match. |
| getAt(findFnc, index) | The match at index. Negative counts back from the end. |
| take(findFnc, count) | The first count matches, as an array. |
| takeLast(findFnc, count) | The last count matches, as an array. |
| filter(findFnc, predicate) | Every match satisfying predicate, as an array. |
Each one returns null until it can satisfy the request in full, which is what makes the waiting work. take(bySelector, 3) does not yield two elements and call it done — it keeps polling until the third has rendered. Likewise getAt(bySelector, 4) waits for a fifth element rather than giving up when there are four.
They wrap any NodeList lookup, not only bySelector:
findElements(
'email',
getFirst((name) => document.getElementsByName(name))
);And they compose with a typed lookup, carrying the element type through:
const inputs = (s: string) => document.querySelectorAll<HTMLInputElement>(s);
const checked = await findElements(
'.option',
filter(inputs, (el) => el.checked)
); // HTMLInputElement[]Writing your own
Any (selector: string) => HTMLElements | null works. The one rule is that a miss must be falsy — findElements treats any truthy return as a hit, and an empty NodeList is still an object, so returning one would resolve the promise instead of polling.
const byDataRole = (role) => document.querySelector(`[data-role="${role}"]`);
const el = await findElements('submit', byDataRole);Migrating from 2.0
Successful lookups no longer write to console.info unless you ask for it — call setLogger(console.info) to get the old behaviour back.
The package now declares exports, so imports must go through the package root or the ./select subpath. Reaching into @mach25/find-elements/lib/… no longer resolves.
Migrating from 1.x
getFirst takes a lookup function rather than a NodeList, so it composes instead of having to be called by hand:
-findElements('.data-row', (s) => getFirst(document.querySelectorAll(s)));
+findElements('.data-row', getFirst(bySelector));That is the only breaking change. getLast, getAt, take, takeLast and filter are new alongside it, and bySelector returns null instead of an empty NodeList when nothing matches — so passing it to findElements now waits for a match rather than resolving immediately with zero elements.
The wrappers live in their own module, but everything is re-exported from the package root, so imports are unchanged.
Requirements
Browser only — it uses document and requestAnimationFrame, so there is no Node or SSR support.
The package is ESM only ("type": "module"); require() will not work. TypeScript declarations are bundled.
Everything is available from the package root. The list wrappers are also reachable on their own subpath if you would rather be explicit about where they come from:
import { take } from '@mach25/find-elements/select';Development
npm run build # compile src/ to lib/
npm test # run the suite once
npm run test:watch # re-run on change
npm run typecheck # type-check src/ and test/, including the type assertionsLicense
MIT
