From 21caf74f1aa450d4617bcccb4e65bf418df8419c Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Sat, 25 Jul 2026 13:57:51 +0200 Subject: [PATCH 1/9] Fix fetchInlineStyles --- src/fetch.ts | 34 +++++++++++++++++++--------- tests/e2e/position-area.test.ts | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 6e86ef3..4edde7f 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -1,6 +1,6 @@ import { nanoid } from 'nanoid/non-secure'; -import { POLYFILLED_STYLE_ATTRIBUTE } from './cascade.js'; +import { POLYFILLED_STYLE_ATTRIBUTE, SHIFTED_PROPERTIES } from './cascade.js'; import { querySelectorAllRoots } from './dom.js'; import { type AnchorPositioningRoot, @@ -63,8 +63,26 @@ async function fetchLinkedStylesheets( return results.filter((loaded) => loaded !== null); } -const ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY = '[style*="anchor"]'; -const ELEMENTS_WITH_INLINE_POSITION_AREA = '[style*="position-area"]'; +// Inline styles are collected so that `cascadeCSS` can shift their declarations +// into custom properties, like it does for the rest of the CSS. That has to +// cover every property the polyfill later reads back through +// `getCSSPropertyValue` — insets, margins, sizing, padding, self-alignment, +// `position-area` — and not just the anchor-specific ones: a target can take +// its `position-area` from a stylesheet while setting its margin inline. +// `anchor` is matched on its own as well, for `anchor()`/`anchor-size()` values. +// Built on first use rather than at module evaluation: `cascade.js` and this +// module are part of an import cycle, so `SHIFTED_PROPERTIES` is not +// necessarily initialized yet when this module is evaluated. +let inlineAnchorStylesQuery: string | undefined; +function elementsWithInlineAnchorStylesQuery() { + inlineAnchorStylesQuery ??= [ + '[style*="anchor"]', + ...Object.keys(SHIFTED_PROPERTIES).map( + (property) => `[style*="${property}"]`, + ), + ].join(','); + return inlineAnchorStylesQuery; +} // Searches for all elements with inline style attributes that include `anchor`. // 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 @@ -74,16 +92,10 @@ function fetchInlineStyles(elements?: HTMLElement[]) { ? elements.filter( (el) => el instanceof HTMLElement && - (el.matches(ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY) || - el.matches(ELEMENTS_WITH_INLINE_POSITION_AREA)), + el.matches(elementsWithInlineAnchorStylesQuery()), ) : Array.from( - document.querySelectorAll( - [ - ELEMENTS_WITH_INLINE_ANCHOR_STYLES_QUERY, - ELEMENTS_WITH_INLINE_POSITION_AREA, - ].join(','), - ), + document.querySelectorAll(elementsWithInlineAnchorStylesQuery()), ); const inlineStyles: Partial[] = []; diff --git a/tests/e2e/position-area.test.ts b/tests/e2e/position-area.test.ts index 113a749..af0da37 100644 --- a/tests/e2e/position-area.test.ts +++ b/tests/e2e/position-area.test.ts @@ -393,6 +393,46 @@ test.describe('with `positionAreaContainingBlock: auto`', () => { ).toHaveCount(1); }); + test('wraps a target whose containing-block-dependent style is inline', async ({ + page, + }) => { + // Inline styles are shifted into custom properties like the rest of the + // CSS, so a target that takes its `position-area` from a stylesheet while + // setting a containing-block-dependent style inline is still recognised as + // needing the wrapper. Without the shift the inline `padding-right: 50%` + // reads back as empty and the target is positioned directly. + 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 style = document.createElement('style'); + style.textContent = ` + #inline-shifted .anchor { anchor-name: --inline-shifted; } + #inline-shifted .target { + position: absolute; + position-anchor: --inline-shifted; + position-area: top; + }`; + document.head.append(style); + + const container = document.createElement('div'); + container.id = 'inline-shifted'; + container.setAttribute('style', 'position: relative'); + container.innerHTML = ` +
Anchor
+
Target
`; + document.body.append(container); + + await polyfill({ positionAreaContainingBlock: 'auto' }); + }); + + await expect( + page.locator('#inline-shifted polyfill-position-area'), + ).toHaveCount(1); + }); + test('positions a wrapped target correctly', async ({ page }) => { await applyPolyfill(page); const section = page.locator('#spanleft-top'); From 5c19e336efe8a2e85afb8a08d5203a4e1a7a4783 Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Mon, 27 Jul 2026 12:21:12 +0200 Subject: [PATCH 2/9] Add example --- position-area.html | 23 ++++++++++++++ public/position-area-page.css | 6 ++++ tests/e2e/position-area.test.ts | 53 ++++++++++++--------------------- 3 files changed, 48 insertions(+), 34 deletions(-) diff --git a/position-area.html b/position-area.html index fcb1aba..d72e820 100644 --- a/position-area.html +++ b/position-area.html @@ -183,6 +183,29 @@

+
+

+ + span-left top, padding set inline ✅ +

+
+
Anchor
+
+ Target with longer content +
+
+

+ The same as the demo above, except that + padding-right: 50% is an inline style rather than a + stylesheet rule. Inline styles are shifted into custom properties like + the rest of the CSS, so auto mode can + still see the percentage padding and wraps the target. Without that + shift the padding reads back as empty, the target is positioned + directly, and the padding resolves against the original containing block + instead of the position-area cell. +

+
+

diff --git a/public/position-area-page.css b/public/position-area-page.css index 2f282f9..a676ab6 100644 --- a/public/position-area-page.css +++ b/public/position-area-page.css @@ -67,6 +67,12 @@ position-area: span-left top; } +/* Same as `.spanleft-top`, but the containing-block-dependent padding is set + * as an inline style on the target instead. */ +.target.inline-shifted { + position-area: span-left top; +} + .target.spanall-left { position-area: span-all left; } diff --git a/tests/e2e/position-area.test.ts b/tests/e2e/position-area.test.ts index af0da37..570800d 100644 --- a/tests/e2e/position-area.test.ts +++ b/tests/e2e/position-area.test.ts @@ -396,41 +396,26 @@ test.describe('with `positionAreaContainingBlock: auto`', () => { test('wraps a target whose containing-block-dependent style is inline', async ({ page, }) => { - // Inline styles are shifted into custom properties like the rest of the - // CSS, so a target that takes its `position-area` from a stylesheet while - // setting a containing-block-dependent style inline is still recognised as - // needing the wrapper. Without the shift the inline `padding-right: 50%` - // reads back as empty and the target is positioned directly. - 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 style = document.createElement('style'); - style.textContent = ` - #inline-shifted .anchor { anchor-name: --inline-shifted; } - #inline-shifted .target { - position: absolute; - position-anchor: --inline-shifted; - position-area: top; - }`; - document.head.append(style); - - const container = document.createElement('div'); - container.id = 'inline-shifted'; - container.setAttribute('style', 'position: relative'); - container.innerHTML = ` -
Anchor
-
Target
`; - document.body.append(container); - - await polyfill({ positionAreaContainingBlock: 'auto' }); - }); + // `#inline-shifted .target` takes its `position-area` from a stylesheet and + // sets `padding-right: 50%` inline. Inline styles are shifted into custom + // properties like the rest of the CSS, so the percentage padding is still + // seen here and the target is wrapped. Without the shift it reads back as + // empty and the target is positioned directly. + await applyPolyfill(page); - await expect( - page.locator('#inline-shifted polyfill-position-area'), - ).toHaveCount(1); + const section = page.locator('#inline-shifted'); + const targetWrapper = section.locator('polyfill-position-area'); + await expect(targetWrapper).toHaveCount(1); + + // The reason it needs the wrapper: the padding has to resolve against the + // position-area cell, not the original parent. + const wrapperContentWidth = await targetWrapper.evaluate( + (el) => el.clientWidth, + ); + const paddingRight = await section + .locator('.target') + .evaluate((el) => parseFloat(getComputedStyle(el).paddingRight)); + expect(paddingRight).toBeCloseTo(wrapperContentWidth / 2, 0); }); test('positions a wrapped target correctly', async ({ page }) => { From d61fb3648118ec95af908f0d7700bde4813d467b Mon Sep 17 00:00:00 2001 From: Jeroen Zwartepoorte Date: Tue, 28 Jul 2026 12:28:32 +0200 Subject: [PATCH 3/9] Replace `style*=` queries with a more performant approach --- src/fetch.ts | 74 +++++++++++++++++++++++++++++----------------------- 1 file changed, 42 insertions(+), 32 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 4edde7f..1bc9879 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -70,49 +70,59 @@ async function fetchLinkedStylesheets( // `position-area` — and not just the anchor-specific ones: a target can take // its `position-area` from a stylesheet while setting its margin inline. // `anchor` is matched on its own as well, for `anchor()`/`anchor-size()` values. +// +// Matching tests the `style` attribute against a single regex rather than +// handing `querySelectorAll` one `[style*="..."]` clause per property. Engines +// do not bucket attribute-substring selectors by attribute presence, so a +// ~50-clause query runs every substring test against every element in the +// document; querying `[style]` and filtering here is an order of magnitude +// faster, and scales with the number of styled elements rather than with the +// size of the document. +// +// A term that contains another term is redundant -- `margin` already matches +// `margin-inline-start`, `anchor` already matches `anchor-name` -- so only the +// shortest distinct ones are kept. +// // Built on first use rather than at module evaluation: `cascade.js` and this // module are part of an import cycle, so `SHIFTED_PROPERTIES` is not // necessarily initialized yet when this module is evaluated. -let inlineAnchorStylesQuery: string | undefined; -function elementsWithInlineAnchorStylesQuery() { - inlineAnchorStylesQuery ??= [ - '[style*="anchor"]', - ...Object.keys(SHIFTED_PROPERTIES).map( - (property) => `[style*="${property}"]`, - ), - ].join(','); - return inlineAnchorStylesQuery; +let inlineAnchorStylesRegex: RegExp | undefined; +function hasInlineAnchorStyles(el: HTMLElement) { + if (!inlineAnchorStylesRegex) { + const terms = ['anchor', ...Object.keys(SHIFTED_PROPERTIES)]; + inlineAnchorStylesRegex = new RegExp( + terms + .filter( + (term) => + !terms.some((other) => other !== term && term.includes(other)), + ) + .join('|'), + ); + } + return inlineAnchorStylesRegex.test(el.getAttribute('style') ?? ''); } // Searches for all elements with inline style attributes that include `anchor`. // 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[]) { - const elementsWithInlineAnchorStyles: HTMLElement[] = elements - ? elements.filter( - (el) => - el instanceof HTMLElement && - el.matches(elementsWithInlineAnchorStylesQuery()), - ) - : Array.from( - document.querySelectorAll(elementsWithInlineAnchorStylesQuery()), - ); + const elementsWithInlineAnchorStyles: HTMLElement[] = ( + elements ?? Array.from(document.querySelectorAll('[style]')) + ).filter((el) => el instanceof HTMLElement && hasInlineAnchorStyles(el)); const inlineStyles: Partial[] = []; - elementsWithInlineAnchorStyles - .filter((el) => el instanceof HTMLElement) - .forEach((el) => { - const dataAttribute = 'data-has-inline-styles'; - // Reuse an existing id rather than minting a new one each run: a - // concurrent run (e.g. another shadow root being polyfilled) may already - // be relying on this element's id in an anchor selector, and re-stamping - // it would invalidate that selector. - const selector = el.getAttribute(dataAttribute) ?? nanoid(12); - el.setAttribute(dataAttribute, selector); - const styles = el.getAttribute('style'); - const css = `[${dataAttribute}="${selector}"] { ${styles} }`; - inlineStyles.push({ el, css }); - }); + elementsWithInlineAnchorStyles.forEach((el) => { + const dataAttribute = 'data-has-inline-styles'; + // Reuse an existing id rather than minting a new one each run: a + // concurrent run (e.g. another shadow root being polyfilled) may already + // be relying on this element's id in an anchor selector, and re-stamping + // it would invalidate that selector. + const selector = el.getAttribute(dataAttribute) ?? nanoid(12); + el.setAttribute(dataAttribute, selector); + const styles = el.getAttribute('style'); + const css = `[${dataAttribute}="${selector}"] { ${styles} }`; + inlineStyles.push({ el, css }); + }); return inlineStyles; } From 56adbe3b86b9e93046893cbc550c1bd95d9f6ed2 Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Mon, 3 Aug 2026 13:49:32 -0400 Subject: [PATCH 4/9] Documentation --- src/fetch.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/fetch.ts b/src/fetch.ts index 1bc9879..7e7d7a3 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -87,6 +87,14 @@ async function fetchLinkedStylesheets( // module are part of an import cycle, so `SHIFTED_PROPERTIES` is not // necessarily initialized yet when this module is evaluated. let inlineAnchorStylesRegex: RegExp | undefined; +/** + * Checks if the given element has inline styles used by the polyfill, including + * margin, inset, sizing, padding, self-alignment, `position-area`, and anchor + * properties. + * + * @param el The element to check. + * @returns True if the element has inline styles used by the polyfill. + */ function hasInlineAnchorStyles(el: HTMLElement) { if (!inlineAnchorStylesRegex) { const terms = ['anchor', ...Object.keys(SHIFTED_PROPERTIES)]; From c3a864d518d96d0319a75674d407aed169e343fa Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Fri, 14 Aug 2026 10:09:17 -0400 Subject: [PATCH 5/9] Add tests, remove term reducer --- src/fetch.ts | 12 +++--- tests/unit/fetch.test.ts | 79 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 7e7d7a3..107c260 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -95,16 +95,14 @@ let inlineAnchorStylesRegex: RegExp | undefined; * @param el The element to check. * @returns True if the element has inline styles used by the polyfill. */ -function hasInlineAnchorStyles(el: HTMLElement) { +export function hasInlineAnchorStyles(el: HTMLElement) { if (!inlineAnchorStylesRegex) { const terms = ['anchor', ...Object.keys(SHIFTED_PROPERTIES)]; + // Match at a declaration boundary, so a term appearing in a *value* does + // not count: `float: left` and `line-height: 1.5` are not styles we read. inlineAnchorStylesRegex = new RegExp( - terms - .filter( - (term) => - !terms.some((other) => other !== term && term.includes(other)), - ) - .join('|'), + `(?:^|;)\\s*(?:${terms.join('|')})`, + 'i', ); } return inlineAnchorStylesRegex.test(el.getAttribute('style') ?? ''); diff --git a/tests/unit/fetch.test.ts b/tests/unit/fetch.test.ts index f9be326..7ec6010 100644 --- a/tests/unit/fetch.test.ts +++ b/tests/unit/fetch.test.ts @@ -1,6 +1,6 @@ import fetchMock from 'fetch-mock'; -import { fetchCSS } from '../../src/fetch.js'; +import { fetchCSS, hasInlineAnchorStyles } from '../../src/fetch.js'; import { getSampleCSS, requestWithCSSType } from '../helpers.js'; describe('fetch stylesheet', () => { @@ -246,3 +246,80 @@ describe('fetch styles manually', () => { expect(styleData[3].css).toContain('top: anchor(--anchor bottom);'); }); }); + +describe('hasInlineAnchorStyles', () => { + function elWithStyle(style: string) { + const el = document.createElement('div'); + el.setAttribute('style', style); + return el; + } + + it('returns false when the element has no style attribute', () => { + const el = document.createElement('div'); + expect(hasInlineAnchorStyles(el)).toBe(false); + }); + + it('returns false for an empty style attribute', () => { + expect(hasInlineAnchorStyles(elWithStyle(''))).toBe(false); + }); + + it.each([ + ['color', 'color: red;'], + ['background', 'background: blue;'], + ['font-weight', 'font-weight: bold;'], + ['display', 'display: flex;'], + ['z-index', 'z-index: 1;'], + ['clear', 'clear: both;'], + ['vertical-align', 'vertical-align: middle;'], + ['letter-spacing', 'letter-spacing: 1px;'], + ['box-sizing', 'box-sizing: border-box;'], + ])( + 'returns false for %s, which is unrelated to the polyfill', + (_name, style) => { + expect(hasInlineAnchorStyles(elWithStyle(style))).toBe(false); + }, + ); + + // Don't match terms that appear in other property names or values. + it.each([ + ['border-top (contains "top")', 'border-top: 1px solid red;'], + ['border-left-width (contains "left")', 'border-left-width: 2px;'], + ['line-height (contains "height")', 'line-height: 1.5;'], + ['float: left (contains "left")', 'float: left;'], + ['text-align: right (contains "right")', 'text-align: right;'], + ['outline-width (contains "width")', 'outline-width: 1px;'], + [ + 'background-position: top (contains "top")', + 'background-position: top right;', + ], + ['column-width (contains "width")', 'column-width: 100px;'], + ['transform-origin: top left', 'transform-origin: top left;'], + ['term as custom property', '--anchor: anchor(--my-anchor);'], + ])('returns true (known false positive) for %s', (_name, style) => { + expect(hasInlineAnchorStyles(elWithStyle(style))).toBe(false); + }); + + it.each([ + ['anchor()', 'top: anchor(--my-anchor end);'], + ['anchor-name', 'anchor-name: --my-anchor;'], + ['anchor-scope', 'anchor-scope: --my-anchor;'], + ['position-anchor', 'position-anchor: --my-anchor;'], + ['position-area', 'position-area: top;'], + ['an inset longhand', 'inset-block-start: 1px;'], + ['a plain inset property', 'top: 1px;'], + ['a margin longhand', 'margin-inline-start: 1px;'], + ['a plain margin property', 'margin-left: 1px;'], + ['a sizing property', 'width: 100px;'], + ['a min-sizing longhand', 'min-inline-size: 100px;'], + ['a padding longhand', 'padding-inline-start: 1px;'], + ['a plain padding property', 'padding: 1px;'], + ['a self-alignment property', 'justify-self: center;'], + ])('returns true when the style includes %s', (_name, style) => { + expect(hasInlineAnchorStyles(elWithStyle(style))).toBe(true); + }); + + it('matches regardless of where the relevant declaration falls', () => { + const el = elWithStyle('color: red; anchor-name: --my-anchor; z-index: 1;'); + expect(hasInlineAnchorStyles(el)).toBe(true); + }); +}); From dc5f29a52f58c6203a14b8d95e5b71202ffb2ee6 Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Fri, 14 Aug 2026 10:12:52 -0400 Subject: [PATCH 6/9] Comment --- src/fetch.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/fetch.ts b/src/fetch.ts index 107c260..4e32fbb 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -97,6 +97,9 @@ let inlineAnchorStylesRegex: RegExp | undefined; */ export function hasInlineAnchorStyles(el: HTMLElement) { if (!inlineAnchorStylesRegex) { + // While there is overlap in the terms (`margin` and `margin-block-start`), + // reducing the list to only the shortest distinct terms doesn't + // significantly improve performance. const terms = ['anchor', ...Object.keys(SHIFTED_PROPERTIES)]; // Match at a declaration boundary, so a term appearing in a *value* does // not count: `float: left` and `line-height: 1.5` are not styles we read. From 5c5272f81c9fabe2a00d4fe845f94f28ab500ede Mon Sep 17 00:00:00 2001 From: James Stuckey Weber Date: Fri, 14 Aug 2026 10:20:00 -0400 Subject: [PATCH 7/9] Update comment --- src/fetch.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 4e32fbb..1c9d284 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -110,10 +110,10 @@ export function hasInlineAnchorStyles(el: HTMLElement) { } return inlineAnchorStylesRegex.test(el.getAttribute('style') ?? ''); } -// Searches for all elements with inline style attributes that include `anchor`. -// 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. +// Searches for all elements with inline style attributes that contain +// declarations used by the polyfill. 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[]) { const elementsWithInlineAnchorStyles: HTMLElement[] = ( elements ?? Array.from(document.querySelectorAll('[style]')) From 3e0b420ce0d5b53ca365e417dd1eb4e6bc08a750 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 14 Aug 2026 10:41:11 -0400 Subject: [PATCH 8/9] Update src/fetch.ts --- src/fetch.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/fetch.ts b/src/fetch.ts index 1c9d284..93ce851 100644 --- a/src/fetch.ts +++ b/src/fetch.ts @@ -79,10 +79,6 @@ async function fetchLinkedStylesheets( // faster, and scales with the number of styled elements rather than with the // size of the document. // -// A term that contains another term is redundant -- `margin` already matches -// `margin-inline-start`, `anchor` already matches `anchor-name` -- so only the -// shortest distinct ones are kept. -// // Built on first use rather than at module evaluation: `cascade.js` and this // module are part of an import cycle, so `SHIFTED_PROPERTIES` is not // necessarily initialized yet when this module is evaluated. From e922b9477b5da141e47c351e192ce1de5463aaa3 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Fri, 14 Aug 2026 10:41:19 -0400 Subject: [PATCH 9/9] Update tests/unit/fetch.test.ts --- tests/unit/fetch.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit/fetch.test.ts b/tests/unit/fetch.test.ts index 7ec6010..113c65a 100644 --- a/tests/unit/fetch.test.ts +++ b/tests/unit/fetch.test.ts @@ -295,7 +295,7 @@ describe('hasInlineAnchorStyles', () => { ['column-width (contains "width")', 'column-width: 100px;'], ['transform-origin: top left', 'transform-origin: top left;'], ['term as custom property', '--anchor: anchor(--my-anchor);'], - ])('returns true (known false positive) for %s', (_name, style) => { + ])('returns false for %s', (_name, style) => { expect(hasInlineAnchorStyles(elWithStyle(style))).toBe(false); });