· 6 min read ·

Chrome 146 Ships the Platform-Level Answers to Problems Libraries Have Owned for Years

Source: chrome-devblog

Chrome 146 landed on March 10, 2026 with a few features that are easy to underestimate if you read the changelog too quickly. Scroll-driven animations get a mention, but those have been stable since Chrome 115. The two things worth paying attention to are the revised Sanitizer API and Scoped Custom Element Registries reaching stable. Both represent the browser absorbing functionality that has lived in userland libraries for years.

The Sanitizer API: DOMPurify’s Long Shadow

If you have written code that accepts any user-generated or third-party HTML, you know DOMPurify. It is the library that most teams reach for when they need to strip XSS vectors out of untrusted markup before injecting it into the DOM. It works by parsing the HTML string in a detached document, walking the resulting tree, applying an element and attribute allowlist, then serializing the cleaned tree back to a string for use with innerHTML. That process is reliable but it has a fundamental structural tension: it operates on strings, which means the browser parses the HTML twice, once in DOMPurify’s detached context and once when you assign the cleaned string to innerHTML. This double-parse is the opening that mutation XSS (mXSS) attacks exploit.

mXSS is subtle. Certain markup strings parse differently depending on where they appear in the document tree. A sanitizer working on a string in one context may produce output that, when re-parsed in a different context by the browser’s actual parser, yields different DOM than expected. Browsers have patched many of these edge cases over time, and DOMPurify includes multiple serialization passes specifically to guard against them. But the root cause is that any sanitizer operating on strings is playing catch-up against the parser.

The Sanitizer API sidesteps this by operating directly at the DOM level. You call element.setHTML(untrustedString, { sanitizer }) and the browser parses the string once, in the correct context, applying the sanitizer’s configuration before the resulting nodes ever touch the live document. There is no serialization step and no second parse. The cleaned fragment goes directly into the element.

const sanitizer = new Sanitizer({
  allowElements: ['p', 'b', 'em', 'ul', 'li', 'a'],
  allowAttributes: { a: ['href', 'rel'], '*': ['class'] },
  dropElements: ['script', 'style'],
});

const el = document.getElementById('output');
el.setHTML(untrustedHTML, { sanitizer });

The default Sanitizer() with no configuration is guaranteed safe: it applies a built-in allowlist that blocks all script execution vectors. You can restrict it further but you cannot configure it to allow <script> or on* event handlers in the safe configuration. This is an important design choice. The API makes the safe path the default and the obvious path, which DOMPurify also does, but here the constraint is enforced at the platform level rather than in library code.

For teams already using Trusted Types, the integration is clean:

const policy = trustedTypes.createPolicy('html-policy', {
  createHTML: (input) => {
    const sanitizer = new Sanitizer();
    const div = document.createElement('div');
    div.setHTML(input, { sanitizer });
    return div.innerHTML;
  },
});

element.innerHTML = policy.createHTML(untrustedInput);

The practical comparison with DOMPurify is straightforward. DOMPurify ships around 52KB minified and requires updates whenever a browser parser changes in a way that introduces new mXSS vectors. The Sanitizer API ships as part of the browser and stays synchronized with the parser automatically. For projects that can drop IE and Safari support, it removes a dependency that requires ongoing security maintenance.

The Safari gap is real. As of Chrome 146, Safari has no support for the Sanitizer API, which means any application targeting a broad audience still needs DOMPurify as a fallback. A reasonable approach in 2026 is to feature-detect and use the native API where available:

const sanitizeHTML = typeof Sanitizer !== 'undefined'
  ? (html, el) => el.setHTML(html)
  : (html, el) => { el.innerHTML = DOMPurify.sanitize(html); };

This gives you the performance and security benefits in Chrome while maintaining coverage elsewhere. The API also went through a significant shape change during its origin trial period: earlier versions made sanitizeFor() the primary method, and the Chrome 146 stable release reflects a revised design where setHTML() is the main integration point and sanitize() takes a DocumentFragment rather than a string. Teams that experimented during the origin trial should audit their usage against the current spec before removing their DOMPurify fallback.

