From c77736956f7a70cc4069c6f0fda45dd974269b34 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sat, 25 Jul 2026 12:17:15 +0200 Subject: [PATCH 01/13] Add a shadow DOM demo for anchors wired up through the CSSOM `anchor-name` and `position-anchor` assigned from JavaScript are dropped by the CSSOM in a browser without native support, so nothing lands in the `style` attribute the polyfill reads. The demo wires up its anchor at runtime the way a design system component would, and does not work as a result. Refs #445 Co-Authored-By: Claude Opus 5 --- shadow-dom.html | 134 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 131 insertions(+), 3 deletions(-) diff --git a/shadow-dom.html b/shadow-dom.html index b835fb9..753b0c4 100644 --- a/shadow-dom.html +++ b/shadow-dom.html @@ -24,6 +24,7 @@
Anchor
Target
`; @@ -161,20 +161,26 @@ // Load the shadow entrypoint first so the `replaceSync` and // `adoptedStyleSheets` patches are installed before any custom // element's `connectedCallback` runs. - const { default: polyfill, patchAndPolyfillConstructedStylesheets } = - await import('/src/index-fn.ts'); + const { + default: polyfill, + patchAndPolyfillConstructedStylesheets, + patchCSSOM, + } = await import('/src/index-fn.ts'); // Patch Constructed stylesheets patchAndPolyfillConstructedStylesheets(); + // Make `anchor-name` and `position-anchor` settable through the + // CSSOM, for the demo below. + patchCSSOM(); + // Now define the custom elements defineCustomElements(); - // Load the polyfill explicitly for the and - // demos. The , - // and demos use - // `adoptedStyleSheets`, so they are already covered by the adopted - // stylesheet patch. + // Load the polyfill explicitly for the demos that don't use + // `adoptedStyleSheets`. The and + // demos do, so they are already covered by + // the adopted stylesheet patch. await polyfill({ roots: [ document.querySelector('anchor-web-component').shadowRoot, @@ -182,6 +188,15 @@ ], }); + // The demo is positioned directly rather than + // wrapped: its target is a popover, and a popover is promoted to the + // top layer, where its containing block is the viewport rather than + // the `` wrapper. + await polyfill({ + roots: [document.querySelector('anchor-position-area').shadowRoot], + positionAreaContainingBlock: false, + }); + btn.innerText = 'Polyfill Applied'; btn.setAttribute('disabled', ''); } else { @@ -553,42 +568,65 @@

button.

- This demo does not work. Like a design system - component would, it sets anchor-name and - position-anchor from JavaScript through the CSSOM. A - browser that needs this polyfill does not support those properties, so - the assignments are silently dropped instead of becoming CSS - declarations — nothing is written to the elements' - style attributes, which is what the polyfill reads. The - anchor is never resolved and the popover stays unpositioned. Writing - the declarations into the style attribute directly is - what makes them visible to the polyfill. + Like a design system component would, it sets + anchor-name and position-anchor from + JavaScript through the CSSOM. A browser that needs this polyfill does + not support those properties, and the CSSOM drops what it does not + know, so the assignments never become CSS declarations and nothing is + written to the style attributes the polyfill reads. + patchCSSOM() makes those properties settable, at the cost + of 'anchorName' in element.style reporting support that + isn't there. +

+

+ So detect native support with CSS.supports(), which the + patch leaves alone: +

+ +
// Not this, `patchCSSOM()` makes it true:
+if (!('anchorName' in document.documentElement.style)) {
+}
+
+// This:
+if (!CSS.supports('anchor-name: --a')) {
+  const { default: polyfill, patchCSSOM } = await import(
+    '@oddbird/css-anchor-positioning/fn'
+  );
+
+  patchCSSOM();
+  await polyfill();
+}
+

+ A popover is promoted to the top layer, where its containing block is + the viewport rather than the + <polyfill-position-area> + wrapper, so this demo is polyfilled with + positionAreaContainingBlock: false and positioned + directly.

<anchor-position-area></anchor-position-area>
 <script>
-<!-- Load the shadow entrypoint before defining custom elements,
-so the replaceSync and adoptedStyleSheets patches are installed
-before any connectedCallback runs. -->
-import { patchAndPolyfillConstructedStylesheets } from '@oddbird/css-anchor-positioning/fn';
-patchAndPolyfillConstructedStylesheets();
+import polyfill, { patchCSSOM } from '@oddbird/css-anchor-positioning/fn';
+
+<!-- Call before any connectedCallback runs, so the anchor properties
+are settable by the time an element wires up its anchor. -->
+patchCSSOM();
+
 class AnchorPositionArea extends HTMLElement {
   connectedCallback() {
     this.attachShadow({ mode: "open" });
-
-    const sheet = new CSSStyleSheet();
-    sheet.replaceSync(`
-      #shadow-position-area-target {
-        margin: initial;
-        position-area: top;
-        width: 14em;
-      }
-    `);
-    this.shadowRoot.adoptedStyleSheets = [sheet];
-
     this.shadowRoot.innerHTML = `
