← All posts

Micra 2.9: listeners outside the element, and waiting for the render

In the 2.8 post I wrote that the half kilobyte of headroom under the size limit is there for fixes, not for new features. 2.9 spends 120 bytes of it on new API, so it’s worth explaining why these particular three things.

Micra’s own guidance has a rule: don’t call addEventListener inside a component, because Micra doesn’t know about that listener and won’t remove it on destroy(). Handlers belong in the markup, as @click or data-on. The rule had one exception written right under it — listeners on document, such as closing a dropdown on an outside click. Those you add in onCreate and remove in onDestroy by hand.

I wanted to know how often that exception actually comes up, so I counted. The only large body of real Micra code I have is Micra Pro: 34 files that define components, templates and library wrappers. In 10 of them there is a hand-written document.addEventListener with its matching removal in onDestroy — five for an outside click, four for global keys (Escape, Tab inside a focus trap, ⌘K for the command palette), one for dragging a slider. And 11 of them contain this:

requestAnimationFrame(() =>
  requestAnimationFrame(() => {
    this.refs.input.focus()
  })
)

It reads as a way to wait until the DOM reflects a state write before focusing or scrolling to something. A third of the components working around the same two things is a gap in the core, not a matter of style.

.window and .outside

Both are event modifiers, next to .prevent, .stop and the key guards. They change where the listener goes.

<div data-component="dropdown" @click.outside="close" @keydown.escape.window="close">
  <button @click="toggle">Menu</button>
  <ul data-show="open">…</ul>
</div>

.window attaches the listener to window, so Escape closes the menu wherever focus happens to be. .outside attaches it to document and runs the handler only when the event’s target is outside the element. Both are recorded with the component’s other listeners and removed on destroy(). The onCreate/onDestroy pair from before goes away entirely:

// before
onCreate() {
  this._outside = (e) => { if (!this.$el.contains(e.target)) this.close() }
  this._esc = (e) => { if (e.key === "Escape") this.close() }
  document.addEventListener("click", this._outside)
  document.addEventListener("keydown", this._esc)
},
onDestroy() {
  document.removeEventListener("click", this._outside)
  document.removeEventListener("keydown", this._esc)
},

// after: nothing — the two attributes on the root do it

There was one more place where a listener could outlive its element: rows of data-each. When a row was removed, its listeners were simply dropped from the component’s list — they lived on the row’s own nodes and died with them. A .window listener inside a row lives on window, so removing the row now removes it explicitly.

The click that opens the panel

.outside had one trap that the tests didn’t show. Take a panel that isn’t the whole component, opened by a button outside it:

<button @click="toggle">Filters</button>
<div data-if="open" @click.outside="close">…</div>

The user clicks the button. The button’s handler sets open = true. Micra renders in a microtask, and browsers run microtasks between event listeners — so by the time the same click bubbles up to document, the panel is already in the DOM, the click target (the button) is outside it, and the panel closes in the same click that opened it.

So .outside listens in the capture phase. A capture listener on document runs before the click reaches the button; at that moment the panel isn’t rendered yet, and the handler skips elements that aren’t connected to the document. By the time the button’s handler opens the panel, the outside check for this click is already over.

The test suite runs in jsdom, and there this bug doesn’t exist: when a test dispatches a click from code, no microtasks run between listeners, the render happens after the whole dispatch, and both versions pass. I checked this one in a real browser with real clicks: a click on the opening button leaves the panel open, a click inside it does nothing, a click elsewhere closes it.

The simplest placement remains the one in the dropdown example: put .outside on the element that contains both the button and the panel. Then a click on the button is a click inside, and none of this comes up.

this.tick()

async open() {
  this.state.isOpen = true
  await this.tick()
  this.refs.input.focus() // the input from data-if is in the DOM now
}

tick() returns a promise that resolves after the pending render. The implementation is one line, and it works because of how the scheduler is built: the first state write in a method queues the render as a microtask, and a microtask queued after it runs after it. Strictly speaking, await Promise.resolve() would do the same. The point of tick() is the name — it says what the code waits for, it’s in the API reference, and it’s what a model writing Micra code will find instead of reaching for setTimeout.

When this post first went out, it said the double requestAnimationFrame always waited two frames for something that finishes within the current task. Moving Pro over to tick() showed that was only half true. In the tabs, the radio group, the form steps and the scroll-to-active-item code, the frames really were waiting for the render, and tick() replaces them. In the eight overlay components — dropdown, popover, listbox, date picker, modal, slide-over, cart, command palette — they wait for something else. Those skins fade the panel in with a CSS transition on visibility, and at the moment the render lands the panel is still visibility: hidden, so focus() does nothing. tick() resolves exactly then, which is too early; those components keep the two frames. tick() is for focusing what a write inserted or changed, not what a transition is about to reveal.

The frames have another cost worth knowing: they stall in a background tab, where browsers throttle animation frames. The lightbox wrapper in Pro ran into that and had to switch to setTimeout.

What didn’t go in

The same count turned up other candidates, and they stay out for now. .debounce for live search: six files use setTimeout, and there’s a recipe for it. A hook that runs after each render with the list of changed keys: the renderer already tracks them, so it would be cheap, but it’s a new concept, and the Pro code gets by without it. Each of these will be reconsidered when someone asks for it with a concrete case.

What changes for you

If you load Micra from cdn.jsdelivr.net/npm/micra.js@2/…, you’re on 2.9.0 already. No breaking changes. The bundle is 7.1 KB gzip, under the 7.5 KB limit.

The suite has 380 tests, nine of them new, covering both modifiers, their removal on destroy() and on row removal, and tick(). The opening-click case is covered by the browser check described above, since jsdom can’t reproduce it.

The full list is in the changelog; the modifiers are on the directives page, and tick() is in the API reference.