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
105 changes: 105 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,114 @@ 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
- `HTMLElement`, and classes extending it, inherit the statics of `Element`
and `Node` (e.g. `ELEMENT_NODE`)

### 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
99 changes: 99 additions & 0 deletions packages/scoped-custom-element-registry/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# Design

How the polyfill works, for maintainers. See the [README](README.md) for usage
and limitations.

## Goals

- Behave as the spec says, so code written against the polyfill works
unchanged with native support.
- Do nothing where the browser's native support is complete.
- Stay small and avoid tree walks where possible.

## Stand-ins and the constructor-call trick

The browser has one native registry per window, so it can't know which
registry an element belongs to. For each tag name defined in any registry,
the polyfill defines a _stand-in_ class natively, once. The browser constructs
a stand-in wherever it would construct a custom element (parsing, cloning,
`createElement`, ...), so the polyfill never searches the DOM for elements to
upgrade.

When the element's registry is known, the polyfill looks up that registry's
definition and _customizes_ the stand-in: it calls the user's constructor on
it, with a patched `HTMLElement` returning the existing element from `super()`
(the "constructor-call trick"). The stand-in's lifecycle callbacks then
delegate to the definition of the element's own registry. An element with no
definition in its registry waits, while connected, for one.

Things the browser fixes per tag at define time (`observedAttributes`,
`formAssociated`, `disabledFeatures`) can't vary by registry, so the polyfill
emulates them: attribute callbacks via patched `setAttribute` and friends,
form-association via the stand-in plus filtering, disabled features via
checks in `attachShadow` and `attachInternals`.

## Each node's registry

A node's registry is either recorded or worked out from where it is:

- **Recorded** in a `WeakMap` whenever a polyfill API creates, clones or
adopts a node, or attaches a shadow root.
- **Worked out** for a node without a record. Such a node was made by a
parser, so it gets the parser's answer: its parent's registry, null if it
has a null registry attribute, and for a declarative shadow root its
document's registry. The answer is then recorded, so later moves don't
change it.

Because the answer for an unrecorded node depends on its position, every
patched API that moves or removes nodes records their registries first.

## When elements are customized

A stand-in is queued when the browser constructs it, since its tree, and so
its registry, isn't known yet. Each patched API that can construct elements is
an _entry point_: it runs the native method, then flushes its queue, so
elements are customized before it returns, as natively. Entry points can
nest; each flushes only its own queue.

The main document's parser isn't an API call. A `MutationObserver` flushes
the queue once parsed elements are inserted, which is before any following
script runs. A microtask would be too early: the browser constructs the
element before setting its attributes or inserting it.

Anything else that constructs elements (e.g. `execCommand`) is flushed in a
microtask.

## Patched APIs

Roughly: element creation (`createElement`, `createElementNS`), insertion
and adoption (`appendChild`, `append`, `insertBefore`, `replaceWith`,
`adoptNode`, `Range.insertNode`, ...), parsing (`innerHTML`, `outerHTML`,
`insertAdjacentHTML`, `setHTMLUnsafe`, `createContextualFragment`, ...),
cloning (`cloneNode`, `importNode`, `Range.cloneContents`,
`Range.extractContents`), `attachShadow`, `attachInternals`, and the
attribute and form APIs mentioned above.

## Cloning

The browser clones nodes without saying which copy came from which source.
The polyfill pairs them by position: the source's and the copy's elements, in
tree order, line up one to one. Each copy gets the registry the spec gives it:
its source's, or, if that's a global registry, its document's global
registry (null in a document with a scoped registry).
Shadow roots are paired the same way. A closed root on a host that wasn't a
custom element can't be reached on the copy; it keeps only the root's
registry.

## Adoption

Moving a node into another document changes null and global registries to
the new document's. Patched insertion methods apply this before the native
move.

## Native support

On load, the polyfill checks that native support is complete: a null
registry is inherited by parsed children, set by the declarative attributes,
and keeps an element from being customized with a global definition. If so,
it does nothing, unless `force` is set. Safari currently fails these checks,
so it uses the polyfill.
Loading
Loading