Skip to content

Commit

Permalink
add options.predicate for @observe()
Browse files Browse the repository at this point in the history
  • Loading branch information
SirPepe committed Aug 29, 2024
1 parent 4ab366d commit 7b35e66
Show file tree
Hide file tree
Showing 5 changed files with 102 additions and 14 deletions.
1 change: 1 addition & 0 deletions Readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -1119,6 +1119,7 @@ el.innerText = "Test"; // cause mutation
- **`Ctor` (function)**: The observer constructor function (probably one of `MutationObserver`, `ResizeObserver`, and `IntersectionObserver`)
- **`options` (object, optional)**: A mixin type consisting of
- All options for the relevant observer type (see MDN for options for [MutationObserver](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#options), [ResizeObserver](https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver#options), [IntersectionObserver](https://developer.mozilla.org/en-US/docs/Web/API/ResizeObserver/observe#options))
- **`predicate` (Function `(records, observer, instance) => boolean`)**: If provided, controls whether or not an observer callback invocation calls the decorated method. Gets passed the observer's callback arguments (an array of records and the observer object) as well as the element instance and must return a boolean.
- **activateOn (Array\<string\>, optional):** Ornament event on which to start observing the element. Defaults to `["init", "connected"]`.
- **deactivateOn (Array\<string\>, optional):** Ornament event on which to stop observing the element. Defaults to `["disconnected"]`.
Expand Down
47 changes: 44 additions & 3 deletions changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,56 @@

## 2.1.0

### FEATURE: `predicate` option for `@observe()`

You can now control if an invocation of an observer callback should cause an
invocation of the decorated method:

```javascript
import { define, observe } from "@sirpepe/ornament";

@define("my-test")
class Test extends HTMLElement {
@observe(MutationObserver, {
childList: true,
// Only call the decorated method when the records contain removals
predicate: (records) => {
const removals = records.filter((r) => r.removedNodes.length > 0);
return removals.length > 0;
},
})
reactToUpdate(records, observer) {
console.log("Something happened!");
}
}

const instance = new Test();
document.body.append(instance);

const el = document.createElement("div");
instance.append(el); // no nodes removed = no output

// Wait some time (mutation observers batch mutations)

el.remove(); // el removed = "Something happened!"
```

This makes it more feasible to combine `@observe()` with `@reactive()` on
methods that need to react to changes but that should not be overburdened with
figuring out whether or not the root cause is actually cause for a reaction.
This work belongs to the decorators, and has always been supported via a
predicate in the options for `@reactive()`. Now `@observe()` can do the same!

### FEATURE: Customizable environment

Two functions have received backwards-compatible updates to make them work in
environments where the `window` object is not the global object. This is only
relevant if you run your component code in eg. Node.js with
[jsdom](https://github.com/jsdom/jsdom) to do SSR. The affected functions are:
[JSDOM](https://github.com/jsdom/jsdom) or similar to do SSR. The affected
functions are:

- **decorator `@define()`**: now takes an optional third argument to customize
the `CustomElementRegistry` to use. Defaults to `window.customElements`.
which `CustomElementRegistry` to use. Defaults to `window.customElements`.
- **transformer `href()`**: now takes an optional options object with a field
`location` that can customize the `Location` object to use. Defaults to
`window.location`.
Expand Down Expand Up @@ -48,7 +89,7 @@ console.log(getTagName(Test)); // > "my-test"
console.log(listAttributes(Test)); // > ["foo", "asdf"]

const { prop, transformer } = getAttribute(Test, "asdf");
// prop = "bar" - the backend accessor for the content attribute "asdf"
// prop = "bar" = name of the public accessor for the content attribute "asdf"
// transformer = the transformer for the content attribute "asdf"

transformer.parse("-1");
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
"scripts": {
"lint": "npx prettier . --check && eslint",
"test": "NODE_ENV=test wtr test/**/*.test.ts --playwright --browsers firefox chromium webkit",
"test-dev": "NODE_ENV=test wtr test/**/metadata.test.ts --playwright --browsers chromium",
"test-dev": "NODE_ENV=test wtr test/**/*.test.ts --playwright --browsers chromium",
"build": "npm run lint && rollup -c rollup.config.mjs && npm run size",
"build-examples": "cd examples && rollup -c rollup.config.mjs",
"types": "rm -rf dist/types && tsc -p tsconfig.build.json",
Expand Down
32 changes: 22 additions & 10 deletions src/decorators.ts
Original file line number Diff line number Diff line change
Expand Up @@ -515,22 +515,29 @@ export function subscribe<T extends HTMLElement>(
};
}

type ObserveBaseOptions = {
type ObserveBaseOptions<T, O extends ObserverCtor1 | ObserverCtor2> = {
activateOn?: (keyof OrnamentEventMap)[]; // defaults to ["init", "connected"]
deactivateOn?: (keyof OrnamentEventMap)[]; // defaults to ["disconnected"]
predicate?: (
entries: Parameters<ConstructorParameters<O>[0]>[0],
observer: Parameters<ConstructorParameters<O>[0]>[1],
instance: T,
) => boolean;
};

// IntersectionObserver, ResizeObserver
type ObserverCtor1 = new (
callback: (...args: unknown[]) => void,
callback: (entries: unknown[], observer: InstanceType<ObserverCtor1>) => void,
options: any,
) => {
observe: (target: HTMLElement) => void;
disconnect: () => void;
};

// MutationObserver
type ObserverCtor2 = new (callback: (...args: unknown[]) => void) => {
type ObserverCtor2 = new (
callback: (entries: unknown[], observer: InstanceType<ObserverCtor2>) => void,
) => {
observe: (target: HTMLElement, options: any) => void;
disconnect: () => void;
};
Expand All @@ -552,28 +559,33 @@ type ObserveDecorator<

export function observe<T extends HTMLElement, O extends ObserverCtor1>(
Ctor: O,
options?: ObserveBaseOptions & ConstructorParameters<O>[1],
options?: ObserveBaseOptions<T, O> & ConstructorParameters<O>[1],
): ObserveDecorator<T, O>;
export function observe<T extends HTMLElement, O extends ObserverCtor2>(
Ctor: O,
options?: ObserveBaseOptions & Parameters<InstanceType<O>["observe"]>[1],
options?: ObserveBaseOptions<T, O> &
Parameters<InstanceType<O>["observe"]>[1],
): ObserveDecorator<T, O>;
export function observe<
T extends HTMLElement,
O extends ObserverCtor1 | ObserverCtor2,
>(
Ctor: O,
options: ObserveBaseOptions = {},
options: ObserveBaseOptions<T, O> = {},
): ObserveDecorator<T, O, unknown[]> {
options.activateOn ??= ["init", "connected"];
options.deactivateOn ??= ["disconnected"];
return function (_, context) {
assertContext(context, "observe", "method/function");
return runContextInitializerOnOrnamentInit(context, (instance: T) => {
const observer = new Ctor(
(...args) => context.access.get(instance).call(instance, ...args),
options,
);
const observer = new Ctor((entries, observer) => {
if (
!options.predicate ||
options.predicate(entries, observer, instance)
) {
context.access.get(instance).call(instance, entries, observer);
}
}, options);
if (options.activateOn?.includes("init")) {
observer.observe(instance, options);
}
Expand Down
34 changes: 34 additions & 0 deletions test/decorators.observe.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,40 @@ describe("Decorators", () => {
expect(fn.getCalls()[0].args).to.eql([instance, ["childList"], true]);
});

test("Respect the predicate", async () => {
const fn = spy();
@define(generateTagName())
class Test extends HTMLElement {
@observe(MutationObserver, {
childList: true,
// Only track removals
predicate: (records) => {
const removals = records.filter(
(record) => record.removedNodes.length > 0,
);
return removals.length > 0;
},
})
test(records: MutationRecord[], observer: MutationObserver) {
fn(
this,
records.map((record) => record.type),
observer instanceof MutationObserver,
);
}
}
const instance = new Test();
document.body.append(instance);
const el = document.createElement("div");
instance.append(el);
await wait(TIMEOUT); // Mutation observers are async
expect(fn.callCount).to.equal(0); // additions are filtered out
el.remove();
await wait(TIMEOUT); // Mutation observers are async
expect(fn.callCount).to.equal(1); // removals are tracked
expect(fn.getCalls()[0].args).to.eql([instance, ["childList"], true]);
});

test("Start/Stop observing based on connected state", async () => {
const fn = spy();
@define(generateTagName())
Expand Down

0 comments on commit 7b35e66

Please sign in to comment.