♻️ Stencil ref Lifecycle

How does a ref attr in a Stencil component get called, and in what circumstances will it be updated with a new value?

This is, it turns out, a bit hard to fully track down!

Let’s go from the inside-out.

setAccessor

A ref callback will be called with a reference to the DOM node a Stencil component is attached to here in the setAccessor function, which is responsible for setting various attributes on a DOM element for a new Vdom node:

src/runtime/vdom/set-accessor.ts:L59-L64

    } else if (BUILD.vdomRef && memberName === 'ref') {
      // minifier will clean this up
      if (newValue) {
        newValue(elm);
      }
    } else if (

that function is, in turn, called in two places within updateElement, a function taking old and new Vdom nodes and essentially porting the attributes from the old node to the new:

src/runtime/vdom/update-element.ts:L24-L36

  if (BUILD.updatable) {
    // remove attributes no longer present on the vnode by setting them to undefined
    for (memberName in oldVnodeAttrs) {
      if (!(memberName in newVnodeAttrs)) {
        setAccessor(elm, memberName, oldVnodeAttrs[memberName], undefined, isSvgMode, newVnode.$flags$);
      }
    }
  }

  // add new & update changed attributes
  for (memberName in newVnodeAttrs) {
    setAccessor(elm, memberName, oldVnodeAttrs[memberName], newVnodeAttrs[memberName], isSvgMode, newVnode.$flags$);
  }

So of the two setAccessor calls here:

  1. the first is to clear attributes on the DOM element for the old Vdom node and
  2. the second is to port attributes from the old Vdom node to the new one’s DOM node

What is updateElement doing? It’s basically called any time a new and old Vdom node need to be reconciled. Additionally, on the first render when there is no old Vdom node it will be called with null, so it handles that case too.

Where is that called? It’s called by two VDom functions, createElm and patch.

createElm

The createElm call is pretty straightforward - that function is called when we need to create a DOM node corresponding to a new Vdom node which didn’t previously have a DOM element associated with it (because its…new).

So the updateElement call in this case passes null for the old Vdom node:

src/runtime/vdom/vdom-render.ts:L103-L106

    // add css classes, attrs, props, listeners, etc.
    if (BUILD.vdomAttribute) {
      updateElement(null, newVNode, isSvgMode);
    }

In updateElement we then have (as seen above) a few lines at the bottom of the function:

src/runtime/vdom/update-element.ts:L33-L36

  // add new & update changed attributes
  for (memberName in newVnodeAttrs) {
    setAccessor(elm, memberName, oldVnodeAttrs[memberName], newVnodeAttrs[memberName], isSvgMode, newVnode.$flags$);
  }

If the new Vdom node has a ref attr the setAccessor call here with that ref callback will end up calling it, since in setAccessor there’s just a check before that happens that the new value for the attr is defined.

Great, so that explains how for a new node the ref callback will be called! 🎉

patch and renderVdom and friends

The patch function is just a bit more involved to unwind. This function, as you might suspect, reconciles an old Vdom tree with a new Vdom tree, making any edits to the DOM that are necessary to produce the right output. It receives old and new Vdom nodes as arguments.

patch is in turn called by renderVdom, then entry point for Stencil’s Vdom:

src/runtime/vdom/vdom-render.ts:L805-L809

export const renderVdom = (hostRef: d.HostRef, renderFnResults: d.VNode | d.VNode[]) => {
  const hostElm = hostRef.$hostElement$;
  const cmpMeta = hostRef.$cmpMeta$;
  const oldVNode: d.VNode = hostRef.$vnode$ || newVNode(null, null);
  const rootVnode = isHost(renderFnResults) ? renderFnResults : h(null, null, renderFnResults as any);

In that function we grab a previous Vdom tree off of the hostRef if present, and create a new empty node if not. The rootVnode is also passed in as an argument from the calling context.

This is a normal Vdom pattern - you have a factory function, h, that takes a runtime representation of DOM attributes and so on and returns a tree of DOM nodes, and then a render function takes that tree and a DOM node and makes it work.

This entry point into the Vdom world is then called in the code that handles updating components.

update-component.ts

This module handles, well, updating components! Great!

Basically there are some functions like scheduleUpdate and dispatchHooks which do things like emitting lifecycle events, calling lifecycle functions, and so on. It also, crucially, kicks off the first call to updateComponent:

src/runtime/update-component.ts:L67-L67

  return then(promise, () => updateComponent(hostRef, instance, isInitialLoad));

The updateComponent function then in turn calls callRender which then, in turn, conditionally calls renderVdom:

src/runtime/update-component.ts:L167-L176

      if (BUILD.vdomRender || BUILD.reflect) {
        // looks like we've got child nodes to render into this host element
        // or we need to update the css class/attrs on the host element
        // DOM WRITE!
        if (BUILD.hydrateServerSide) {
          return Promise.resolve(instance).then((value) => renderVdom(hostRef, value));
        } else {
          renderVdom(hostRef, instance);
        }
      } else {

The long and short of this is that any time that a component updates the updateComponent function should end up being called which should result in a DOM re-render and can then normally result in the ref callback being called again as well - see below for more on that.

A complication

So above we reviewed the code paths that can lead to the bits of setAccessor which actually call the ref callback being called:

src/runtime/vdom/set-accessor.ts:L59-L63

    } else if (BUILD.vdomRef && memberName === 'ref') {
      // minifier will clean this up
      if (newValue) {
        newValue(elm);
      }

Here’s the if conditional:

src/runtime/vdom/set-accessor.ts:L24-L24

  if (oldValue !== newValue) {

The complication here is that if the value passed to the ref attr doesn’t change between re-renders then it will only ever be called once.

This normally won’t be an issue because people use an inlined arrow function, like so:

  <div ref={el => (this.divEl = el)}>gonna update</div>

However, if you have a component like this the ref callback will only get called once:

@Component({
  tag: 'my-component',
  styleUrl: 'my-component.css',
  shadow: true,
})
export class MyComponent {
  render() {
    return <div ref={stableReference}>
    Hello, World! I'm not gonna call my ref more than once.
    </div>;
  }
}

function stableReference(el) {
  console.log(el);
}

This was a little surprising to me!