Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 14 additions & 34 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,18 @@ name: tests

on: [push, pull_request]

permissions:
contents: read

jobs:
lint:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 16
node-version: 24.5

- name: NPM install
run: npm ci
Expand All @@ -25,47 +28,24 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
with:
node-version: 16
node-version: 24.5

- name: NPM install
run: npm ci

- name: Lerna bootstrap
run: npm run bootstrap

- name: Build
run: npm run build

- name: Test
run: npm test

sauce:
# Skip if this is from a forked repository because we can't access Sauce
# secrets.
if: github.event.pull_request.base.repo.id == github.event.pull_request.head.repo.id

runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
with:
node-version: 16

- name: NPM install
run: npm ci

- name: Lerna bootstrap
run: npm run bootstrap
- name: Install playwright browsers (+ deps)
run: |
cd packages/tests && npx playwright install --with-deps
cd $GITHUB_WORKSPACE/packages/scoped-custom-element-registry && npx playwright install --with-deps

- name: Build
run: npm run build

- name: Test
env:
SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }}
SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }}
run: npm run test-sauce
run: lerna run test --concurrency 1 --stream --no-bail
103 changes: 103 additions & 0 deletions packages/scoped-custom-element-registry/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,112 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## Unreleased

- `define` no longer writes `formAssociated` onto the class; whether a tag is
form-associated is still fixed by its first definition
- `element.constructor === HTMLElement` holds for elements whose prototype is
`HTMLElement.prototype`, including ones not yet customized
- As natively, an upgrade's constructor can't construct its own class again
after calling `super()`; a direct construction (`createElement`, the main
parser) still can
- `importNode` honors `selfOnly`, and rejects a null `customElementRegistry`
with a `TypeError`
- `Range.createContextualFragment` in a template's context gives elements a
null registry

### Changed

