Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Proposal: SelectorObserver #1285

Open
keithamus opened this issue Apr 29, 2024 · 1 comment
Open

Proposal: SelectorObserver #1285

keithamus opened this issue Apr 29, 2024 · 1 comment
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest

Comments

@keithamus
Copy link

What problem are you trying to solve?

The TL;DR is: there are many good reasons to observe when a selector matches N number of elements, and when N changes. But here are some more concrete examples:

Example 1: Observing state only exposed in CSS.

Often times an elements state can only be properly observed via CSS selectors, for e.g. :dir() & :lang() can match implicit parents (ref whatwg/html#7039 /cc @claviska), :is(:popover-open, :modal, :fullscreen) are the only way to observe if something is in the top layer (ref whatwg/html#8783 /cc @straker, ref whatwg/html#9075 /cc @sanajaved7), and with CSS CustomStates a custom element can hide any number of state transitions behind the :state() selector without exposing equivalent observable properties from JS, such as events.

Counter: It could be argued that state that is only observable in CSS should remain there (although .matches() seems to belie that). It could also be argued that any state exposed in CSS should have an equivalent state exposed in JS.

Example 2: Observing when a new element is added that matches a given selector.

It can be useful to simply discover when new elements come in or out of the DOM that match a given selector; for example many implementations of a "custom element lazy define" (ref WICG/webcomponents#782 /cc @justinfagnani) seek this approach. See also a more generic proposal for MountObserver (ref WICG/webcomponents#896 /cc @bahrus).

Counter: Finding elements matching a selector is quite possible with MutationObserver but the code gets unwieldy very quickly, and it can quickly run into issues where the main thread is blocked because it's re-running querySelector on a large DOM, on each mutation.

Example 3: Ergonomic API for attaching a behaviour to new elements as they enter/leave the page

For a very long time, most of GitHub's JS was powered by the principle that we could observe elements as the enter the DOM. The https://github.com/josh/selector-observer library powered this, and it allowed developers to express a selector, which would fire a callback whenever the element count for the selector changed. This library concatenates all the given selectors into one giant querySelector (which, fun story, caused some regressions in Firefox from stylo, ref https://bugzilla.mozilla.org/show_bug.cgi?id=1422522).

What solutions exist today?

MutationObserver & the selector-observer library are probably the closest.

How would you solve it?

I'd propose making a new SelectorObserver class which has similar ergonomics to other *Observer classes. You pass it a callback, and a selector, and it can lazily accumulate records and give them to the callback. Each SelectorObserverRecord could represent one observed selector, and addedNodes could represent newly matching nodes (either because they've just entered the DOM, or because they've now changed state), while removedNodes could represent no-longer-matching nodes (either because they've changed state, or just left the DOM):

const so = new SelectorObserver((records) => {
  for(const record of records) {
    const selector = record.selector;
    for(const node of record.addedNodes) {
      console.log(`${node} now matches ${selector}`)
    }
    for(const node of record.removedNodes) {
      console.log(`${node} no longer matches ${selector}`)
    }
  }
});

so.observe({ selector: '.my-selector', subtree: true })

The IDL might look a little like:

[Exposed=Window]
interface SelectorObserver {
  constructor(SelectorCallback callback);

  undefined observe(Node target, DOMString selector, optional SelectorObserverInit options = {});
  undefined disconnect();
  sequence<SelectorRecord> takeRecords();
};

callback SelectorCallback = undefined (sequence<SelectorRecord> mutations, SelectorObserver observer);

dictionary SelectorObserverInit {
  boolean subtree = false;
};

[Exposed=Window]
interface SelectorRecord {
  readonly attribute DOMString selector;
  [SameObject] readonly attribute Node target;
  [SameObject] readonly attribute NodeList addedNodes;
  [SameObject] readonly attribute NodeList removedNodes;
  readonly attribute Node? previousSibling;
  readonly attribute Node? nextSibling;
};

For the :dir()/:lang() example in whatwg/html#7039 (@claviska) it'd look a bit like:

const so = new SelectorObserver(records => {
  for(const record of records) {
    for(const node of [...record.addedNodes, ...record.removedNodes]) {
      recalculateI18n(node);
    }
  }
})
so.observe(myElement, ':dir(ltr)')
so.observe(myElement, ':dir(rtl)');
for (const lang of supportedLanguages) {
  so.observe(myElement, `:lang(${lang})`);
}

The top-layer example in whatwg/html#8783 & whatwg/html#9075 (@sanajaved7, @straker) it'd look a bit like:

const so = new SelectorObserver((records) => {
  for(const record of records) {
    for(const node of record.addedNodes) {
      updateAnchoredPosition(node);
    }
  }
})
so.observe(document, ':modal, :popover-open, :fullscreen', { subtree: true });

For the Custom Elements examples in WICG/webcomponents#782 (@justinfagnani) & WICG/webcomponents#896 (@bahrus) might become:

const imports = new Map();
const so = new SelectorObserver(records => {
  for(const record of records) {
    if (imports.has(record.selector) {
      import(imports.get(record.selector)).then(moduleRecord => {
        customElements.define(record.selector, moduleRecord.default))
      })
      imports.delete(record.selector);
    }
  }
})

const lazyDefine = (tagName, importString) => {
  imports.set(tagName, importString)
  so.observe(document, tagName, { subtree: true });
}

Anything else?

Hopefully folks don't mind the pings, but I thought it would be nice to encourage some discussion from those who have similar use-cases.

@keithamus keithamus added needs implementer interest Moving the issue forward requires implementers to express interest addition/proposal New features or enhancements labels Apr 29, 2024
@bkardell
Copy link

the selector observer link has some good related history links, but there are a bunch of projects which do/did some of this...

Just wanted to link up this related history from this repo too.. See #77 and #398

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
addition/proposal New features or enhancements needs implementer interest Moving the issue forward requires implementers to express interest
Development

No branches or pull requests

2 participants