From 6ffd7a33f6a6833cbbda21a9d0d2435427654f88 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Thu, 12 Dec 2024 17:09:54 +0100 Subject: [PATCH 1/3] Add support for auto-updating components on DOM change Fix: #562 --- packages/js-toolkit/Base/Base.ts | 37 +++++++++++++++++++++++- packages/js-toolkit/helpers/createApp.ts | 21 -------------- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/packages/js-toolkit/Base/Base.ts b/packages/js-toolkit/Base/Base.ts index e219359f..f5c54677 100644 --- a/packages/js-toolkit/Base/Base.ts +++ b/packages/js-toolkit/Base/Base.ts @@ -1,10 +1,10 @@ -/* eslint-disable no-use-before-define */ import { getComponentElements, getEventTarget, addToQueue, addInstance, deleteInstance, + getInstances, } from './utils.js'; import { ChildrenManager, @@ -13,6 +13,7 @@ import { EventsManager, OptionsManager, } from './managers/index.js'; +import { useMutation } from '../services/MutationService.js'; import { noop, isDev, isFunction, isArray } from '../utils/index.js'; let id = 0; @@ -74,6 +75,8 @@ export class Base { */ static readonly $isBase = true as const; + static __mutationSymbol = Symbol('mutation'); + /** * The instance parent. */ @@ -312,6 +315,38 @@ export class Base { this[`__${service.toLowerCase()}`] = new this.__managers[`${service}Manager`](this); } + const service = useMutation(document.documentElement, { childList: true, subtree: true }); + const key = this.constructor.__mutationSymbol; + if (!service.has(key)) { + service.add(key, (props) => { + for (const mutation of props.mutations) { + if (mutation.type !== 'childList') continue; + + // Terminate components whose root element has been removed from the DOM + for (const node of mutation.removedNodes) { + if (node.isConnected) continue; + + for (const instance of getInstances()) { + if (node === instance.$el || node.contains(instance.$el)) { + console.log(instance.$id, 'terminating'); + instance.$terminate(); + } + } + } + + // Update components whose children have been updated + for (const node of mutation.addedNodes) { + for (const instance of getInstances()) { + if (instance.$el.contains(node)) { + console.log(instance.$id, 'updating'); + instance.$update(); + } + } + } + } + }); + } + this.$on('mounted', () => { addInstance(this); }); diff --git a/packages/js-toolkit/helpers/createApp.ts b/packages/js-toolkit/helpers/createApp.ts index dc855448..588afbe7 100644 --- a/packages/js-toolkit/helpers/createApp.ts +++ b/packages/js-toolkit/helpers/createApp.ts @@ -1,8 +1,6 @@ import type { Base, BaseConstructor, BaseProps } from '../Base/index.js'; import type { Features } from '../Base/features.js'; -import { getInstances } from '../Base/index.js'; import { features } from '../Base/features.js'; -import { useMutation } from '../services/index.js'; import { isBoolean, isObject, isString } from '../utils/index.js'; export type CreateAppOptions = Partial & { @@ -42,25 +40,6 @@ export function createApp, T extends BaseProps = features.set('attributes', attributes); } - // Terminate components whose root element has been removed from the DOM - const service = useMutation(document.documentElement, { childList: true, subtree: true }); - const symbol = Symbol('createApp'); - service.add(symbol, (props) => { - for (const mutation of props.mutations) { - if (mutation.type === 'childList') { - for (const node of mutation.removedNodes) { - if (!node.isConnected) { - for (const instance of getInstances()) { - if (node === instance.$el || node.contains(instance.$el)) { - instance.$terminate(); - } - } - } - } - } - } - }); - async function init() { app = new App(root) as S & Base; await app.$mount(); From f20c8a3571b4935b908c4646b48cd3057abb6d31 Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Fri, 13 Dec 2024 21:46:00 +0100 Subject: [PATCH 2/3] Limit action number of execution to the minimum needed --- packages/js-toolkit/Base/Base.ts | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/packages/js-toolkit/Base/Base.ts b/packages/js-toolkit/Base/Base.ts index f5c54677..a5a03f9a 100644 --- a/packages/js-toolkit/Base/Base.ts +++ b/packages/js-toolkit/Base/Base.ts @@ -319,6 +319,9 @@ export class Base { const key = this.constructor.__mutationSymbol; if (!service.has(key)) { service.add(key, (props) => { + const updates = new Map any>(); + const terminations = new Map any>(); + for (const mutation of props.mutations) { if (mutation.type !== 'childList') continue; @@ -327,23 +330,29 @@ export class Base { if (node.isConnected) continue; for (const instance of getInstances()) { - if (node === instance.$el || node.contains(instance.$el)) { - console.log(instance.$id, 'terminating'); - instance.$terminate(); + if (!terminations.has(instance) && (node === instance.$el || node.contains(instance.$el))) { + terminations.set(instance, () => instance.$terminate()); } } } - // Update components whose children have been updated + // Update components whose children have been terminationd for (const node of mutation.addedNodes) { for (const instance of getInstances()) { - if (instance.$el.contains(node)) { - console.log(instance.$id, 'updating'); - instance.$update(); + if (!updates.has(instance) && instance.$el.contains(node)) { + updates.set(instance, () => instance.$update()); } } } } + + for (const update of updates.values()) { + update(); + } + + for (const termination of terminations.values()) { + termination(); + } }); } From 3e9c5d33e139d94988b9147eccd5606cc4028cff Mon Sep 17 00:00:00 2001 From: Titouan Mathis Date: Mon, 16 Dec 2024 10:47:48 +0100 Subject: [PATCH 3/3] WIP --- packages/demo/meta.config.js | 3 + packages/demo/src/js/app.ts | 58 ++--------------- .../js/components/ParentNativeEvent/Child.js | 23 +++++++ .../js/components/ParentNativeEvent/index.js | 20 ++++-- .../templates/pages/child-native-event.twig | 3 +- packages/js-toolkit/Base/Base.ts | 62 +++++++++++++------ .../Base/managers/ChildrenManager.ts | 35 ++++++++++- 7 files changed, 124 insertions(+), 80 deletions(-) diff --git a/packages/demo/meta.config.js b/packages/demo/meta.config.js index f329eb86..03ee68e3 100644 --- a/packages/demo/meta.config.js +++ b/packages/demo/meta.config.js @@ -4,6 +4,9 @@ import { prototyping } from '@studiometa/webpack-config-preset-prototyping'; export default defineConfig({ presets: [prototyping({ ts: true })], + server(config) { + config.snippet = false; + }, webpack(config) { config.resolve.alias = { ...config.resolve.alias, diff --git a/packages/demo/src/js/app.ts b/packages/demo/src/js/app.ts index b89a3132..660763c1 100644 --- a/packages/demo/src/js/app.ts +++ b/packages/demo/src/js/app.ts @@ -11,6 +11,7 @@ import { BaseConfig, withDrag, withName, + getInstances, } from '@studiometa/js-toolkit'; import { matrix } from '@studiometa/js-toolkit/utils'; import ScrollToDemo from './components/ScrollToDemo.js'; @@ -24,52 +25,6 @@ import ScrolledInViewOffset from './components/ScrolledInViewOffset.js'; import MediaQueryDemo from './components/MediaQueryDemo.js'; import PointerProps from './components/PointerProps.js'; -let numberOfTick = 0; -let time = performance.now(); -let interval = setInterval(() => { - const newTime = performance.now(); - numberOfTick += 1; - console.log('#%d blocking time: %d ms', numberOfTick, newTime - time); - time = newTime; - - if (numberOfTick > total * 2) { - clearInterval(interval) - } -}, 0) - -let count = 0; -const total = 66; - -function getDeepNestedComponentName(index) { - return `TestDeepNested${index}` -} - -function makeDeepNestedComponent(index) { - return class extends withExtraConfig(Base, { name: getDeepNestedComponentName(index), components: {} }) { - mounted() { - // console.log(this.$id); - } - }; -} - -const TestDeepNested = makeDeepNestedComponent(0); -let CurrentClass = TestDeepNested; -while (count < total) { - count += 1; - const NewClass = makeDeepNestedComponent(count); - // @ts-ignore - CurrentClass.config.components[NewClass.config.name] = NewClass; - - CurrentClass = NewClass; -} - - -const TestManyInstance = class extends withExtraConfig(Base, { name: 'TestManyInstance', debug: false }) { - mounted() { - // console.log(this.$id); - } -}; - /** * App class. */ @@ -98,8 +53,8 @@ class App extends Base { AnimateTestMultiple, ResponsiveOptions, ScrolledInViewOffset, - TestDeepNested, - TestManyInstance, + // TestDeepNested, + // TestManyInstance, Accordion: (app) => importWhenVisible( async () => { @@ -183,6 +138,7 @@ class App extends Base { * @inheritdoc */ mounted() { + window.APP = this; this.$log('Mounted 🎉'); } @@ -198,13 +154,9 @@ class App extends Base { this.$log('resized', props); } - onDocumentClick(event) { - console.log('onDocumentClick', event); - } - onWindowResize(event) { console.log('onWindowResize', event); } } -export default createApp(App); +export default createApp(App, { root: document.querySelector('main') }); diff --git a/packages/demo/src/js/components/ParentNativeEvent/Child.js b/packages/demo/src/js/components/ParentNativeEvent/Child.js index 66cd833e..eaffb5de 100644 --- a/packages/demo/src/js/components/ParentNativeEvent/Child.js +++ b/packages/demo/src/js/components/ParentNativeEvent/Child.js @@ -9,5 +9,28 @@ export default class Child extends Base { */ static config = { name: 'Child', + log: true, + debug: false, }; + + msg(...msg) { + this.$el.textContent = `[${this.$id}] ${msg.join(' ')}`; + } + + mounted() { + this.msg('Mounted', performance.now()); + } + + updated() { + this.msg('Updated', performance.now()); + } + + destroyed() { + this.$log('destroyed'); + } + + terminated() { + this.$log('terminated'); + this.msg('Terminated', performance.now()); + } } diff --git a/packages/demo/src/js/components/ParentNativeEvent/index.js b/packages/demo/src/js/components/ParentNativeEvent/index.js index 71bcddf0..b12ec344 100644 --- a/packages/demo/src/js/components/ParentNativeEvent/index.js +++ b/packages/demo/src/js/components/ParentNativeEvent/index.js @@ -1,4 +1,5 @@ import { Base } from '@studiometa/js-toolkit'; +import { createElement } from '@studiometa/js-toolkit/utils'; import Child from './Child.js'; /** @@ -11,16 +12,27 @@ export default class ParentNativeEvent extends Base { static config = { name: 'ParentNativeEvent', log: true, + debug: false, components: { Child, }, }; - onChildClick(...args) { - this.$log(this.$id, 'onChildClick', ...args); + updated() { + this.$log(this.$children.Child); } - onChildDede(...args) { - this.$log(this.$id, 'onChildDede', ...args); + onDocumentClick({ event }) { + if (event.metaKey) { + this.$el.firstElementChild.remove(); + } else if (event.altKey) { + this.$children.Child[0].$el.dataset.component = ''; + } else { + this.$el.append( + createElement('button', { + dataComponent: 'Child', + }), + ); + } } } diff --git a/packages/demo/src/templates/pages/child-native-event.twig b/packages/demo/src/templates/pages/child-native-event.twig index fd0192b9..464141cc 100644 --- a/packages/demo/src/templates/pages/child-native-event.twig +++ b/packages/demo/src/templates/pages/child-native-event.twig @@ -1,7 +1,6 @@ {% extends '@layouts/base.twig' %} {% block main %} -
- +
{% endblock %} diff --git a/packages/js-toolkit/Base/Base.ts b/packages/js-toolkit/Base/Base.ts index a5a03f9a..61007699 100644 --- a/packages/js-toolkit/Base/Base.ts +++ b/packages/js-toolkit/Base/Base.ts @@ -15,6 +15,7 @@ import { } from './managers/index.js'; import { useMutation } from '../services/MutationService.js'; import { noop, isDev, isFunction, isArray } from '../utils/index.js'; +import { features } from './features.js'; let id = 0; @@ -315,44 +316,65 @@ export class Base { this[`__${service.toLowerCase()}`] = new this.__managers[`${service}Manager`](this); } - const service = useMutation(document.documentElement, { childList: true, subtree: true }); + const service = useMutation(document.querySelector('main'), { + childList: true, + subtree: true, + attributes: true, + attributeFilter: [features.get('attributes').component], + }); const key = this.constructor.__mutationSymbol; if (!service.has(key)) { service.add(key, (props) => { - const updates = new Map any>(); - const terminations = new Map any>(); + const actions = new Map any>(); + const instances = getInstances(); for (const mutation of props.mutations) { - if (mutation.type !== 'childList') continue; - - // Terminate components whose root element has been removed from the DOM + // Update parent instance when an instance node has been removed from the DOM. for (const node of mutation.removedNodes) { - if (node.isConnected) continue; + if (node.nodeType !== Node.ELEMENT_NODE) continue; + + for (const instance of instances) { + if (actions.has(instance.$parent ?? instance)) continue; + if (instance.$el.isConnected) continue; + if (!node.contains(instance.$el)) continue; + + actions.set(instance.$parent ?? instance, () => { + if (instance.$parent) { + instance.$update(); + } else { + instance.$destroy(); + } + }); + } + } + + // Update instances whose child DOM has changed + for (const node of mutation.addedNodes) { + if (node.nodeType !== Node.ELEMENT_NODE) continue; + if (actions.has(node)) continue; - for (const instance of getInstances()) { - if (!terminations.has(instance) && (node === instance.$el || node.contains(instance.$el))) { - terminations.set(instance, () => instance.$terminate()); + for (const instance of instances) { + if (instance.$el.contains(node)) { + actions.set(node, () => instance.$update()); } } } - // Update components whose children have been terminationd - for (const node of mutation.addedNodes) { - for (const instance of getInstances()) { - if (!updates.has(instance) && instance.$el.contains(node)) { - updates.set(instance, () => instance.$update()); + // Update instances when a data-component attribute has changed + if (mutation.type === 'attributes') { + for (const instance of instances) { + if (actions.has(instance)) continue; + + if (instance.$el.contains(mutation.target)) { + actions.set(instance, () => instance.$update()); } } } } - for (const update of updates.values()) { + for (const update of actions.values()) { update(); } - - for (const termination of terminations.values()) { - termination(); - } }); } diff --git a/packages/js-toolkit/Base/managers/ChildrenManager.ts b/packages/js-toolkit/Base/managers/ChildrenManager.ts index 8481de8b..c9cca911 100644 --- a/packages/js-toolkit/Base/managers/ChildrenManager.ts +++ b/packages/js-toolkit/Base/managers/ChildrenManager.ts @@ -5,7 +5,9 @@ import { getComponentElements, addToQueue } from '../utils.js'; /** * Children manager. */ -export class ChildrenManager extends AbstractManager { +export class ChildrenManager< + T extends Record[]>, +> extends AbstractManager { /** * Store async component promises to avoid calling them multiple times and * waiting for them when they are already resolved. @@ -27,9 +29,32 @@ export class ChildrenManager extends AbstractManager { * Register instances of all children components. */ async registerAll() { + const previousProps = { ...this.__children }; + for (const [name, component] of Object.entries(this.__config.components)) { this.__register(name, component); } + + let childrenToDestroy = new Set(); + for (const [previousName, previousChildren] of Object.entries(previousProps)) { + if (!this.props[previousName]) { + childrenToDestroy = childrenToDestroy.union(new Set(previousChildren)); + continue; + } + + const previousChildrenSet = new Set(previousChildren); + const childrenSet = new Set(this.props[previousName]); + const diff = previousChildrenSet.difference(childrenSet); + childrenToDestroy = childrenToDestroy.union(diff); + } + + for (const child of childrenToDestroy) { + if (child instanceof Promise) { + child.then(instance => instance.$destroy()) + } else { + child.$destroy(); + } + } } /** @@ -78,6 +103,8 @@ export class ChildrenManager extends AbstractManager { await instance[hook](); } + __children: T = {} as T; + /** * Register instance of a child component. * @@ -114,6 +141,12 @@ export class ChildrenManager extends AbstractManager { } } + Object.defineProperty(this.__children, name, { + enumerable: true, + configurable: true, + value: children, + }); + return children; }