Repository: Textualize/textual

Issue:
Offset Widgets Popping In And Out While Scrolling
I am working on a timeline widget and have an issue with disappearing/popping widgets if they have an offset set while scrolling off the original region/viewport. I am wondering if this is an intended behaviour or if I will have to change my approach to this functionality in order to prevent popping? Expected behaviour for me would be that the widgets stay in view without disappearing.

From my limited exploration of the textual source code I am thinking that this might be a compositor issue where the viewport logic doesn't properly validate the widgets location with the offset included.

I have added a MRE and video to demonstrate the issue, so please let me know if this needs more information.

## Video 

[offset_bug.webm](https://github.com/user-attachments/assets/e220b501-c1d6-4d10-bb7f-3ccdcacf57b8)

# Working Example
<details>
  <summary>main.py</summary>

```python
from __future__ import annotations

from dataclasses import dataclass

from textual import on
from textual.app import App, ComposeResult
from textual.containers import ScrollableContainer
from textual.events import MouseDown, MouseEvent, MouseMove, MouseUp, Resize
from textual.geometry import Offset
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Label


class Adjustable(Widget):
    @dataclass
    class Resize(Message):
        adjustable: Adjustable
        delta: int

    def __init__(self, id: str) -> None:
        super().__init__(id=id)
        self.clicked: Offset | None = None
        self.styles.width = 10

    def compose(self) -> ComposeResult:
        yield Label("Test Content")

    async def on_mouse_down(self, event: MouseDown) -> None:
        if self.app.mouse_captured is None:
            self.capture_mouse()
        await self.is_focused(event)

    async def on_mouse_up(self, event: MouseUp) -> None:
        if self.app.mouse_captured:
            self.capture_mouse(False)
        await self.is_unfocused()

    async def is_focused(self, event: MouseEvent) -> None:
        self.clicked = event.offset
        self.moving = 2 < event.offset.x < (self.size.width - 2)

    async def is_unfocused(self) -> None:
        self.clicked = None

    async def on_mouse_move(self, event: MouseMove) -> None:
        if self.clicked is not None and event.button != 0:
            if hasattr(self, "moving") and self.moving:
                await self._move(event)
            else:
                await self._resize(event)

    async def _resize(self, event: MouseMove) -> None:
        delta = event.delta_x
        if self.clicked and self.clicked.x < 2:
            self.offset += Offset(delta, 0)
            delta *= -1
        self.styles.width = self.styles.width.value + delta

        self.refresh()
        self.post_message(Adjustable.Resize(self, delta))

    async def _move(self, event: MouseMove) -> None:
        if event.delta:
            self.offset = self.offset + Offset(event.delta.x)


class Timeline(Widget):
    def __init__(self) -> None:
        super().__init__()
        self.styles.width = 5760
        self.adjustables = (
            Adjustable(id="adj-1"),
            Adjustable(id="adj-2"),
            Adjustable(id="adj-3"),
        )

    def compose(self) -> ComposeResult:
        yield from self.adjustables

    @on(Adjustable.Resize)
    def _resize_compensation(self, message: Adjustable.Resize) -> None:
        offset = Offset(message.delta)
        for i in range(1, len(self.adjustables) + 1):
            adj = self.adjustables[-i]
            if adj.id == message.adjustable.id:
                break
            with adj.prevent(Adjustable.Resize, Resize):
                adj.offset = adj.offset - offset


class BugReportApp(App):
    CSS = """
    Adjustable {
        background: $panel;
        align-vertical: middle;
        height: 100%;
        min-width: 6;
        border: outer $secondary;
    }
    ScrollableContainer {
        height: auto;
        Timeline {
            background: $panel-darken-1;
            width: 100%;
            height: 100%;
            layout: horizontal;
            height: 13;
        }
    }
    """

    def compose(self) -> ComposeResult:
        with ScrollableContainer():
            yield Timeline()


if __name__ == "__main__":
    app = BugReportApp()
    app.run()
```

</details>

[Gist](https://gist.github.com/ddkasa/4c22a9ddff79f731732c8b91cf60021c)


# Textual Diagnostics

## Versions

| Name    | Value  |
|---------|--------|
| Textual | 0.86.1 |
| Rich    | 13.9.4 |

## Python

| Name           | Value                                   |
|----------------|-----------------------------------------|
| Version        | 3.12.7                                  |
| Implementation | CPython                                 |
| Compiler       | GCC 14.2.1 20240912 (Red Hat 14.2.1-3)  |
| Executable     | /home/dk/dev/toggl-tui/.venv/bin/python |

## Operating System

| Name    | Value                                               |
|---------|-----------------------------------------------------|
| System  | Linux                                               |
| Release | 6.11.7-200.fc40.x86_64                              |
| Version | #1 SMP PREEMPT_DYNAMIC Fri Nov  8 19:21:57 UTC 2024 |

## Terminal

| Name                 | Value       |
|----------------------|-------------|
| Terminal Application | Kitty       |
| TERM                 | xterm-kitty |
| COLORTERM            | truecolor   |
| FORCE_COLOR          | *Not set*   |
| NO_COLOR             | *Not set*   |

## Rich Console options

| Name           | Value                |
|----------------|----------------------|
| size           | width=274, height=54 |
| legacy_windows | False                |
| min_width      | 1                    |
| max_width      | 274                  |
| is_terminal    | True                 |
| encoding       | utf-8                |
| max_height     | 54                   |
| justify        | None                 |
| overflow       | None                 |
| no_wrap        | False                |
| highlight      | None                 |
| markup         | None                 |
| height         | None                 |

Hints from issue comments:
@willmcgugan: An MRE should ideally be a bit more minimal than that. But I can reproduce it.

It's a clever widget. Kudos.@ddkasa: > An MRE should ideally be a bit more minimal than that. But I can reproduce it.

In hindsight I definitely could have trimmed the example a bit more. I have edited to be shorter if that helps.
@github-actions: Don't forget to [star](https://github.com/Textualize/textual) the repository!

Follow [@textualizeio](https://twitter.com/textualizeio) for Textual updates.@willmcgugan: Please try v0.86.2

And keep us posted with your progress. That's an interesting widget!@ddkasa: > Please try v0.86.2

Working as intended. Thank you for the quick fix!

Solution patch:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 5e94fa68a0..2c2f3b2337 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,12 +5,15 @@ All notable changes to this project will be documented in this file.
 The format is based on [Keep a Changelog](http://keepachangelog.com/)
 and this project adheres to [Semantic Versioning](http://semver.org/).
 
-## [Unreleased]
+
+## [0.86.2] - 2024-11-18
 
 ### Fixed
 
+- Fixed visibility glitch for widgets with an offset https://github.com/Textualize/textual/pull/5253
 - Fixed theme variables being unavailable in code until refresh_css was called https://github.com/Textualize/textual/pull/5254
 
+
 ## [0.86.1] - 2024-11-16
 
 ### Fixed
@@ -2549,6 +2552,9 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
 - New handler system for messages that doesn't require inheritance
 - Improved traceback handling
 
+
+[0.86.2]: https://github.com/Textualize/textual/compare/v0.86.1...v0.86.2
+[0.86.1]: https://github.com/Textualize/textual/compare/v0.86.0...v0.86.1
 [0.86.0]: https://github.com/Textualize/textual/compare/v0.85.2...v0.86.0
 [0.85.2]: https://github.com/Textualize/textual/compare/v0.85.1...v0.85.2
 [0.85.1]: https://github.com/Textualize/textual/compare/v0.85.0...v0.85.1
diff --git a/pyproject.toml b/pyproject.toml
index 32baaceca2..45b8f4e573 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
 [tool.poetry]
 name = "textual"
-version = "0.86.1"
+version = "0.86.2"
 homepage = "https://github.com/Textualize/textual"
 repository = "https://github.com/Textualize/textual"
 documentation = "https://textual.textualize.io/"
diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py
index 347e8ce09e..8c2469670f 100644
--- a/src/textual/_arrange.py
+++ b/src/textual/_arrange.py
@@ -6,7 +6,7 @@
 from typing import TYPE_CHECKING, Iterable, Mapping, Sequence
 
 from textual._partition import partition
-from textual.geometry import Region, Size, Spacing
+from textual.geometry import NULL_OFFSET, NULL_SPACING, Region, Size, Spacing
 from textual.layout import DockArrangeResult, WidgetPlacement
 
 if TYPE_CHECKING:
@@ -133,7 +133,8 @@ def _arrange_dock_widgets(
     region_offset = region.offset
     size = region.size
     width, height = size
-    null_spacing = Spacing()
+    null_spacing = NULL_SPACING
+    null_offset = NULL_OFFSET
 
     top = right = bottom = left = 0
 
@@ -173,6 +174,7 @@ def _arrange_dock_widgets(
         append_placement(
             _WidgetPlacement(
                 dock_region.translate(region_offset),
+                null_offset,
                 null_spacing,
                 dock_widget,
                 top_z,
@@ -202,7 +204,8 @@ def _arrange_split_widgets(
     placements: list[WidgetPlacement] = []
     append_placement = placements.append
     view_region = size.region
-    null_spacing = Spacing()
+    null_spacing = NULL_SPACING
+    null_offset = NULL_OFFSET
 
     for split_widget in split_widgets:
         split = split_widget.styles.split
@@ -226,7 +229,9 @@ def _arrange_split_widgets(
             raise AssertionError("invalid value for split edge")  # pragma: no-cover
 
         append_placement(
-            _WidgetPlacement(split_region, null_spacing, split_widget, 1, True)
+            _WidgetPlacement(
+                split_region, null_offset, null_spacing, split_widget, 1, True
+            )
         )
 
     return placements, view_region
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index 13869c838a..7aac04393c 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -33,7 +33,7 @@
 from textual._cells import cell_len
 from textual._context import visible_screen_stack
 from textual._loop import loop_last
-from textual.geometry import NULL_OFFSET, NULL_SPACING, Offset, Region, Size, Spacing
+from textual.geometry import NULL_SPACING, Offset, Region, Size, Spacing
 from textual.map_geometry import MapGeometry
 from textual.strip import Strip, StripRenderable
 
@@ -535,8 +535,6 @@ def _arrange_root(
             Compositor map and set of widgets.
         """
 
-        ORIGIN = NULL_OFFSET
-
         map: CompositorMap = {}
         widgets: set[Widget] = set()
         add_new_widget = widgets.add
@@ -580,15 +578,9 @@ def add_widget(
                 add_new_widget(widget)
             else:
                 add_new_invisible_widget(widget)
-            styles_offset = styles.offset
-            layout_offset = (
-                styles_offset.resolve(region.size, clip.size)
-                if styles_offset
-                else ORIGIN
-            )
 
             # Container region is minus border
-            container_region = region.shrink(styles.gutter).translate(layout_offset)
+            container_region = region.shrink(styles.gutter)
             container_size = container_region.size
 
             # Widgets with scrollbars (containers or scroll view) require additional processing
@@ -643,15 +635,25 @@ def add_widget(
                         )
 
                     # Add all the widgets
-                    for sub_region, _, sub_widget, z, fixed, overlay in reversed(
-                        placements
-                    ):
+                    for (
+                        sub_region,
+                        sub_region_offset,
+                        _,
+                        sub_widget,
+                        z,
+                        fixed,
+                        overlay,
+                    ) in reversed(placements):
                         layer_index = get_layer_index(sub_widget.layer, 0)
                         # Combine regions with children to calculate the "virtual size"
                         if fixed:
-                            widget_region = sub_region + placement_offset
+                            widget_region = (
+                                sub_region + sub_region_offset + placement_offset
+                            )
                         else:
-                            widget_region = sub_region + placement_scroll_offset
+                            widget_region = (
+                                sub_region + sub_region_offset + placement_scroll_offset
+                            )
 
                         widget_order = order + ((layer_index, z, layer_order),)
 
@@ -699,7 +701,7 @@ def add_widget(
                             )
 
                     map[widget] = _MapGeometry(
-                        region + layout_offset,
+                        region,
                         order,
                         clip,
                         total_region.size,
@@ -711,19 +713,15 @@ def add_widget(
             elif visible:
                 # Add the widget to the map
 
-                widget_region = region + layout_offset
-
                 if widget.absolute_offset is not None:
                     margin = styles.margin
-                    widget_region = widget_region.at_offset(
-                        widget.absolute_offset + margin.top_left
-                    )
-                    widget_region = widget_region.translate(
-                        styles.offset.resolve(widget_region.grow(margin).size, size)
+                    region = region.at_offset(widget.absolute_offset + margin.top_left)
+                    region = region.translate(
+                        styles.offset.resolve(region.grow(margin).size, size)
                     )
                 has_rule = styles.has_rule
                 if has_rule("constrain_x") or has_rule("constrain_y"):
-                    widget_region = widget_region.constrain(
+                    region = region.constrain(
                         styles.constrain_x,
                         styles.constrain_y,
                         styles.margin,
@@ -731,7 +729,7 @@ def add_widget(
                     )
 
                 map[widget._render_widget] = _MapGeometry(
-                    widget_region,
+                    region,
                     order,
                     clip,
                     region.size,
diff --git a/src/textual/_spatial_map.py b/src/textual/_spatial_map.py
index 86debb1ddc..8f6175321a 100644
--- a/src/textual/_spatial_map.py
+++ b/src/textual/_spatial_map.py
@@ -6,7 +6,7 @@
 
 from typing_extensions import TypeAlias
 
-from textual.geometry import Region
+from textual.geometry import Offset, Region
 
 ValueType = TypeVar("ValueType")
 GridCoordinate: TypeAlias = "tuple[int, int]"
@@ -57,7 +57,7 @@ def _region_to_grid_coordinates(self, region: Region) -> Iterable[GridCoordinate
         )
 
     def insert(
-        self, regions_and_values: Iterable[tuple[Region, bool, bool, ValueType]]
+        self, regions_and_values: Iterable[tuple[Region, Offset, bool, bool, ValueType]]
     ) -> None:
         """Insert values into the Spatial map.
 
@@ -65,19 +65,19 @@ def insert(
         indicates fixed regions. Fixed regions don't scroll and are always visible.
 
         Args:
-            regions_and_values: An iterable of (REGION, FIXED, OVERLAY, VALUE).
+            regions_and_values: An iterable of (REGION, OFFSET, FIXED, OVERLAY, VALUE).
         """
         append_fixed = self._fixed.append
         get_grid_list = self._map.__getitem__
         _region_to_grid = self._region_to_grid_coordinates
         total_region = self.total_region
-        for region, fixed, overlay, value in regions_and_values:
+        for region, offset, fixed, overlay, value in regions_and_values:
             if fixed:
                 append_fixed(value)
             else:
                 if not overlay:
                     total_region = total_region.union(region)
-                for grid in _region_to_grid(region):
+                for grid in _region_to_grid(region + offset):
                     get_grid_list(grid).append(value)
         self.total_region = total_region
 
diff --git a/src/textual/layout.py b/src/textual/layout.py
index adb7ab5c2d..8e494fcb74 100644
--- a/src/textual/layout.py
+++ b/src/textual/layout.py
@@ -39,6 +39,7 @@ def spatial_map(self) -> SpatialMap[WidgetPlacement]:
             self._spatial_map.insert(
                 (
                     placement.region.grow(placement.margin),
+                    placement.offset,
                     placement.fixed,
                     placement.overlay,
                     placement,
@@ -75,7 +76,7 @@ def get_visible_placements(self, region: Region) -> list[WidgetPlacement]:
         culled_placements = [
             placement
             for placement in visible_placements
-            if placement.fixed or overlaps(placement.region)
+            if placement.fixed or overlaps(placement.region + placement.offset)
         ]
         return culled_placements
 
@@ -84,6 +85,7 @@ class WidgetPlacement(NamedTuple):
     """The position, size, and relative order of a widget within its parent."""
 
     region: Region
+    offset: Offset
     margin: Spacing
     widget: Widget
     order: int = 0
@@ -92,7 +94,7 @@ class WidgetPlacement(NamedTuple):
 
     @classmethod
     def translate(
-        cls, placements: list[WidgetPlacement], offset: Offset
+        cls, placements: list[WidgetPlacement], translate_offset: Offset
     ) -> list[WidgetPlacement]:
         """Move all placements by a given offset.
 
@@ -103,10 +105,18 @@ def translate(
         Returns:
             Placements with adjusted region, or same instance if offset is null.
         """
-        if offset:
+        if translate_offset:
             return [
-                cls(region + offset, margin, layout_widget, order, fixed, overlay)
-                for region, margin, layout_widget, order, fixed, overlay in placements
+                cls(
+                    region + translate_offset,
+                    offset,
+                    margin,
+                    layout_widget,
+                    order,
+                    fixed,
+                    overlay,
+                )
+                for region, offset, margin, layout_widget, order, fixed, overlay in placements
             ]
         return placements
 
diff --git a/src/textual/layouts/grid.py b/src/textual/layouts/grid.py
index d7dfc95896..a1b0823d6d 100644
--- a/src/textual/layouts/grid.py
+++ b/src/textual/layouts/grid.py
@@ -5,7 +5,7 @@
 
 from textual._resolve import resolve
 from textual.css.scalar import Scalar
-from textual.geometry import Region, Size, Spacing
+from textual.geometry import NULL_OFFSET, Region, Size, Spacing
 from textual.layout import ArrangeResult, Layout, WidgetPlacement
 
 if TYPE_CHECKING:
@@ -258,6 +258,7 @@ def apply_height_limits(widget: Widget, height: int) -> int:
         rows = resolve(row_scalars, size.height, gutter_horizontal, size, viewport)
 
         placements: list[WidgetPlacement] = []
+        _WidgetPlacement = WidgetPlacement
         add_placement = placements.append
         widgets: list[Widget] = []
         add_widget = widgets.append
@@ -272,26 +273,34 @@ def apply_height_limits(widget: Widget, height: int) -> int:
             x2, cell_width = columns[min(max_column, column + column_span)]
             y2, cell_height = rows[min(max_row, row + row_span)]
             cell_size = Size(cell_width + x2 - x, cell_height + y2 - y)
-            width, height, margin = widget._get_box_model(
+            box_width, box_height, margin = widget._get_box_model(
                 cell_size,
                 viewport,
                 Fraction(cell_size.width),
                 Fraction(cell_size.height),
             )
             if self.stretch_height and len(children) > 1:
-                height = (
-                    height
-                    if (height > cell_size.height)
-                    else Fraction(cell_size.height)
-                )
+                if box_height <= cell_size.height:
+                    box_height = Fraction(cell_size.height)
+
             region = (
-                Region(x, y, int(width + margin.width), int(height + margin.height))
+                Region(
+                    x, y, int(box_width + margin.width), int(box_height + margin.height)
+                )
                 .crop_size(cell_size)
                 .shrink(margin)
             )
+
+            placement_offset = (
+                styles.offset.resolve(cell_size, viewport)
+                if styles.has_rule("offset")
+                else NULL_OFFSET
+            )
+
             add_placement(
-                WidgetPlacement(
+                _WidgetPlacement(
                     region + offset,
+                    placement_offset,
                     (
                         margin
                         if gutter_spacing is None
diff --git a/src/textual/layouts/horizontal.py b/src/textual/layouts/horizontal.py
index ce032d27a5..c62fae3c1d 100644
--- a/src/textual/layouts/horizontal.py
+++ b/src/textual/layouts/horizontal.py
@@ -4,7 +4,7 @@
 from typing import TYPE_CHECKING
 
 from textual._resolve import resolve_box_models
-from textual.geometry import Region, Size
+from textual.geometry import NULL_OFFSET, Region, Size
 from textual.layout import ArrangeResult, Layout, WidgetPlacement
 
 if TYPE_CHECKING:
@@ -24,6 +24,7 @@ def arrange(
     ) -> ArrangeResult:
         placements: list[WidgetPlacement] = []
         add_placement = placements.append
+        viewport = parent.app.size
 
         child_styles = [child.styles for child in children]
         box_margins: list[Spacing] = [
@@ -52,7 +53,7 @@ def arrange(
             [styles.width for styles in child_styles],
             children,
             size,
-            parent.app.size,
+            viewport,
             resolve_margin,
             resolve_dimension="width",
         )
@@ -75,20 +76,31 @@ def arrange(
 
         _Region = Region
         _WidgetPlacement = WidgetPlacement
+        _Size = Size
         for widget, (content_width, content_height, box_margin), margin in zip(
             children, box_models, margins
         ):
-            overlay = widget.styles.overlay == "screen"
+            styles = widget.styles
+            overlay = styles.overlay == "screen"
+            offset = (
+                styles.offset.resolve(
+                    _Size(content_width.__floor__(), content_height.__floor__()),
+                    viewport,
+                )
+                if styles.has_rule("offset")
+                else NULL_OFFSET
+            )
             offset_y = box_margin.top
             next_x = x + content_width
             add_placement(
                 _WidgetPlacement(
                     _Region(
-                        int(x),
+                        x.__floor__(),
                         offset_y,
-                        int(next_x - int(x)),
-                        int(content_height),
+                        (next_x - x.__floor__()).__floor__(),
+                        content_height.__floor__(),
                     ),
+                    offset,
                     box_margin,
                     widget,
                     0,
diff --git a/src/textual/layouts/vertical.py b/src/textual/layouts/vertical.py
index a5bf67aa40..c7b8087f7f 100644
--- a/src/textual/layouts/vertical.py
+++ b/src/textual/layouts/vertical.py
@@ -4,7 +4,7 @@
 from typing import TYPE_CHECKING
 
 from textual._resolve import resolve_box_models
-from textual.geometry import Region, Size
+from textual.geometry import NULL_OFFSET, Region, Size
 from textual.layout import ArrangeResult, Layout, WidgetPlacement
 
 if TYPE_CHECKING:
@@ -22,6 +22,7 @@ def arrange(
     ) -> ArrangeResult:
         placements: list[WidgetPlacement] = []
         add_placement = placements.append
+        viewport = parent.app.size
 
         child_styles = [child.styles for child in children]
         box_margins: list[Spacing] = [
@@ -80,11 +81,21 @@ def arrange(
 
         _Region = Region
         _WidgetPlacement = WidgetPlacement
+        _Size = Size
         for widget, (content_width, content_height, box_margin), margin in zip(
             children, box_models, margins
         ):
-            overlay = widget.styles.overlay == "screen"
+            styles = widget.styles
+            overlay = styles.overlay == "screen"
             next_y = y + content_height
+            offset = (
+                styles.offset.resolve(
+                    _Size(content_width.__floor__(), content_height.__floor__()),
+                    viewport,
+                )
+                if styles.has_rule("offset")
+                else NULL_OFFSET
+            )
             add_placement(
                 _WidgetPlacement(
                     _Region(
@@ -93,6 +104,7 @@ def arrange(
                         content_width.__floor__(),
                         next_y.__floor__() - y.__floor__(),
                     ),
+                    offset,
                     box_margin,
                     widget,
                     0,
diff --git a/src/textual/widget.py b/src/textual/widget.py
index 9f758b64cb..5427109b7b 100644
--- a/src/textual/widget.py
+++ b/src/textual/widget.py
@@ -437,9 +437,9 @@ def __init__(
         self._content_width_cache: tuple[object, int] = (None, 0)
         self._content_height_cache: tuple[object, int] = (None, 0)
 
-        self._arrangement_cache: FIFOCache[tuple[Size, int], DockArrangeResult] = (
-            FIFOCache(4)
-        )
+        self._arrangement_cache: FIFOCache[
+            tuple[Size, int, Widget], DockArrangeResult
+        ] = FIFOCache(4)
 
         self._styles_cache = StylesCache()
         self._rich_style_cache: dict[tuple[str, ...], tuple[Style, Style]] = {}


Test patch:
diff --git a/tests/test_arrange.py b/tests/test_arrange.py
index 4b671b8334..b22b8a1459 100644
--- a/tests/test_arrange.py
+++ b/tests/test_arrange.py
@@ -2,7 +2,7 @@
 
 from textual._arrange import TOP_Z, arrange
 from textual.app import App
-from textual.geometry import Region, Size, Spacing
+from textual.geometry import NULL_OFFSET, Region, Size, Spacing
 from textual.layout import WidgetPlacement
 from textual.widget import Widget
 
@@ -27,9 +27,11 @@ def test_arrange_dock_top():
 
     assert result.placements == [
         WidgetPlacement(
-            Region(0, 0, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
+            Region(0, 0, 80, 1), NULL_OFFSET, Spacing(), header, order=TOP_Z, fixed=True
+        ),
+        WidgetPlacement(
+            Region(0, 1, 80, 23), NULL_OFFSET, Spacing(), child, order=0, fixed=False
         ),
-        WidgetPlacement(Region(0, 1, 80, 23), Spacing(), child, order=0, fixed=False),
     ]
     assert result.widgets == {child, header}
 
@@ -45,9 +47,16 @@ def test_arrange_dock_left():
     result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
     assert result.placements == [
         WidgetPlacement(
-            Region(0, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
+            Region(0, 0, 10, 24),
+            NULL_OFFSET,
+            Spacing(),
+            header,
+            order=TOP_Z,
+            fixed=True,
+        ),
+        WidgetPlacement(
+            Region(10, 0, 70, 24), NULL_OFFSET, Spacing(), child, order=0, fixed=False
         ),
-        WidgetPlacement(Region(10, 0, 70, 24), Spacing(), child, order=0, fixed=False),
     ]
     assert result.widgets == {child, header}
 
@@ -63,9 +72,16 @@ def test_arrange_dock_right():
     result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
     assert result.placements == [
         WidgetPlacement(
-            Region(70, 0, 10, 24), Spacing(), header, order=TOP_Z, fixed=True
+            Region(70, 0, 10, 24),
+            NULL_OFFSET,
+            Spacing(),
+            header,
+            order=TOP_Z,
+            fixed=True,
+        ),
+        WidgetPlacement(
+            Region(0, 0, 70, 24), NULL_OFFSET, Spacing(), child, order=0, fixed=False
         ),
-        WidgetPlacement(Region(0, 0, 70, 24), Spacing(), child, order=0, fixed=False),
     ]
     assert result.widgets == {child, header}
 
@@ -81,9 +97,16 @@ def test_arrange_dock_bottom():
     result = arrange(container, [child, header], Size(80, 24), Size(80, 24))
     assert result.placements == [
         WidgetPlacement(
-            Region(0, 23, 80, 1), Spacing(), header, order=TOP_Z, fixed=True
+            Region(0, 23, 80, 1),
+            NULL_OFFSET,
+            Spacing(),
+            header,
+            order=TOP_Z,
+            fixed=True,
+        ),
+        WidgetPlacement(
+            Region(0, 0, 80, 23), NULL_OFFSET, Spacing(), child, order=0, fixed=False
         ),
-        WidgetPlacement(Region(0, 0, 80, 23), Spacing(), child, order=0, fixed=False),
     ]
     assert result.widgets == {child, header}
 
diff --git a/tests/test_compositor.py b/tests/test_compositor.py
new file mode 100644
index 0000000000..e5dbcdbc6d
--- /dev/null
+++ b/tests/test_compositor.py
@@ -0,0 +1,42 @@
+from textual.app import App, ComposeResult
+from textual.containers import Container
+from textual.widgets import Static
+
+
+async def test_compositor_scroll_placements():
+    """Regression test for https://github.com/Textualize/textual/issues/5249
+    The Static should remain visible.
+    """
+
+    class ScrollApp(App):
+        CSS = """
+        Screen {
+            overflow: scroll;
+        }
+        Container {
+            width: 200vw;
+        }
+        #hello {
+            width: 20;
+            height: 10;
+            offset: 50 10;
+            background: blue;
+            color: white;
+        }
+        """
+
+        def compose(self) -> ComposeResult:
+            with Container():
+                yield Static("Hello", id="hello")
+
+        def on_mount(self) -> None:
+            self.query_one("Screen").scroll_to(20, 0, animate=False)
+
+    app = ScrollApp()
+    async with app.run_test() as pilot:
+        await pilot.pause()
+        static = app.query_one("#hello")
+        widgets = app.screen._compositor.visible_widgets
+        # The static wasn't scrolled out of view, and should be visible
+        # This wasn't the case <= v0.86.1
+        assert static in widgets
diff --git a/tests/test_spatial_map.py b/tests/test_spatial_map.py
index 6b053633c7..02fb1ea45c 100644
--- a/tests/test_spatial_map.py
+++ b/tests/test_spatial_map.py
@@ -1,7 +1,7 @@
 import pytest
 
 from textual._spatial_map import SpatialMap
-from textual.geometry import Region
+from textual.geometry import Offset, Region
 
 
 @pytest.mark.parametrize(
@@ -44,9 +44,9 @@ def test_get_values_in_region() -> None:
 
     spatial_map.insert(
         [
-            (Region(10, 5, 5, 5), False, False, "foo"),
-            (Region(5, 20, 5, 5), False, False, "bar"),
-            (Region(0, 0, 40, 1), True, False, "title"),
+            (Region(10, 5, 5, 5), Offset(), False, False, "foo"),
+            (Region(5, 20, 5, 5), Offset(), False, False, "bar"),
+            (Region(0, 0, 40, 1), Offset(), True, False, "title"),
         ]
     )