- Updated to latest [proposed spec](https://github.com/whatwg/html/issues/10854)
- Elements are customized once their tree is known: at the end of the DOM call
that created them, or, for the main document's parser, once inserted, before
any script that follows them runs
- The registry of parsed content is worked out from where it is, so reading
registries and calling `initialize` during parsing match native
- Cloning keeps each node's registry, including in clonable shadow roots
- Adoption into another document changes null and global registries, as
natively
- `attachInternals` returns the element's single `ElementInternals`, and
honors `disabledFeatures`

### Added

- customElements.initialize: sets registry on a DOM tree
- document.createElement(NS): takes options with {customElementRegistry}
- document.importNode: takes options with {selfOnly, customElementRegistry}
- Node.customElementRegistry set to creating registry
- Null registries: `customElementRegistry: null` options, and the
`customelementregistry` / `scopedcustomelementregistry` attributes
- Declarative shadow roots with a null registry, via
`shadowrootcustomelementregistry` plus `polyfill-shadowrootcustomelementregistry`
on the host
- `polyfill-customelementregistry` / `polyfill-scopedcustomelementregistry`:
null registry attributes always handled by the polyfill
- `:state(polyfill-undefined)` on elements the polyfill hasn't customized, for
use with or instead of `:defined`
- `Range.cloneContents`, `Range.createContextualFragment`, `Range.insertNode`
and `Range.surroundContents` respect registries

### Fixed

- Fixes [issue](https://github.com/webcomponents/polyfills/issues/613) with setting `shadowRoot.customElements` on Safari's native implementation
- Errors thrown by constructors and callbacks are reported rather than thrown,
and don't stop other elements from upgrading
- `define` with an invalid name no longer leaves the registry in a bad state
- `connectedMoveCallback` is called for `moveBefore`, instead of disconnecting
and reconnecting the element
- Upgrade order: an element whose constructor calls a DOM API (e.g.
`attachShadow`) finishes before other elements created with it upgrade
- `importNode(node)` copies shallowly by default
- `disabledFeatures: ['shadow']` is honored
- An adopted element inserted into a disconnected tree, or passed to
`upgrade()`, no longer loses its upgrade when later connected
- A constructor can create or construct other elements before calling
`super()`
- An upgrade fails if the constructor returns a different object
- A constructor that throws before calling `super()` runs once
- `define` throws a `TypeError` for a non-constructor and a `SyntaxError` for an
invalid name before reading the class
- `define` reads `observedAttributes` only when there's an
`attributeChangedCallback`, and form callbacks only for a form-associated
class; it captures `disabledFeatures` once
- Customized built-in elements work in the global registry; a scoped registry
rejects `extends` with a `NotSupportedError`
- `form.elements` is the native collection again, live and with native named
lookup, filtered through a `Proxy` to leave out elements whose own class
isn't form-associated; those elements can no longer set form values
- `define` reads the class in the specified order and rejects callbacks that
aren't functions and `observedAttributes` or `disabledFeatures` that aren't
iterable; a customized built-in isn't visible until its class has been read
- An upgrade fails if the class disables shadow roots and the element has one
- `attachInternals` respects `disabledFeatures` for a directly constructed
element; an element whose constructor failed gets no form callbacks and
isn't a form control; `form.elements` treats only canonical numbers as
indexes, resolves names like `entries` to controls, and filters its keys and
property descriptors too
- A closed shadow root copied on a host without internals, including one
nested in another, is paired before any constructor can change its source,
or else keeps only its registry, instead of a reference to its source
- A copy with a global registry in a document with a scoped registry gets
null; `ElementInternals` form members (including `form`, `labels`,
`validity`) throw for an element that isn't form-associated, or whose
constructor failed; `define` rejects a class whose prototype isn't an
object; a directly constructed element whose class isn't form-associated,
of a tag that is, isn't part of its form
- Copies keep their sources' registries in template contents (without
`importNode`'s registry), in `Range.extractContents`, and in a cloned
document, which keeps a scoped registry; `whenDefined` rejects invalid names
itself
- An error in a callback the polyfill calls (initial attribute callbacks,
`connectedCallback` on upgrade, `attributeChangedCallback` from
`setAttribute`) is reported, not thrown, and the remaining callbacks run
- `attributeChangedCallback` follows each element's own definition: a class
defined in several registries, or a subclass of another defined class, no
longer gets extra or duplicate callbacks; removing an absent attribute
doesn't call back
- `define` leaves the class unchanged if it fails, throws `NotSupportedError`
for a used name or constructor, and while another definition is running
- `Range.createContextualFragment` gives new elements a null context registry
- A parsed element keeps its registry when moved or removed before anything
needed it

## [0.0.10] - 2025-02-26

Expand Down
160 changes: 128 additions & 32 deletions packages/scoped-custom-element-registry/README.md
Original file line number Diff line number Diff line change
@@ -1,39 +1,135 @@
# Scoped CustomElementRegistry polyfill prototype

## 🚨 Work in progress

This polyfill explores implementation of a non-finalized spec proposal and is
currently a work in progress.
# Scoped CustomElementRegistry polyfill

## Overview

Scoped CustomElementRegistry polyfill based on [Scoped Custom Element
Registries](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Scoped-Custom-Element-Registries.md)
WICG proposal.
Scoped CustomElementRegistry polyfill based on [DOM Spec]https://html.spec.whatwg.org/multipage/custom-elements.html], originally proposed via [Scoped Custom Element
Registries](https://github.com/WICG/webcomponents/blob/gh-pages/proposals/Scoped-Custom-Element-Registries.md).

See [MDN](https://developer.mozilla.org/en-US/docs/Web/API/CustomElementRegistry/CustomElementRegistry) for current native support status.

Technique: uses native CustomElements to register stand-in classes that
delegate to the constructor in the registry for the element's scope; this
avoids any manual treewalks to identify custom elements that need upgrading.
Constructor delegation is achieved by constructing a bare `HTMLElement`,
inspecting its tree scope (or the tree scope of the node it was created via) to
determine its registry, and then applying the "constructor call trick" to
upgrade it.

Notes/limitations:

- In order to leverage native CE when available, `observedAttributes` handling
must be simulated by patching `setAttribute`/`getAttribute`/`toggleAttribute` to call
`attributeChangedCallback` manually, since while we can delegate constructors,
the `observedAttributes` respected by the browser are fixed at define time.
This means that native reflecting properties are not observable when set via
properties.
- In theory, this should be able to be layered on top of the Custom Elements
polyfill for use on older browsers, although this is yet to be tested. Use of
`Reflect.construct` may need to be modified in those cases

Outstanding TODOs:

- [ ] [#419](https://github.com/webcomponents/polyfills/issues/419): Convert source to TS (to match convention in this monorepo)
- [ ] [#420](https://github.com/webcomponents/polyfills/issues/420): Test and work out layering strategy with standard custom-elements polyfill
- [ ] [#421](https://github.com/webcomponents/polyfills/issues/421): Test and work out layering strategy with shady-dom & shady-css polyfills
- [ ] [#422](https://github.com/webcomponents/polyfills/issues/422): Add benchmarks
Stand-ins are queued when constructed and customized once their tree, and so
their registry, is known: at the end of the DOM call that created them, or,
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.


- `new CustomElementRegistry()`, and `define`, `get`, `getName`, `whenDefined`,
`upgrade` and `initialize`.
- `customElementRegistry` on elements, shadow roots and documents.
- `attachShadow`, `createElement`, `createElementNS` and `importNode` with a
`customElementRegistry` option, including `null`.
- Declarative shadow roots, and parsing (`innerHTML`, `setHTMLUnsafe`, etc.)
into scoped trees.
- Null registries, via `customelementregistry` or `scopedcustomelementregistry`
on elements, and `shadowrootcustomelementregistry` on declarative shadow
roots (see below).
- Cloning (`cloneNode`, `importNode`, `Range.cloneContents`) keeps each node's
registry, including in clonable shadow roots.
- Adoption into another document, as natively.

The polyfill does nothing in browsers with native support. Set
`window.CustomElementRegistryPolyfill = {force: true}` before loading it to
use it anyway.

## 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.

- **Declarative shadow roots with a null registry** also need
`polyfill-shadowrootcustomelementregistry` on the host: the parser consumes
the template, so the polyfill never sees its attribute. Use it only on a
host that has such a root: the polyfill treats any root attached to that
host as its declarative root, so `attachShadow` there gets a null registry,
whatever registry is passed.
```html
<x-host polyfill-shadowrootcustomelementregistry>
<template shadowrootmode="open" shadowrootcustomelementregistry>…</template>
</x-host>
```
- **`polyfill-customelementregistry`** (or `polyfill-scopedcustomelementregistry`)
is equivalent to the standard attribute but always handled by the polyfill,
never by the browser. Use it only if you want the polyfill's behavior in
browsers with native support (e.g. when forcing); don't combine it with the
standard attribute.
- **`:defined`** matches elements the polyfill hasn't customized yet. Use
`:is(:not(:defined), :state(polyfill-undefined))` instead of `:not(:defined)`;
it's correct with or without the polyfill.
- **Elements the main parser creates upgrade once inserted**, rather than
when constructed: a script that follows them sees them upgraded, as
natively, but their constructors run a little later, just before such a
script or the next custom element the parser creates.
- **Cloning a closed shadow root:** when a closed, clonable shadow root is on
an element that wasn't a defined custom element when the root was attached
(for example a `div`, or an undefined custom element like `my-element`), the
polyfill can't reach the copy's shadow root after cloning. Elements in the
copy that are only defined later get the shadow root's registry (or, in a
shadow root nested inside it, the document's), even if their originals used
a different one. This only matters if elements inside
the root use a different registry than the root itself. To avoid it, do any
of these:
- put the shadow root on a custom element that's defined before the root is
attached;
- define the elements inside before cloning;
- make the shadow root open.
- **Uncommon APIs** the polyfill doesn't patch (e.g.
`execCommand('insertHTML')`) upgrade elements a microtask later than natively.
- **`observedAttributes`** is simulated by patching `setAttribute`,
`removeAttribute` and `toggleAttribute`, since the browser fixes it at define
time. Attributes changed any other way (e.g. through reflecting properties
or `setAttributeNS`) are not observed.
- **Form-associated elements:** whether a tag can be form-associated is fixed
by its first definition. If a later definition of the same tag is
form-associated when the first isn't, list the tag in
`window.CustomElementRegistryPolyfill.formAssociated` (a `Set`) before
loading. An element whose own class isn't form-associated, of a tag that
is, is left out of its form's data, `elements` and `length`, but it's still
in `form[index]` and the form's named properties, matches `:disabled`, and
is disabled by a `disabled` attribute.
- **Customized built-in elements** (`extends`) are defined natively in the
global registry, where the browser supports them. A name can't be used both
for a customized built-in and in any registry's autonomous definitions,
since the polyfill defines every autonomous name natively. The polyfill
reads the class before the browser does, so a one-shot iterable (e.g. a
generator) for `observedAttributes` or `disabledFeatures` is empty when the
browser reads it, and nothing is observed.

## Differences from native implementations

Where browsers currently differ from the spec, the polyfill follows the spec.
Tests of these are skipped when the browser's native support is used.

- Safari 26 and 27 customize an element created with a null registry using
the global registry's definition, while still reporting a null registry.
The polyfill is used there.

- Chromium and WebKit ignore `customelementregistry` when `innerHTML` uses
their fast-path parser (e.g. for `<div customelementregistry></div>`), but
honor it when the full parser runs. `setHTMLUnsafe` always honors it.
- Chromium:
- `customElements.initialize(document)` doesn't throw a `NotSupportedError`
(Safari and Firefox do);
- a scoped registry accepts `extends` instead of throwing a
`NotSupportedError`;
- a cloned document gets a null registry, instead of keeping its scoped
one;
- a copy of an element with a global registry, in a document with a scoped
registry, gets that scoped registry instead of null;
- `ElementInternals.setFormValue` works for an element whose constructor
failed, which isn't form-associated;
- adopting a host gives its shadow root a null registry but leaves the
elements inside it global.

## Testing

`npm test` builds the polyfill and runs the tests with Playwright in
Chromium, Firefox and WebKit, then again in Chromium with the polyfill forced
(`npm run test:forced`), so its code is tested in a browser with native
support too. Setting `FORCE_POLYFILL=true` forces it in any run. Where the
browser's native support is used, tests of polyfill-only behavior are
skipped (see `itWithPolyfill` in `test/utils.js`).

To run the tests in a Firefox-engine browser without native support, see
`test/wtr.firefox.config.js`.
Loading
Loading