Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 10 additions & 8 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -796,7 +796,7 @@ <h2>
<section id="position-try-tactics" class="demo-item">
<h2>
<a href="#position-try-tactics" aria-hidden="true">🔗</a>
Fallbacks with <code>position-try-fallbacks</code> ⚠️
Fallbacks with <code>position-try-fallbacks</code>
</h2>
<div style="position: relative" class="demo-elements tight">
<div class="scroll-container">
Expand All @@ -815,11 +815,9 @@ <h2>
</div>
<div class="note">
<p>
<strong>⚠️ Note: </strong>This example is broken in both polyfilled
and non-polyfilled browsers.
</p>
<p>
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:
</p>
<ol>
<li>
Expand Down Expand Up @@ -902,7 +900,9 @@ <h2>
</div>
<div class="note">
<p>
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:
</p>
<ol>
<li>
Expand Down Expand Up @@ -1005,7 +1005,9 @@ <h2>
</div>
<div class="note">
<p>
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:
</p>
<ol>
<li>
Expand Down
19 changes: 19 additions & 0 deletions public/position-try-tactics-combined.css
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down
17 changes: 17 additions & 0 deletions public/position-try-tactics.css
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
20 changes: 20 additions & 0 deletions public/position-try.css
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down
45 changes: 43 additions & 2 deletions src/cascade.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = [
...ACCEPTED_POSITION_TRY_PROPERTIES,
Expand All @@ -29,6 +29,45 @@ export const SHIFTED_PROPERTIES: Record<string, string> = [
{} as Record<string, string>,
);

/**
* 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,
});
Comment on lines +58 to +62

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

TIL about CSS.registerProperty 🎉

} 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.
Expand Down Expand Up @@ -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);
Expand Down
32 changes: 18 additions & 14 deletions src/dom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ 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. 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. On older
* engines they fall back to inheriting like any other custom property.
*/
export function getCSSPropertyValue(
el: HTMLElement | PseudoElement,
Expand All @@ -51,9 +55,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,
Expand Down Expand Up @@ -210,11 +211,12 @@ 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. 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,
Expand All @@ -232,10 +234,12 @@ 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`.
* 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,
Expand Down
27 changes: 21 additions & 6 deletions src/fallback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@
selector: string,
tactics: PositionTryOptionsTryTactics[],
) {
// todo: This currently only uses the styles from the first match. Each

Check warning on line 164 in src/fallback.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'todo: This currently only uses the...'
// element may have different styles and need a separate fallback definition.
const el: HTMLElement | null = document.querySelector(selector);
if (el) {
Expand Down Expand Up @@ -275,7 +275,7 @@
end: 'start',
},
'flip-start': {
// TODO: Requires fuller logic

Check warning on line 278 in src/fallback.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'TODO: Requires fuller logic'
},
};

Expand All @@ -300,7 +300,7 @@
tactic: PositionTryOptionsTryTactics,
) {
if (tactic === 'flip-start') {
// TODO: Handle flip-start

Check warning on line 303 in src/fallback.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'TODO: Handle flip-start'
return prop;
} else {
const mapping = PositionAreaPropertyMapping[tactic];
Expand All @@ -316,7 +316,7 @@
valueAst: Value,
tactic: PositionTryOptionsTryTactics,
) {
// TODO: Handle flip-start

Check warning on line 319 in src/fallback.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'TODO: Handle flip-start'
if (key === 'margin') {
const [first, second, third, fourth] = valueAst.children.toArray();
if (tactic === 'flip-block') {
Expand Down Expand Up @@ -371,7 +371,7 @@
declarations[key] ??= 'revert';
}

// todo: This does not support percentage anchor-side values, nor anchor

Check warning on line 374 in src/fallback.ts

View workflow job for this annotation

GitHub Actions / Lint

Unexpected 'todo' comment: 'todo: This does not support percentage...'
// functions that are passed through custom properties.
walk(valueAst, {
visit: 'Function',
Expand Down Expand Up @@ -576,11 +576,19 @@
// Parse `position-try`, `position-try-order`, and
// `position-try-fallbacks` declarations
const { order, options } = getPositionFallbackValues(node);
const anchorPosition: AnchorPosition = {};
if (order) {
anchorPosition.order = order;
}
Comment on lines -579 to -582

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Claude's explanation of this change:

In the polyfill, a comma-separated selector list that uses position-try-fallbacks (e.g. #a, #b { position-try-fallbacks: flip-block, … }) caused the second target to be positioned using the first target's fallbacks. Visibly, in Firefox the second target stayed static (un-anchored) and targets could revert to a non-anchored position when a fallback couldn't be placed.

Cause

In parsePositionFallbacks, the anchorPosition object was declared outside the selectors.forEach loop, so it was shared across every selector in the list. Because try-tactic fallbacks are keyed per selector (${selector}-${tactics}), each selector's fallbacks accumulated into that one shared object — and since validPositions[selectorA] and validPositions[selectorB] both referenced it, the second target's fallback list began with the first target's fallbacks (which resolve against the wrong anchor). @position-try (at-rule) fallbacks were unaffected because their names are shared across selectors.

Fix

Give each selector its own anchorPosition, and separate the two concerns that were conflated under a single dedup guard:

  • Add a fallback to this selector's position — now tracked per selector.
  • Inject the generated @position-try rule into the stylesheet — still deduped once per stylesheet.

Tests

Added a regression unit test asserting that each selector in a shared rule gets only its own try-tactic fallback. Existing unit (186) and e2e (37) suites pass.

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<string>();
options?.forEach((tryObject) => {
let name;
// Apply try fallback
Expand Down Expand Up @@ -623,12 +631,19 @@
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: {
Expand Down
Loading
Loading