Scoped Custom Element Registries: The Collision Problem Finally Solved

The global customElements registry is first-write-wins. If two libraries both define a <ui-button> custom element, whichever one loads first owns that name and the second silently fails to register. This has made it genuinely difficult to compose web component libraries, run multiple versions of a component system on the same page, or isolate untrusted third-party components from the host document.

Scoped Custom Element Registries add a CustomElementRegistry constructor. You create an isolated registry, define elements in it, and attach it to a shadow root at creation time:

const registry = new CustomElementRegistry();
registry.define('my-button', ButtonComponent);
registry.define('my-input', InputComponent);

class MyWidget extends HTMLElement {
  constructor() {
    super();
    this.attachShadow({ mode: 'open', registry });
  }

  connectedCallback() {
    this.shadowRoot.innerHTML = `
      <my-button>Submit</my-button>
      <my-input type="text"></my-input>
    `;
  }
}

customElements.define('my-widget', MyWidget);

The HTML parser, when processing markup inside a shadow root with a scoped registry, looks up element names in the scoped registry first and falls back to the global registry. The same class can be defined under different names in different scopes with no collision:

const registryA = new CustomElementRegistry();
const registryB = new CustomElementRegistry();

registryA.define('ui-button', ButtonV1);
registryB.define('ui-button', ButtonV2); // no error, separate scopes

The polyfill for this (@webcomponents/scoped-custom-element-registry) has existed for several years and works by wrapping the global registry with name-mangling, appending a scope identifier to element tag names under the hood. The native implementation is cleaner because it hooks directly into the parser. No monkey-patching, no synthetic tag names leaking into DevTools.

This matters most for design systems and component libraries that want to ship encapsulated components without owning the global namespace. A team embedding a third-party widget can now be confident that the widget’s internal elements stay inside its shadow root scope. A micro-frontend architecture where independent teams own separate subtrees of the page can now co-exist without negotiating a shared global registry.

The CustomElementRegistry instance exposes the same methods as window.customElements: define(), get(), getName(), upgrade(), and whenDefined(). The API surface is intentionally familiar. The only new surface is the registry option on attachShadow().

Scroll-Driven Animations: Still Worth Knowing

The announcement lists scroll-triggered animations prominently, which is accurate in the sense that Chrome 146 continues refining them, but the stable release of scroll-driven animations was Chrome 115 back in July 2023. Firefox shipped them by default in Firefox 132. The core API is animation-timeline with scroll() and view() functions in CSS:

/* Progress bar tied to document scroll */
#progress {
  animation: grow linear;
  animation-timeline: scroll(root);
}

/* Element that fades in as it enters the viewport */
.card {
  animation: fade-in linear;
  animation-timeline: view();
  animation-range: entry 0% entry 100%;
  animation-fill-mode: both;
}

@keyframes grow {
  from { transform: scaleX(0); }
  to   { transform: scaleX(1); }
}

@keyframes fade-in {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: translateY(0); }
}

If you have been avoiding this because of browser support concerns, Firefox 132 removing the flag means the compatibility picture is now good enough for most production use cases. The main holdout is Safari, which has partial support but continues to lag on the animation-range range syntax. The scroll-timeline polyfill from Google’s Bramus Van Damme remains a viable bridge for Safari coverage.

The Pattern Underneath These Features

What Chrome 146 represents, taken together, is a platform growing into problems that libraries have owned by necessity rather than by design. DOMPurify exists because there was no native sanitization primitive. The global custom element registry collision problem exists because the spec never anticipated library-scale component ecosystems. Both are now platform concerns with platform solutions.

The transition is not instantaneous. Safari’s absence from both the Sanitizer API and scoped registries means library support will matter for some time. But the direction is clear. The web platform is accreting the defensive and organizational infrastructure that used to require careful library selection, and Chrome 146 is a concrete step in that direction.

For anyone building applications that take and render user-provided content, including chat interfaces, comment systems, or anything embedding third-party markup, the Sanitizer API stable landing is the one to watch. The moment Safari ships it, dropping DOMPurify becomes a straightforward dependency audit rather than a compatibility risk.

Was this interesting?