diff --git a/packages/demo/meta.config.js b/packages/demo/meta.config.js index f329eb864..03ee68e3e 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 b89a31323..660763c19 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 66cd833ea..eaffb5dea 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 71bcddf07..b12ec3442 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 fd0192b98..464141ccf 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 e219359f4..61007699d 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,7 +13,9 @@ import { EventsManager, OptionsManager, } 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; @@ -74,6 +76,8 @@ export class Base { */ static readonly $isBase = true as const; + static __mutationSymbol = Symbol('mutation'); + /** * The instance parent. */ @@ -312,6 +316,68 @@ export class Base { this[`__${service.toLowerCase()}`] = new this.__managers[`${service}Manager`](this); } + 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 actions = new Map any>(); + const instances = getInstances(); + + for (const mutation of props.mutations) { + // Update parent instance when an instance node has been removed from the DOM. + for (const node of mutation.removedNodes) { + 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 instances) { + if (instance.$el.contains(node)) { + actions.set(node, () => 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 actions.values()) { + update(); + } + }); + } + this.$on('mounted', () => { addInstance(this); }); diff --git a/packages/js-toolkit/Base/managers/ChildrenManager.ts b/packages/js-toolkit/Base/managers/ChildrenManager.ts index 8481de8bf..c9cca9110 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; } diff --git a/packages/js-toolkit/helpers/createApp.ts b/packages/js-toolkit/helpers/createApp.ts index dc855448e..588afbe7b 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();