Stencil version:
@stencil/[email protected]
I'm submitting a ... (check one with "x")
[ ] bug report
[x] feature request
[ ] support request => Please do not submit support requests here, use one of these channels: https://forum.ionicframework.com/ or https://stencil-worldwide.slack.com
Current behavior:
native slots are exposing various programatic API's:
How can those be used within stencil component?
I mean at @skatejs we don't use slotchange event because it won't trigger at 1st time render, instead we are using MutationObserver for that provided as childrenChangedCallback() api
Expected behavior:
create API for hooking into projected/lightDOM changes within stencil component
So I think us moving to using shadow dom by default, and therefore native slots, by default should solve this for the majority of use cases. Our hope is that basically, nobody will have to use our custom slot implementation in most cases. We will pretty much only be using our custom slot implementation for @ionic/core and thats just to ease the update path for ionic-angular 4 users.
So im not totally sure we would want to add support for these APIs to our custom slots. Ill discuss this with the team and see what they think.
at @skatejs we support both use cases (with exception that when ShadowDom is turned off we don't emulate named slots), so maybe you can do that as well.
Simply doable with MutationObserver. As I've said slotchange is not very good designed API and mostly shouldn't be used
I think for now we'll put this feature on the back burner, since it's not a widely used feature, and I'd like to keep all the nodes free of MutationObservers if possible.
I'm not sure if I'm following a 100%, but I think reacting to children changes might definitely be a "widely used feature" regardless of what "technology" is used in the background to get notified (e.g. slotchange or a mutation observer or even a vdom diff...) - for example:
@Children({subtree: true})
childrenChanged() {
// recalculate dimensions...
}
Right now, my workaround is:
componentDidLoad() {
const observer = new MutationObserver(() => this.onChildrenChange())
observer.observe(this.el, { characterData: true, subtree: true })
}
@kraftwer1 for the time being I created a decorator and a custom lifecycle hook:
export function Children(selector?: any | string, observerOptions?: MutationObserverInit): PropertyDecorator {
return (target, propertyKey) => {
// in order to query children we need a reference to the host, thus we setup a custom
// stencil property which is flagged as element reference and then connected to the
// host element at runtime, like the `@Element()` decorator does internally
const properties = target.constructor['properties'];
properties.__parentElementRef = { elementRef: true };
Object.defineProperty(target.constructor, 'properties', { value: properties });
// prepare observer options
let observer: MutationObserver;
const observerInit: MutationObserverInit = Object.assign({
subtree: true,
childList: true
}, observerOptions);
// we search all child elements by default
let querySelector = '*';
// modify the query selector from the param
if (selector !== undefined) {
if (typeof selector === 'string') {
querySelector = selector;
} else if ('is' in selector) {
querySelector = selector.is;
}
}
// we need a reference to the host element instance, thus we have to monkey patch the
// `componentDidLoad` lifecycle hook to gain access after the component is rendered
let componentDidLoad = () => {};
let componentDidUnload = () => {};
if ('componentDidLoad' in target) {
componentDidLoad = target['componentDidLoad'];
}
if ('componentDidUnload' in target) {
componentDidUnload = target['componentDidUnload'];
}
target['componentDidLoad'] = function (...args) {
// create an child observer
observer = new MutationObserver(mutations => this.componentChildrenUpdate(mutations));
observer.observe(this.__parentElementRef, observerInit);
// query for children and set to decorated property
this[propertyKey] = Array.from((this.__parentElementRef as HTMLElement).querySelectorAll(querySelector));
// call original implementation
componentDidLoad.apply(this, args);
};
target['componentDidUnload'] = function (...args) {
observer.disconnect();
// call original implementation
componentDidUnload.apply(this, args);
};
};
}
export interface ComponentChildrenUpdate {
componentChildrenUpdate(mutations): void;
}
@davidenke Thank you I've been using this !
I'm trying to upgrade my library project to [email protected] and it broke the @Children annotation : const properties = target.constructor['properties']; return undefined
I don't understand what is this properties field in the class constructor, can you explain please ?
@Elvynia Stencil < 1.0.0 added a magic property to the component class called properties which is now missing (at least at the moment when the decorator tries to access it).
This is a very hacky solution as you can see...
@davidenke I am so sad now ! I got a bigger problem right now with the new version and redux-observable so I'll try to fix this later if I can. Thanks for answering !
-- edit --
I can't find any way to obtain the element in the decorator but I learned 2 interesting things:
-- edit 2 --
I found a way, wasn't hard at all finally ! Just needed to hook in componentDidLoad sooner to have access to this.el. Here's the 2 pieces of code I use for @Children and @Child :
children.ts
export function Children(selector?: any | string, observerOptions?: MutationObserverInit): PropertyDecorator {
return (target: any, propertyKey: string) => {
let { componentDidLoad, componentDidUnload } = target;
// prepare observer options
let observer: MutationObserver;
target['componentDidLoad'] = function (...args) {
const observerInit: MutationObserverInit = Object.assign({
subtree: true,
childList: true
}, observerOptions);
// we search all child elements by default
let querySelector = '*';
// modify the query selector from the param
if (selector !== undefined) {
if (typeof selector === 'string') {
querySelector = selector;
} else if ('is' in selector) {
querySelector = selector.is;
}
}
// create an child observer
if (this.componentChildrenUpdate) {
observer = new MutationObserver(mutations => this.componentChildrenUpdate(mutations));
observer.observe(this.el, observerInit);
}
// query for children and set to decorated property
this[propertyKey] = Array.from((this.el as HTMLElement).querySelectorAll(querySelector));
// call original implementation
return componentDidLoad && componentDidLoad.apply(this, args);
};
target['componentDidUnload'] = function (...args) {
if (observer) {
observer.disconnect();
}
componentDidUnload && componentDidUnload.call(this, args);
}
};
}
export interface ComponentChildrenUpdate {
componentChildrenUpdate(mutations): void;
}
child.ts
export function Child(selector?: any | string, observerOptions?: MutationObserverInit): PropertyDecorator {
return (target: any, propertyKey) => {
let { componentDidLoad, componentDidUnload } = target;
// prepare observer options
let observer: MutationObserver;
target['componentDidLoad'] = function (...args) {
const observerInit: MutationObserverInit = Object.assign({
subtree: true,
attributes: true
}, observerOptions);
// we search all child elements by default
let querySelector = '*';
// modify the query selector from the param
if (selector !== undefined) {
if (typeof selector === 'string') {
querySelector = selector;
} else if ('is' in selector) {
querySelector = selector.is;
}
}
// create an child observer
if (this.componentChildUpdate) {
observer = new MutationObserver(mutations => this.componentChildUpdate(mutations));
observer.observe(this.el, observerInit);
}
// query for child and set to decorated property
this[propertyKey] = (this.el as HTMLElement).querySelector(querySelector);
// call original implementation
return componentDidLoad && componentDidLoad.apply(this, args);
}
target['componentDidUnload'] = function (...args) {
if (observer) {
observer.disconnect();
}
// call original implementation
componentDidUnload.apply(this, args);
};
};
}
export interface ComponentChildUpdate {
componentChildUpdate(mutations): void;
}
Just noting that migrating from Angular, which may be a popular use case, an API like the one described in this issue would be useful to migrate all those contentChildren away.
Just noting that migrating from Angular, which may be a popular use case, an API like the one described in this issue would be useful to migrate all those
contentChildrenaway.
In the end both Angular Decorators are useful ViewChild/ViewChildren and ContentChild/ContentChildren. As a beginner it's hard to determine the difference between all four, so I'd propose the integration of similar technics regardless of the naming and the QueryList / reference stuff.
@adamdbradley
I think for now we'll put this feature on the back burner, since it's not a widely used feature, and I'd like to keep all the nodes free of MutationObservers if possible.
I'm trying to migrate a project from Angular right now, and the lack of an API for this is most definitely a pain point. To give you an idea of the use case, I have a bunch of UI components like menus, menubars, toolbars, tab menus, etc., that need an up-to-date list of their children to keep track of the main component state and to handle accessible keyboard functionality. In Angular I can get an observable list of those children like this:
export class MenuComponent implements AfterContentInit {
@ContentChildren(MenuItemComponent)
menuItems: QueryList<MenuItemComponent>;
menuItems$: Observable<QueryList<MenuItemComponent>>;
ngAfterContentInit(): void {
// 'changes' doesn't fire on the first load, but this is easily fixed by piping to the `startWith` operator
this.menuItems$ = this.menuItems.changes.pipe(startWith(this.menuItems));
}
}
And from there I can do various things, like:
As long as the menu items are completely static, most of this stuff can be done just as easily without needing the changes property, but the problem I've run into numerous times is that the consumer will use the result of some network call to determine what menu items to render, which means they get rendered asynchronously, which makes a static query unreliable. It can also break if the library re-renders those DOM elements for any reason, since the references held by the static query would no longer be valid (though it sounds like Stencil is pretty smart about making targeted updates to the DOM, so maybe that wouldn't be an issue).
Personally, I would be okay with something that just worked off the slotchange event since the limitation @Hotell mentioned is the same one that exists in Angular and it's honestly not a big deal to account for IMO.
Most helpful comment
I'm not sure if I'm following a 100%, but I think reacting to children changes might definitely be a "widely used feature" regardless of what "technology" is used in the background to get notified (e.g. slotchange or a mutation observer or even a vdom diff...) - for example:
Right now, my workaround is: