--- title: Fixing JavaScript observability, one library at a time description: Sentry is adding TracingChannel support to 44 JavaScript libraries upstream, replacing fragile monkey-patching with native observability that works across all runtimes. tags: ['javascript', 'observability'] ---
# Fixing JavaScript observability, one library at a time
---
--- title: The Curious Case of Vue's Function Props description: A curious look at Vue's function props and their unexpected advantages over emits. tags: ['frontend', 'vuejs'] ---
# The Curious Case of Vue's Function Props
---
--- title: Wait for that tick description: Learn how Vue's nextTick works, why DOM updates are batched, and how to avoid timing issues when working with refs and animations. tags: ['frontend', 'vuejs'] ---
# Wait for that tick
---
--- title: Falling With Style description: Learn how Vue.js fall-through attributes work, when they're useful, and common pitfalls to avoid. Master class, style, and event handling in components. tags: ['frontend', 'vuejs'] ---
# Falling With Style
---
--- title: Vue.js Magical Reactivity Has Some Quirks description: Why does Vue's reactivity sometimes behave unexpectedly?! tags: ['frontend', 'vuejs'] ---
# Vue.js Magical Reactivity Has Some Quirks
---
--- title: Just Use useTemplateRef for Template Refs description: Learn how to use useTemplateRef for easier template refs in Vue.js. tags: ['frontend', 'vuejs'] ---
# Just Use useTemplateRef for Template Refs
---
--- title: Getting Started With Provide/Inject In Vue.js description: Explore what's beyond props and events with Vue's powerful Provide/Inject API. tags: ['frontend', 'vuejs'] ---
# Getting Started With Provide/Inject In Vue.js
---
--- title: Understanding Vue.js Directives description: learn to create custom directives, and discover practical use cases and the best practices of using them in your projects. tags: ['frontend', 'vuejs'] ---
# Understanding Vue.js Directives
---
--- title: Handling Async Components' loading errors description: What happens when the component fails to load? Let's see how to handle this situation. tags: ['frontend', 'vue', 'ux'] ---
# Handling Async Components' loading errors
So having worked at Rasayel for a while now, we've built the most complex web app I have ever worked on. It's a huge project with many moving parts, and it's been a great learning experience. One of the things I've learned is how to handle errors, but they come in various shapes. One aspect I have been ignoring for a while is the importance of handling errors when loading async components, and this is what this article is about.
## When do we use async components?
One of the great things about Vue is how easy it made marking a component as "lazy-loaded" or "async" (I will be using these terms exchangeably). This allows for a more performant application, as we don't need to load the component until required. This is especially useful when we have a large application with many components, and we want to reduce the initial load time.
This has been straightforward to do in Vue since the days of Vue 2 and is still the same in Vue 3. You would need to use the `defineAsyncComponent` function to define a component that will be loaded asynchronously. Here's an example:
```js
const AsyncComp = defineAsyncComponent(
() => import('./components/MyComponent.vue'),
);
```
The `import()` function is a dynamic import that returns a promise. When the promise resolves, the component is loaded and can be used.
So most of the time you do it explicitly like this, but one of the main use cases for async components is when you use it to define a route component in Vue Router. Here's an example:
```js
const router = createRouter({
// ...
routes: [
{
path: '/settings',
component: () => import('./pages/UserSettings.vue'),
},
],
});
```
This is particularly important as apps with client-side routing or SPAs (Single Page Applications) will need to load the component when the route is visited. Otherwise, you would be forcing the users to download the entire app with all of its pages, and sub-components all at once. Suffice it to say, this will make your app slower to load initially and potentially unresponsive.
Certain frameworks and configurations even do it for you under the hood by default because it is such a critical good practice. For example, Nuxt.js and [unplugin-vue-router](https://github.com/posva/unplugin-vue-router).
So far this sounds good and in most cases, you don't need to worry about anything beyond what I showed you. But what happens when the component fails to load? What could even occur that would cause the component to fail to load? and How would your app behave in such a situation?
## What could go wrong?
Lazy loading a component is essentially a network request that fetches what the component requires to be rendered. This means it will load a JS file, maybe a CSS file, or maybe some other assets. And because it is a network request, it can fail for the same reasons a `fetch` call may fail:
- Maybe the device is offline.
- Maybe the server is down.
- Maybe the file(s) doesn't/don't exist.
- Your CI pipeline could have failed to build the app correctly or the file was deleted.
- The app might be trying to load an older version of the file that the new deployment has since been renamed/removed.
These are all good reasons, but they are exceptions. Many things need to go wrong for any of those issues to happen which is why many developers don't feel the need to handle them. I won't try to convince you otherwise, but the next one is more common than you think.
So at Rasayel, in 3 years of building and shipping countless features that involve tons of lazy loading, we can add one more reason to this list, adblockers.
Adblockers are known to block requests that contain certain keywords like "ad" "banner" "popup" "dialog" or most of the market-y words you can think of.
In our case, it blocked any component that had the word "campaign" in its name and started blocking "dialog" components as well recently with some adblockers.
While this is outside of your control, it is something you can handle gracefully and in an informative way to the user. This is what the UX of errors is all about: **if you cannot recover, inform**.
## The Problem
You know now what could go wrong, but what happens when it does? What happens with your app? At best nothing happens. At worst the app crashes. Neither of these is a good user experience.
To make matters a bit harder, when any of the above happens. They all throw the exact same error, you cannot tell by just looking at the error why it happened. You can only guess using the context of the error and with the help of some browser APIs.
Here's an example of what the error looks like in the console:
```
Failed to fetch dynamically imported module https://....
```
So you can see that the error message is not very informative, and it doesn't tell you why it failed. So trying to tell your user what went wrong is harder than you think.
Let's first see if we can prevent the app from crashing or if we can do something when the component fails to load.
## onError and errorComponent
Luckily, Vue's `defineAsyncComponent` has a couple of APIs you can use to handle errors. The first one is `errorComponent` which is an alternative component that renders when the async target component fails to load, you can specify it using the extended object definition for an async component. Here is an example:
```js
// You need to import it synchronously, otherwise what's the point?
const AsyncComp = defineAsyncComponent({
// the loader function, whatever we had before
loader: () => import('./Foo.vue'),
// Will render this if the `Foo.vue` fails to load for whatever reason
errorComponent: ErrorComponent,
});
```
The error component will receive the error that caused the async component to fail to load as a prop, so you can use it to inform the user about what went wrong.
Here is a quick definition of an error component:
```vue
{{ error.message }}
```
Here is a full example in action, we are loading a component that fails to load on purpose:
This is good for loading in content sections of the app, but not that great for overlay components like dialogs or modals because there is the matter of positioning, but the main thing is you need to create an error component for the different kinds of async components you have around your apps.
Another API we could use is `onError`, which is a callback that is called when the async component fails to load. This is useful if you want to log the error or send it to a logging service, among other things. Here is an example:
```ts
const AsyncComp = defineAsyncComponent({
// the loader function, whatever we had before
loader: () => import('./Foo.vue'),
// Will render this if the `Foo.vue` fails to load for whatever reason
onError(error, retry, fail, attempts) {
// Do stuff...
},
});
```
We have a few cool arguments to play with here, let me explain them:
- `error`: The error that caused the async component to fail to load.
- `retry`: A function that you can call to retry loading the component.
- `fail`: A function that you can call to re-throw the error up the chain.
- If there is a global error handler or boundary, it will catch it.
- If there isn't, the app will crash which is what was happening without this API.
- `attempts`: The number of times the component has tried to load.
Here is a simple example that doesn't do much, we just tell the user that we failed to do something and that they should try again:
A few bits are going on here, so let me try to break it down:
- We have a global `` component that we will trigger via a global event whenever any async error happens.
- We use DOM events and `CustomEvent` object to create a custom event that we can listen to globally.
- We use the `onError` callback to trigger the event when the async component fails to load from anywhere in our app.
This while a bit more complex than the previous example, is more flexible and gives you more options and control over what you want to do with the error.
Even tho the example is a bit lacking, this API allows you to do more than render an error component. You can retry loading the component, or do something else entirely.
To improve upon this we need to dig more into the error and try to inform the user about what went wrong.
## Disambiguating the error
The error itself doesn't tell us much, but we can go over the reasons we mentioned earlier and try to confirm or rule out each one of them. Let's start with the user connection.
The next few examples will be simplified for brevity, but you can expand upon them with the previous examples to make them more useful.
At the end of the article, I will show you a more complete example.
### Network
The `navigator` object has a property called `onLine` that tells you if the user is online or not. This is not a perfect solution, but it's a good start. Here's how you can use it:
```js
const AsyncComp = defineAsyncComponent({
loader: () => import('./Foo.vue'),
onError() {
if (!navigator.onLine) {
console.error('The user is offline');
return;
}
},
});
```
The `navigator.onLine` reports its value based on the user's device. So if you disconnect from the Wifi or turn on the airplane mode, it will return `false`. However it doesn't work well with the low-fi situation where the user device is connected to a network, but the network itself is down or spotty.
Another thing you can do is to try to send a simple request to a server, it needs to be something you know is always up. A static page is perfect for this. Here's an example:
```js {9-14}
const AsyncComp = defineAsyncComponent({
loader: () => import('./Foo.vue'),
async onError() {
if (!navigator.onLine) {
console.error('The user is offline');
return;
}
try {
await fetch('/online.html');
} catch (err) {
console.error('The user is offline or has a bad network');
return;
}
},
});
```
This is where I like the `fetch` behavior, it only throws if the request is never made due to a network error. If the request is made but the server responds with an error, it doesn't throw. This is perfect for our use case.
Even if our static page is down, the request will still be made and the error will not be thrown. However, if the user's device is offline or the user has some other spotty network issue, the request will not be made and the error will be thrown.
Notice that I'm still checking if the user is offline using `navigator.onLine`, because if the device is disconnected then it is a bit redundant to make the request. `navigator.onLine` is accurate in this case, but if the user is connected to a network that's spotty or down, then it's not as reliable.
You can optimize this. We don't need to make a `GET` request. Instead, we could make a `HEAD` request, which is a lot faster and doesn't download the entire page. This ensures we are conscious of the user bandwidth and gives us what we need. Here's how you can do it:
```js
const AsyncComp = defineAsyncComponent({
loader: () => import('./Foo.vue'),
async onError() {
if (!navigator.onLine) {
console.error('The user is offline');
return;
}
try {
await fetch('/online.html'); // [!code --]
await fetch('/online.html', { method: 'HEAD' }); // [!code ++]
} catch (err) {
console.error('The user is offline or has a bad network');
return;
}
},
});
```
You can dig down further if you want and try to figure out what kind of network error it is by inspecting the error itself. But this here is enough for us to know if the user is offline or has a bad network.
Here is how it works: before you try it out, make sure you are offline, you can do so by opening the devtools, and going to the network tab, and choosing "Offline" from the throttle dropdown, or just disconnect your device.
{
const module = window.__modules__[key];
const blob = new Blob([module], { type: 'text/javascript' });
const url = URL.createObjectURL(blob).replace('blob:', '');
await (await fetch(url)).text();
return Promise.resolve(window.__modules__[key]);
};`}
files={{
'App.vue': 'network-check',
'ErrorDialog.vue': 'global-error-dialog',
'utils.ts': 'utils',
'AsyncDemo.vue': 'happy-network',
}}
client:visible
/>
In the demo, we had a chance to recover automatically by waiting for the user to come back online. But usually, you don't want to block their UI while they are offline, also retrial isn't always the best solution. You can let them do the action again instead after informing them. I just included this bit to show you how much flexibility we have here.
### Component Assets Existence
The request we made earlier doesn't tell us if the component's assets exist or not, but we can try to confirm that by doing a similar `fetch` on our component's JS file and checking the response status code. We have a few cases to handle:
- **Non-200**:
- **4xx**: 404 if the file doesn't exist, the server may return 403 or 401 if the file is protected.
- **5xx**: Ok wow, we have a serious issue at hand here.
- **200**: The file exists and can be downloaded, this is a weird one.
The handling here is up to you, but in my opinion, there is not much difference between a 4xx and a 5xx. What we are doing here is checking the downloadability of the file, in either case, the user cannot download the file and that's what matters.
A message telling your user to reload the app and try again is usually good enough here, you could also show them a retry button. If the file is off-limits for one reason or another you cannot recover from this but what is crucial is you send the error to your logging service with the exact details so you or your team can debug it later.
```js
const AsyncComp = defineAsyncComponent({
loader: () => import('./Foo.vue'),
async onError(error, retry, _, attempts) {
//...
try {
// We don't have the URL that failed directly, so regex it is.
// Some browsers might not give you the URL in the error message.
const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
const response = await fetch(url, { method: 'HEAD' });
// Hmm, got 200. This is weird.
if (response.ok && attempts < 2) {
// Might as well retry the import, could've been a blip on the network.
// If it works, then it works, the user won't notice it.
retry();
return;
}
// Non-200 status code, we have a problem.
dispatchAsyncError({
message:
'Could not download the component, looks like a 404',
retry,
});
// Send this to your logging service, this is crucial.
logError({
message: 'Failed to load async component',
error,
status: response.status,
});
} catch (error) {
// Fetch errors mean their network is bad, 4xx and 5xx won't be caught here.
// same handling as before...
}
},
});
```
This is a bit simpler, we do some sort of auto recovery here by checking if the file is downloadable, and if it is then we retry on the spot so the user won't be aware of it. However, if it fails we inform the user and log the error.
Here is an example of a component 404ing:
This situation is a bit rare, if you try to load in a component that you don't have, your bundler will likely complain. I know `vite` will scream at me if I do that.
This is more for some weird cases where maybe file name case sensitivity is at play or your CI pipeline replaced the files with a fresh deployment or some other weird case. For us, this was too common until we fixed our deployment pipeline to keep all old assets for long user sessions. The main thing here, if something happens you will know exactly what went wrong which puts you on the path to fixing it.
### AdBlockers
Ok, we all use AdBlockers, right? But at Rasayel some customers had such an aggressive adblocker that it blocked any component that had the word "campaign" in its name. Campaigns are a big part of our app, so this was a big issue for us.
We considered obfuscating the component names during the build, but this will come back to bite us if an error happens regarding that component since we won't be able to tell which one it is. But if that works for you then go for it!
Still, there is no telling what an adblocker may decide to block and how they might evolve in the future to keep doing so. So we need to handle this gracefully, telling the user that "if they have an adblocker, they should disable it" is more than enough here. We just need to detect it.
We can start by inspecting the error message or error name given to us in the `onError` callback:
```js
const AsyncComp = defineAsyncComponent({
loader: () => import('./Ads.vue'),
async onError(error) {
console.log(error.message, error.name);
},
});
```
But remember what I said at the start of this article? The error is always...
```
Failed to fetch dynamically imported module: http://....
```
So it is not very helpful, and indeed we only used it to pick up the file URL which will be useful here. We could try to fetch the file with our `HEAD` method and inspect the response just like what we did before with non-200 responses. In this case, you will get no response because the error is thrown just like the network error.
```js
const AsyncComp = defineAsyncComponent({
loader: () => import('./components/Ads.vue'),
async onError(error) {
try {
const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
const response = await fetch(url, { method: 'HEAD' });
} catch (err) {
// The request didn't go through, this is either a network error or an adblocker.
}
},
});
```
This makes distinguishing between a network error and an adblocker a bit harder, but remember that we already checked for network errors. So if the request fails, then it is likely an adblocker. And we can inform the user about it.
Here is an example, if you are using an adblocker add the following to your blocklist:
```
/BigAd.vue
```
We can combine this with the downloadability check we did earlier to avoid making a request twice.
```js
const AsyncComp = defineAsyncComponent({
loader: () => import('./Foo.vue'),
async onError(error, retry, fail, attempts) {
//...
try {
const url = error.message.match(/https?:\/\/[^ ]+/)?.[0];
const response = await fetch(url, { method: 'HEAD' });
if (response.ok && attempts < 2) {
retry();
return;
}
// Non-200 status code, we have a problem.
dispatchAsyncError({
message:
'Could not download the component, looks like a 404',
retry,
});
// Log error to your logging service
// ...
} catch (error) {
dispatchAsyncError({
// [!code ++]
message: 'Looks like you have an adblocker on', // [!code ++]
retry, // [!code ++]
fail, // [!code ++]
}); // [!code ++]
}
},
});
```
## When to call `fail`?
So you may have felt that we are not making use of the `fail` function in the previous examples.
However, if you check the `ErrorDialog.vue` component code we used earlier, you will find that it calls it whenever the user dismisses the dialog without resolving the issue.
```js
function onExitErrorHandling() {
dialogEl.value?.close();
// You should use `fail` to tell Vue the error handler wasn't successful in recovering
callbackProps.value.fail?.();
}
```
This is not required in my opinion but it appears to be a good practice, there aren't many resources on this so I'm not sure myself.
I tested a few scenarios with or without calling it, and it seems like Vue assumes the component error was resolved if you don't call it.
So semantically, I would call it if the user dismisses the dialog as the issue has not been resolved. This means you will need to ignore this error from popping up in your logging service since you already have a better handling for it which is a win.
## All together now
Putting everything together and cleaning it up will give us a nice error handling system for our async components.
It's all a mess however and we can clean it up by breaking up the logic into smaller functions and creating a `defineAsyncComponent` wrapper that incorporates these error handler strategies. This way we can use it across our app without repeating ourselves.
Here it is all in action, try changing the name of the async component to any of the following:
- `/BigAd.vue` if you want to test adblocker detection, make sure to add it to your blocklist.
- `/AnythingWeird.vue` if you want to test 404 and non-200 responses.
- If you want to test offline detection, disconnect or simulate an offline connection before clicking the button.
If you want to reset the example, reload this article.
{
const module = window.__modules__[key];
const blob = new Blob([module], { type: 'text/javascript' });
const url = URL.createObjectURL(blob).replace('blob:', '');
await (await fetch(url)).text();
return Promise.resolve(window.__modules__[key]);
};`}
client:visible
/>
The `utils.ts` is where the magic happens, but overall we now have a sound strategy for handling these kinds of errors. You can expand upon this by adding more checks or more error handling strategies.
## Other Ideas and Explorations
I have considered other ways to handle these errors, sadly you won't get much from the error object itself. Because whenever this error is thrown, you only get the obscure one that is a TypeError, however the fetch error that caused it won't be thrown and won't be connected to this event typically. This is the main problem we have here.
So one thing you can probably explore to connect the fetch error itself with the error event is to use a service worker to make that connection for you. But I found this a bit too advanced to cover here.
Another limitation is you cannot at the moment use both `errorComponent` and `onError` together, you can only use one of them. This is a bit limiting as I imagine where you want to perform some action and then render an error component. An example here is you have a tab/accordion system that lazy loads its contents, you want to run our error handling logic and render a component in-place of the tab/accordion that failed to load. One way to do this is to move some of the logic we have written here to the error component itself, but remember you don't have access to `retry` or `fail` or the `attempts` count.
One more thing before I finish. You may want to choose a better message copy to display for the user, I used plain ones here so you know what happened but your users may appreciate a different tone and wording.
## Conclusion
This might look a bit niche or an overkill, but this will make it easier not to waste time on debugging these kinds of issues where the user device is either blocking the file or their network is dodgy, and allows us to focus on the real issue where everything looks fine but the component isn't loading and even then, we have more info to work with.
I believe this is one of the least explored APIs in Vue.js and with this article, I hope you can make your apps more robust and user-friendly and evolve this API if need be.
---
--- title: Fix Your Annoying Popups with the CloseWatcher API description: Interesting new API to help us build better and more accessible UIs tags: ['frontend', 'web-api', 'platform'] ---
# Fix Your Annoying Popups with the CloseWatcher API
So my product designer came to me the other day and asked me to implement something to cancel an action whenever the user hits the Esc key. I was like "Sure, no problem" and I went on my way to implement it but then I noticed the way I was doing it was fundamentally flawed.
The [CloseWatcher](https://web.dev/blog/web-platform-12-2023) is a new API that has been recently introduced in Chrome 120. The main job of this API is for listening and responding to "close requests", but what are they and why is this even a problem? And why is it related to my short story earlier?
## What is a close request?
A close request is when the user using your web app wants to close something, just about anything. This can range from sidebars, menus, popovers, modals, an accordion, or whatever UI that has an open/close interaction.
The [proposal](https://github.com/WICG/close-watcher) defines it as:
> platform-mediated interaction that's intended to close an in-page component. This is distinct from page-mediated interactions, such as clicking on an "x" or "Done" button, or clicking on the backdrop outside of the modal.
Usually, those close requests are triggered by hitting the Esc key, specifically "pressing down" the key. But that's just for the desktop. On mobile and specifically on Android, the "back" key is also another source for triggering those close requests. On iOS there is no such way to do that without the [VoiceOver assistive "Z" gesture](https://support.apple.com/en-eg/guide/iphone/iph3e2e2281/ios#:~:text=To%20dismiss%20the%20Item%20Chooser,making%20a%20%E2%80%9Cz%E2%80%9D).
## Why is this a problem?
We've been closing modals and opening menus for more than a decade on the web now, so why is this suddenly a problem? One reason is we are in the golden area of web apps and the platform is trying to normalize, standardize, and offer as much help as possible to the developers to write simpler and better web apps through more standard APIs for complex web UI needs.
But also recently, the `popover` API was introduced, and not so recently the `dialog` element was also introduced, and both defined some interesting interaction and accessibility behaviors when it comes to closing stuff that has been well established in the web for a while now across multiple UI frameworks and libraries, but never standardized.
Some of those behaviors are:
- Clicking outside the popover closes it automatically.
- Pressing the Esc key closes the popover automatically.
- Pressing the Esc closes the last open popover, if there are multiple popovers open.
- Continually pressing the Esc will close the popovers one by one, starting from the last opened one until none are left.
We've been doing click-outside detection for a while now, also detecting a key press on the Esc key is not that hard, but the last two behaviors are a bit tricky to implement and it is not always clear how to do it.
Let's say you are building a floating component with any framework of your choice. That can be a menu or a modal or whatever, and so you want to support the last two behaviors I just listed, how would you do it?
Better yet, let's go through a few examples.
### Example: Sidebar
This is a simple sidebar that shows up when you press the button, the sidebar happens to have an input of type `search`.
- Vue.js, but the API is framework agnostic and can be used with any framework or even vanilla JS.
- `@vueuse/core` for the `onClickOutside` and `onKeyDown` functions.
- `tailwindcss` for styling.
If you open the sidebar, click outside, or press Esc it closes as expected.
However, there is a thing about `input[type="search"]` that you may not be aware of. When pressing the Esc key, the [input will clear itself](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/input/search#differences_between_search_and_text_types), so if you have some text in the input and you press Esc it **_should_** clear it.
But here, we close the sidebar altogether so that may be unexpected to the user, especially if they prefer to use the keyboard to navigate around the UI, this is the first problem here. Merely listing to the Esc key is not enough, we need to consider if other parts of the UI are also interested in the same key-down event.
You can try handling it yourself by checking the currently active element and if it happens to be an input text of type search, then you ignore the event until it is safe to close the sidebar.
```ts
onKeyDown('Escape', (e) => {
// Skip if the active element is a search input
if (
document.activeElement?.tagName === 'INPUT' &&
document.activeElement?.type === 'search'
) {
return;
}
isOpen.value = false;
});
```
This would work nicely, but I don't think you would be building a web app with just a sidebar and a search input. You would have modals, other inputs, pickers, menus, and more stuff that could be interested in that precious Esc keystroke, so are you going to conditionally filter out every other thing?
Say you are building another component, are you going to check if the sidebar is open before you decide to close that other component? Wait, that means all components that "open", need to be aware of each other somehow so they can manage their close behavior.
### Example: Context Menu
Here is another example with a different kind of issue. We have this context menu component that has a list of items. If the user wants to close the current open item then they need to hit the Esc key or click outside the menu.
That is all straightforward, but let me throw a wrench in there and add another requirement. The menu can be nested.
Now immediately you would ask a few questions about the behavior here, one of them is "Does the Esc key close the currently open menu item or all the menu items currently open?".
We can argue whether that is an expected behavior or not. But this is the same behavior you can observe with nested popovers or even multiple popovers at the same time and if the menu is to be fully keyboard-navigable then it is reasonable to implement that behavior.
How can we go about doing that? A shared state or a store could help those to become aware of one another's state or if it should be closing on that Esc keystroke or not.
Or if you like Vue.js's `provide/inject` then the components can inform one another of their hierarchy and their current "open" state, I use that latter approach in my work.
Going with the shared state idea, you could have a state that behaves like a stack, the last item is the last one to respond to the `keydown` event.
```js [closeStack.js]
export const closeStack = shallowRef([]);
```
```vue
```
This is a bit clever, but again. These menus will NOT be the only closable component to be be present in your app, you want to have a way to uniformly and reliably close any kind of closable UI element in order. This is how I implement the desired behavior today, but remembering to add the `closeStack` to every component that can be closed is a bit of a hassle could be a major UX problem.
This is where the `CloseWatcher` API comes in.
## The CloseWatcher API
So all this presentation on the issues and we have not yet talked about the API itself. I will try my best to summarize the API:
```js
let watcher = new CloseWatcher();
// Listen for close request
watcher.onclose = () => {
// Add your close logic
};
// Trigger the close event and handler
watcher.close();
// Dispose of the watcher instance
watcher.destroy();
```
There are some other things that the watcher can do but let's leave that till later when we are more familiar with the API.
The close watcher API takes into consideration many things, all of which are done for you. That includes native UI elements with pickers, special behaviors with full-screen, dialogs, popovers, and inputs and it even cleans up our code by centralizing the closure logic of the UI component into a single place.
This is a significant shift in mindset when handling these kinds of things, you no longer think about key presses, or back button presses. Those are now "close requests" and you are **_handling or issuing close requests_**.
One implication of this mindset is your UI component close logic is no longer scattered around, it should only exist in the `close` event handler on the watcher instance.
This also means we no longer need to listen for the Esc key or the back button press, we just need to listen for the `onclose` event and handle it accordingly.
Here is a quick rundown of a typical implementation of a component that opens/closes.
```js
let watcher;
function onOpen() {
// Change state or however your component is meant to be "open".
// ...
// Initialize a new instance every time we open the component
watcher = new CloseWatcher();
watcher.onclose = () => {
// Close the component or however your component is meant to be "closed".
// Dispose of the watcher instance
watcher.destroy();
};
}
function onCloseClick() {
// Close the component by triggering the handler.
watcher?.close();
}
```
This may look a bit confusing at first, but it will all make sense once you see how it can "fix" the examples earlier.
### Sidebar - Revisited
The changes are minor, we just need to create a new instance of the `CloseWatcher` and listen for the `onclose` event, here is a snippet of the logic.
```js
let watcher;
const isOpen = ref(false);
function onOpenClick() {
isOpen.value = true;
// Initialize a new instance every time we open the sidebar
watcher = new CloseWatcher();
// Closure logic is handled by the watcher
watcher.onclose = () => {
// Dispose of the instance
watcher.destroy();
// Close the sidebar
isOpen.value = false;
};
}
onClickOutside(sidebar, () => {
// Close that sidebar by the watcher
watcher?.close();
});
```
That looks simple enough, but one weird part that I didn't wrap my head around initially was the need to create a new watcher every time the component is "opened".
This is because the watcher is a one-time use instance, once it receives a `close` event it cannot be used again. So we need to create a new one every time we open the sidebar. This makes sense because of a couple of things:
- You cannot close a closed UI, the `onclose` shouldn't trigger by then.
- What if you have multiple things that can be opened and closed, you cannot share watchers here and you want to only close the last opened one.
- No need to tell the watcher itself that something has opened. Creating the instance means "I've opened something, be on the lookout when it should be closed."
Here is the full example with the `CloseWatcher` API.
Notice that now if you have a search input with a value, pressing the Esc key will clear it first without closing the sidebar. The next Esc keystroke will close the sidebar and we didn't have to code in any kind of exceptions or filtration of the events.
### Nested Menus - Revisited
Similarly with the nested menu, we do not need to do a lot of work to make it work as expected. We just need to create a new instance of the `CloseWatcher` every time we open a menu item and close it when we close it.
We don't need to maintain a state of stacks for nested items or any other UI component. It is one of those things that truly "just works".
The amazing thing here to me is this `ContextMenu` component is a recursive nested component and they don't care about which one is open and which one is whose child. They just open and close themselves in order all thanks to the `CloseWatcher` API which routes the close request to only one watcher instance at a time, the last one typically.
## Interrupting Close Requests
There is one more thing that the `CloseWatcher` API can do for us, and that is interrupting close requests. This is useful when you want to prevent the close request from happening, maybe you want to show a confirmation dialog or the user has forgotten to save their work in a closeable UI and you want to make sure they know what they are doing.
This is where the `cancel` and `requestClose` come in, you want to ask nicely if the current UI can close and if it cannot then you can cancel the close request.
```js
watcher.oncancel = (e) => {
e.preventDefault();
// decide to close or not
if (something) {
watcher.close();
}
};
// Ask nicely to trigger the "cancel" event
watcher.requestClose();
```
Here is a rather complicated example. This example has a toggle form that can be opened and closed, but if closed then the user will lose any changes done to their text input. So we want to ask them nicely if they are sure that they want to close the form or not.
Take your time to go through the code and understand the moving pieces and play around with the example, here are a few scenarios to try:
1. Open the form, type a value, and **_click_** "Save" The form closes and the value is saved.
2. Open the form, change the value and **_click_** "Cancel" and then **_click_** confirm: Form closes and the value is lost.
3. Open the form, change the value and **_click_** "Cancel" and then **_click_** cancel: Form stays open.
4. Open the form, change the value and **_press_** Esc: Confirmation dialog appears, **_press_** Esc again: Form stays open.
- We are exposing a Vue component through a composable so we get a nice async API for showing/closing the confirmation.
- We have two close watchers at play here, one for the form and one for the confirmation dialog.
- All the closure logic is handled by the watchers
- We are using `requestClose` to trigger the confirmation dialog to show up.
- We are using `close` to forcibly close the form if the user confirms or if they save their changes.
- We are listening for `oncancel` to prevent the form from closing until the user confirms their action.
## Behavior on mobile
The `CloseWatcher` API is not only for the desktop, it also works on mobile. On Android, the back button is the main way to trigger close requests, and on iOS it is the VoiceOver "Z" gesture.
Let's focus on the Android use case because it does have a few interesting behaviors that are worth mentioning.
Consider the last sidebar example, on the mobile, a sliding menu like that could potentially cover most of the screen. So to the user, it may as well be a new page. So clicking the back button is expected to close the menu or the sidebar.
An issue I faced in my full-time work while working on a hybrid web app version using Capacitor, is that the back button doubles as a navigation command to "go back". With modern frameworks that use the history API and client-side routing, you can easily confuse the browser into navigating to the previous page rather than just closing the current open UI. Unless you are using native popovers or dialog elements, the browser will navigate to the previous page which is frustrating.
I handled it by listening to the back button press and preventing the default behavior, then triggering a global event that would propagate to the router and all components that were meant to be closed. Once a component handles it, it is marked as "handled" and the back navigation is skipped.
So doesn't work well especially if there are a lot of UIs interested in that back button press, and it is not a very elegant solution. Consider the nested menu on mobile, it would typically cover most of the screen and with each nested item opening, it would slide over covering the previous one.
## Review
You can use the `CloseWatcher` API with any kind of closable UI, and not only popovers and menus. Here is an API summary:
```ts
// initialize a new instance, usually in the UI open handler
let watcher = new CloseWatcher();
// listen for close requests
// All the logic for closure and cleanup belongs here
watcher.onclose = () => {
// close the UI by toggling the state or whatever
// Dispose of the watcher instance.
watcher.destroy();
};
// Listen for cancel requests that precede close requests
// Use this if you want to interrupt the close request
watcher.oncancel = (e) => {
// You can prevent the close request by calling `preventDefault`
e.preventDefault();
// decide to close or not...
if (something) {
watcher.close();
}
};
// Trigger a close request manually
// This will go through the cancel handler first if there is one
// Then the close handler.
watcher.requestClose();
// Triggers the close handler, bypassing the cancel handler.
watcher.close();
```
This API is still experimental and is subject to change, it is only available in Chrome 120+. However, it is a good time to start thinking about how you can use it in your apps and how it can help you build better components.
The standardization of this behavior and being able to hook into it is a big deal, we've seen a lot of recently introduced APIs and new web platform features that can take advantage of this API. The proposal menu mentions a few of them:
- a `