Skip to content

[#279] Fix position-try demos and polyfill logic - #424

Merged
jamesnw merged 12 commits into
mainfrom
jgerigmeyer/279-fix-position-fallback
Jul 21, 2026
Merged

[#279] Fix position-try demos and polyfill logic#424
jamesnw merged 12 commits into
mainfrom
jgerigmeyer/279-fix-position-fallback

Conversation

@jgerigmeyer

@jgerigmeyer jgerigmeyer commented Jun 27, 2026

Copy link
Copy Markdown
Member

Resolves #279

Background

The position-try demos didn't update on scroll, and were broken in native browsers too, not just under the polyfill. Investigating in real native Chrome (150) confirmed two distinct problems:

  1. Demo authoring bug (affects native). Per spec, position-try options are evaluated by checking whether the target overflows its inset-modified containing blocknot the scrollport of an ancestor scroll container. Every demo target's containing block was its .scroll-container (position: relative), which also contains the anchor. So scrolling moved the anchor and target together and the target never overflowed its containing block → fallbacks never triggered, natively or otherwise. The polyfill only appeared to work because it measured overflow against the scrollport, diverging from native.

  2. Polyfill inheritance leak. Properties shifted into custom properties (height, insets, margins, anchor-name, …) are all non-inherited in CSS, but custom properties inherit by default. So height: 400px on a .scroll-container leaked through --height-<uuid> into every descendant target and got stamped into every generated fallback.

Changes

  • Demos (public/position-try*.css): make the position-try scroll containers position: static so each target's containing block is promoted to the surrounding .demo-elements wrapper (an ancestor outside the scroller). Now scrolling pushes the target to overflow and fallbacks trigger — natively and under the polyfill. Added overflow: clip on the wrapper so a target whose anchor has scrolled out of view doesn't float over other content, and gave the combined demo's anchor an offset so its base position fits at rest. Removed the "this example is broken" warning in index.html.

  • Non-inherited custom properties (src/cascade.ts, src/dom.ts): register the shifted custom properties with CSS.registerProperty({ syntax: '*', inherits: false }) so a value set on an ancestor is no longer read back as if it were set directly on a descendant. Registration is global and runs once; it no-ops where CSS.registerProperty is unavailable. Updated the now-inaccurate inheritance comments.

  • Overflow basis (src/polyfill.ts): rewrote checkOverflow to measure the target's margin-box against its containing block (offsetParent) using getBoundingClientRect, matching the spec/native behavior instead of floating-ui's scrollport-based detectOverflow. Both rects are read in viewport coordinates, so the page's scroll offset cancels out (the previous standalone detectOverflow call produced wildly wrong values once the containing block was no longer a near-viewport scroll container). Dropped the now-unused detectOverflow/MiddlewareState imports and platformWithCache.

  • Test (tests/e2e/polyfill.test.ts): the target's offsetParent is now the (non-scrollable) wrapper, so the @position-fallback test scrolls .scroll-container directly and captures the pre-polyfill position rather than hardcoding it.

Behavior change to flag for review

checkOverflow now matches native semantics (overflow vs. containing block). This preserves existing behavior for the common cases — an absolutely-positioned target whose containing block is a scroll container still flips against that scroller's visible box, and a fixed target flips against the viewport — so nothing in the suite regressed. But it is a change to core polyfill logic and worth a careful look.

Verification

  • Native (Chrome 150): all position-try targets now flip on scroll. The combined demo flips but non-monotonically, which is inherent to its dual-inline-axis options plus native scroll-offset compensation.
  • Polyfill (Chromium 124): all five targets flip cleanly on scroll; no height: 400px leaks into generated fallbacks; no console errors.

@netlify

netlify Bot commented Jun 27, 2026

Copy link
Copy Markdown

Deploy Preview for anchor-polyfill ready!

Name Link
🔨 Latest commit 67ee689
🔍 Latest deploy log https://app.netlify.com/projects/anchor-polyfill/deploys/6a5e8a7440c8d6000872090d
😎 Deploy Preview https://deploy-preview-424--anchor-polyfill.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@netlify

netlify Bot commented Jun 27, 2026

Copy link
Copy Markdown

Deploy Preview for anchor-position-wpt canceled.

Name Link
🔨 Latest commit 67ee689
🔍 Latest deploy log https://app.netlify.com/projects/anchor-position-wpt/deploys/6a5e8a74e612ea00079e4d16

Comment thread src/fallback.ts
Comment on lines -579 to -582
const anchorPosition: AnchorPosition = {};
if (order) {
anchorPosition.order = order;
}

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.

Comment thread src/cascade.ts Outdated
@jgerigmeyer
jgerigmeyer marked this pull request as ready for review June 30, 2026 16:45
@jgerigmeyer
jgerigmeyer requested a review from jamesnw June 30, 2026 16:45

@jamesnw jamesnw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Some testing notes-

In Firefox 124- Demo doesn't work. Adding padding (removing .tight from .demo-elements) makes it work, but the target goes outside of the scroll container before falling back. When it falls back, it still has the inheritance bug (CSS.registerProperty came to Firefox in 128). Fallback only happens on the y-axis.

In Firefox 128- Demo works. Adding padding makes the the targets go outside of the scroll container before falling back.

I'm not sure if this is because the custom properties are required to make it fallback correctly, or if the incorrect values are the making the fallback happen. We should figure out how crucial non-inheritance is to this change, and see if there's an alternate way of addressing that.

Comment thread src/cascade.ts Outdated
Comment thread src/cascade.ts Outdated
Comment thread public/position-try-tactics-combined.css Outdated
Comment thread src/polyfill.ts
@@ -1,7 +1,5 @@
import {
autoUpdate,
detectOverflow,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nice, this was simple enough to replace. I wonder if this reduces the polyfill size, and if there are other simple-to-replace parts that would help with that?

@jgerigmeyer jgerigmeyer Jul 10, 2026

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.

Added #430

Build size comparison (gzip)

Assets

File Before After Δ
favicon-*.svg 0.83 kB 0.83 kB
shadow-dom.html 2.96 kB 2.96 kB
position-area.html 3.48 kB 3.48 kB
index.html 8.45 kB 8.45 kB
index-fn (entry) 0.11 kB 0.11 kB
modulepreload-polyfill 0.39 kB 0.39 kB
positionArea 0.40 kB 0.40 kB
main 0.94 kB 0.95 kB +0.01 kB (+1.1%)
shadowDOM 1.57 kB 1.58 kB +0.01 kB (+0.6%)
index-fn (chunk) 38.20 kB 37.98 kB -0.22 kB (-0.6%)

Bundles

File Before After Δ
css-anchor-positioning.js 41.35 kB 40.36 kB -0.99 kB (-2.4%)
css-anchor-positioning.umd.cjs 36.02 kB 35.24 kB -0.78 kB (-2.2%)
css-anchor-positioning-fn.js 41.60 kB 40.62 kB -0.98 kB (-2.4%)
css-anchor-positioning-fn.umd.cjs 36.33 kB 35.53 kB -0.80 kB (-2.2%)

The four main bundles each shed ~2.2–2.4% gzipped, driven by the index-fn chunk shrinking.

* main:
  Remove sideEffects workaround
  upgrade deps
  chore(deps): Bump nanoid from 5.1.15 to 5.1.16 in the prod group
  Lint
  Update src/transform.ts
  Fix test
  Add note about nvm
  Work around parentElement being null when directly under the shadow root
@jgerigmeyer

jgerigmeyer commented Jul 11, 2026

Copy link
Copy Markdown
Member Author

Some testing notes-

In Firefox 124- Demo doesn't work. Adding padding (removing .tight from .demo-elements) makes it work, but the target goes outside of the scroll container before falling back. When it falls back, it still has the inheritance bug (CSS.registerProperty came to Firefox in 128). Fallback only happens on the y-axis.

In Firefox 128- Demo works. Adding padding makes the the targets go outside of the scroll container before falling back.

I'm not sure if this is because the custom properties are required to make it fallback correctly, or if the incorrect values are the making the fallback happen. We should figure out how crucial non-inheritance is to this change, and see if there's an alternate way of addressing that.

@jamesnw What do you think of the fallback added in https://github.com/oddbird/css-anchor-positioning/pull/424/changes/4f781ef3b4476ddb5aa642bb3adf926e388de558..a5e9543039f4f0366ec550bef0f0d99688f3c8fc? Test again and see if this fixes what you were seeing.

@jgerigmeyer
jgerigmeyer requested a review from jamesnw July 11, 2026 18:32

@jamesnw jamesnw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think things are largely more similar than they were. There are a few differences still with non-polyfilled browsers, but I think that may be due to other issues.

I think before approval, I'd like to see if we can handle shadow trees easily, and verify with @mirisuzanne that resetting custom properties to initial won't have unintended consequences.

Comment thread src/cascade.ts Outdated
* emulating non-inheritance. The reset has the lowest possible specificity, so
* real declarations still win, and it only touches our private custom
* properties, never the native properties that drive layout. Injected into
* `document.head`, this reset does not reach separate shadow trees.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would it be sufficient to also add it to the roots passed in options.roots?

@jgerigmeyer jgerigmeyer Jul 20, 2026

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.

@jgerigmeyer
jgerigmeyer requested a review from mirisuzanne July 20, 2026 15:49
Comment thread src/cascade.ts Outdated
@jgerigmeyer
jgerigmeyer requested a review from jamesnw July 20, 2026 20:23

@jamesnw jamesnw left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

This looks good to me- thanks for the fixes!

@jamesnw
jamesnw merged commit 7eccd26 into main Jul 21, 2026
13 checks passed
@jamesnw
jamesnw deleted the jgerigmeyer/279-fix-position-fallback branch July 21, 2026 13:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[BUG] Position-try-fallbacks demo breaks at certain screen widths

3 participants