From a29354b99bd1718d3bb2d63674f16731e129ace6 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Wed, 12 Aug 2026 15:13:26 -0400 Subject: [PATCH 1/8] Apply polyfill options to constructed-stylesheet shadow runs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- README.md | 13 +++ src/index-fn.ts | 5 +- src/shadow.ts | 69 ++++++++++---- tests/e2e/shadow-dom.test.ts | 25 ++++- tests/unit/shadow.test.ts | 171 +++++++++++++++++++++++++++++++++++ 5 files changed, 259 insertions(+), 24 deletions(-) create mode 100644 tests/unit/shadow.test.ts diff --git a/README.md b/README.md index af10bf3..6c387ab 100644 --- a/README.md +++ b/README.md @@ -88,6 +88,19 @@ stylesheet source text, and patches the `ShadowRoot.prototype.adoptedStyleSheets setter to automatically run the polyfill for each shadow root once its host element's `connectedCallback` finishes. +Those automatic runs use the same [options](#configuration) as `polyfill()`, +either passed directly or read from +`window.ANCHOR_POSITIONING_POLYFILL_OPTIONS`: + +```js +patchAndPolyfillConstructedStylesheets({ positionAreaContainingBlock: false }); +``` + +The `roots` and `elements` options are ignored, since each run is scoped to the +shadow root being positioned. The global is read when each run happens, not when +`patchAndPolyfillConstructedStylesheets()` is called, so it can still be set +afterwards; an explicit argument takes precedence over the global. + You can view a more complete demo [here](https://anchor-positioning.oddbird.net/shadow-dom.html). diff --git a/src/index-fn.ts b/src/index-fn.ts index b03d4db..f58b6cb 100644 --- a/src/index-fn.ts +++ b/src/index-fn.ts @@ -1,5 +1,8 @@ import { polyfill } from './polyfill.js'; -export { patchAndPolyfillConstructedStylesheets } from './shadow.js'; +export { + type ConstructedStylesheetsPolyfillOptions, + patchAndPolyfillConstructedStylesheets, +} from './shadow.js'; export default polyfill; diff --git a/src/shadow.ts b/src/shadow.ts index 891fed7..6aafcff 100644 --- a/src/shadow.ts +++ b/src/shadow.ts @@ -5,33 +5,59 @@ interface CustomElementHost extends HTMLElement { connectedCallback?: () => void; } +/** + * Options accepted by `patchAndPolyfillConstructedStylesheets()`. `roots` and + * `elements` are omitted because each polyfill run it sets up is scoped to a + * single shadow root, and always overrides both. + */ +export type ConstructedStylesheetsPolyfillOptions = Omit< + AnchorPositioningPolyfillOptions, + 'elements' | 'roots' +>; + // Marks host elements whose `connectedCallback` has already been wrapped, so we // don't wrap it more than once if multiple stylesheets are adopted. const patchedHosts = new WeakSet(); +// Whether the `adoptedStyleSheets` setter has already been patched. The patched +// setter can't be compared against the original (we only have the original from +// the descriptor we're replacing), so track it here — otherwise every call to +// `patchAndPolyfillConstructedStylesheets()` would nest another wrapper. +let adoptedStyleSheetsPatched = false; + +// Options given to the most recent `patchAndPolyfillConstructedStylesheets()` +// call, if any. Read at run time rather than captured, so that a global set +// after the patch call (or a later call with different options) is honored. +let polyfillOptions: ConstructedStylesheetsPolyfillOptions | undefined; + +function runPolyfill(shadowRoot: ShadowRoot) { + return polyfill({ + ...(polyfillOptions ?? window.ANCHOR_POSITIONING_POLYFILL_OPTIONS ?? {}), + // Both are always overridden: the run is scoped to this shadow root, and an + // explicit `elements` list opts out of fetching adopted stylesheets + // entirely, which is the very thing we're here to polyfill. + elements: undefined, + roots: [shadowRoot], + }); +} + /** * Wraps the `connectedCallback` of a shadow root's host element so that, after * the original callback runs (and the shadow DOM is populated), the polyfill is * run for that shadow root to position its anchored elements, using the options * given to `patchAndPolyfillConstructedStylesheets`. */ -function patchHostConnectedCallback( - shadowRoot: ShadowRoot, - options: AnchorPositioningPolyfillOptions, -) { +function patchHostConnectedCallback(shadowRoot: ShadowRoot) { const host = shadowRoot.host as CustomElementHost; if (patchedHosts.has(host)) { return; } patchedHosts.add(host); - // `roots` is always overridden, to scope the run to this shadow root. - const runPolyfill = () => polyfill({ ...options, roots: [shadowRoot] }); - const originalConnectedCallback = host.connectedCallback; host.connectedCallback = function (this: CustomElementHost) { originalConnectedCallback?.call(this); - void runPolyfill(); + void runPolyfill(shadowRoot); }; // If the host is already connected (e.g. `adoptedStyleSheets` was assigned @@ -40,7 +66,7 @@ function patchHostConnectedCallback( // has finished and the shadow DOM has been populated. if (host.isConnected) { queueMicrotask(() => { - void runPolyfill(); + void runPolyfill(shadowRoot); }); } } @@ -55,13 +81,17 @@ function patchHostConnectedCallback( * their shadow roots are queued for positioning. * * The given options are passed on to each polyfill run this sets up, except for - * `roots`, which is always the shadow root being positioned. Defaults to - * `window.ANCHOR_POSITIONING_POLYFILL_OPTIONS`, matching `polyfill()`. + * `roots` and `elements`, which are always scoped to the shadow root being + * positioned. When omitted, options are read from + * `window.ANCHOR_POSITIONING_POLYFILL_OPTIONS` at the time each run happens, so + * the global can still be set after this is called. Calling this more than once + * replaces the options used by subsequent runs. */ export function patchAndPolyfillConstructedStylesheets( - options: AnchorPositioningPolyfillOptions = window.ANCHOR_POSITIONING_POLYFILL_OPTIONS ?? - {}, + options?: ConstructedStylesheetsPolyfillOptions, ) { + polyfillOptions = options; + // Patch `replaceSync` to capture the source text of constructed stylesheets // so the polyfill can later re-parse it. if (CSSStyleSheet.prototype.replaceSync === originalReplaceSync) { @@ -71,6 +101,10 @@ export function patchAndPolyfillConstructedStylesheets( }; } + if (adoptedStyleSheetsPatched) { + return; + } + const adoptedStyleSheetsDescriptor = Object.getOwnPropertyDescriptor( ShadowRoot.prototype, 'adoptedStyleSheets', @@ -84,19 +118,16 @@ export function patchAndPolyfillConstructedStylesheets( // To position only after the shadow DOM is populated, we wrap the host // element's `connectedCallback` and run the polyfill for the shadow root once // the (original) callback has finished. - if ( - adoptedStyleSheetsDescriptor && - originalAdoptedStyleSheetsSet && - adoptedStyleSheetsDescriptor.set === originalAdoptedStyleSheetsSet - ) { + if (adoptedStyleSheetsDescriptor && originalAdoptedStyleSheetsSet) { Object.defineProperty(ShadowRoot.prototype, 'adoptedStyleSheets', { ...adoptedStyleSheetsDescriptor, set(this: ShadowRoot, sheets: CSSStyleSheet[]) { originalAdoptedStyleSheetsSet.call(this, sheets); if (sheets.length > 0) { - patchHostConnectedCallback(this, options); + patchHostConnectedCallback(this); } }, }); + adoptedStyleSheetsPatched = true; } } diff --git a/tests/e2e/shadow-dom.test.ts b/tests/e2e/shadow-dom.test.ts index e0f1f21..40fbd4c 100644 --- a/tests/e2e/shadow-dom.test.ts +++ b/tests/e2e/shadow-dom.test.ts @@ -1,5 +1,8 @@ import { expect, type Page, test } from '@playwright/test'; +// Type-only: the entry is imported dynamically at runtime from a path the dev +// server resolves (see below), which would otherwise be typed `any`. +import type * as fnModule from '../../src/index-fn.js'; import { expectWithinOne } from './utils.js'; test.beforeEach(async ({ page }) => { @@ -115,10 +118,16 @@ test('applies global polyfill options to adopted stylesheets in shadow root', as const wrapper = page.locator('anchor-adopted-styles POLYFILL-POSITION-AREA'); const target = page.locator('anchor-adopted-styles .target'); + const anchor = page.locator('anchor-adopted-styles .anchor'); // The unwrapped path marks the target itself instead of adding a wrapper. await expect(target).toHaveAttribute('data-anchor-position-area'); await expect(wrapper).toHaveCount(0); + + // The target is still positioned, not merely left unwrapped. + const anchorBox = await anchor.boundingBox(); + const targetBox = await target.boundingBox(); + expect(targetBox!.y).toBeCloseTo(anchorBox!.y + anchorBox!.height, 0); }); test('applies explicit polyfill options to adopted stylesheets in shadow root', async ({ @@ -136,9 +145,12 @@ test('applies explicit polyfill options to adopted stylesheets in shadow root', await page.evaluate(async () => { // Resolved by the Vite dev server at runtime; the indirection keeps `tsc` - // and the import linter from trying to resolve it statically. + // and the import linter from trying to resolve it statically. The cast + // restores the type checking that a non-literal `import()` gives up. const fnEntry = '/src/index-fn.ts'; - const { patchAndPolyfillConstructedStylesheets } = await import(fnEntry); + const { patchAndPolyfillConstructedStylesheets } = (await import( + fnEntry + )) as typeof fnModule; patchAndPolyfillConstructedStylesheets({ positionAreaContainingBlock: false, @@ -172,11 +184,16 @@ test('applies explicit polyfill options to adopted stylesheets in shadow root', const target = page.locator('explicit-options .target'); const wrapper = page.locator('explicit-options POLYFILL-POSITION-AREA'); + const anchor = page.locator('explicit-options .anchor'); - // Waiting on the attribute lets the queued polyfill run finish before the - // wrapper is asserted to be absent. + // The attribute is only set on the unwrapped path, so waiting on it both + // sequences the queued polyfill run and asserts which path was taken. await expect(target).toHaveAttribute('data-anchor-position-area'); await expect(wrapper).toHaveCount(0); + + const anchorBox = await anchor.boundingBox(); + const targetBox = await target.boundingBox(); + expect(targetBox!.y).toBeCloseTo(anchorBox!.y + anchorBox!.height, 0); }); test('positions every custom-element host sharing one constructed stylesheet', async ({ diff --git a/tests/unit/shadow.test.ts b/tests/unit/shadow.test.ts new file mode 100644 index 0000000..5e12101 --- /dev/null +++ b/tests/unit/shadow.test.ts @@ -0,0 +1,171 @@ +import { type AnchorPositioningPolyfillOptions } from '../../src/polyfill.js'; + +const polyfillMock = vi.hoisted(() => + vi.fn<(options?: AnchorPositioningPolyfillOptions) => Promise>(() => + Promise.resolve(), + ), +); + +vi.mock('../../src/polyfill.js', () => ({ polyfill: polyfillMock })); + +// jsdom's `ShadowRoot.prototype` has no own `adoptedStyleSheets` descriptor, so +// there is nothing for the polyfill to patch. Install a minimal accessor that +// behaves like the real one for the purposes of these tests. +const adoptedSheets = new WeakMap(); + +function installAdoptedStyleSheets() { + Object.defineProperty(ShadowRoot.prototype, 'adoptedStyleSheets', { + configurable: true, + get(this: ShadowRoot) { + return adoptedSheets.get(this) ?? []; + }, + set(this: ShadowRoot, sheets: CSSStyleSheet[]) { + adoptedSheets.set(this, sheets); + }, + }); +} + +// Imports the module fresh, so its patched-once state doesn't leak between +// tests. +async function loadShadowModule() { + vi.resetModules(); + return await import('../../src/shadow.js'); +} + +// Attaches a connected shadow root and adopts a stylesheet into it, then waits +// for the polyfill run queued by the patched setter. +async function adoptStylesheet() { + const host = document.createElement('div'); + document.body.append(host); + const shadowRoot = host.attachShadow({ mode: 'open' }); + shadowRoot.adoptedStyleSheets = [{} as CSSStyleSheet]; + await new Promise((resolve) => queueMicrotask(resolve)); + return shadowRoot; +} + +function optionsOfLastRun() { + return polyfillMock.mock.lastCall?.[0]; +} + +describe('patchAndPolyfillConstructedStylesheets', () => { + // Each `loadShadowModule()` re-evaluates `utils.js`, which captures whatever + // `replaceSync` is current as the "original" — so without restoring it, every + // test would leave another wrapper stacked on the shared prototype. + let originalReplaceSync: typeof CSSStyleSheet.prototype.replaceSync; + + beforeEach(() => { + originalReplaceSync = CSSStyleSheet.prototype.replaceSync; + installAdoptedStyleSheets(); + polyfillMock.mockClear(); + }); + + afterEach(() => { + CSSStyleSheet.prototype.replaceSync = originalReplaceSync; + delete (ShadowRoot.prototype as Partial).adoptedStyleSheets; + delete window.ANCHOR_POSITIONING_POLYFILL_OPTIONS; + document.body.replaceChildren(); + }); + + it('runs the polyfill scoped to the shadow root', async () => { + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets(); + + const shadowRoot = await adoptStylesheet(); + + expect(polyfillMock).toHaveBeenCalledTimes(1); + expect(optionsOfLastRun()).toMatchObject({ roots: [shadowRoot] }); + }); + + it('applies global options set before the patch call', async () => { + window.ANCHOR_POSITIONING_POLYFILL_OPTIONS = { + positionAreaContainingBlock: false, + }; + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets(); + + await adoptStylesheet(); + + expect(optionsOfLastRun()).toMatchObject({ + positionAreaContainingBlock: false, + }); + }); + + it('applies global options set after the patch call', async () => { + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets(); + window.ANCHOR_POSITIONING_POLYFILL_OPTIONS = { + positionAreaContainingBlock: false, + }; + + await adoptStylesheet(); + + expect(optionsOfLastRun()).toMatchObject({ + positionAreaContainingBlock: false, + }); + }); + + it('prefers explicit options over global options', async () => { + window.ANCHOR_POSITIONING_POLYFILL_OPTIONS = { + positionAreaContainingBlock: true, + }; + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets({ + positionAreaContainingBlock: false, + }); + + await adoptStylesheet(); + + expect(optionsOfLastRun()).toMatchObject({ + positionAreaContainingBlock: false, + }); + }); + + it('uses the options from the most recent call', async () => { + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets({ + positionAreaContainingBlock: true, + }); + patchAndPolyfillConstructedStylesheets({ + positionAreaContainingBlock: false, + }); + + await adoptStylesheet(); + + // A second call must neither nest another setter wrapper (which would run + // the polyfill twice) nor keep the first call's options. + expect(polyfillMock).toHaveBeenCalledTimes(1); + expect(optionsOfLastRun()).toMatchObject({ + positionAreaContainingBlock: false, + }); + }); + + it('overrides `roots` and `elements` from the given options', async () => { + // An explicit `elements` list opts out of fetching adopted stylesheets, so + // it must not carry over into these runs. + window.ANCHOR_POSITIONING_POLYFILL_OPTIONS = { + elements: [document.createElement('div')], + roots: [document], + }; + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets(); + + const shadowRoot = await adoptStylesheet(); + + expect(optionsOfLastRun()).toMatchObject({ + elements: undefined, + roots: [shadowRoot], + }); + }); + + it('does not run the polyfill when no stylesheets are adopted', async () => { + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets(); + + const host = document.createElement('div'); + document.body.append(host); + host.attachShadow({ mode: 'open' }).adoptedStyleSheets = []; + await new Promise((resolve) => queueMicrotask(resolve)); + + expect(polyfillMock).not.toHaveBeenCalled(); + }); +}); From 8fcb9ef189ab48ebd91bf08facb5c331ed2ca48e Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Wed, 12 Aug 2026 15:31:38 -0400 Subject: [PATCH 2/8] Position hosts that adopt a stylesheet before being connected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 `` 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. --- src/shadow.ts | 63 ++++++++++++++++++++++---------- tests/e2e/shadow-dom.test.ts | 70 ++++++++++++++++++++++++++++++++++++ tests/unit/shadow.test.ts | 42 ++++++++++++++++++++++ 3 files changed, 156 insertions(+), 19 deletions(-) diff --git a/src/shadow.ts b/src/shadow.ts index 6aafcff..b823ee1 100644 --- a/src/shadow.ts +++ b/src/shadow.ts @@ -1,10 +1,6 @@ import { type AnchorPositioningPolyfillOptions, polyfill } from './polyfill.js'; import { captureAdoptedStylesheetText, originalReplaceSync } from './utils.js'; -interface CustomElementHost extends HTMLElement { - connectedCallback?: () => void; -} - /** * Options accepted by `patchAndPolyfillConstructedStylesheets()`. `roots` and * `elements` are omitted because each polyfill run it sets up is scoped to a @@ -41,33 +37,62 @@ function runPolyfill(shadowRoot: ShadowRoot) { }); } +// Hosts that adopted a stylesheet while disconnected, mapped to the run that +// positions them once they enter the document. Emptied by `connectionObserver`. +const pendingHosts = new Map void>(); +let connectionObserver: MutationObserver | undefined; + +/** + * Runs `onConnected` once `host` is in the document. Custom element lifecycle + * callbacks are captured when the element is defined, so patching the host's + * own `connectedCallback` here would never be invoked for the reaction; watch + * the document for the host being inserted instead. + */ +function whenConnected(host: HTMLElement, onConnected: () => void) { + pendingHosts.set(host, onConnected); + + // `connectedCallback` reactions run synchronously during insertion, so by the + // time observer records are delivered the shadow DOM has been populated. + connectionObserver ??= new MutationObserver(() => { + for (const [pendingHost, callback] of pendingHosts) { + if (pendingHost.isConnected) { + pendingHosts.delete(pendingHost); + callback(); + } + } + if (pendingHosts.size === 0) { + connectionObserver?.disconnect(); + connectionObserver = undefined; + } + }); + connectionObserver.observe(document, { childList: true, subtree: true }); +} + /** - * Wraps the `connectedCallback` of a shadow root's host element so that, after - * the original callback runs (and the shadow DOM is populated), the polyfill is - * run for that shadow root to position its anchored elements, using the options - * given to `patchAndPolyfillConstructedStylesheets`. + * Queues the polyfill run that positions a shadow root's anchored elements, + * using the options given to `patchAndPolyfillConstructedStylesheets`. The run + * is deferred until the host is connected and its shadow DOM is populated, + * which is not yet the case when `adoptedStyleSheets` is assigned from a + * constructor or before the host is inserted. */ function patchHostConnectedCallback(shadowRoot: ShadowRoot) { - const host = shadowRoot.host as CustomElementHost; + const host = shadowRoot.host as HTMLElement; if (patchedHosts.has(host)) { return; } patchedHosts.add(host); - const originalConnectedCallback = host.connectedCallback; - host.connectedCallback = function (this: CustomElementHost) { - originalConnectedCallback?.call(this); + const run = () => { void runPolyfill(shadowRoot); }; - // If the host is already connected (e.g. `adoptedStyleSheets` was assigned - // from within the host's `connectedCallback`), the wrapper above won't run - // for the current connection, so run the polyfill once the current callback - // has finished and the shadow DOM has been populated. + // Already connected (e.g. `adoptedStyleSheets` was assigned from within the + // host's `connectedCallback`): run once that callback has finished and the + // shadow DOM has been populated. if (host.isConnected) { - queueMicrotask(() => { - void runPolyfill(shadowRoot); - }); + queueMicrotask(run); + } else { + whenConnected(host, run); } } diff --git a/tests/e2e/shadow-dom.test.ts b/tests/e2e/shadow-dom.test.ts index 40fbd4c..442ef51 100644 --- a/tests/e2e/shadow-dom.test.ts +++ b/tests/e2e/shadow-dom.test.ts @@ -196,6 +196,76 @@ test('applies explicit polyfill options to adopted stylesheets in shadow root', expect(targetBox!.y).toBeCloseTo(anchorBox!.y + anchorBox!.height, 0); }); +test('positions a host that adopts its stylesheet before being connected', async ({ + page, +}) => { + // A custom element that builds its shadow root in the constructor adopts its + // stylesheet while still disconnected. Custom element lifecycle callbacks are + // captured when the element is defined, so the polyfill can't hook the host's + // `connectedCallback` at that point — it has to wait for the host to enter + // the document. + await page.goto('/shadow-dom.html'); + + await page.evaluate(async () => { + const fnEntry = '/src/index-fn.ts'; + const { patchAndPolyfillConstructedStylesheets } = (await import( + fnEntry + )) as typeof fnModule; + + patchAndPolyfillConstructedStylesheets(); + + const sheet = new CSSStyleSheet(); + sheet.replaceSync(` + .anchor { anchor-name: --constructor-anchor; } + .target { + position: absolute; + position-anchor: --constructor-anchor; + position-area: bottom span-left; + } + `); + + customElements.define( + 'adopts-in-constructor', + class extends HTMLElement { + constructor() { + super(); + this.attachShadow({ mode: 'open' }); + this.shadowRoot!.adoptedStyleSheets = [sheet]; + this.shadowRoot!.innerHTML = ` +
Anchor
+
Target
`; + } + }, + ); + + // Constructed (and adopting) well before it is connected. + const host = document.createElement('adopts-in-constructor'); + await new Promise((resolve) => setTimeout(resolve, 50)); + document.body.append(host); + }); + + const anchor = page.locator('adopts-in-constructor .anchor'); + const wrapper = page.locator('adopts-in-constructor POLYFILL-POSITION-AREA'); + + // Assert the generated wrapper, not geometry. An unresolved `anchor()` or + // `position-area` leaves the target at its static position, which for a + // target that directly follows its anchor in flow is the same place the + // anchored position would put it — so position assertions alone pass whether + // or not the polyfill ran. The wrapper only exists if it ran. + await expect(wrapper).toHaveCount(1); + + const anchorBox = await anchor.boundingBox(); + const wrapperBox = await wrapper.boundingBox(); + + // `bottom` aligns the target's top with the anchor's bottom; `span-left` + // aligns their right edges. + expect(wrapperBox!.y).toBeCloseTo(anchorBox!.y + anchorBox!.height, 0); + expect(wrapperBox!.x + wrapperBox!.width).toBeCloseTo( + anchorBox!.x + anchorBox!.width, + 0, + ); +}); + test('positions every custom-element host sharing one constructed stylesheet', async ({ page, }) => { diff --git a/tests/unit/shadow.test.ts b/tests/unit/shadow.test.ts index 5e12101..39b5ec5 100644 --- a/tests/unit/shadow.test.ts +++ b/tests/unit/shadow.test.ts @@ -43,6 +43,17 @@ async function adoptStylesheet() { return shadowRoot; } +// Adopts a stylesheet into a shadow root whose host is not in the document +// yet, mirroring a custom element that builds its shadow root in its +// constructor. Returns the host, still disconnected. +function adoptStylesheetWhileDisconnected(tagName: string) { + customElements.define(tagName, class extends HTMLElement {}); + const host = document.createElement(tagName); + const shadowRoot = host.attachShadow({ mode: 'open' }); + shadowRoot.adoptedStyleSheets = [{} as CSSStyleSheet]; + return { host, shadowRoot }; +} + function optionsOfLastRun() { return polyfillMock.mock.lastCall?.[0]; } @@ -157,6 +168,37 @@ describe('patchAndPolyfillConstructedStylesheets', () => { }); }); + it('runs the polyfill for a host connected after adopting', async () => { + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets(); + + const { host, shadowRoot } = + adoptStylesheetWhileDisconnected('adopt-early'); + + // Nothing to position until the host is in the document. + expect(polyfillMock).not.toHaveBeenCalled(); + + document.body.append(host); + await vi.waitFor(() => expect(polyfillMock).toHaveBeenCalledTimes(1)); + + expect(optionsOfLastRun()).toMatchObject({ roots: [shadowRoot] }); + }); + + it('applies options to a host connected after adopting', async () => { + const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); + patchAndPolyfillConstructedStylesheets({ + positionAreaContainingBlock: false, + }); + + const { host } = adoptStylesheetWhileDisconnected('adopt-early-options'); + document.body.append(host); + await vi.waitFor(() => expect(polyfillMock).toHaveBeenCalledTimes(1)); + + expect(optionsOfLastRun()).toMatchObject({ + positionAreaContainingBlock: false, + }); + }); + it('does not run the polyfill when no stylesheets are adopted', async () => { const { patchAndPolyfillConstructedStylesheets } = await loadShadowModule(); patchAndPolyfillConstructedStylesheets(); From 82b0b8534cfa5aa9619b6f30d96926ff8ebf3111 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Wed, 12 Aug 2026 15:19:32 -0400 Subject: [PATCH 3/8] Ship type declarations and stop the demo build deleting them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- package.json | 3 ++- src/@types/global.d.ts | 11 ----------- src/global.ts | 14 ++++++++++++++ src/index-fn.ts | 2 ++ src/index-wpt.ts | 2 ++ src/index.ts | 2 ++ tests/tsconfig.json | 1 + vite.config.ts | 4 ++++ 8 files changed, 27 insertions(+), 12 deletions(-) delete mode 100644 src/@types/global.d.ts create mode 100644 src/global.ts diff --git a/package.json b/package.json index 0456227..ae7e2e5 100644 --- a/package.json +++ b/package.json @@ -49,7 +49,8 @@ "package.json" ], "scripts": { - "build": "run-s build:demo build:dist build:fn", + "clean": "node -e \"require('node:fs').rmSync('dist', { recursive: true, force: true })\"", + "build": "run-s clean build:demo build:dist build:fn", "build:dist": "vite build", "build:fn": "cross-env BUILD_FN=1 vite build", "build:wpt": "cross-env BUILD_WPT=1 vite build", diff --git a/src/@types/global.d.ts b/src/@types/global.d.ts deleted file mode 100644 index b975c9b..0000000 --- a/src/@types/global.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { type AnchorPositioningPolyfillOptions } from '../polyfill.ts'; - -export {}; - -declare global { - interface Window { - UPDATE_ANCHOR_ON_ANIMATION_FRAME?: boolean; - ANCHOR_POSITIONING_POLYFILL_OPTIONS?: AnchorPositioningPolyfillOptions; - CHECK_LAYOUT_DELAY?: boolean; - } -} diff --git a/src/global.ts b/src/global.ts new file mode 100644 index 0000000..8f7d5ca --- /dev/null +++ b/src/global.ts @@ -0,0 +1,14 @@ +// This is a compiled module rather than an ambient `.d.ts`, so that `tsc` +// emits it to `dist/` and the entry points can pull it in with a side-effect +// import — otherwise these `Window` properties would be missing for consumers. +import { type AnchorPositioningPolyfillOptions } from './polyfill.js'; + +export {}; + +declare global { + interface Window { + UPDATE_ANCHOR_ON_ANIMATION_FRAME?: boolean; + ANCHOR_POSITIONING_POLYFILL_OPTIONS?: AnchorPositioningPolyfillOptions; + CHECK_LAYOUT_DELAY?: boolean; + } +} diff --git a/src/index-fn.ts b/src/index-fn.ts index f58b6cb..9bc262d 100644 --- a/src/index-fn.ts +++ b/src/index-fn.ts @@ -1,3 +1,5 @@ +import './global.js'; + import { polyfill } from './polyfill.js'; export { diff --git a/src/index-wpt.ts b/src/index-wpt.ts index bb11d4d..8f9446e 100644 --- a/src/index-wpt.ts +++ b/src/index-wpt.ts @@ -1,3 +1,5 @@ +import './global.js'; + import { polyfill } from './polyfill.js'; // Used by the WPT test harness to delay test assertions diff --git a/src/index.ts b/src/index.ts index 9b24c17..4278d84 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,5 @@ +import './global.js'; + import { polyfill } from './polyfill.js'; // apply polyfill diff --git a/tests/tsconfig.json b/tests/tsconfig.json index 9c902fd..7c467f1 100644 --- a/tests/tsconfig.json +++ b/tests/tsconfig.json @@ -13,6 +13,7 @@ "include": [ "./**/*.ts", "./../src/@types/", + "./../src/global.ts", "./../*.config.ts", "./../*.config.js" ] diff --git a/vite.config.ts b/vite.config.ts index 19cc4f1..e2fa240 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -9,6 +9,10 @@ export default defineConfig({ }, build: process.env.BUILD_DEMO ? { + // Every build is additive; `npm run clean` empties `dist/` once, up + // front. Otherwise this build would wipe the declaration files that + // `npm run types` emits there. + emptyOutDir: false, rollupOptions: { input: { main: resolve(import.meta.dirname, 'index.html'), From cf56b91a7f5b68147c356c53f1dc67d266ecedc0 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Wed, 12 Aug 2026 16:33:42 -0400 Subject: [PATCH 4/8] Position hosts connected inside another shadow root 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. --- README.md | 9 ++- src/shadow.ts | 103 +++++++++++++++++++++-------------- tests/e2e/shadow-dom.test.ts | 72 ++++++++++++++++++++++-- tests/unit/shadow.test.ts | 25 ++++++--- 4 files changed, 157 insertions(+), 52 deletions(-) diff --git a/README.md b/README.md index 6c387ab..8782263 100644 --- a/README.md +++ b/README.md @@ -61,7 +61,7 @@ been applied. ### Constructed stylesheets (`adoptedStyleSheets`) If your custom elements use [constructed stylesheets](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet) -(via `new CSSStyleSheet()` + `replaceSync()` + `shadowRoot.adoptedStyleSheets`), call `patchAndPolyfillConstructedStylesheets()` **before** any custom element's `connectedCallback` runs: +(via `new CSSStyleSheet()` + `replaceSync()` + `shadowRoot.adoptedStyleSheets`), call `patchAndPolyfillConstructedStylesheets()` **before** any of those custom elements are defined: ```html