Skip to content

[Feature] Add support for auto-updating components on DOM change #565

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

Draft
wants to merge 3 commits into
base: develop
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/demo/meta.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
58 changes: 5 additions & 53 deletions packages/demo/src/js/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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.
*/
Expand Down Expand Up @@ -98,8 +53,8 @@ class App extends Base {
AnimateTestMultiple,
ResponsiveOptions,
ScrolledInViewOffset,
TestDeepNested,
TestManyInstance,
// TestDeepNested,
// TestManyInstance,
Accordion: (app) =>
importWhenVisible(
async () => {
Expand Down Expand Up @@ -183,6 +138,7 @@ class App extends Base {
* @inheritdoc
*/
mounted() {
window.APP = this;
this.$log('Mounted πŸŽ‰');
}

Expand All @@ -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') });
23 changes: 23 additions & 0 deletions packages/demo/src/js/components/ParentNativeEvent/Child.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
}
20 changes: 16 additions & 4 deletions packages/demo/src/js/components/ParentNativeEvent/index.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Base } from '@studiometa/js-toolkit';
import { createElement } from '@studiometa/js-toolkit/utils';
import Child from './Child.js';

/**
Expand All @@ -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',
}),
);
}
}
}
3 changes: 1 addition & 2 deletions packages/demo/src/templates/pages/child-native-event.twig
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
{% extends '@layouts/base.twig' %}
{% block main %}
<div data-component="ParentNativeEvent">
<button data-component="Child">Click me</button>
<div data-component="ParentNativeEvent" class="inline-grid gap-4">
<button data-component="Child">Click me</button>
</div>
{% endblock %}
68 changes: 67 additions & 1 deletion packages/js-toolkit/Base/Base.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
/* eslint-disable no-use-before-define */
import {
getComponentElements,
getEventTarget,
addToQueue,
addInstance,
deleteInstance,
getInstances,
} from './utils.js';
import {
ChildrenManager,
Expand All @@ -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;

Expand Down Expand Up @@ -74,6 +76,8 @@ export class Base<T extends BaseProps = BaseProps> {
*/
static readonly $isBase = true as const;

static __mutationSymbol = Symbol('mutation');

/**
* The instance parent.
*/
Expand Down Expand Up @@ -312,6 +316,68 @@ export class Base<T extends BaseProps = BaseProps> {
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, () => 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);
});
Expand Down
35 changes: 34 additions & 1 deletion packages/js-toolkit/Base/managers/ChildrenManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
/**
* Children manager.
*/
export class ChildrenManager<T> extends AbstractManager<T> {
export class ChildrenManager<
T extends Record<string, Base[] | Promise<Base>[]>,
> extends AbstractManager<T> {
/**
* Store async component promises to avoid calling them multiple times and
* waiting for them when they are already resolved.
Expand All @@ -27,9 +29,32 @@
* 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<Base>();
for (const [previousName, previousChildren] of Object.entries(previousProps)) {
if (!this.props[previousName]) {
childrenToDestroy = childrenToDestroy.union(new Set(previousChildren));

Check failure on line 41 in packages/js-toolkit/Base/managers/ChildrenManager.ts

View workflow job for this annotation

GitHub Actions / build

No overload matches this call.

Check failure on line 41 in packages/js-toolkit/Base/managers/ChildrenManager.ts

View workflow job for this annotation

GitHub Actions / code-quality

No overload matches this call.
continue;
}

const previousChildrenSet = new Set(previousChildren);

Check failure on line 45 in packages/js-toolkit/Base/managers/ChildrenManager.ts

View workflow job for this annotation

GitHub Actions / build

No overload matches this call.

Check failure on line 45 in packages/js-toolkit/Base/managers/ChildrenManager.ts

View workflow job for this annotation

GitHub Actions / code-quality

No overload matches this call.
const childrenSet = new Set(this.props[previousName]);

Check failure on line 46 in packages/js-toolkit/Base/managers/ChildrenManager.ts

View workflow job for this annotation

GitHub Actions / build

No overload matches this call.

Check failure on line 46 in packages/js-toolkit/Base/managers/ChildrenManager.ts

View workflow job for this annotation

GitHub Actions / code-quality

No overload matches this call.
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();
}
}
}

/**
Expand Down Expand Up @@ -78,6 +103,8 @@
await instance[hook]();
}

__children: T = {} as T;

/**
* Register instance of a child component.
*
Expand Down Expand Up @@ -114,6 +141,12 @@
}
}

Object.defineProperty(this.__children, name, {
enumerable: true,
configurable: true,
value: children,
});

return children;
}

Expand Down
21 changes: 0 additions & 21 deletions packages/js-toolkit/helpers/createApp.ts
Original file line number Diff line number Diff line change
@@ -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<Features> & {
Expand Down Expand Up @@ -42,25 +40,6 @@ export function createApp<S extends BaseConstructor<Base>, 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<T>;
await app.$mount();
Expand Down
Loading