diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1fe552873..0e82afb70 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,10 +6,15 @@ All notable changes to this project will be documented in this file. The format
### Added
-- Add a createElement function ([#548](https://github.com/studiometa/js-toolkit/pull/548), [a6417f2f](https://github.com/studiometa/js-toolkit/commit/a6417f2f))
+- Add support for `Symbol` as service keys ([#559](https://github.com/studiometa/js-toolkit/pull/559), [7ce96a41](https://github.com/studiometa/js-toolkit/commit/7ce96a41))
+- Add a `withMutation` decorator ([#559](https://github.com/studiometa/js-toolkit/pull/559), [fdce27a2](https://github.com/studiometa/js-toolkit/commit/fdce27a2))
+- Add a `useMutation` service ([#559](https://github.com/studiometa/js-toolkit/pull/559), [0c804b79](https://github.com/studiometa/js-toolkit/commit/0c804b79))
+- Add a `cache` utility function ([#559](https://github.com/studiometa/js-toolkit/pull/559), [b3fe8080](https://github.com/studiometa/js-toolkit/commit/b3fe8080))
+- Add a `createElement` function ([#548](https://github.com/studiometa/js-toolkit/pull/548), [a6417f2f](https://github.com/studiometa/js-toolkit/commit/a6417f2f))
### Changed
+- Refactor service instances cache handling ([#559](https://github.com/studiometa/js-toolkit/pull/559), [e87dca16](https://github.com/studiometa/js-toolkit/commit/e87dca16))
- Refactor decorators to not override the name config ([#549](https://github.com/studiometa/js-toolkit/issues/549), [#550](https://github.com/studiometa/js-toolkit/pull/550), [6436ef7d](https://github.com/studiometa/js-toolkit/commit/6436ef7d))
- Refactor all exports to be named exports ([#551](https://github.com/studiometa/js-toolkit/pull/551), [2e046016](https://github.com/studiometa/js-toolkit/commit/2e046016))
diff --git a/packages/docs/.vitepress/config.js b/packages/docs/.vitepress/config.js
index 3b5c218b9..0964118b0 100644
--- a/packages/docs/.vitepress/config.js
+++ b/packages/docs/.vitepress/config.js
@@ -209,6 +209,7 @@ function getServicesSidebar() {
{ text: 'useDrag', link: '/api/services/useDrag.html' },
{ text: 'useKey', link: '/api/services/useKey.html' },
{ text: 'useLoad', link: '/api/services/useLoad.html' },
+ { text: 'useMutation', link: '/api/services/useMutation.html' },
{ text: 'usePointer', link: '/api/services/usePointer.html' },
{ text: 'useRaf', link: '/api/services/useRaf.html' },
{ text: 'useResize', link: '/api/services/useResize.html' },
@@ -227,6 +228,7 @@ function getDecoratorsSidebar() {
{ text: 'withMountOnMediaQuery', link: '/api/decorators/withMountOnMediaQuery.html' },
{ text: 'withMountWhenInView', link: '/api/decorators/withMountWhenInView.html' },
{ text: 'withMountWhenPrefersMotion', link: '/api/decorators/withMountWhenPrefersMotion.html' },
+ { text: 'withMutation', link: '/api/decorators/withMutation.html' },
{ text: 'withRelativePointer', link: '/api/decorators/withRelativePointer.html' },
{ text: 'withResponsiveOptions', link: '/api/decorators/withResponsiveOptions.html' },
{ text: 'withScrolledInView', link: '/api/decorators/withScrolledInView.html' },
@@ -256,6 +258,7 @@ function getUtilsSidebar() {
link: '/utils/',
collapsed: true,
items: [
+ { text: 'cache', link: '/utils/cache.html' },
{ text: 'createElement', link: '/utils/createElement.html' },
{ text: 'debounce', link: '/utils/debounce.html' },
{ text: 'keyCodes', link: '/utils/keyCodes.html' },
diff --git a/packages/docs/api/decorators/withMutation.md b/packages/docs/api/decorators/withMutation.md
new file mode 100644
index 000000000..35b7061d9
--- /dev/null
+++ b/packages/docs/api/decorators/withMutation.md
@@ -0,0 +1,67 @@
+# withMutation
+
+Use this decorator to add a `mutated(props)` hook managed by the [mutation](/api/services/useMutation.html) service.
+
+## Usage
+
+```js
+import { Base, withMutation } from '@studiometa/js-toolkit';
+
+export default class Component extends withMutation(Base, {
+ target: (instance) => instance.$el,
+}) {
+ static config = {
+ name: 'Component',
+ };
+
+ mutated(props) {
+ for (const mutation of props.mutations) {
+ console.log(mutation); // MutationRecord
+ }
+ }
+}
+```
+
+### Parameters
+
+- `BaseClass` (`Base`): the class to add mutation observation to
+- `options?` (`{ target?: (instance:Base) => Node } & MutationObserverInit`): define which element should be observed (defaults to the component's root element) and any options for the mutation observer
+
+### Return value
+
+- `Base`: a new class extending the given class with mutation observability enabled
+
+## API
+
+### Class methods
+
+#### `mutated`
+
+The `mutated` class method will be triggered when a DOM mutation occurs on the given target.
+
+**Arguments**
+
+- `props` (`MutationServiceProps`): the [mutation service props](/api/services/useMutation.md#props)
+
+## Examples
+
+### Update a component when its children have changed
+
+This decorator can be used to update a component when its inner HTML has changed to rebind refs and child components.
+
+```js
+import { Base, withMutation } from '@studiometa/js-toolkit';
+
+export default class Component extends withMutation(Base, {
+ childList: true,
+ subtree: true,
+}) {
+ static config = {
+ name: 'Component',
+ };
+
+ mutated(props) {
+ this.$update();
+ }
+}
+```
diff --git a/packages/docs/api/services/useMutation.md b/packages/docs/api/services/useMutation.md
new file mode 100644
index 000000000..0719cf1ae
--- /dev/null
+++ b/packages/docs/api/services/useMutation.md
@@ -0,0 +1,62 @@
+# Mutation service
+
+The mutation service can be used to observe DOM mutations on a component with the MutationObserver API.
+
+## Usage
+
+```js
+import { useMutation } from '@studiometa/js-toolkit';
+
+const { add, remove } = useMutation();
+
+// Add a callback
+add('custom-id', ({ mutations }) => {
+ console.log('Some attribute has changed!');
+});
+
+// Remove the callback
+remove('custom-id');
+```
+
+## Parameters
+
+### `target`
+
+- Type: `Node`
+
+The target element to observe, defaults to `document.documentElement`.
+
+**Example**
+
+Observe any attribute mutation on the `
` element:
+
+```js
+const service = useMutation(document.body);
+```
+
+### `options`
+
+- Type: [`MutationObserverInit`](https://developer.mozilla.org/en-US/docs/Web/API/MutationObserver/observe#options)
+
+Options for the mutation observer, defaults to `{ attributes: true }` only.
+
+**Example**
+
+Observe everything on the given element:
+
+```js
+const element = document.querySelector('#element');
+const service = useMutation(element, {
+ attributes: true,
+ childList: true,
+ subtree: true,
+});
+```
+
+## Props
+
+### `mutations`
+
+- Type: `MutationRecord[]`
+
+The list of `MutationRecord` that triggered the callback.
diff --git a/packages/docs/utils/cache.md b/packages/docs/utils/cache.md
new file mode 100644
index 000000000..cc8327d75
--- /dev/null
+++ b/packages/docs/utils/cache.md
@@ -0,0 +1,28 @@
+# cache
+
+Cache the result of a function with a list of keys.
+
+## Usage
+
+```js
+import { cache } from '@studiometa/js-toolkit/utils';
+
+const keys = [document.body, Symbol('key')];
+const callback = () => performance.now();
+
+console.log(cache(keys, callback)); // 100
+console.log(cache(keys, callback) === cache(keys, callback)); // true
+
+setTimeout(() => {
+ console.log(cache(keys, callback)); // 100
+}, 100);
+```
+
+### Parameters
+
+- `keys` (`Array`): a list of keys to be used to cache the result of the callback
+- `callback` (`() => any`): the callback executed to retrieve the value to cache
+
+### Return value
+
+The value returned by the `callback` function given as parameter.
diff --git a/packages/js-toolkit/decorators/index.ts b/packages/js-toolkit/decorators/index.ts
index 28b77ce67..f0923a6e3 100644
--- a/packages/js-toolkit/decorators/index.ts
+++ b/packages/js-toolkit/decorators/index.ts
@@ -7,6 +7,7 @@ export * from './withIntersectionObserver.js';
export * from './withMountOnMediaQuery.js';
export * from './withMountWhenInView.js';
export * from './withMountWhenPrefersMotion.js';
+export * from './withMutation.js';
export * from './withName.js';
export * from './withRelativePointer.js';
export * from './withResponsiveOptions.js';
diff --git a/packages/js-toolkit/decorators/withMutation.ts b/packages/js-toolkit/decorators/withMutation.ts
new file mode 100644
index 000000000..aecfca227
--- /dev/null
+++ b/packages/js-toolkit/decorators/withMutation.ts
@@ -0,0 +1,55 @@
+import type { BaseDecorator, BaseInterface } from '../Base/types.js';
+import type { Base, BaseProps, BaseConfig } from '../Base/index.js';
+import type { MutationServiceOptions, MutationServiceProps } from '../services/index.js';
+import { useMutation } from '../services/index.js';
+
+export type MutationDecoratorOptions = MutationServiceOptions & {
+ target?: (this: Base, instance: Base) => Node;
+};
+
+export interface WithMutationInterface extends BaseInterface {
+ mutated?(props: MutationServiceProps): void;
+}
+
+/**
+ * Add a mutation observer to a component.
+ */
+export function withMutation(
+ BaseClass: typeof Base,
+ { target = (instance) => instance.$el, ...options }: MutationDecoratorOptions = {},
+): BaseDecorator {
+ /**
+ * Class.
+ */
+ class WithMutation extends BaseClass {
+ /**
+ * Config.
+ */
+ static config: BaseConfig = {
+ ...BaseClass.config,
+ emits: ['mutated'],
+ };
+
+ /**
+ * Class constructor.
+ */
+ constructor(element: HTMLElement) {
+ super(element);
+ this.$on('mounted', () => {
+ this.$services.register(
+ 'mutated',
+ useMutation.bind(undefined, target.call(this, this), options),
+ );
+ this.$services.enable('mutated');
+ });
+
+ this.$on('destroyed', () => {
+ this.$services.disable('mutated');
+ this.$services.unregister('mutated');
+ });
+ }
+ }
+
+ // @ts-ignore
+ return WithMutation;
+}
diff --git a/packages/js-toolkit/services/AbstractService.ts b/packages/js-toolkit/services/AbstractService.ts
index 7f3308ddf..714bc243a 100644
--- a/packages/js-toolkit/services/AbstractService.ts
+++ b/packages/js-toolkit/services/AbstractService.ts
@@ -1,3 +1,5 @@
+import { cache } from '../utils/cache.js';
+
export interface ServiceInterface {
/**
* Remove a function from the resize service by its key.
@@ -48,19 +50,17 @@ export class AbstractService {
/**
* Get a service instance as a singleton based on the given key.
*/
- static getInstance>(key: any = this, ...args: any[]) {
- if (!this.__instances.has(key)) {
+ static getInstance>(keys: any = [this], ...args: any[]) {
+ return cache(keys, () => {
// @ts-ignore
const instance = new this(...args);
- this.__instances.set(key, {
+ return {
add: (key, callback) => instance.add(key, callback),
remove: (key) => instance.remove(key),
has: (key) => instance.has(key),
props: () => instance.props,
- } as T);
- }
-
- return this.__instances.get(key);
+ } as T;
+ });
}
/**
@@ -76,28 +76,28 @@ export class AbstractService {
/**
* Holds all the callbacks that will be triggered.
*/
- callbacks: Map unknown> = new Map();
+ callbacks: Map unknown> = new Map();
/**
* Does the service has the given key?
*/
- has(key: string): boolean {
+ has(key: string | symbol): boolean {
return this.callbacks.has(key);
}
/**
* Get a service callback by its key.
*/
- get(key: string): (props: PropsType) => unknown {
+ get(key: string | symbol): (props: PropsType) => unknown {
return this.callbacks.get(key);
}
/**
* Add a callback to the service.
*/
- add(key: string, callback: (props: PropsType) => unknown) {
+ add(key: string | symbol, callback: (props: PropsType) => unknown) {
if (this.has(key)) {
- console.warn(`The key \`${key}\` has already been added.`);
+ console.warn(`The key \`${String(key)}\` has already been added.`);
return;
}
@@ -113,7 +113,7 @@ export class AbstractService {
/**
* Remove a callback from the service by its key.
*/
- remove(key: string) {
+ remove(key: string | symbol) {
this.callbacks.delete(key);
// Kill the service when we remove the last callback
diff --git a/packages/js-toolkit/services/DragService.ts b/packages/js-toolkit/services/DragService.ts
index aef384bc8..d61f0d646 100644
--- a/packages/js-toolkit/services/DragService.ts
+++ b/packages/js-toolkit/services/DragService.ts
@@ -316,5 +316,9 @@ export class DragService extends AbstractService {
* ```
*/
export function useDrag(target: HTMLElement, options: DragServiceOptions): DragServiceInterface {
- return DragService.getInstance(target, target, options);
+ return DragService.getInstance(
+ [target, JSON.stringify(options)],
+ target,
+ options,
+ );
}
diff --git a/packages/js-toolkit/services/MutationService.ts b/packages/js-toolkit/services/MutationService.ts
new file mode 100644
index 000000000..7dd92f183
--- /dev/null
+++ b/packages/js-toolkit/services/MutationService.ts
@@ -0,0 +1,52 @@
+import type { ServiceInterface } from './AbstractService.js';
+import { AbstractService } from './AbstractService.js';
+import { isEmpty } from '../utils/is.js';
+
+export interface MutationServiceProps {
+ mutations: MutationRecord[];
+}
+
+export type MutationServiceInterface = ServiceInterface;
+
+export type MutationServiceOptions = MutationObserverInit;
+
+export class MutationService extends AbstractService {
+ props: MutationServiceProps = {
+ mutations: [],
+ };
+
+ target: Node;
+
+ options: MutationObserverInit;
+
+ observer: MutationObserver;
+
+ constructor(target?: Node, options?: MutationObserverInit) {
+ super();
+ this.target = target ?? document.documentElement;
+ this.options = options;
+ }
+
+ init() {
+ this.observer = new MutationObserver((mutations) => {
+ this.props.mutations = mutations;
+ this.trigger(this.props);
+ });
+ this.observer.observe(this.target, isEmpty(this.options) ? { attributes: true } : this.options);
+ }
+
+ kill() {
+ this.observer.disconnect();
+ this.observer = null;
+ }
+}
+
+/**
+ * Use the mutation service.
+ */
+export function useMutation(
+ target?: Node,
+ options?: MutationObserverInit,
+): MutationServiceInterface {
+ return MutationService.getInstance([target, JSON.stringify(options)], target, options);
+}
diff --git a/packages/js-toolkit/services/PointerService.ts b/packages/js-toolkit/services/PointerService.ts
index 8608ada6d..06d1bd9b5 100644
--- a/packages/js-toolkit/services/PointerService.ts
+++ b/packages/js-toolkit/services/PointerService.ts
@@ -170,5 +170,5 @@ export class PointerService extends AbstractService {
* Use the pointer service.
*/
export function usePointer(target: HTMLElement | Window = window): PointerServiceInterface {
- return PointerService.getInstance(target, target);
+ return PointerService.getInstance([target], target);
}
diff --git a/packages/js-toolkit/services/ResizeService.ts b/packages/js-toolkit/services/ResizeService.ts
index 1f510a757..c01d6fda0 100644
--- a/packages/js-toolkit/services/ResizeService.ts
+++ b/packages/js-toolkit/services/ResizeService.ts
@@ -96,5 +96,5 @@ export class ResizeService<
export function useResize(
breakpoints?: T,
): ResizeServiceInterface {
- return ResizeService.getInstance(breakpoints, breakpoints);
+ return ResizeService.getInstance([breakpoints], breakpoints);
}
diff --git a/packages/js-toolkit/services/index.ts b/packages/js-toolkit/services/index.ts
index b1875d6db..5b9eb117f 100644
--- a/packages/js-toolkit/services/index.ts
+++ b/packages/js-toolkit/services/index.ts
@@ -2,6 +2,7 @@ export * from './AbstractService.js';
export * from './DragService.js';
export * from './KeyService.js';
export * from './LoadService.js';
+export * from './MutationService.js';
export * from './PointerService.js';
export * from './RafService.js';
export * from './ResizeService.js';
diff --git a/packages/js-toolkit/utils/cache.ts b/packages/js-toolkit/utils/cache.ts
new file mode 100644
index 000000000..4d3eb097d
--- /dev/null
+++ b/packages/js-toolkit/utils/cache.ts
@@ -0,0 +1,21 @@
+const map = new Map();
+
+/**
+ * Cache the result of a callback in map instances.
+ */
+export function cache(keys: any[], callback: () => T): T {
+ let value = map;
+ let index = 1;
+
+ for (const key of keys) {
+ if (!value.has(key)) {
+ const newValue = index === keys.length ? callback() : new Map();
+ value.set(key, newValue);
+ }
+
+ value = value.get(key);
+ index += 1;
+ }
+
+ return value as T;
+}
diff --git a/packages/js-toolkit/utils/index.ts b/packages/js-toolkit/utils/index.ts
index c1f74f875..787854ae6 100644
--- a/packages/js-toolkit/utils/index.ts
+++ b/packages/js-toolkit/utils/index.ts
@@ -29,3 +29,4 @@ export * from './wait.js';
export * from './random.js';
export * from './memo.js';
export * from './loadElement.js';
+export * from './cache.js';
diff --git a/packages/tests/decorators/index.spec.ts b/packages/tests/decorators/index.spec.ts
index f1d0cca67..1577d01a4 100644
--- a/packages/tests/decorators/index.spec.ts
+++ b/packages/tests/decorators/index.spec.ts
@@ -13,6 +13,7 @@ test('decorators exports', () => {
"withMountOnMediaQuery",
"withMountWhenInView",
"withMountWhenPrefersMotion",
+ "withMutation",
"withName",
"withRelativePointer",
"withResponsiveOptions",
diff --git a/packages/tests/decorators/withMutation.spec.ts b/packages/tests/decorators/withMutation.spec.ts
new file mode 100644
index 000000000..68b207030
--- /dev/null
+++ b/packages/tests/decorators/withMutation.spec.ts
@@ -0,0 +1,34 @@
+import { describe, it, expect, vi } from 'vitest';
+import { Base, withMutation } from '@studiometa/js-toolkit';
+import { nextTick } from '@studiometa/js-toolkit/utils';
+import { h } from '#test-utils';
+
+describe('The `withMutation` decorator', () => {
+ it('should add a `mutated` hook', async () => {
+ const fn = vi.fn();
+
+ class Foo extends withMutation(Base) {
+ static config = {
+ name: 'Foo',
+ };
+
+ mutated(props) {
+ fn(props);
+ }
+ }
+
+ const div = h('div');
+ const foo = new Foo(div);
+
+ await foo.$mount();
+ div.classList.add('foo');
+ await nextTick();
+
+ expect(fn).toHaveBeenCalledTimes(1);
+
+ await foo.$destroy();
+ div.classList.remove('foo');
+
+ expect(fn).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/packages/tests/index.spec.ts b/packages/tests/index.spec.ts
index 1f9305555..7eb38bfb5 100644
--- a/packages/tests/index.spec.ts
+++ b/packages/tests/index.spec.ts
@@ -10,6 +10,7 @@ describe('The package exports', () => {
"DragService",
"KeyService",
"LoadService",
+ "MutationService",
"PointerService",
"RafService",
"ResizeService",
@@ -28,6 +29,7 @@ describe('The package exports', () => {
"useDrag",
"useKey",
"useLoad",
+ "useMutation",
"usePointer",
"useRaf",
"useResize",
@@ -41,6 +43,7 @@ describe('The package exports', () => {
"withMountOnMediaQuery",
"withMountWhenInView",
"withMountWhenPrefersMotion",
+ "withMutation",
"withName",
"withRelativePointer",
"withResponsiveOptions",
diff --git a/packages/tests/services/AbstractService.spec.ts b/packages/tests/services/AbstractService.spec.ts
index 9758aed44..1f0076027 100644
--- a/packages/tests/services/AbstractService.spec.ts
+++ b/packages/tests/services/AbstractService.spec.ts
@@ -37,6 +37,17 @@ describe('The `Service` class', () => {
expect(service.has('key')).toBe(false);
});
+ it('should accept Symbol as key', () => {
+ const { service } = getContext();
+ const fn = vi.fn();
+ const key = Symbol('key');
+ service.add(key, fn);
+ expect(service.has(key)).toBe(true);
+ expect(service.get(key)).toBe(fn);
+ service.remove(key);
+ expect(service.has(key)).toBe(false);
+ });
+
it('should init and kill itself when adding or removing a callback', () => {
const { service, fn } = getContext();
service.add('key', () => fn('callback'));
diff --git a/packages/tests/services/MutationService.spec.ts b/packages/tests/services/MutationService.spec.ts
new file mode 100644
index 000000000..70836644c
--- /dev/null
+++ b/packages/tests/services/MutationService.spec.ts
@@ -0,0 +1,54 @@
+import { describe, it, expect, vi } from 'vitest';
+import { useMutation } from '@studiometa/js-toolkit';
+import { nextTick } from '@studiometa/js-toolkit/utils';
+import { h } from '#test-utils';
+
+describe('The `useMutation` service', () => {
+ it('should trigger on attribute change by default', async () => {
+ const div = h('div');
+ const service = useMutation(div);
+ const fn = vi.fn();
+
+ service.add('key', fn);
+ div.classList.add('foo');
+ await nextTick();
+
+ expect(fn).toHaveBeenLastCalledWith(service.props());
+ fn.mockRestore();
+
+ service.remove('key');
+ div.classList.add('foo');
+
+ expect(fn).not.toHaveBeenCalled();
+ });
+
+ it('should accept mutation observer options as second parameter', async () => {
+ const em = h('em');
+ const span = h('span', em);
+ const div = h('div', span);
+ const service = useMutation(div, { childList: true, subtree: true });
+ const fn = vi.fn();
+
+ service.add('key', fn);
+ em.remove();
+ await nextTick();
+
+ expect(fn).toHaveBeenLastCalledWith(service.props());
+ expect(service.props().mutations[0].removedNodes[0]).toBe(em);
+
+ service.remove('key');
+ });
+
+ it('should use documentElement as default target', async () => {
+ const service = useMutation();
+ const fn = vi.fn();
+
+ service.add('key', fn);
+ document.documentElement.classList.add('foo');
+
+ await nextTick();
+
+ expect(fn).toHaveBeenLastCalledWith(service.props());
+ service.remove('key');
+ });
+});
diff --git a/packages/tests/services/index.spec.ts b/packages/tests/services/index.spec.ts
index 6774f65f9..6d19f658b 100644
--- a/packages/tests/services/index.spec.ts
+++ b/packages/tests/services/index.spec.ts
@@ -8,6 +8,7 @@ test('components exports', () => {
"DragService",
"KeyService",
"LoadService",
+ "MutationService",
"PointerService",
"RafService",
"ResizeService",
@@ -15,6 +16,7 @@ test('components exports', () => {
"useDrag",
"useKey",
"useLoad",
+ "useMutation",
"usePointer",
"useRaf",
"useResize",
diff --git a/packages/tests/utils/cache.spec.ts b/packages/tests/utils/cache.spec.ts
new file mode 100644
index 000000000..aeef780f5
--- /dev/null
+++ b/packages/tests/utils/cache.spec.ts
@@ -0,0 +1,10 @@
+import { describe, it, expect } from 'vitest';
+import { cache } from '@studiometa/js-toolkit/utils';
+
+describe('cache function', () => {
+ it('caches values with multiple keys', async () => {
+ const keys = ['one', 'two', 'three'];
+ const callback = () => new Map();
+ expect(cache(keys, callback)).toBe(cache(keys, callback))
+ });
+});
diff --git a/packages/tests/utils/index.spec.ts b/packages/tests/utils/index.spec.ts
index 91807be4b..918db16af 100644
--- a/packages/tests/utils/index.spec.ts
+++ b/packages/tests/utils/index.spec.ts
@@ -11,6 +11,7 @@ describe('@studiometa/js-toolkit/utils exports', () => {
"addStyle",
"animate",
"boundingRectToCircle",
+ "cache",
"camelCase",
"clamp",
"clamp01",