Stencil: api: projected children/slot api hooks within stencil component

Created on 8 Oct 2017  路  12Comments  路  Source: ionic-team/stencil

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:

  • slotchange event listener
  • assignedNodes

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

enhancement

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:

@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 })
}

All 12 comments

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

https://codesandbox.io/s/qzvz4j1q4q

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:

  • Instead of making a property decorator, using a method decorator should give the element as first parameter (medium article). I'm going to try that !
  • This other article demonstrate how to achieve fetching children without a decorator

-- 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 contentChildren away.

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:

  • Close the menu when an item is clicked
  • Focus the first item when the menu is opened (per ARIA specs)
  • Listen to arrow key presses to cycle user focus through the menu items (also per ARIA specs)
  • Open/close submenus based on hover states or left/right arrow key presses

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.

Was this page helpful?
0 / 5 - 0 ratings