Skip to content

[scoped-custom-element-registry] Redesign the polyfill - #668

Open
sorvell wants to merge 15 commits into
masterfrom
scoped-registry-redesign
Open

sorvell wants to merge 15 commits into
masterfrom
scoped-registry-redesign

Conversation

@sorvell

@sorvell sorvell commented Oct 1, 2026 •

Copy link
Copy Markdown
Collaborator

Supersedes #604. Redesigns the polyfill to follow the current spec, including the customElementRegistry property on node, initialize, null registries, the customelementregistry attribute, declarative shadow root's shadowrootcustomelementregistry template attribute, and creation and import options using {customElementRegistry:...}.

The polyfill functions only where native support is missing or incomplete. Chrome ~150+, Safari 26.6+, and Firefox 160 (currently in Nightly) have native support but Safari fails detection because it can incorrectly customize null-registry elements. Chrome/Firefox use native support unless the polyfill is forced by setting window.CustomElementRegistryPolyfill = {force: true} before loading the polyfill script.

Size: 30.1 KB minified, 9.2 KB gzipped (previous version was 13.0 KB and 4.2 KB).

How it works and what's changed in this version

  • Stand-ins (unchanged from previous version): each tag defined in any registry gets one native stand-in definition. The browser constructs stand-ins, and the polyfill customizes each one with the definition from that element's own registry, using the constructor-call trick.
  • Registries are now stored per node and not taken from a creation-context stack. Per spec a node's registry is fixed when it's created, and for parsed /cloned content it comes from the tree
    (ancestors, null-registry attributes, declarative roots). One call can mix several registries, which a stack can't express.
    So the polyfill records each node's registry once the native call has built its tree, via a walk of the new content.
  • Queue, then flush when the call returns. A stand-in's constructor only adds it to a queue. Each patched API that can construct elements has its own queue, which it flushes before returning. Why: an element's registry depends on its tree, which only exists once the native call has built it, and a single call can need several registries (declarative roots with their own registry, clones). Again, a context stack can't express that. Also, this matches native timing, where upgrades run as the call's custom element reactions.
  • Main-document parsing: a MutationObserver runs while the document loads and customizes parsed elements once they're inserted, so a script that follows an element sees it upgraded, meaning the queue/flush isn't observable.
  • Cloning: the browser clones natively, then each copy is paired with its source by index and gets the source's registry, including in clonable shadow roots and template contents.
  • Adoption: node adoption is now spec correct where the node's registry becomes the adopting document's default registry if it has the source document's default registry.;
  • Special handling: attachInternals, disabledFeatures and form-associated elements are handled per element's own class but need special wrapping handling since the polyfill interacts with them.

Compatibility and behavior changes

  • Spec names are used throughout. The earlier registry and customElements options to attachShadow still work (for backwards compatibility with the previous polyfill version).
  • A declarative root with a null registry needs polyfill-shadowrootcustomelementregistry on its host, alongside the standard attribute on its template. This is because the polyfill cannot observe the shadowrootcustomelementregistry attribute on the DSD template element.
  • :defined matches stand-ins that haven't been customized, so use :is(:not(:defined), :state(polyfill-undefined)) instead.
  • Detection checks that native null registries actually work; CustomElementRegistryPolyfill.force loads the polyfill anyway.
  • Where browsers diverge from the spec, the polyfill follows the spec. The README lists those differences, with filed bugs.

Where to look. Start with the "How it works" comment near the top of the source, then:

  • withDeferredUpgrades and flushUpgrades: the queues;
  • getRegistry: how a node's registry is worked out;
  • customize: the upgrade, constructor identity and re-entry;
  • observeParsing;
  • cloneWithRegistries, pairClonedElement and pairUnreachableCopyRoots;
  • adoptRegistries;
  • the forms section at the end (isFormControl and the collection proxy).

Tests

