Skip to content

fix: constructed-stylesheet shadow runs and missing type declarations - #459

Open
jgerigmeyer wants to merge 8 commits into
mainfrom
fix/shadow-options-and-packaging
Open

fix: constructed-stylesheet shadow runs and missing type declarations#459
jgerigmeyer wants to merge 8 commits into
mainfrom
fix/shadow-options-and-packaging

Conversation

@jgerigmeyer

@jgerigmeyer jgerigmeyer commented Aug 12, 2026

Copy link
Copy Markdown
Member

Summary

Three fixes to the constructed-stylesheets shadow-DOM path and to what ships in dist/.

1. Apply polyfill options to constructed-stylesheet shadow runs (follow-up to #443)

PR #443 fixed the symptom of #442 but left three defects behind:

  • The re-patch guard compared descriptor.set against a value read from that same descriptor, so it was always true. Every call to patchAndPolyfillConstructedStylesheets() stacked another wrapper on the adoptedStyleSheets setter, and combined with the patchedHosts short-circuit, a second call's options were silently discarded.
  • Options were captured when the patch was installed, so a global set afterwards was ignored — despite the docs telling callers to install the patch as early as possible.
  • A global elements list was forwarded into the shadow runs, where it makes fetchCSS skip adopted stylesheets entirely, disabling the feature this function exists to provide.

Options are now read at run time; elements and roots are overridden and omitted from the accepted options type (ConstructedStylesheetsPolyfillOptions), so passing either is a compile error rather than a silent no-op.

2. Position hosts that adopt a stylesheet before being connected

patchHostConnectedCallback() wrapped the host element's own connectedCallback property. That never ran: lifecycle callbacks are looked up when the element is defined and stored on the definition, so assigning host.connectedCallback afterwards has no effect on the reaction. Everything kept working only via the host.isConnected branch, meaning a host that adopts while disconnected — the familiar pattern of building the shadow root in the constructor — was never polyfilled at all.

customElements.define is now patched to wrap connectedCallback on the constructor's prototype before the registry captures it. That fires wherever the host is connected, including inside another component's shadow root, which a document-level MutationObserver cannot see — mutation records don't cross shadow boundaries.

The trade-off, now documented in the README: a host adopting while disconnected is positioned only if it is a custom element defined after this call. The documented requirement was already to call this before defining elements; a non-custom-element host with a detached shadow root is not covered, since nothing signals that it has been connected.

3. Ship type declarations and stop the demo build deleting themFixes #449

src/@types/global.d.ts was an input .d.ts, which tsc does not copy to outDir, so consumers following the documented config example got TS2339 on window.ANCHOR_POSITIONING_POLYFILL_OPTIONS. Moved to a compiled module pulled in by each entry with a side-effect import, which tsc preserves in declaration output.

Separately, only the library builds set emptyOutDir: false, so build:demo — the first step of build — wiped any declarations npm run types had emitted. prepack was correct only by ordering luck. Builds are now additive, with a clean step up front.

Related

`patchAndPolyfillConstructedStylesheets()` set up its own `polyfill()`
runs with an explicit options object, so `polyfill()` never fell back to
`window.ANCHOR_POSITIONING_POLYFILL_OPTIONS`. Global options such as
`positionAreaContainingBlock: false` were silently ignored for shadow
roots with adopted stylesheets. Follow-up to #443, which fixed the
symptom but left three problems behind:

- The re-patch guard compared `descriptor.set` against a value read from
  that same descriptor, so it was always true and every call stacked
  another wrapper on the `adoptedStyleSheets` setter. Combined with the
  `patchedHosts` short-circuit, a second call's options were silently
  discarded. Track the patched state in a module flag instead.
- Options were captured when the patch was installed, so a global set
  afterwards was ignored — despite the docs telling callers to install
  the patch as early as possible. Read them at run time instead.
- A global `elements` list was forwarded to the shadow runs, where it
  makes `fetchCSS` skip adopted stylesheets entirely, disabling the very
  feature this function provides. Override it alongside `roots`, and
  omit both from the accepted options type so passing them is a
  compile error rather than a silent no-op.

Adds unit coverage for the options contract, and extends the e2e tests
to assert the target is actually positioned rather than just unwrapped.
`patchHostConnectedCallback()` wrapped the host element's own
`connectedCallback` property, intending to position the shadow root once
the host was connected and its shadow DOM populated. That wrapper never
ran: custom element lifecycle callbacks are looked up when the element
is defined and stored on the definition, so assigning
`host.connectedCallback` afterwards has no effect on the reaction.

Everything kept working only because of the `host.isConnected` branch,
which covers the common case of adopting from within
`connectedCallback`. A host that adopts while disconnected — the
familiar pattern of building the shadow root in the constructor — was
never polyfilled at all.

Watch for the host entering the document with a shared MutationObserver
instead, which disconnects itself once no hosts are pending. Observer
records are delivered after the insertion's `connectedCallback`
reactions have run, so the shadow DOM is populated by then.

The e2e test asserts the generated `<polyfill-position-area>` wrapper
rather than geometry. An unresolved `position-area` leaves the target at
its static position, which for a target that directly follows its anchor
in flow is where the anchored position would put it anyway — geometry
assertions there pass with or without a polyfill run.
Two packaging bugs left `dist/` in a state that didn't match what
`package.json` promises.

`src/@types/global.d.ts` declared the `Window` properties the README
tells consumers to set, but `tsc` does not copy input `.d.ts` files to
`outDir`, and nothing in the emitted output referenced it. Consumers
following the documented configuration example got `TS2339: Property
'ANCHOR_POSITIONING_POLYFILL_OPTIONS' does not exist on type 'Window'`.
Move it to a compiled module and pull it into each entry with a
side-effect import, which `tsc` preserves in the declaration output, so
`dist/index.d.ts` and `dist/index-fn.d.ts` now reference `./global.js`.

Only the library builds set `emptyOutDir: false`, so `build:demo` — the
first step of `build` — wiped any declarations a previous `npm run
types` had emitted. `prepack` was correct only by ordering luck. Make
every build additive and empty `dist/` once, up front, via a `clean`
step.

Verified against a consumer installed from `npm pack`: it typechecks the
documented `window.ANCHOR_POSITIONING_POLYFILL_OPTIONS` assignment and
rejects an unknown option, and fails as before when the side-effect
import is removed.
@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploy Preview for anchor-polyfill ready!

Name Link
🔨 Latest commit 1b25a5d
🔍 Latest deploy log https://app.netlify.com/projects/anchor-polyfill/deploys/6a8081d18586eb0008724945
😎 Deploy Preview https://deploy-preview-459--anchor-polyfill.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Aug 12, 2026

Copy link
Copy Markdown

Deploy Preview for anchor-position-wpt canceled.

Name Link
🔨 Latest commit 1b25a5d
🔍 Latest deploy log https://app.netlify.com/projects/anchor-position-wpt/deploys/6a8081d106de6700088f125f

The MutationObserver watching `document` for pending hosts missed the
common case it was meant to cover: mutation records don't cross shadow
boundaries, so a host appended into another component's shadow root
produced no record and was never positioned. Nesting custom elements is
the normal composition pattern, so that was most of the case.

Patch `customElements.define` instead, wrapping `connectedCallback` on
the constructor's prototype before the registry captures it. That is the
hook the original code reached for and couldn't get after the fact, and
it fires wherever the host is connected. It also removes the observer's
unbounded `pendingHosts` map, which pinned every host that adopted a
stylesheet and was then discarded without connecting, and kept a
document-wide subtree observer alive for the lifetime of the page.

The trade-off is that a host adopting while disconnected is now only
positioned if it is a custom element defined after this runs. The
documented requirement was already to call this before defining
elements; the README now says why, and notes the non-custom-element
case.

The nested case is covered by an e2e test only: jsdom delivers mutation
records across shadow boundaries, so the equivalent unit test passes
with or without the fix.
@jgerigmeyer
jgerigmeyer requested a review from jamesnw August 12, 2026 21:04
@jgerigmeyer
jgerigmeyer marked this pull request as ready for review August 12, 2026 21:04
@jamesnw

jamesnw commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

@jpzwarte Can you take a look at this PR? Thanks!

Comment thread README.md Outdated
Comment thread src/shadow.ts Outdated
patchedHosts.add(host);
customElementsPatched = true;

const originalDefine = customElements.define;

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.

This only works for the global custom elements registry, not any scoped custom element registries. Let's look at that in a new PR.

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.

Can we patch CustomElementRegistry.prototype.define instead perhaps? That would then work for scoped custom element registries as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

@jpzwarte Great catch! Can you test the fix pushed in 1b25a5d?

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.

I can't contribute directly to your branch; i have to still use a fork etc.

On my fork i've added an example to shadow-dom.html:

CleanShot 2026-08-16 at 11 16 37@2x CleanShot 2026-08-16 at 11 16 45@2x

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.

I've created a PR for adding this example to your branch, but i see your branch isn't up-to-date with main, so the PR includes a bunch of unwanted changes. Perhaps merge main into this one first?

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.

To be clear:

  • First screenshot is Chrome 153: supports anchor positioning and scoped registries
  • Second screenshot is Firefox dev edition 154: supports anchor positioning but not scoped registries

But on main the polyfill doesn't try and patch CustomElementRegistry.prototype.define, so you shouldn't se any difference there.

On this branch, you should see a difference if you switch between patching CustomElementRegistry.prototype.define and window.customElements.define.

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.

Now i am confused: i don't think this is testable. There aren't any browsers that don't support CSS Anchor Positioning yet, but do support Scoped Custom Element Registries. I don't think that's a real world usecase:

https://caniuse.com/css-anchor-positioning vs https://caniuse.com/wf-scoped-custom-element-registries

It's still probably better to patch CustomElementRegistry.prototype.define, but testing the polyfill in that combination is not possible?!

Assigning `customElements.define` installs an own property that shadows
`CustomElementRegistry.prototype.define`, so scoped registries created
with `new CustomElementRegistry()` never got the `connectedCallback`
wrapper. Patch the prototype instead, which every registry inherits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

[BUG] window. ANCHOR_POSITIONING_POLYFILL_OPTIONS is not typed

3 participants