+      <style>
+        #shadow-position-area-target {
+          margin: initial;
+          position-area: top;
+          width: 14em;
+        }
+      </style>
       <div id="shadow-position-area-anchor">Anchor</div>
       <div id="shadow-position-area-target" popover="manual">Popover (Target)</div>
     `;
@@ -596,8 +634,7 @@ 

const anchor = this.shadowRoot.getElementById("shadow-position-area-anchor"), target = this.shadowRoot.getElementById("shadow-position-area-target"); - // Set from JavaScript, the way a design system component would. Without - // native support the CSSOM drops both, so the polyfill never sees them. + // Dropped by the CSSOM without `patchCSSOM()`. anchor.style.anchorName = target.style.positionAnchor = "--shadow-position-area-anchor"; @@ -605,6 +642,11 @@

} } customElements.define("anchor-position-area", AnchorPositionArea); + +await polyfill({ + roots: [document.querySelector("anchor-position-area").shadowRoot], + positionAreaContainingBlock: false, +}); </script>

diff --git a/src/cssom.ts b/src/cssom.ts new file mode 100644 index 0000000..07f38ee --- /dev/null +++ b/src/cssom.ts @@ -0,0 +1,220 @@ +// The properties this patch makes settable through the CSSOM, mapped to the CSS +// property they write. A browser without native anchor positioning does not +// know them, and the CSSOM drops what it does not know: `el.style.anchorName = +// '--foo'` only sets a property on the style object and never produces a CSS +// declaration, while `el.style.setProperty('anchor-name', '--foo')` is ignored +// outright. Reading the value back still returns it, so the assignment looks +// like it worked. The polyfill reads the `style` attribute, so without this +// patch it never sees anchors that are wired up from JavaScript. +const PATCHED_PROPERTIES = { + anchorName: 'anchor-name', + positionAnchor: 'position-anchor', +} as const; + +const PATCHED_CSS_PROPERTIES: string[] = Object.values(PATCHED_PROPERTIES); + +// The element an inline `CSSStyleDeclaration` belongs to. Inline styles have no +// `ownerNode`, so the only way back to the element is to record it while +// `element.style` is being accessed — which is exactly when a declaration is +// about to be read or written. +const inlineStyleOwners = new WeakMap(); + +// The values set through the patched accessors, and the authoritative copy of +// them. They are mirrored into the `style` attribute for the polyfill (and for +// devtools) to read, but any later CSSOM write reserializes the declaration +// block and drops declarations the browser does not understand, so the +// attribute on its own cannot be trusted: +// +// el.setAttribute('style', 'anchor-name: --foo; color: red'); +// el.style.top = '1px'; +// el.getAttribute('style'); // 'color: red; top: 1px;' +const inlineValues = new WeakMap>(); + +// Every element that has a value in `inlineValues`, so the mirrors can be +// restored before a polyfill run reads them. Held weakly, so this never keeps +// an element alive. +const patchedElements = new Set>(); + +let patched = false; + +/** Reads a declaration from an element's `style` attribute. */ +function getInlineDeclaration(element: Element, property: string) { + const [, value = ''] = + new RegExp(`(?:^|;)\\s*${property}\\s*:([^;]*)`, 'i').exec( + element.getAttribute('style') ?? '', + ) ?? []; + return value.trim(); +} + +/** + * Writes a declaration into an element's `style` attribute, leaving the other + * declarations untouched. An empty value removes the declaration. + * + * The attribute is edited as text rather than through the CSSOM, which would + * drop the declaration again. + */ +function setInlineDeclaration( + element: Element, + property: string, + value: string, +) { + let style = (element.getAttribute('style') ?? '') + .replace(new RegExp(`(^|;)\\s*${property}\\s*:[^;]*;?`, 'i'), '$1') + .replace(/^\s*;/, '') + .trim(); + + if (value) { + style = style && !style.endsWith(';') ? `${style};` : style; + style = `${style}${style ? ' ' : ''}${property}: ${value};`; + } + + if (style) { + element.setAttribute('style', style); + } else if (element.hasAttribute('style')) { + element.setAttribute('style', ''); + } +} + +function writeValue(element: Element, property: string, value: string) { + let values = inlineValues.get(element); + if (!values) { + values = new Map(); + inlineValues.set(element, values); + patchedElements.add(new WeakRef(element)); + } + + if (value) { + values.set(property, value); + } else { + values.delete(property); + } + + setInlineDeclaration(element, property, value); +} + +/** + * Re-applies the values set through the patched accessors to the `style` + * attributes they were mirrored into, in case a later CSSOM write dropped them, + * and drops elements that have been garbage collected. + * + * Called at the start of a polyfill run, so that everything downstream can keep + * reading the `style` attribute as the single source of inline styles. + */ +export function restoreInlineAnchorValues() { + if (!patched) return; + + for (const ref of patchedElements) { + const element = ref.deref(); + if (!element) { + patchedElements.delete(ref); + continue; + } + + for (const [property, value] of inlineValues.get(element) ?? []) { + if (getInlineDeclaration(element, property) !== value) { + setInlineDeclaration(element, property, value); + } + } + } +} + +/** + * Makes `anchor-name` and `position-anchor` settable through the CSSOM, so that + * anchors wired up from JavaScript — `element.style.anchorName = '--foo'` — are + * visible to the polyfill in browsers without native anchor positioning. + * + * This is opt-in, and does nothing when anchor positioning is supported + * natively, because it has a side effect worth knowing about: defining these + * properties makes `'anchorName' in document.documentElement.style` return + * `true`, which is a common way to detect native support. Use + * `CSS.supports('anchor-name: --a')` for that instead — it is unaffected. + * + * Values set before this is called are not picked up; call it as early as + * possible, alongside the other patches. + */ +export function patchCSSOM() { + if (patched || CSS.supports('anchor-name: --a')) return; + patched = true; + + // Record which element an inline declaration belongs to. + for (const proto of [HTMLElement.prototype, SVGElement.prototype]) { + const descriptor = Object.getOwnPropertyDescriptor(proto, 'style'); + const getStyle = descriptor?.get; + if (!descriptor || !getStyle) continue; + + Object.defineProperty(proto, 'style', { + ...descriptor, + get(this: Element) { + const style = getStyle.call(this) as CSSStyleDeclaration; + inlineStyleOwners.set(style, this); + return style; + }, + }); + } + + const ownerOf = (style: CSSStyleDeclaration) => inlineStyleOwners.get(style); + + for (const [property, cssProperty] of Object.entries(PATCHED_PROPERTIES)) { + Object.defineProperty(CSSStyleDeclaration.prototype, property, { + configurable: true, + enumerable: true, + get(this: CSSStyleDeclaration) { + const element = ownerOf(this); + // Not an inline style (a computed style, say), so there is no value of + // ours to report. + return (element && inlineValues.get(element)?.get(cssProperty)) ?? ''; + }, + set(this: CSSStyleDeclaration, value: string) { + const element = ownerOf(this); + if (element) { + writeValue(element, cssProperty, `${value ?? ''}`.trim()); + } + }, + }); + } + + // The dashed form goes through these, and is dropped just the same. + const { getPropertyValue, removeProperty, setProperty } = + CSSStyleDeclaration.prototype; + + CSSStyleDeclaration.prototype.setProperty = function ( + property: string, + value: string | null, + priority?: string, + ) { + const element = PATCHED_CSS_PROPERTIES.includes(property) + ? ownerOf(this) + : undefined; + if (element) { + writeValue(element, property, `${value ?? ''}`.trim()); + return; + } + return setProperty.call(this, property, value, priority); + }; + + CSSStyleDeclaration.prototype.getPropertyValue = function ( + property: string, + ): string { + const element = PATCHED_CSS_PROPERTIES.includes(property) + ? ownerOf(this) + : undefined; + if (element) { + return inlineValues.get(element)?.get(property) ?? ''; + } + return getPropertyValue.call(this, property); + }; + + CSSStyleDeclaration.prototype.removeProperty = function ( + property: string, + ): string { + const element = PATCHED_CSS_PROPERTIES.includes(property) + ? ownerOf(this) + : undefined; + if (element) { + const previous = inlineValues.get(element)?.get(property) ?? ''; + writeValue(element, property, ''); + return previous; + } + return removeProperty.call(this, property); + }; +} diff --git a/src/fetch.ts b/src/fetch.ts index 6e86ef3..2e058f9 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -69,7 +69,10 @@ const ELEMENTS_WITH_INLINE_POSITION_AREA = '[style*="position-area"]'; // For each element found, adds a new 'data-has-inline-styles' attribute with a // random UUID value, and then formats the styles in the same manner as CSS from // style tags. -function fetchInlineStyles(elements?: HTMLElement[]) { +function fetchInlineStyles( + roots: AnchorPositioningRoot[], + elements?: HTMLElement[], +) { const elementsWithInlineAnchorStyles: HTMLElement[] = elements ? elements.filter( (el) => @@ -77,14 +80,23 @@ function fetchInlineStyles(elements?: HTMLElement[]) { (el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY) || el.matches(ELEMENTS_WITH_INLINE_POSITION_AREA)), ) - : Array.from( - document.querySelectorAll( - [ - ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY, - ELEMENTS_WITH_INLINE_POSITION_AREA, - ].join(','), - ), - ); + : (() => { + const query = [ + ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY, + ELEMENTS_WITH_INLINE_POSITION_AREA, + ].join(','); + // The document is searched as well as the roots: a run scoped to a + // shadow root still needs inline styles from the outer tree, where its + // host and any light-DOM anchors live. Searching the roots on top of + // that is what finds inline styles *inside* a shadow root, which + // `document.querySelectorAll()` does not reach. + return [ + ...new Set([ + ...(document.querySelectorAll(query) as NodeListOf), + ...querySelectorAllRoots(roots, query), + ]), + ]; + })(); const inlineStyles: Partial[] = []; elementsWithInlineAnchorStyles @@ -160,7 +172,7 @@ export async function fetchCSS( ? (options.elements ?? []) : undefined; - const inlines = fetchInlineStyles(elementsForInlines); + const inlines = fetchInlineStyles(options.roots, elementsForInlines); // Collect constructed stylesheets adopted on the polyfill roots. Skipped when // an explicit `elements` list is provided, as that opts into element-only diff --git a/src/index-fn.ts b/src/index-fn.ts index b03d4db..a5a969d 100644 --- a/src/index-fn.ts +++ b/src/index-fn.ts @@ -1,5 +1,6 @@ import { polyfill } from './polyfill.js'; +export { patchCSSOM } from './cssom.js'; export { patchAndPolyfillConstructedStylesheets } from './shadow.js'; export default polyfill; diff --git a/src/polyfill.ts b/src/polyfill.ts index f55ff92..a283132 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -6,6 +6,7 @@ import { } from '@floating-ui/dom'; import { cascadeCSS } from './cascade.js'; +import { restoreInlineAnchorValues } from './cssom.js'; import { getCSSPropertyValue, getOffsetParent } from './dom.js'; import { fetchCSS } from './fetch.js'; import { @@ -760,6 +761,11 @@ export async function polyfill( useAnimationFrameOrOption ?? window.ANCHOR_POSITIONING_POLYFILL_OPTIONS, ); + // Anchors set through the patched CSSOM accessors live in a registry; make + // sure the `style` attributes they mirror into are up to date before + // anything reads them. + restoreInlineAnchorValues(); + // fetch CSS from stylesheet and inline style let styleData = await fetchCSS(options); diff --git a/tests/e2e/shadow-dom.test.ts b/tests/e2e/shadow-dom.test.ts index e0f1f21..1f394ee 100644 --- a/tests/e2e/shadow-dom.test.ts +++ b/tests/e2e/shadow-dom.test.ts @@ -299,3 +299,53 @@ test('emulates non-inheritance of shifted properties inside a shadow root withou expect(result.containerHeight).toBe('400px'); expect(result.targetHeight).toBe(''); }); + +test('picks up anchors set through the CSSOM', async ({ page }) => { + // `patchCSSOM()` makes `anchor-name` and `position-anchor` settable through + // the CSSOM. Without it those assignments are dropped by a browser that has + // no native anchor positioning, and never reach the `style` attribute the + // polyfill reads. The elements live in a shadow root, so this also covers + // inline styles being collected per polyfill root rather than from + // `document`. + await applyPolyfill(page); + + 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. + const fnEntry = '/src/index-fn.ts'; + const { default: polyfill } = await import(fnEntry); + + const host = document.createElement('div'); + host.id = 'cssom-anchors'; + document.body.append(host); + + const shadow = host.attachShadow({ mode: 'open' }); + shadow.innerHTML = ` + +
+ Anchor +
Target
+
`; + + const anchor = shadow.getElementById('anchor')!, + target = shadow.getElementById('target')!; + + anchor.style.anchorName = '--cssom-anchor'; + target.style.positionAnchor = '--cssom-anchor'; + + await polyfill({ roots: [shadow] }); + }); + + const anchor = page.locator('#cssom-anchors #anchor'); + const target = page.locator('#cssom-anchors #target'); + + const anchorBox = (await anchor.boundingBox())!; + const targetBox = (await target.boundingBox())!; + + // `top: anchor(bottom)` and `left: anchor(right)` put the target's top-left + // corner on the anchor's bottom-right corner. + expect(targetBox.y).toBeCloseTo(anchorBox.y + anchorBox.height, 0); + expect(targetBox.x).toBeCloseTo(anchorBox.x + anchorBox.width, 0); +}); From b35753d2c10d95e0ffc9fac6b559160dd925f427 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sat, 25 Jul 2026 13:43:45 +0200 Subject: [PATCH 03/13] Update README --- README.md | 69 +++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 65 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index af10bf3..dc27b81 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,7 @@ To use the polyfill, add this script tag to your document ``: ```js @@ -58,6 +58,21 @@ polyfill(); The `polyfill` function returns a promise that resolves when the polyfill has been applied. +### Feature detection + +Use `CSS.supports()` to check for native support: + +```js +if (!CSS.supports('anchor-name: --a')) { + // Load the polyfill. +} +``` + +Checking for the property on a style declaration — +`'anchorName' in document.documentElement.style` — works too, but only until +something defines that property. [`patchCSSOM()`](#setting-anchor-properties-from-javascript) +does exactly that, so `CSS.supports()` is the check to rely on. + ### Constructed stylesheets (`adoptedStyleSheets`) If your custom elements use [constructed stylesheets](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet) @@ -65,7 +80,7 @@ If your custom elements use [constructed stylesheets](https://developer.mozilla. ```html @@ -58,21 +58,6 @@ polyfill(); The `polyfill` function returns a promise that resolves when the polyfill has been applied. -### Feature detection - -Use `CSS.supports()` to check for native support: - -```js -if (!CSS.supports('anchor-name: --a')) { - // Load the polyfill. -} -``` - -Checking for the property on a style declaration — -`'anchorName' in document.documentElement.style` — works too, but only until -something defines that property. [`patchCSSOM()`](#setting-anchor-properties-from-javascript) -does exactly that, so `CSS.supports()` is the check to rely on. - ### Constructed stylesheets (`adoptedStyleSheets`) If your custom elements use [constructed stylesheets](https://developer.mozilla.org/en-US/docs/Web/API/CSSStyleSheet/CSSStyleSheet) @@ -80,7 +65,7 @@ If your custom elements use [constructed stylesheets](https://developer.mozilla. ```html