Roughly 370 tests per browser, run in Chromium, Firefox and WebKit, and again in Chromium with the polyfill forced. They cover:

  • creation, import and clone options;
  • registry inheritance through parsing, moves, adoption and clones;
  • null registries and declarative roots;
  • upgrade order and timing, per call and during main-document parsing;
  • constructor and callback errors;
  • forms;
  • the earlier registry options.

#669 adds the WPT custom-element registry tests to the suite: about 2,400 per browser, with a small per-browser list of known failures, each with a known cause.

Follow-ups

  • Adds WPT tests and fixes cross realm issues #669: registries shared across same-origin windows, spec adoption order, and the WPT integration.
  • Cloning cost: pairing walks each clone. A cache for repeatedly cloned templates is being explored.
  • Firefox: it constructs elements parsed by setHTMLUnsafe late, so those upgrade a microtask late (microtask could be removed is supporting Firefox < 158 is unnecessary).

@google-cla

google-cla Bot commented Oct 1, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@sorvell
sorvell force-pushed the scoped-registry-redesign branch from 7b18f61 to 2162b48 Compare October 1, 2026 13:34
Steve Orvell added 4 commits October 1, 2026 06:42
…orced-polyfill Chromium run

- tests.yml: Node 24.5, actions v4, install Playwright browsers, drop the
  Saucelabs job
- scoped-custom-element-registry: test:forced runs the tests in Chromium with
  the polyfill forced (FORCE_POLYFILL), and npm test depends on it
- test/wtr.firefox.config.js: run the tests locally in a Firefox-engine
  browser without native support
A rewrite. Start with the "How it works" overview at the top of
src/scoped-custom-element-registry.ts, then read the file rather than the
line diff.
behavior.test.js and parsing.test.html(.js) are new; tests of polyfill-only
behavior, and of spec behavior Chromium gets wrong, use itWithPolyfill so the
suite also runs against native implementations.
@sorvell
sorvell force-pushed the scoped-registry-redesign branch from 2162b48 to 698b7c2 Compare October 1, 2026 13:43
'registry'
] = registry;
(shadowRoot as ShadowRootInitWithSettableCustomElements)[
'customElements'

@thepassle thepassle Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should this not be customElementRegistry?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

registry and customElements are names from earlier versions of the proposal, set as plain properties so old code keeps working. Can drop them if we don't think back compat matters anymore.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right, but the new name of the property is customElementRegistry, which should be set on the shadowRoot, no?

? clonedShadowRootRegistries.get(host)!
: host.hasAttribute(DSD_HOST_ATTRIBUTE)
? null
: getRegistry(host.ownerDocument);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isnt this unnecessarily recusirve? This could just be getDefaultRegistry(node), right?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It recurses one level because going through getRegistry(host.ownerDocument) picks up a registry initialized on a document specifically rather than its default.


## Usage notes

- **Load it first**, before any markup that uses custom elements is parsed.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's add instructions on how to load it.

for the main document's parser, once inserted (before any script that follows
them runs). The "constructor call trick" is then used to upgrade them.

## Supported

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What's unsupported? Let's add a design doc.

}
return instance;
} as unknown) as typeof HTMLElement;
window.HTMLElement.prototype = NativeHTMLElement.prototype;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small suggestion: restore HTMLElement's static prototype chain.

   window.HTMLElement.prototype = NativeHTMLElement.prototype;
+  Object.setPrototypeOf(window.HTMLElement, Object.getPrototypeOf(NativeHTMLElement)); // Element
   Object.defineProperty(window.HTMLElement, 'name', {value: 'HTMLElement'});

The patched HTMLElement's [[Prototype]] is Function.prototype, while natively it's Element, so it and every class extending it lose the statics inherited from Node/Element.

Check Before After
Object.getPrototypeOf(HTMLElement) === Element false true
HTMLElement.ELEMENT_NODE undefined 1
class X extends HTMLElement {}; X.ELEMENT_NODE undefined 1

Construction and instanceof are unaffected.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants