From 0deb59660a132c4e232f2016a37aa8092321f9ce Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Sat, 27 Jun 2026 10:43:12 -0400 Subject: [PATCH 1/5] Fix position-try demos and polyfill logic --- index.html | 10 ++- public/position-try-tactics-combined.css | 19 ++++++ public/position-try-tactics.css | 17 +++++ public/position-try.css | 20 ++++++ src/cascade.ts | 45 ++++++++++++- src/dom.ts | 26 ++++---- src/polyfill.ts | 81 +++++++++++++++--------- tests/e2e/polyfill.test.ts | 13 ++-- 8 files changed, 175 insertions(+), 56 deletions(-) diff --git a/index.html b/index.html index 5190eb01..1d3efae5 100644 --- a/index.html +++ b/index.html @@ -796,7 +796,7 @@

- Fallbacks with position-try-fallbacks ⚠️ + Fallbacks with position-try-fallbacks

@@ -815,11 +815,9 @@

- ⚠️ Note: This example is broken in both polyfilled - and non-polyfilled browsers. -

-

- With polyfill applied, the following positions are attempted in order: + Scroll the box to move the anchors toward its edges. As an anchor + nears an edge, its target would overflow the box, so the following + positions are attempted in order:

  1. diff --git a/public/position-try-tactics-combined.css b/public/position-try-tactics-combined.css index 91468c6c..0d6a88b1 100644 --- a/public/position-try-tactics-combined.css +++ b/public/position-try-tactics-combined.css @@ -1,5 +1,24 @@ +/* Promote the target's containing block to the `.demo-elements` wrapper + (outside the scroll container) so scrolling can push it to overflow and + trigger fallbacks. Position-try options are evaluated against the + inset-modified containing block, not the scrollport. See + https://github.com/oddbird/css-anchor-positioning/issues/279. */ +#position-try-tactics-combined .scroll-container { + position: static; +} + +/* Clip the target at the wrapper (which tightly bounds the scroll container) so + a target whose anchor has scrolled out of view doesn't float over other + content. The wrapper is the target's containing block, so this does not + affect where fallbacks are evaluated. */ +#position-try-tactics-combined .demo-elements { + overflow: clip; +} + #my-anchor-try-tactics-combined { anchor-name: --my-anchor-try-tactics-combined; + top: 5em; + position: relative; } #my-target-try-tactics-combined { diff --git a/public/position-try-tactics.css b/public/position-try-tactics.css index d670c076..672d738c 100644 --- a/public/position-try-tactics.css +++ b/public/position-try-tactics.css @@ -1,3 +1,20 @@ +/* Promote the targets' containing block to the `.demo-elements` wrapper + (outside the scroll container) so scrolling can push them to overflow and + trigger fallbacks. Position-try options are evaluated against the + inset-modified containing block, not the scrollport. See + https://github.com/oddbird/css-anchor-positioning/issues/279. */ +#position-try-tactics .scroll-container { + position: static; +} + +/* Clip targets at the wrapper (which tightly bounds the scroll container) so a + target whose anchor has scrolled out of view doesn't float over other + content. The wrapper is the targets' containing block, so this does not + affect where fallbacks are evaluated. */ +#position-try-tactics .demo-elements { + overflow: clip; +} + #my-anchor-try-tactics { anchor-name: --my-anchor-try-tactics; } diff --git a/public/position-try.css b/public/position-try.css index 90eaa272..0f15f160 100644 --- a/public/position-try.css +++ b/public/position-try.css @@ -1,3 +1,23 @@ +/* The targets' containing block must be an ancestor *outside* the scroll + container so that scrolling can push them to overflow (and trigger fallbacks). + Position-try options are evaluated against the inset-modified containing + block, not the scrollport — so if the scroll container were the containing + block, the target would scroll in lockstep with its anchor and never + overflow. Making the scroll container non-positioned promotes the containing + block up to the `.demo-elements` wrapper. See + https://github.com/oddbird/css-anchor-positioning/issues/279. */ +#position-try .scroll-container { + position: static; +} + +/* Clip targets at the wrapper (which tightly bounds the scroll container) so a + target whose anchor has scrolled out of view doesn't float over other + content. The wrapper is the targets' containing block, so this does not + affect where fallbacks are evaluated. */ +#position-try .demo-elements { + overflow: clip; +} + #my-anchor-fallback { anchor-name: --my-anchor-fallback; } diff --git a/src/cascade.ts b/src/cascade.ts index ed4241d0..f3e5da8f 100644 --- a/src/cascade.ts +++ b/src/cascade.ts @@ -14,8 +14,8 @@ import { /** * Map of CSS property to CSS custom property that the property's value is * shifted into. This is used to subject properties that are not yet natively - * supported to the CSS cascade and inheritance rules. It is also used by the - * fallback algorithm to find initial, non-computed values. + * supported to the CSS cascade so later stages can read computed values. It is + * also used by the fallback algorithm to find initial, non-computed values. */ export const SHIFTED_PROPERTIES: Record = [ ...ACCEPTED_POSITION_TRY_PROPERTIES, @@ -29,6 +29,45 @@ export const SHIFTED_PROPERTIES: Record = [ {} as Record, ); +/** + * Register the shifted custom properties as non-inherited. + * + * Every property we shift (insets, margins, sizing, self-alignment, + * `position-anchor`, `position-area`, `anchor-name`, `anchor-scope`) is + * non-inherited in CSS, but custom properties inherit by default. Without + * registering them as non-inherited, a value set on an ancestor (e.g. + * `height: 400px` on a scroll container) would be inherited by descendants + * through the shifted custom property and incorrectly read back as if it were + * set directly on them — leaking, for example, into every generated position + * fallback. See https://github.com/oddbird/css-anchor-positioning/issues/279. + * + * Registered properties are global to the document (including shadow trees), so + * this only needs to run once. + */ +let propertiesRegistered = false; +export function registerShiftedProperties() { + if ( + propertiesRegistered || + typeof CSS === 'undefined' || + typeof CSS.registerProperty !== 'function' + ) { + return; + } + for (const customProperty of Object.values(SHIFTED_PROPERTIES)) { + try { + CSS.registerProperty({ + name: customProperty, + syntax: '*', + inherits: false, + }); + } catch { + // Ignore properties that are already registered (e.g. by an earlier run + // of the polyfill). + } + } + propertiesRegistered = true; +} + /** * Shift property declarations for properties that are not yet natively * supported into custom properties. @@ -130,6 +169,8 @@ function expandInsetShorthands(node: CssNode, block?: Block) { * the polyfill to work as expected. */ export function cascadeCSS(styleData: StyleData[]) { + registerShiftedProperties(); + for (const styleObj of styleData) { let changed = false; const ast = getAST(styleObj.css, true); diff --git a/src/dom.ts b/src/dom.ts index b2c667de..81dedbeb 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -35,8 +35,10 @@ export const enum AnchorScopeValue { /** * Gets the computed value of a CSS property for an element or pseudo-element. * - * Note: values for properties that are not natively supported are *always* - * subject to CSS inheritance. + * Note: properties that are not natively supported are read from the custom + * property they were shifted into. Those custom properties are registered as + * non-inherited (see `registerShiftedProperties`), so this mirrors the + * (non-inherited) behavior of the properties they stand in for. */ export function getCSSPropertyValue( el: HTMLElement | PseudoElement, @@ -51,9 +53,6 @@ export function getCSSPropertyValue( /** * Checks whether a given element or pseudo-element has the given property * value. - * - * Note: values for properties that are not natively supported are *always* - * subject to CSS inheritance. */ export function hasStyle( element: HTMLElement | PseudoElement, @@ -210,11 +209,9 @@ export function getElementsBySelector( /** * Checks whether the given element has the given anchor name, based on the - * element's computed style. - * - * Note: because our `--anchor-name` custom property inherits, this function - * should only be called for elements which are known to have an explicitly set - * value for `anchor-name`. + * element's computed style. Our `--anchor-name` custom property is registered + * as non-inherited (see `registerShiftedProperties`), so this reflects only a + * value set directly on the element, matching native `anchor-name`. */ export function hasAnchorName( el: PseudoElement | HTMLElement, @@ -231,11 +228,10 @@ export function hasAnchorName( } /** - * Checks whether the given element serves as a scope for the given anchor. - * - * Note: because our `--anchor-scope` custom property inherits, this function - * should only be called for elements which are known to have an explicitly set - * value for `anchor-scope`. + * Checks whether the given element serves as a scope for the given anchor. Our + * `--anchor-scope` custom property is registered as non-inherited (see + * `registerShiftedProperties`), so this reflects only a value set directly on + * the element, matching native `anchor-scope`. */ export function hasAnchorScope( el: PseudoElement | HTMLElement, diff --git a/src/polyfill.ts b/src/polyfill.ts index 66c5c47b..75ed4eaf 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -1,7 +1,5 @@ import { autoUpdate, - detectOverflow, - type MiddlewareState, platform, type Rect, type VirtualElement, @@ -38,8 +36,6 @@ import { strategyForElement, } from './utils.js'; -const platformWithCache = { ...platform, _c: new Map() }; - export const resolveLogicalSideKeyword = (side: AnchorSide, rtl: boolean) => { let percentage: number | undefined; switch (side) { @@ -126,6 +122,9 @@ const getBorders = (el: HTMLElement, axis: 'x' | 'y') => { ); }; +const getBorder = (el: HTMLElement, dir: 'top' | 'right' | 'bottom' | 'left') => + parseInt(getCSSPropertyValue(el, `border-${dir}-width`), 10) || 0; + const getMargin = (el: HTMLElement, dir: 'top' | 'right' | 'bottom' | 'left') => parseInt(getCSSPropertyValue(el, `margin-${dir}`), 10) || 0; @@ -451,29 +450,53 @@ async function applyAnchorPositions( } } -async function checkOverflow(target: HTMLElement, offsetParent: HTMLElement) { - const rects = await platform.getElementRects({ - reference: target, - floating: target, - strategy: strategyForElement(target), - }); - const overflow = await detectOverflow( - { - x: target.offsetLeft, - y: target.offsetTop, - platform: platformWithCache, - rects, - elements: { - floating: target, - reference: offsetParent, - }, - strategy: strategyForElement(target), - } as unknown as MiddlewareState, - { - padding: getMargins(target), - }, - ); - return overflow; +// How far the target's margin-box overflows each edge of its containing block. +// Positive values indicate overflow on that side. +// +// Per spec, position-try options are evaluated against the target's +// inset-modified containing block — not the scrollport of an arbitrary ancestor +// scroll container. We therefore measure against the target's `offsetParent` +// (its containing block), which is what native browsers use. Both rects are read +// in viewport coordinates via `getBoundingClientRect`, so the page's scroll +// offset cancels out regardless of how far down the document the target is. See +// https://github.com/oddbird/css-anchor-positioning/issues/279. +function checkOverflow(target: HTMLElement, offsetParent: HTMLElement) { + const targetRect = target.getBoundingClientRect(); + const margin = getMargins(target); + const marginBox = { + top: targetRect.top - margin.top, + bottom: targetRect.bottom + margin.bottom, + left: targetRect.left - margin.left, + right: targetRect.right + margin.right, + }; + + // The containing block for an absolutely positioned element is the padding box + // of its `offsetParent`. When there is no element offsetParent (e.g. a + // fixed-positioned target), the containing block is the viewport. + let containingBlock; + if (offsetParent === document.documentElement) { + containingBlock = { + top: 0, + left: 0, + right: document.documentElement.clientWidth, + bottom: document.documentElement.clientHeight, + }; + } else { + const parentRect = offsetParent.getBoundingClientRect(); + containingBlock = { + top: parentRect.top + getBorder(offsetParent, 'top'), + left: parentRect.left + getBorder(offsetParent, 'left'), + right: parentRect.right - getBorder(offsetParent, 'right'), + bottom: parentRect.bottom - getBorder(offsetParent, 'bottom'), + }; + } + + return { + top: containingBlock.top - marginBox.top, + bottom: marginBox.bottom - containingBlock.bottom, + left: containingBlock.left - marginBox.left, + right: marginBox.right - containingBlock.right, + }; } async function applyPositionFallbacks( @@ -507,7 +530,7 @@ async function applyPositionFallbacks( } checking = true; target.removeAttribute('data-anchor-polyfill'); - const defaultOverflow = await checkOverflow(target, offsetParent); + const defaultOverflow = checkOverflow(target, offsetParent); // If none of the sides overflow, don't try fallbacks if (Object.values(defaultOverflow).every((side) => side <= 0)) { target.removeAttribute('data-anchor-polyfill-last-successful'); @@ -520,7 +543,7 @@ async function applyPositionFallbacks( for (const [index, { uuid }] of fallbacks.entries()) { target.setAttribute('data-anchor-polyfill', uuid); - const overflow = await checkOverflow(target, offsetParent); + const overflow = checkOverflow(target, offsetParent); // If none of the sides overflow, use this fallback and stop loop. if (Object.values(overflow).every((side) => side <= 0)) { diff --git a/tests/e2e/polyfill.test.ts b/tests/e2e/polyfill.test.ts index e3d0edf7..225e9ed3 100644 --- a/tests/e2e/polyfill.test.ts +++ b/tests/e2e/polyfill.test.ts @@ -220,16 +220,21 @@ test('applies polyfill for `@position-fallback`', async ({ page }) => { const target = page.locator(targetSel); await target.scrollIntoViewIfNeeded(); - await expect(target).toHaveCSS('left', '0px'); + // Capture the target's pre-polyfill (static) position so we can assert the + // polyfill moves it. + const initialLeft = await target.evaluate( + (node) => getComputedStyle(node).left, + ); await applyPolyfill(page); - await expect(target).not.toHaveCSS('left', '0px'); + await expect(target).not.toHaveCSS('left', initialLeft); await expect(target).not.toHaveCSS('width', '100px'); await target.evaluate((node: HTMLElement) => { - (node.offsetParent as HTMLElement).scrollLeft = 180; - (node.offsetParent as HTMLElement).scrollTop = 120; + const scrollContainer = node.closest('.scroll-container') as HTMLElement; + scrollContainer.scrollLeft = 180; + scrollContainer.scrollTop = 120; }); await expect(target).toHaveCSS('width', '100px'); From 5cd9353f26d1b5009d0e5a3af2ce25481d08f4ee Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 29 Jun 2026 15:33:18 -0400 Subject: [PATCH 2/5] review --- index.html | 8 +++-- src/dom.ts | 26 ++++++++++------ src/polyfill.ts | 57 ++++++++++++++++++++--------------- tests/e2e/polyfill.test.ts | 61 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 35 deletions(-) diff --git a/index.html b/index.html index 1d3efae5..6b910356 100644 --- a/index.html +++ b/index.html @@ -900,7 +900,9 @@

- With polyfill applied, the following positions are attempted in order: + Scroll the box to move the anchors toward its edges. As an anchor + nears an edge, its target would overflow the box, so the following + positions are attempted in order:

  1. @@ -1003,7 +1005,9 @@

- With polyfill applied, the following positions are attempted in order: + Scroll the box to move the anchor toward its edges. As the anchor + nears an edge, the target would overflow the box, so the following + positions are attempted in order:

  1. diff --git a/src/dom.ts b/src/dom.ts index 81dedbeb..0992753f 100644 --- a/src/dom.ts +++ b/src/dom.ts @@ -36,9 +36,11 @@ export const enum AnchorScopeValue { * Gets the computed value of a CSS property for an element or pseudo-element. * * Note: properties that are not natively supported are read from the custom - * property they were shifted into. Those custom properties are registered as + * property they were shifted into. Where `CSS.registerProperty` is supported + * (Safari 16.4+, Firefox 128+), those custom properties are registered as * non-inherited (see `registerShiftedProperties`), so this mirrors the - * (non-inherited) behavior of the properties they stand in for. + * (non-inherited) behavior of the properties they stand in for. On older + * engines they fall back to inheriting like any other custom property. */ export function getCSSPropertyValue( el: HTMLElement | PseudoElement, @@ -209,9 +211,12 @@ export function getElementsBySelector( /** * Checks whether the given element has the given anchor name, based on the - * element's computed style. Our `--anchor-name` custom property is registered - * as non-inherited (see `registerShiftedProperties`), so this reflects only a - * value set directly on the element, matching native `anchor-name`. + * element's computed style. Where `CSS.registerProperty` is supported, our + * `--anchor-name` custom property is registered as non-inherited (see + * `registerShiftedProperties`), so this reflects only a value set directly on + * the element, matching native `anchor-name`. On older engines the custom + * property inherits, so this should only be called for elements known to have + * an explicitly set value for `anchor-name`. */ export function hasAnchorName( el: PseudoElement | HTMLElement, @@ -228,10 +233,13 @@ export function hasAnchorName( } /** - * Checks whether the given element serves as a scope for the given anchor. Our - * `--anchor-scope` custom property is registered as non-inherited (see - * `registerShiftedProperties`), so this reflects only a value set directly on - * the element, matching native `anchor-scope`. + * Checks whether the given element serves as a scope for the given anchor. + * Where `CSS.registerProperty` is supported, our `--anchor-scope` custom + * property is registered as non-inherited (see `registerShiftedProperties`), so + * this reflects only a value set directly on the element, matching native + * `anchor-scope`. On older engines the custom property inherits, so this should + * only be called for elements known to have an explicitly set value for + * `anchor-scope`. */ export function hasAnchorScope( el: PseudoElement | HTMLElement, diff --git a/src/polyfill.ts b/src/polyfill.ts index 75ed4eaf..5453dad7 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -125,15 +125,19 @@ const getBorders = (el: HTMLElement, axis: 'x' | 'y') => { const getBorder = (el: HTMLElement, dir: 'top' | 'right' | 'bottom' | 'left') => parseInt(getCSSPropertyValue(el, `border-${dir}-width`), 10) || 0; -const getMargin = (el: HTMLElement, dir: 'top' | 'right' | 'bottom' | 'left') => - parseInt(getCSSPropertyValue(el, `margin-${dir}`), 10) || 0; - +// Read the element's used margins from its computed style. Unlike reading the +// shifted `--margin-*` custom properties, this resolves the `margin` shorthand, +// logical margins, percentages, and `auto` to physical pixel values, and +// reflects any margin applied by the currently-active position fallback. const getMargins = (el: HTMLElement) => { + const style = getComputedStyle(el); + const margin = (dir: 'top' | 'right' | 'bottom' | 'left') => + parseFloat(style.getPropertyValue(`margin-${dir}`)) || 0; return { - top: getMargin(el, 'top'), - right: getMargin(el, 'right'), - bottom: getMargin(el, 'bottom'), - left: getMargin(el, 'left'), + top: margin('top'), + right: margin('right'), + bottom: margin('bottom'), + left: margin('left'), }; }; @@ -473,23 +477,28 @@ function checkOverflow(target: HTMLElement, offsetParent: HTMLElement) { // The containing block for an absolutely positioned element is the padding box // of its `offsetParent`. When there is no element offsetParent (e.g. a // fixed-positioned target), the containing block is the viewport. - let containingBlock; - if (offsetParent === document.documentElement) { - containingBlock = { - top: 0, - left: 0, - right: document.documentElement.clientWidth, - bottom: document.documentElement.clientHeight, - }; - } else { - const parentRect = offsetParent.getBoundingClientRect(); - containingBlock = { - top: parentRect.top + getBorder(offsetParent, 'top'), - left: parentRect.left + getBorder(offsetParent, 'left'), - right: parentRect.right - getBorder(offsetParent, 'right'), - bottom: parentRect.bottom - getBorder(offsetParent, 'bottom'), - }; - } + const containingBlock: { + top: number; + left: number; + right: number; + bottom: number; + } = + offsetParent === document.documentElement + ? { + top: 0, + left: 0, + right: document.documentElement.clientWidth, + bottom: document.documentElement.clientHeight, + } + : (() => { + const parentRect = offsetParent.getBoundingClientRect(); + return { + top: parentRect.top + getBorder(offsetParent, 'top'), + left: parentRect.left + getBorder(offsetParent, 'left'), + right: parentRect.right - getBorder(offsetParent, 'right'), + bottom: parentRect.bottom - getBorder(offsetParent, 'bottom'), + }; + })(); return { top: containingBlock.top - marginBox.top, diff --git a/tests/e2e/polyfill.test.ts b/tests/e2e/polyfill.test.ts index 225e9ed3..7a5275bd 100644 --- a/tests/e2e/polyfill.test.ts +++ b/tests/e2e/polyfill.test.ts @@ -241,6 +241,67 @@ test('applies polyfill for `@position-fallback`', async ({ page }) => { await expect(target).toHaveCSS('height', '100px'); }); +test('applies `@position-try` fallback for a fixed-positioned target', async ({ + page, +}) => { + // A fixed-positioned target's containing block is the viewport, so its + // `offsetParent` resolves to the document element. This exercises the + // viewport branch of `checkOverflow`. The anchor sits near the bottom of the + // viewport, so the base position (below the anchor) overflows and the + // fallback (above the anchor) should be applied. + await page.evaluate(() => { + const style = document.createElement('style'); + style.textContent = ` + #fixed-fallback-anchor { + anchor-name: --fixed-fallback-anchor; + position: fixed; + top: 90vh; + left: 50px; + width: 100px; + height: 20px; + } + #fixed-fallback-target { + position: fixed; + position-anchor: --fixed-fallback-anchor; + top: anchor(bottom); + left: anchor(left); + width: 100px; + height: 100px; + position-try-fallbacks: --fixed-flip; + } + @position-try --fixed-flip { + bottom: anchor(top); + top: revert; + } + `; + document.head.append(style); + const anchor = document.createElement('div'); + anchor.id = 'fixed-fallback-anchor'; + const target = document.createElement('div'); + target.id = 'fixed-fallback-target'; + document.body.append(anchor, target); + }); + + await applyPolyfill(page); + + const { anchor, target } = await page.evaluate(() => { + const anchorEl = document.getElementById('fixed-fallback-anchor')!; + const targetEl = document.getElementById('fixed-fallback-target')!; + return { + anchor: anchorEl.getBoundingClientRect(), + target: targetEl.getBoundingClientRect(), + }; + }); + + // The target is anchored horizontally (`left: anchor(left)`), which only holds + // if `anchor()` actually resolved — guarding against a trivial pass where the + // polyfill didn't run and the target sits at its static position. + expect(target.left).toBeCloseTo(anchor.left, 0); + // The base position (below the anchor) overflows the viewport, so the fallback + // flips the target above its anchor to keep it in view. + expect(target.bottom).toBeLessThanOrEqual(anchor.top + 1); +}); + test('applies manual polyfill', async ({ page }) => { const applyButton = page.locator('#apply-polyfill-manually'); await applyButton.click(); From e93b9d27afe57cc4be2892c036ea8623c4528183 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 29 Jun 2026 15:54:43 -0400 Subject: [PATCH 3/5] review --- src/polyfill.ts | 16 ++++++++-------- tests/e2e/polyfill.test.ts | 10 +++++----- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/polyfill.ts b/src/polyfill.ts index 5453dad7..83280a7d 100644 --- a/src/polyfill.ts +++ b/src/polyfill.ts @@ -116,14 +116,14 @@ const getBorders = (el: HTMLElement, axis: 'x' | 'y') => { : ['border-top-width', 'border-bottom-width']; return ( props.reduce( - (total, prop) => total + parseInt(getCSSPropertyValue(el, prop), 10), + (total, prop) => total + parseFloat(getCSSPropertyValue(el, prop)), 0, ) || 0 ); }; const getBorder = (el: HTMLElement, dir: 'top' | 'right' | 'bottom' | 'left') => - parseInt(getCSSPropertyValue(el, `border-${dir}-width`), 10) || 0; + parseFloat(getComputedStyle(el).getPropertyValue(`border-${dir}-width`)) || 0; // Read the element's used margins from its computed style. Unlike reading the // shifted `--margin-*` custom properties, this resolves the `margin` shorthand, @@ -460,10 +460,10 @@ async function applyAnchorPositions( // Per spec, position-try options are evaluated against the target's // inset-modified containing block — not the scrollport of an arbitrary ancestor // scroll container. We therefore measure against the target's `offsetParent` -// (its containing block), which is what native browsers use. Both rects are read -// in viewport coordinates via `getBoundingClientRect`, so the page's scroll -// offset cancels out regardless of how far down the document the target is. See -// https://github.com/oddbird/css-anchor-positioning/issues/279. +// (its containing block), which is what native browsers use. Both rects are +// read in viewport coordinates via `getBoundingClientRect`, so the page's +// scroll offset cancels out regardless of how far down the document the target +// is. See https://github.com/oddbird/css-anchor-positioning/issues/279. function checkOverflow(target: HTMLElement, offsetParent: HTMLElement) { const targetRect = target.getBoundingClientRect(); const margin = getMargins(target); @@ -474,8 +474,8 @@ function checkOverflow(target: HTMLElement, offsetParent: HTMLElement) { right: targetRect.right + margin.right, }; - // The containing block for an absolutely positioned element is the padding box - // of its `offsetParent`. When there is no element offsetParent (e.g. a + // The containing block for an absolutely positioned element is the padding + // box of its `offsetParent`. When there is no element offsetParent (e.g. a // fixed-positioned target), the containing block is the viewport. const containingBlock: { top: number; diff --git a/tests/e2e/polyfill.test.ts b/tests/e2e/polyfill.test.ts index 7a5275bd..197499d9 100644 --- a/tests/e2e/polyfill.test.ts +++ b/tests/e2e/polyfill.test.ts @@ -293,12 +293,12 @@ test('applies `@position-try` fallback for a fixed-positioned target', async ({ }; }); - // The target is anchored horizontally (`left: anchor(left)`), which only holds - // if `anchor()` actually resolved — guarding against a trivial pass where the - // polyfill didn't run and the target sits at its static position. + // The target is anchored horizontally (`left: anchor(left)`), which only + // holds if `anchor()` actually resolved — guarding against a trivial pass + // where the polyfill didn't run and the target sits at its static position. expect(target.left).toBeCloseTo(anchor.left, 0); - // The base position (below the anchor) overflows the viewport, so the fallback - // flips the target above its anchor to keep it in view. + // The base position (below the anchor) overflows the viewport, so the + // fallback flips the target above its anchor to keep it in view. expect(target.bottom).toBeLessThanOrEqual(anchor.top + 1); }); From 34fd47c63da2589aa4f9d29576d52453daf69865 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 29 Jun 2026 17:16:48 -0400 Subject: [PATCH 4/5] Fix bug in parsePositionFallbacks --- src/fallback.ts | 27 +++++++++++++++++++++------ tests/unit/fallback.test.ts | 32 +++++++++++++++++++++++++++++++- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/src/fallback.ts b/src/fallback.ts index dd49f203..9fbfb7ea 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -576,11 +576,19 @@ export function parsePositionFallbacks(styleData: StyleData[]) { // Parse `position-try`, `position-try-order`, and // `position-try-fallbacks` declarations const { order, options } = getPositionFallbackValues(node); - const anchorPosition: AnchorPosition = {}; - if (order) { - anchorPosition.order = order; - } selectors.forEach(({ selector }) => { + // Each selector in a comma-separated list gets its own position, so + // fallbacks generated for one selector don't leak onto another. This + // matters for try-tactics, whose fallbacks are keyed per selector + // (`${selector}-${tactics}`); without per-selector scoping, the second + // target ends up trying the first target's fallbacks. See + // https://github.com/oddbird/css-anchor-positioning/issues/279. + const anchorPosition: AnchorPosition = {}; + if (order) { + anchorPosition.order = order; + } + // Track which fallbacks have been added to *this* selector. + const selectorFallbacksAdded = new Set(); options?.forEach((tryObject) => { let name; // Apply try fallback @@ -623,12 +631,19 @@ export function parsePositionFallbacks(styleData: StyleData[]) { fallbackTargets[dataAttr] ??= []; fallbackTargets[dataAttr].push(selector); - if (!fallbacksAdded.has(name)) { + // Add the fallback to this selector's position (deduped per + // selector). + if (!selectorFallbacksAdded.has(name)) { + selectorFallbacksAdded.add(name); anchorPosition.fallbacks ??= []; anchorPosition.fallbacks.push(fallbacks[name]); + } + + // Inject the generated `@position-try` block once per stylesheet, + // scoped to a unique data-attr. + if (!fallbacksAdded.has(name)) { fallbacksAdded.add(name); - // Add `@position-try` block, scoped to a unique data-attr this.stylesheet?.children.prependData({ type: 'Rule', prelude: { diff --git a/tests/unit/fallback.test.ts b/tests/unit/fallback.test.ts index 6fd4053c..cda4036b 100644 --- a/tests/unit/fallback.test.ts +++ b/tests/unit/fallback.test.ts @@ -3,8 +3,9 @@ import type * as csstree from 'css-tree'; import { applyTryTacticsToSelector, getPositionTryDeclaration, + parsePositionFallbacks, } from '../../src/fallback.js'; -import { getAST, INSTANCE_UUID } from '../../src/utils.js'; +import { getAST, INSTANCE_UUID, type StyleData } from '../../src/utils.js'; const setup = (styles: string) => { document.body.innerHTML = `
    Test
    `; @@ -428,4 +429,33 @@ describe('fallback', () => { }); }); }); + + describe('parsePositionFallbacks', () => { + it('scopes try-tactic fallbacks per selector in a selector list', () => { + // Two targets sharing one rule via a comma-separated selector, each with + // a different `bottom` value so their flip-block fallbacks differ. + document.body.innerHTML = + `
    ` + + `
    `; + const styleData: StyleData[] = [ + { css: '#a, #b { position-try-fallbacks: flip-block; }' }, + ]; + + const { validPositions } = parsePositionFallbacks(styleData); + + // Each selector should get only its own fallback — not the other's. (A + // shared `anchorPosition` object previously merged both, so `#b` started + // with `#a`'s fallback; see #279.) + expect(validPositions['#a'].fallbacks).toHaveLength(1); + expect(validPositions['#b'].fallbacks).toHaveLength(1); + expect(validPositions['#a'].fallbacks?.[0].declarations).toMatchObject({ + top: '10px', + bottom: 'revert', + }); + expect(validPositions['#b'].fallbacks?.[0].declarations).toMatchObject({ + top: '20px', + bottom: 'revert', + }); + }); + }); }); From 363ad7cc8435dec49c20357c27e6bdc43ba2be20 Mon Sep 17 00:00:00 2001 From: Jonny Gerig Meyer Date: Mon, 29 Jun 2026 17:18:23 -0400 Subject: [PATCH 5/5] lint --- src/fallback.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/fallback.ts b/src/fallback.ts index 9fbfb7ea..c748dcef 100644 --- a/src/fallback.ts +++ b/src/fallback.ts @@ -580,8 +580,8 @@ export function parsePositionFallbacks(styleData: StyleData[]) { // Each selector in a comma-separated list gets its own position, so // fallbacks generated for one selector don't leak onto another. This // matters for try-tactics, whose fallbacks are keyed per selector - // (`${selector}-${tactics}`); without per-selector scoping, the second - // target ends up trying the first target's fallbacks. See + // (`${selector}-${tactics}`); without per-selector scoping, the + // second target ends up trying the first target's fallbacks. See // https://github.com/oddbird/css-anchor-positioning/issues/279. const anchorPosition: AnchorPosition = {}; if (order) {