Element.getAttributeNames()-- when used in conjunction withElement.getAttribute()-- is allegedly more performant thanElement.attributes.- MDN's docs/notes on live HTML collections might prove insightful here.
- Based on MDN's notes about
DocumentOrShadowRoot.activeElementandDocument.hasFocus(), it seems likedocument.activeElementis only guaranteed to be:focused if said element is in aDocumentsatisfyingDocument.hasFocus() === true. This is worth remembering. That said, in some cases (as in some of ourcomboboxlogic), it is sufficient to just checkdocument.activeElementto achieve the desired behavior. What you need to do depends on your use case.
- Screen Readers and
[role="option"][inert]: It seems that if the only child in alistboxis a[role="option"][inert]HTMLElement, then Screen Readers (or at least VoiceOver) will view thelistboxas empty. This is a big deal because it's important to have some kind of indicator that nooptions match the user's current filter. Showing a blank screen (i.e., showing no menu when nooptions match the filter) could work, but that may not be as clear. BetweenMantine,Shadcn UI,Reach UI,React Select, andVue Select, Mantine is the only Component Library that doesn't have a "No Options" display. If we can meet accessibility requirements and make things clearer for visual users, we should definitely do both. - You should prefer private
staticmethods for event handlers whenever possible.However, if you need access to(This comment is outdated. See thethis, then just use a private arrow function.EDITnote below.) Certainly, if you can find a way to avoid usingthisso that you can keep a method asstatic, then do so. However, you should do this reasonably. For example, it is unreasonable to expose a copy of a private field (get matchingOptions() { return this.#matchingOptions.slice() }) strictly for the sake of being able to use astaticmethod for an event handler. Not only does this waste memory at runtime, but it runs the risk of introducing confusion and unforeseen bugs.Keep things simple and just use an arrow function if you need to; it shouldn't harm your end users.(Technically still true. But most of the time, if not all of the time, you shouldn't need an arrow function.)- As a side note, for this very reason, you should avoid using
thisas much as possible (within reason). It's theoretically possible that in the future, a refactor would make itself known which would enable you convert your instance arrow function back to astaticmethod. Those opportunities will become more obvious if you, for example, reach for acomboboxviaevent.targetinstead of throughthis. - EDIT: We recently learned that private members are lexically scoped to the class body, not to
this. This means that a private field declared inMyElementcan be accessed by a static event handler method defined inMyElementas long as the handler has a reference to aMyElementinstance. This is game changing because it means we can actually use private fields instaticmethods, which will improve performance by killing the need forSymboloverhead and by allowing us to replace arrow functions /bind(this)(which allocate memory per class instance) withstaticmethods (which allocate memory per class).- NOTE: This is something amazing that we can leverage, but we must use it with caution. A
MyElementclass instance can access the private fields of a differentMyElementclass instance through the approach mentioned above. So you'll want your event handlers to be registered in such a way that you know you're accessing only the class instance that you want/expect.
- NOTE: This is something amazing that we can leverage, but we must use it with caution. A
- As a side note, for this very reason, you should avoid using
- Currently, our
comboboxcomponent displays a "No Options" message (which is aninert, fakeoption) if the user's filter doesn't match any of the component's options. (This is only infiltermode, obviously.) This message is modifiable, but some people may not want to display a message at all. In that case, they can simply use CSS to hide thelistboxif it doesn't contain anyoptions which aren't [data-filtered-out]; there are no code changes that need to be made on our side. But this is at least worth documenting when we write the overall documentation for this component.
- Apparently, the
focusevent triggers after themousedownevent but before themouseupevent, which triggers immediately before theclickevent. We were able to use this knowledge to differentiate between:focusing that was initiated by clicking vs. tabbing in our code. - If a
Rangepoints to content inside aTextNode (which is itselfCharacterData, which seems to have many helpful methods), then usingCharacterData.insertData()will not reset theRangeoffsets to the beginning of the Node (unlike changing theNode.nodeValuedirectly with something liketextNode.nodeValue = MY_STRING). - According to the Spec, an element's child nodes will be completely removed when the
Element.textContentsetter is called. If the setter is called with an empty string (or anything that is mapped to an empty string), then the element will be left empty. (This seems similar to howElement.innerHTMLbehaves when given an empty string.) If the setter is called with a regular string, then a newTextnode is created and inserted as the the element's only child.- We'll need to be mindful of this in the codebase. If we expect a component to always have a text node (as is the assumption for the
<combobox-field>), then we'll need to avoid usingElement.textContent = "". Instead, we'll need to leverageText.nodeValue = ""or another similarly-safe approach (likeCharacterData.data = ""). - This piece of information is also important when it comes to modifying
Ranges, as deleting/replacing the reference to an element's originalTextnode can lead to unexpected/broken behavior when trying to work withRanges that point to the element's contents.
- We'll need to be mindful of this in the codebase. If we expect a component to always have a text node (as is the assumption for the
- For some odd reason, in
ChromeandFirefox, the UserAgent (UA) would try to focus thelistboxin the<select-enhancer>wheneverTabwas pressed if thecomboboxwas expanded. This happened both whileTabbing forwards and backwards. The behavior is most easily replicated if you force thecomboboxto be expanded withsetAttributeand then try moving focus withTab. (Safari did not exhibit this awful and confusing behavior.) This caused unexpected bugs in the code, so now we have to place a[tabindex="-1"]attribute on thelistboxelement. disabledform controls that arecontenteditablebehave differently across different browsers. Consider<button disabled contenteditable="true">Content</button>in each of the 3 Major Browsers:- Chrome (137): Prevents focusing via Keyboard Navigation (
Tabbing), Clicking, andHTMLElement.focus(). This is the ideal behavior. - Safari (18.5): Prevents focusing via Keyboard Navigation (
Tabbing) andHTMLElement.focus(). However, if the element is clicked, it receives a cursor and can still be modified. - Firefox (140): Does not prevent ANY of the 3 aforementioned means of focusing an element. To Firefox, if the element is
contenteditable, it's focusable. - Due to these discrepancies, if you want a
[contenteditable]form control not to be interactive whendisabled, then you'll have to toggle[contenteditable]between"false"and"true"yourself.
- Chrome (137): Prevents focusing via Keyboard Navigation (
- Apparently, when a
:focusedelement is dynamically given the attribute[contenteditable="true"], a cursor is not put inside the element. (This is at least true for Google Chrome138on MacOS15.5.) Although the practicality of such a scenario is probably small, this is still something worth noting. This means that users have no way of knowing that an element with which they're interacting has become editable without leaving the element and re-focusing it. The way that our<combobox-field>gets around this issue is by highlighting all of its own text content if it becomes editable while being in focus. - Apparently, elements that are
[contenteditable]have all of their child DOM Nodes removed when 1) There is no more text within the element and 2)Backspace(notDelete) is pressed (Svelte REPL Example). What's even more interesting is that this behavior has some minor differences across browsers:- Chrome (138) + Firefox (140): If
Backspaceis pressed in a[contenteditable]element containing a single emptyTextNode, the Node will be preserved in the DOM; it will not be deleted. - Safari (18.5): If
Backspaceis pressed in a[contenteditable]element containing a single emptyTextNode, the Node will be removed from the DOM. - These manual checks were run on MacOS 15.5. Safari's treatment of
TextNodes might be a bug, but it also might be behavior that isn't strictly defined or enforced by the WHATWG/W3C. Thus, if you want to counteract this Safari behavior, your best bet is to use aMutationObserverthat reinserts the desired DOM Node(s). As an example, theComboboxFieldhas to do this because it assumes that it always has a consistent, singularTextNode for displaying important information to Users (e.g, the User's filter, thecombobox's current value, etc.). Having aMutationObserverlike this also protects against undesirable manipulation of the<combobox-field>'s child nodes.- TODO: Although a
MutationObserveris a suitable solution, you should consider opening a Safari bug in case this really is a bug on their part. (That said, it might be a good idea to maintain the currentMutationObserver/logic even if Safari changes the Browser Behavior. This is because doing so would protect against undesirable child node manipulation from outside sources, such as rogue developers or misused JS Frameworks.)
- TODO: Although a
- Chrome (138) + Firefox (140): If
- From our brief experience, it seems that the properties of
ElementInternals.validitycannot be spread into a new object, nor can they bestructuredCloned. Additionally,ElementInternals.setValidity()will always default anyValidityStateproperties that you don't pass to the function tofalse. This makes it incredibly difficult to transition between different error states, because you have to track previously-established error messages (when transitioning, for example, from 2 errors to 1), and you have to write unnecessarily verbose code to maintain the current error state when you only want to, say, update an error message. These matters get even worse when you want one kind of error message to unconditionally take precedence over another one (e.g.,customError>valueMissing). These issues should probably be reported to the WHATWG on GitHub. - Related to our previous note, it seems that some object properties are enumerable whereas others are not. Just like
ValidityState's object properties are non-enumerable, mostEventproperties are also non-enumerable (except forisTrusted). So copying details like those belonging to anInputEventwill be more verbose if you are trying to copy only part of the object. If you want to copy the entire object, thenElement.dispatchEvent(new event.constructor(event.type, event))will do just fine. - Apparently, in
Google Chrome 138onMacOS 15.5, Chrome will remove focus from an HTMLElement if it is moved somewhere else in the DOM. (Firefox 140and Safari18.5do not exhibit this behavior.) This is important to remember as we write our tests. Since this is specifically related to browsers and how they choose to triggerFocusEvents, this is something that developers will have to take responsibility for; this is not caused by a flaw in our component. - The order in which Web Components are registered matters. Particularly, the order matters when an HTML Page is loaded with the Web Components in the markup. In this scenario, the
connectedCallback()s of these Web Components will run in the order in which the Custom Elements were defined. However, if the Web Components were not connected to the DOM on page load and were instead added after the browser's initial HTML Parsing, then theconnectedCallback()s will be run in the natural order which one would expect (i.e., Parent, then all Children, in order). - Oddly enough, there are still more browser discrepancies. In
Google Chrome 140onMacOS 15.6.1andSafari 18.6onMacOS 15.6.1, noclickevent will be dispatched if the following actions occur in succession: 1) pressing the mouse down in a text-editable element (such as an<input>or[contenteditable="true"]element), 2) moving the mouse outside the text-editable element, 3) releasing the mouse. Indeed, this is the behavior that I think people would typically expect. However,Firefox 142onMacOS 15.6.1does not exhibit this behavior!!! Instead, theclickevent is dispatched on the element on which the mouse was originally pressed down. In fact, in Firefox, themouseleaveevent doesn't fire until after theclickevent has fired. This might be an insanely annoying and unintuitive Browser implementation, or it's a Browser bug. - Apparently, elements created inside
<template>s are created in an entirely different "Document context" than the Window's. Because of this, any Custom Elements registered withwindow.customElements.define()won't be recognized/acknowledged by elements created within the<template>(e.g., withtemplate.innerHTML). This can be resolved by cloning thetemplate's nodes into the Window's Document (e.g., withdocument.importNode(template.content, true)), then attaching the cloned nodes to the Window's DOM. We had to learn this the hard way in one of our Playwright tests. Below are some helpful resources on this topic:- https://stackoverflow.com/questions/55791168/initialisation-of-custom-elements-inside-document-fragment/67252742#67252742
- https://dev.to/mayankav/template-vs-div-3b32 (Especially helps with understanding why
Vueuses<template>) - Also read up on
Document.adoptNode()andDocument.importNode(), which are distinct fromNode.cloneNode().
- Apparently, in some Browsers, the
input.checkedproperty will still be updated if theclickevent is canceled; however, the value will be reverted after the event has finished processing. This means that if you're trying to manually apply the effects of theclickevent forcheckboxes, you'll have to be a little more clever/mindful with your approach. See: https://stackoverflow.com/a/32710212/17777687. - Typically, a
pointerdown(ormousedown) event is eventually followed by apointerup(ormouseup) event when the user "releases" their pointer (e.g., a mouse). We rely on this behavior because at times we dynamically attach a{ once: true }pointerupevent listener within apointerdownevent listener. However, we learned from Google Gemini that there are edge cases where thepointerupevent won't fire at all. These scenarios are important to be aware of to avoid memory leaks. The 5 scenarios are below:- Browser Drag-and-Drop Activation (Easily Solved)
- A
pointercancelevent will be dispatched instead. (There is no equivalentmouse*event.) However, this shouldn't be a practical problem if the element is not[draggable="true"]
- A
- Native Alerts/Modals (Easily Solved)
- Calling a browser-native modal with something like
alert()orprompt()will prevent thepointerupevent from ever firing, and no accompanyingpointercancelevent will be fired either. That said, the simple way to avoid this problem is to avoid using such JS functions to begin with. Native<dialog>s are typically better than the browser-native ones anyway.
- Calling a browser-native modal with something like
- Context Menu Triggers (Easily Solved)
- Right-clicking an element on the page will open a
contextmenu. When this happens, you'll be able to see thepointerdownevent, but thepointerupevent won't be fired because attention is moved to the OS-nativecontextmenu. To circumvent this, restrict your event handler to listen only forpointerdownevents with the specificMouseEvent.buttonof interest.
- Right-clicking an element on the page will open a
- Loss of Window Focus
- If the user navigates away from the current window while holding their pointer down, then
pointerdownwill fire but notpointerup. You can avoid this issue by watching for when theWindowisblurrred. But setting up such an event listener is typically overkill — as long as you have a valid way to avoid memory leaks (if you're dynamically addingpointeruplisteners inpointerdownlisteners).
- If the user navigates away from the current window while holding their pointer down, then
- Event Propagation Interruption
- Obviously, if event propagation is stopped aggressively, you might not be able to listen for
pointerupat a higher level in the DOM. So don't stop the event's propagation. And encourage users of any libraries you produce to avoid such behaviors as well.
- Obviously, if event propagation is stopped aggressively, you might not be able to listen for
- The above scenarios were the main concerns. There are other scenarios that are not really worth flagging because they aren't very realistic. (For example, an element obviously can't receive a
pointerupevent if it's removed from the DOM. But such a UX would never even make practical sense to begin with.) But the above are certainly worth being aware of.
- Browser Drag-and-Drop Activation (Easily Solved)
- TypeScript seems to have some very firm rules about declaring dynamic property names in
classes. Basically, if you're going to go that route, then you'll need a static string or a uniqueSymbol(which can be "dynamic" — i.e., calculated at runtime). The latter is especially helpful for avoiding TypeScript errors with Index Signatures. (For example, you don't want yourclassto becomeRecord<string, number>.Symbols seem pretty much guaranteed to help you avoid this, though constant, static strings also seem to be usable in later versions of TypeScript as well.)- We explored dynamic property names in the
ComboboxFieldto see if we could have "private" instance data that could still be accessed fromstaticevent handler methods. Due to the index signature issue mentioned previously,Symbols seemed to be the best approach here. Unfortunately, those who read our source code could potentially still leverage theSymbolregistry to access data that they should not touch. To get around this, a secure, random runtime string can be generated and passed toSymbolwhen the library is first loaded. People could technically still get around this by brute forcing theSymbolregistry. But for anyone building a true, meaningful application, this would not be worth their time. (And it would certainly hurt performance for Users.) So this approach virtually holds the same security as private fields.- There are caveats with this
Symbolapproach, however. The largest concern is that — at least as of today (2025-08-30) — JSDocs don't work as cleanly on these kinds of dynamically-declared properties. Additionally, there is a (minor) cost for generating random strings (say, UUIDs) at runtime; and it's likely that more memory would have to be allocated than what is needed for regular, private fields. (That said, if you get to preserve your use ofstaticmethods with this approach, then the memory allocation might be superior in the end.) As a side note, remember that even if the two random strings you generate are exactly the same, you won't run into any issues there sinceSymbol()always produces a unique value. So name clashing within yourclasswon't be a practical concern. - EDIT: We recently learned that private members are lexically scoped to the class body, not to
this. See our notes inComboboxField+listbox. We're only keeping the originalSymbolnote here because it's useful to know.
- There are caveats with this
- We explored dynamic property names in the
- For some reason, importing anything from
preact/compatcan confuse TypeScript. For example, after importing frompreact/compat, attempting to read a global React type likeReact.MouseEventwill actually make TS try to read from a Preact Event Type instead. Similarly, JSX will suddenly start being read as Preact JSX in React files unless/** @jsxImportSource react */is specifically applied to a file. Thepreact/compatmodule must be doing some aggressive type augmentation to cause this to happen... I guess that's helpful if you're trying to migrate from React to Preact, but it's very annoying otherwise. - According to TypeScript expert Matt Pocock (who also appeals to the TypeScript Team's recommendations), TypeScript developers are discouraged from using
.d.tsfiles to define local TS types modularly. It seems that.d.tsfiles are for library code (e.g., type definitions for JavaScript), and for defining types globally within your locally developed application/project; but they are not intended for defining an application's local type modules. For that, regular.tsfiles are recommended.- As a side note, it seems
skipLibCheck: truewill turn off type checking for all.d.tsfiles. This doesn't just mean library.d.tsfiles, but local app.d.tsfiles as well. Typically, you do not want this for local type modules.
- As a side note, it seems
Claude Code Discovery about TypeScript and unique Symbols (2026-08-25)
WARNING: ALL OF THE CONTENT WITHIN THIS DISCLOSURE WIDGET IS ENTIRELY AI-GENERATED BY CLAUDE CODE (OPUS 4.8 High), AND IT MAY NOT BE UP-TO-DATE OR ACCURATE. We went through a rather-unenjoyable debugging session, trying to figure out why this[uniqueSymbol] was producing errors in TypeScript at compile time. The short of it seems to be that accessing this[uniqueSymbol] in some cases can be dangerous (for TS, not for JS), and that it's better to perform type casting first so that TypeScript tries to read uniqueSymbol from a known type rather than from This. For example, (this as MenuElement)[uniqueSymbol] works, whereas this[uniqueSymbol] may not, even if this is used within the MenuElement class.
It's not clear why this behavior exists. It's also not clear why re-ordering certain statements works (see below). Claude's theory about how TypeScript gathers type information about this might be legitimate, but it also might be pure conjecture. We're only capturing its statements below so that you know what it was "thinking". Even then, we came up with a solution (i.e., direct type casting) that was different from what Claude suggested in the original chat. So... take things with a grain of salt.
FINALLY: Note that this is a Claude Code chat related to a past iteration of the MenuElement. We learned a way to get private field access working within static event handlers and static MutationObserver callbacks defined in the same class body (as discussed earlier in the ComboboxField + listbox section). So we no longer have a need for the less-secure, slightly-less-performant Symbol approach.
unique symbolcomputed class fields can get silently dropped from aclass's type (TS5.9.3,2026-08-25). While buildingMenuElement, we hit 14TS7053errors ("expression of type'unique symbol'can't be used to index type'MenuElement'") on every access of ourSymbol-keyed fields — even thoughComboboxFielduses the exact same pattern without complaint.Symbol-keyed fields are "late-bound" members: TypeScript only attaches them to the class type via a lazy pass that runs when the class's member table is first fully resolved. The bug: inside a single method, a method call on the barethiskeyword that is checked before athis[someSymbol]access forces that member table to be resolved and cached without the late-boundSymbolmembers. That poisoned table is then shared class-wide, so allSymbolaccesses break — including ones on other (non-this) variables in completely different methods. Minimal repro (noextends, no decorators needed):Reorder soconst s = Symbol("s"); class Foo { /** @type {string} */ [s] = ""; method() { this.bar(); this[s] = ""; } // `this.bar()` BEFORE `this[s]` => TS7053 on `this[s]` bar() {} }
this[s]comes first, split the two across separate methods, or drop thethis.call — and the error vanishes. This is whyComboboxFieldwas fine: it never indexes aSymboloffthis; it always goes through a concretely-typed local (const combobox = /** @type {ComboboxField} */ (event.currentTarget); combobox[editingKey] = ...), which resolves the class type through the normal (late-bound-intact) path and never triggers the reentrantthis-keyword resolution. Its ~90this.method()calls are harmless because none is ever followed by athis[symbol]in the same method.- The fix (what
MenuElementnow does): the onlythis[symbol]accesses were the two lines in#unregisterMenuButton; castingthisto the class type there fixes all 14 errors. Note the cast is required — a plainconst self = this; self[symbol]does not work (it keeps the polymorphicthistype and the same poisoning path); you needconst self = /** @type {MenuElement} */ (this), or cast inline. General rule of thumb, consistent with our existing advice to avoidthis: reach forSymbol-keyed state through a concretely-typed variable, notthis. - This looks like a genuine TypeScript compiler bug (late-binding order-sensitivity), but no GitHub Issue has been filed yet. Full investigation/context lives in the Claude Code session
menu-element-ts-symbols-1(id17ac0c18-db31-45ff-a556-91e8c55b5e12).
- The fix (what
- Useful links/docs:
- Firefox is the only Browser that supports multi-selection. We could skip supporting it, but we'll choose to support it to avoid breaking user expectations. Note that when Firefox encounters a multi-select for an
<input>element, it will place the selection at the very end of the last selection range when text is inserted into the form control. (You should TEST that you're satisfying this behavior.) - Unlike
ChromeandFirefox, inSafariyou cannot useSelection.addRange()to set theDocument's current selection to something WITHIN aShadow DOM. However, you CAN useSelection.setBaseAndExtent()for this. Note that Chrome/Firefox supportSelection.addRange()if theRangehas "legal" access to content in aShadow DOM. However, since we're trying to create something with cross-browser support, this is irrelevant. Some helpful resources:- Some hiccups people have run into when trying to get Selections in the Shadow DOM: https://stackoverflow.com/questions/62054839/shadowroot-getselection/70523247#70523247
- What seems to be some discussion on the new expectations/hopes for how
Selectionwill behave in the Shadow DOM: https://github.com/mfreed7/shadow-dom-selection?tab=readme-ov-file#changes-to-existing-selection-apis. (This may not technically be the official spec, but it seems to be the direction in which things are moving so far.)
- Apparently, all (non-static)
Ranges previously associated with aTextNode get emptied once the referenced node'snodeValueis re-set withset Node.nodeValue(). (TheRanges probably don't know what to point to anymore since the underlying value of theNodehas technically changed.) This makes it imperative that you know when to leverage the aStaticRangeand when to work directly with a regular, dynamicRange(or a value that it cached before being modified).- Alternatively, you can just leverage the
CharacterDatamethods, likeinsertData()anddeleteData(). These methods will preserve theRangeinformation of theTextnode that you're modifying (as much as possible) -- even if some of theRange's contents are deleted bydeleteData(). Why worry about whatset Node.nodeValue()will do and how you should cache yourRangedata when you can just use theCharacterDatamethods to help you avoid worrying about such things?
- Alternatively, you can just leverage the
- By default, browsers will collapse multiple occurrences of whitespace between character strings. So if you have a
<div contenteditable="true">and you executediv.textContent = "Hello" + " ".repeat(5) + "World", then the browser will display what looks like"Hello World"even thoughget div.textContent()will return a string with the appropriate number of spaces. To force the browser to display all whitespace instead of collapsing it, you need to use thewhite-spaceCSS property. (You can also trywhite-space-collapse, but broad and stable support for thepreserve-spacesvalue is missing.)- If you're wondering how users can insert multiple spaces into
[contenteditable]elements, it's because the browser inserts HTML into the[contenteditable]element instead of pure white space. In other words, the browser will insert something likeHello Worldinstead of using empty strings only. However, this behavior is not always desirable. We concluded that this behavior wasn't viable for ourComboboxFieldsince we're accepting and manipulating Plaintext only instead of HTML. (That is, we use"text/plain"inDataTransfer.getData()instead of"text/html".) So we intentionally used pure white spaces when re-implementing theinputevent. (Indeed, I think we were forced to.) Consequently, we recommend users update thewhite-spaceCSS property of the<combobox-field>as needed.
- If you're wondering how users can insert multiple spaces into
- The
[contenteditable="plaintextonly"]attribute setting seems to behave inconsistently between Browsers. For this reason, it's best to just stick to[contenteditable="true"]for now, even though it doesn't represent what we're truly looking for. Here are some things we found:- Safari + Firefox: When
[contenteditable="plaintextonly"], thebeforeinputevent will still showtext/htmldata in theInputEvent.dataTransfer.typeseven though such information is arguably irrelevant, andInputEvent.getTargetRanges()will return the correct information. Additionally,InputEvent.datawill benull. - Chrome: When
[contenteditable="plaintextonly"], thetext/htmldata will not be exposed by theInputEvent. In fact,InputEvent.dataTransferwill benull! And the developer will have to checkInputEvent.datainstead. This makes some sense. However, Chrome also excludes theInputEvent.getTargetRanges()data in this scenario, making it difficult (if not impossible) to reasonably re-implement theinputevent's behavior fully. - Due to these inconsistencies (especially when it comes to
dataTransfervsdata), it's best to just stick to[contenteditable="true"]since doing so will result in consistent behavior across browsers, in addition to guaranteeing that we'll always have access to the correct Target Ranges. - It's currently unclear whether we should file a Chromium Bug for this right now since it isn't clear which browser has the "correct" behavior. Both implementations have some downsides to them.
- Safari + Firefox: When
- It seems that when you're inserting text into an empty element, the
StaticRangereturned frombeforeinputpoints to the element itself instead of the emptyTextnode that the element holds (assuming element contains aTextnode). When you're leveraging theStaticRanges returned fromInputEvent.getTargetRanges(), you need to take this into account. If you can force circumstances that allow you to make valid assumptions, then you can work conveniently with those assumptions.- For example, if you can guarantee that the target element will always have a single
Textnode and nothing else, then you can just opearte directly on thatTextnode instead of referencingStaticRange.startContainer. - Otherwise, you'll need to bring about whatever circumstances that you require. For example, if you need the target element to have a
Textnode, then you can check the element returned fromStaticRange.startContainerfor a singleTextnode child. If one exists, you can operate on it. If one doesn't exist, you can insert a newTextnode into the element and then operate on that text node.
- For example, if you can guarantee that the target element will always have a single
- It seems to be safe to remove
\rs and\ns from anInputEvent's data before interacting with theStaticRanges fromgetTargetRanges(). This is because newlines are represented by newly-createdHTMLElements, which themselves becomecontenteditable(because of the owning/wrappingcontenteditableelement) and have the ability to receivebeforeinputevents which bubble up to the owning element that listens for allbeforeinputevents. This paradigm makes it unnecessary for theStaticRanges to take newlines into account in theiroffsets, so characters like\rand\ndon't influence theoffsets at all. Therefore, it is perfectly safe (from what we can tell from our brief testing in Firefox) to delete all\rs and\ns from a string before interacting with theStaticRanges.
- Playwright's approach to its
ControlOrMetakey (search forControlOrMetaor forresolveSmartModifierString) involves leveragingprocess.platform. - Apparently, MacOS doesn't allow tabbing to controls like
<button>s by default. Because of this, if you run Playwright against a Safari (WebKit) browser on MacOS, all tests expecting<button>s (or other similar controls) to be focused viaTabnavigation will fail. (Note: This is not an issue for all controls. For instance,textboxes work just fine.) This is why your Safari tests may work on a Linux Machine in CI, but not locally on your MacOS machine. The solution to this problem is to enable tabbing into controls on your local MacOS.- Enabling this in Safari > Settings > Advanced > "Press Tab to highlight each item on a webpage" will not work. It will work for your local Safari version, but not for your Playwright Tests.
- Instead, visit System Settings > Keyboard > Keyboard Navigation. The subtext for this option says something like:
Use keyboard navigation to move focus between controls. Press the Tab key to move focus forward and Shift Tab to move focus backward.
- Toggle "Keyboard Navigation" on by clicking its toggle switch. Now your Playwright test(s) should be passing.
- See Stack Overflow for additional details.
- In
@playwright/test@1.54.1, theLocator.fill()method does not work in Firefox when used on a[contenteditable]element whose text content and cursor location are modified by abeforeinputevent. (See microsoft/playwright#36715 and Playwright Issue Firefox Fill.) In such a scenario, Playwright will duplicate the provided text when running against Firefox. This issue does not happen during manual testing, nor does it happen if Playwright is run against Chrome or Safari. (We discovered this bug on a MacOS machine. Not sure how other machines behave.) Moreover, this issue does not occur when usingLocator.press()orPage.keyboard.press(). It's not clear why theLocator.fill()bug exists; but for now, if it becomes a problem, you can circumvent the issue by sticking to Keyboard Pressing instead of Locator Filling. Doing so is just as reliable (if not more reliable), but it's slightly less convenient. - Apparently, valid CSS identifiers cannot start with an unescaped number. When working with unique IDs in JavaScript, this is basically never a concern because
document.getElementById(), which accepts IDs starting with unescaped numbers (since they constitute valid IDs), is always preferred overdocument.querySelector()for performance reasons. However, this concern does matter in Playwright, since it tends to usedocument.querySelector*()for itspage.locator()method. Note that althoughdocument.querySelector("#123")is invalid,document.querySelector("[id='123']")is perfectly acceptable (notice the intentional use of quotation marks), so the latter is ALWAYS to be preferred over the former in Playwright Tests. - There are some tests which, for some reason or another, are flaky when run in a specific Playwright Test Browser, on a specific Operating System... especially in headless mode. This is annoying because it makes it difficult to discern which test failures are due to legitimate flaws in our code, and which ones are due to the idiosyncratic bugs of Operating Systems/Browsers/Playwright Test Browsers/CI. So here's what we've decided to do:
- If a test keeps failing in CI, try it locally.
- If the test works locally both in Headless Mode and in UI Mode, run the Playwright Tests on the Operating System that CI is using.
- If the test is consistently failing, see if you can debug the reason for the failing test. (This is more important for consistently failing tests than it is for flaky tests. Even so, this is still a good procedure to follow for flaky tests.)
- If you determine that the failure is due to a real bug in our code, fix it.
- If you determine that the failure is due to a need to use Playwright differently, do so.
- If you determine that the failure is due to idiosyncratic OS/Playwright Browser bugs, and the REAL browsers exhibit the expected/desired behavior just fine, then document this finding and skip the incorrectly-failing portions of the test.
- Avoid skipping an entire test altogether when possible, as skipping an entire test undesirably decreases our coverage.
- Ideally, you should open a GitHub Issue (with a minimal reproduction) if you discover legitimately buggy behavior in Playwright.
- Or you can wait and hope that a future version of Playwright randomly fixes the problem. (lol)
- If you determine that a test flakes due to idiosyncratic OS/Playwright Browser bugs, then annotate the Playwright Test as flaky using the format
FLAKY/Operating_System/Browser/Mode (Headless or UI). Example:FLAKY/Linux/Firefox/Headless. This at least lets us know what to expect in CI, and it will help us know when we need to re-run tests rather than attempt to fix something.- Ideally, we don't alter the test code in a flaky test, because the test still gives us some kind of signal as to whether or not our code is working as expected. If our production code ever breaks functionality, then even these flaky tests might legitimately catch that.
- If more than one item in a category causes flakiness, you can list each offender in a
|-separated list. Example:FLAKY/Linux/Firefox/Headless|UI. If all items in a category cause flakiness, you can useALLinstead of listing out each offender individually. Example:FLAKY/Linux/Firefox/ALL. Of course, this is mainly relevant for theModeindicator; if your tests are legitimately failing in all browsers, then your code (or your test approach) is likely the problem.
- React:
- Svelte:
- Solid
- Vue:
- Preact:
Although the onToggle prop is generally available on all of React's HTMLElement tags, some versions of React will ignore the toggle event if it is dispatched "unnaturally". For example, you can listen for natural toggle events on the <details> element or a [popover] element just fine. But if you call div.dispatchEvent(new ToggleEvent("toggle", { ... })), an onToggle function prop on said <div> will not be called.
This is annoying because it means Custom Elements which dispatch their own toggle events will not work if onToggle is applied to them. Instead ontoggle must be applied to Custom Elements in React, which is an odd and potentially-confusing Developer Experience. ("Why do I see both onToggle and ontoggle in the TypeScript suggestions?") Literally every other noteworthy framework (including Preact) will acknowledge toggle events as normal. React deviating from this is peculiar, and I don't think this deviation always existed.
In any case, this is something to be aware of. For now, remember to add explicit ontoggle definitions to whichever Custom Elements dispatch their own toggle event. If you ever want to prove that a JS Framework does (or doesn't) support the toggle event in the "natural framework way", you can run a quick demo. Example:
// React
function App() {
return (
<div>
<toggleable-element onToggle={console.log}></toggleable-element>
<details onToggle={console.log}>
<summary>Natively Toggleable</summary>
This element will ALWAYS dispatch a <code>toggle</code> event when its "button" is clicked.
</details>
</div>
);
}
export default App;// Pure JS/TS
class ToggleableElement extends HTMLElement {
#style = document.createElement("style");
#button = document.createElement("button");
#container = document.createElement("div");
#shadowRoot = this.attachShadow({ mode: "closed" });
constructor() {
super();
this.#container.id = crypto.randomUUID();
this.#container.textContent = "Wonderful!";
this.#button.type = "button";
this.#button.textContent = "Toggle Me!";
this.#button.ariaExpanded = String(false);
this.#button.setAttribute("aria-controls", this.#container.id);
this.#style.innerHTML = '[aria-expanded="false"] + * { display: none; }';
this.#shadowRoot.append(this.#style, this.#button, this.#container);
}
connectedCallback() {
this.#button.addEventListener("click", ToggleableElement.#handleClick, { passive: true });
}
disconnectedCallback() {
this.#button.removeEventListener("click", ToggleableElement.#handleClick);
}
static #handleClick(event: PointerEvent) {
// Toggle Button State
const button = event.currentTarget as HTMLButtonElement;
const expanded = button.ariaExpanded === String(true);
button.ariaExpanded = String(!expanded);
// Dispatch Toggle `event`
const root = button.getRootNode() as ShadowRoot;
const host = root.host as ToggleableElement;
const oldState = expanded ? "open" : "closed";
const newState = expanded ? "closed" : "open";
host.dispatchEvent(new ToggleEvent("toggle", { oldState, newState }));
}
}
if (!customElements.get("toggleable-element")) customElements.define("toggleable-element", ToggleableElement);- The ordering of your package
exportsrules matters. Always be sure to place more specific export paths before less specific ones. For example, place"./*.js": "./*.js"and especially".": "./index.js"before"./*": "./*.js". - Building TS Projects Using Project References: Typically, it should be sufficient for you to run
tsc --build(ortsc -b) to build your "root"tsconfig.jsonfile along with all of its dependencies (listed underreferences). However, if you're type-checking even a single*.vuefile, you have to usevue-tsc, which seems to require developers to pass all relevanttsconfigs to it during a TS Project References Build. For example, you'll have to runvue-tsc --build . srcinstead ofvue-tsc --build. Somehow, Svelte files aren't picked up bytsc(unlike Vue files), and Svelte can lint its files separately throughsvelte-check. This is something Vue should look into. - The downside of using Project References is that there is no "linting only mode". That is, you must run
tsc --buildin order for all the types to be checked (and seen) correctly. And that means some files (specifically those which belong tocompositeTSConfigs) will emit.d.tsfiles even if you don't want them to. However, you can always clean these emitted files up easily withtsc --build --cleanor with your own node script. Why bother with the headache? Here are just a few reasons:- If you can structure your code rightly (without sacrificing sanity) with Node Workspaces and Project References, you can easily build your various projects without having to write or maintain your own build scripts.
- Node Workspaces and Project References, when used together, help you learn how well your package
exportswork, how reliable your type definitions are, and how your global type augmentations behave BEFORE you publish your NPM Package. - With Project References, you can pass an empty array (
[]) tocompilerOptions.typesfor a subset of your projects. In our case, it means that we can prevent thesrc/**/*.jsfiles from accidentally picking up Node'ssetTimeout()function when we just want to use the browser'ssetTimoutfunction. At the same time, we can still allow global Node.js types to exist in the rest of the project. Very helpful.
- Getting
@typescript-eslintto work with project references requires theprojectServiceboolean option to be set. We think we've set it up correctly in this repo. We can fix it later if not, but at least lint seems to be behaving correctly over all. - Somewhere along the way, using NPM Workspaces and TS Project References seemed to break
npm's ability to "see" the*.d.tsfiles during annpm publish. (Or perhaps something else broke this, such as the way we've written our scripts. However, after doing a little bit of testing, that seems much less likely.) It seems other people have run into problems like this before, and the simple solution is to use thefilesconfiguration option in the relevantpackage.jsonfile instead of using a.npmignorefile.