position: absolute/fixed boxes anchor to their DOM parent's flow position — containing-block reparenting missing from layout-tree construction (fixable without taffy#212)
- Dominant language
- Rust
- Stars
- 4.1k
- Forks
- 203
- Avg merge
- 8h 58m
- Merged PRs (30d)
- 112
Description
## Summary
A `position: absolute` (or `fixed`) box resolves its insets against the box its
**DOM parent** occupies in flow, instead of against the nearest positioned
ancestor (`absolute`) or the viewport (`fixed`). Any static parent with a
margin or a non-zero flow position displaces the out-of-flow box by exactly
that offset. On a page with **no author CSS at all**, the UA
`body { margin: 8px }` already displaces every `fixed` element.
## Reproduction
blitz-dom at rev `2fa6434d`, viewport 800×600. Rects read back by summing
`final_layout().location` along the `layout_parent` chain (the same walk the
paint/hit-test origin computation uses). Behavior re-confirmed by inspection
on current main (`d8e860a`): `child_ids()` still returns the DOM-derived
`layout_children`, and no reparent pass exists anywhere in `collect_layout_children`.
### 1. No author CSS — UA default body margin
```html
hello
```
| box | Chromium | blitz |
| --- | --- | --- |
| fixed | `(0, 0)` | `(8, 16)` — the flow position after `body` margin collapse |
### 2. Positioned grandparent, static intermediate parent
```html
#gp { position: relative; width: 300px; height: 200px; padding-top: 1px }
#mid { width: 200px; height: 100px; margin-left: 30px }
#abs { position: absolute; top: 5px; left: 15px; width: 40px; height: 20px }
```
| box | Chromium | blitz |
| --- | --- | --- |
| `#mid` (in flow) | `(30, 1)` | `(30, 1)` ✓ |
| `#abs` | `(15, 5)` — `#gp` **padding box** + insets | `(45, 6)` — `#mid` border box + insets |
### 3. Collapsed margins leak into unanchored boxes
Variant of (2) with `#mid { margin-top: 40px }`, plus a sibling
`
plus `
| box | Chromium | blitz |
| --- | --- | --- |
| `#abs` | `(15, 45)` | `(45, 45)` |
| sibling absolute (no positioned ancestor → ICB) | `(10, 5)` | `(10, 45)` |
| `#fx` | `(12, 8)` | `(12, 48)` |
When the static parent's flow origin happens to coincide with the viewport
origin, the numbers agree and the bug is invisible. That presumably explains
why #690's scoping found "inside a positioned ancestor with a definite height
the same markup is correct": in that repro the abs box's DOM parent *was* the
positioned ancestor, so the DOM-parent anchor happened to be the right one.
## Root cause
`BaseDocument` implements taffy's `CacheTree`/container traits directly over
the DOM slab: `child_ids()` (src/layout/mod.rs) returns the node's
`layout_children`, which `collect_layout_children` builds from the DOM
children — out-of-flow children are classified but still pushed to the same
container, and `layout_parent` is set to the DOM recursion parent
unconditionally (src/resolve.rs). Taffy's contract is that a
`Position::Absolute` child resolves against **its taffy parent**:
`perform_absolute_layout_on_absolute_children` (taffy compute/block.rs)
anchors insets at the parent's padding box, and the static-position fallback
uses the parent's content-box inset. With the taffy tree shaped exactly like
the DOM tree, the DOM parent therefore acts as the containing block.
## Relation to #690 / DioxusLabs/taffy#212
#690 reported the ICB flavor of this and was routed to taffy#212
(`position: static` support). But per the contract above, resolving the
nearest positioned ancestor is the **tree builder's** job and does not need to
block on taffy gaining `static`: the reparenting can happen in blitz-dom at
layout-tree construction, the same place other engines do it at box-tree
build time.
## Suggested fix
A reparent pass over the collected layout children:
1. `position: fixed` → hoist to the root node (taffy then anchors it against
the root's box = the viewport-sized canvas).
2. `position: absolute` → hoist to the nearest ancestor whose `position` is
not `static`; its padding box then becomes the containing block taffy
already knows how to anchor against.
3. Two wrinkles worth noting up front:
- The static-position fallback (auto insets) must still be computed in the
**original** flow context — harvest static candidates during the normal
flow walk and reparent afterwards. Prior art:
[obscura-render](https://github.com/h4ckf0r0day/obscura)'s
`reparent_inset_positioned_nodes` +
`resolve_static_positions_and_reparent` (crates/obscura-render/src/dom.rs).
- `layout_parent` must follow the reparent, or origin walks (paint
transforms, hit-testing, any getBoundingClientRect-equivalent) will sum
locations along the wrong chain.
4. Paint order: reparented boxes must stay in DOM order for stacking
(CSS2.1 appendix E); the obscura pass keeps the candidate list ordered by
original position for exactly this reason.
## Impact
Every `position: fixed` header/banner on a page that doesn't zero the body
margins, and every absolutely-positioned dropdown/tooltip/overlay inside a
static parent with margins, renders displaced by the parent's flow offset —
and the coordinates reported through layout-derived APIs inherit the same
offset.
Contributor guide
Research direction
Start in src/layout/mod.rs and src/resolve.rs, tracing collect_layout_children, child_ids(), and layout_parent construction; compare the behavior with taffy's perform_absolute_layout_on_absolute_children in taffy compute/block.rs. Preserve original flow context and DOM order while reparenting absolute and fixed boxes. Done means containing-block and viewport anchoring match the reported Chromium coordinates, including origin walks used for painting and hit-testing.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- rust
- Domain
- frontend, web-dev
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 50/100