From 87bab5f2e53a093d0ff222dd17e01356f52ac57c Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 15:55:57 +0000 Subject: [PATCH 1/5] test(web): reproduce sidebar drag jumping to the settled drop hint (#27669) This test reproduces #27669: starting a drag on a row near the top of a tall sortable list scrolls the viewport straight to the off-screen settled-placeholder drop hint. It fails on main. Co-Authored-By: Claude Opus 5.5 --- .../SidebarListMarkers.browser.test.tsx | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx new file mode 100644 index 0000000000000..9bf79ad3ed878 --- /dev/null +++ b/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx @@ -0,0 +1,73 @@ +import { DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; +import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { describe, expect, it, vi } from "vite-plus/test"; +import { render } from "../../../test/render"; +import { sidebarMarkerId } from "../Sidebar.logic"; +import { SidebarSectionPlaceholder } from "./SidebarListMarkers"; + +const ROW_IDS = Array.from({ length: 40 }, (_, index) => `thread-${index}`); + +function Row(props: { id: string }) { + const { setNodeRef, listeners } = useSortable({ id: props.id }); + return ( +
  • + {props.id} +
  • + ); +} + +function SortableList() { + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 3 } })); + return ( +
    + + +
      + {ROW_IDS.map((id) => ( + + ))} + +
    +
    +
    +
    + ); +} + +function pointer(type: string, target: Element, deltaY: number) { + const rect = target.getBoundingClientRect(); + return new PointerEvent(type, { + bubbles: true, + cancelable: true, + isPrimary: true, + pointerId: 1, + button: 0, + buttons: 1, + clientX: rect.left + 10, + clientY: rect.top + rect.height / 2 + deltaY, + }); +} + +describe("SidebarSectionPlaceholder", () => { + it("does not scroll the list to an off-screen settled drop hint when a drag starts", async () => { + await using screen = await render(); + const scroller = screen.getByTestId("scroller").element(); + const row = screen.getByTestId("thread-1").element(); + + row.dispatchEvent(pointer("pointerdown", row, 0)); + document.dispatchEvent(pointer("pointermove", row, -6)); + const hint = screen.getByTestId("sidebar-settled-placeholder").element(); + await vi.waitFor(() => expect(hint.getAttribute("style")).toContain("translate")); + + expect(scroller.scrollTop, "drag start scrolled the list to the settled drop hint").toBe(0); + document.dispatchEvent(pointer("pointerup", row, -6)); + }); +}); From 31834a44da85d5601d25a286ec991167ce6a8b60 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 16:01:04 +0000 Subject: [PATCH 2/5] fix(web): dragging a sidebar thread no longer jumps or runs the list to the bottom Starting any drag scrolled the sidebar straight to the off-screen settled drop hint, because the #26736 reveal called scrollIntoView unconditionally. It now only nudges a hint its scroll viewport already partly shows, which keeps the clipped-border fix. Auto-scroll used dnd-kit's 20% edge zone at full acceleration, so resting the pointer near the bottom ran the list away. It now uses a 5% zone and acceleration 3. Fixes pingdotgg/t3code#27669 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/components/Sidebar.tsx | 6 +++++- .../SidebarListMarkers.browser.test.tsx | 20 +++++++++++++++++++ .../components/sidebar/SidebarListMarkers.tsx | 10 ++++++++-- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index de16be6d2dcfb..c8a0729723373 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -17,6 +17,7 @@ import { DndContext, useSensor, useSensors, + type AutoScrollOptions, type DragEndEvent, type DragOverEvent, type DragStartEvent, @@ -857,6 +858,9 @@ function checkThreadOperations( const SIDEBAR_DRAG_DISTANCE = 6; +// dnd-kit's 20% edge zone at full speed runs the list away while the pointer rests near the bottom. +const SIDEBAR_AUTO_SCROLL: AutoScrollOptions = { threshold: { x: 0, y: 0.05 }, acceleration: 3 }; + const SIDEBAR_DRAG_LABEL_HEIGHT = 24; const threadSearchResultId = (index: number) => `sidebar-thread-search-result-${index}`; @@ -3166,7 +3170,7 @@ export default function Sidebar({ routeReveal }: { routeReveal: SidebarRouteReve > { expect(scroller.scrollTop, "drag start scrolled the list to the settled drop hint").toBe(0); document.dispatchEvent(pointer("pointerup", row, -6)); }); + + it("reveals a settled drop hint the viewport clips at its bottom edge", async () => { + await using screen = await render(); + const scroller = screen.getByTestId("scroller").element(); + const placeholder = screen.getByTestId("sidebar-settled-placeholder").element(); + const box = placeholder.firstElementChild!; + const clipped = box.getBoundingClientRect(); + scroller.scrollTop += + clipped.top + clipped.height / 2 - scroller.getBoundingClientRect().bottom; + const row = screen.getByTestId("thread-38").element(); + + row.dispatchEvent(pointer("pointerdown", row, 0)); + document.dispatchEvent(pointer("pointermove", row, -6)); + await vi.waitFor(() => expect(placeholder.getAttribute("style")).toContain("translate")); + + expect(box.getBoundingClientRect().bottom).toBeLessThanOrEqual( + scroller.getBoundingClientRect().bottom, + ); + document.dispatchEvent(pointer("pointerup", row, -6)); + }); }); diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 104bda5582b4d..8239be0b08ec4 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -1,4 +1,5 @@ import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; +import { getScrollableAncestors } from "@dnd-kit/core"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { type ReactNode, useLayoutEffect } from "react"; @@ -24,8 +25,13 @@ function SortableSidebarMarker(props: { }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the projected drop box inside the scroll viewport as drag transforms move it useLayoutEffect(() => { - if (props.revealOverflow && transform?.y !== undefined) { - node.current?.firstElementChild?.scrollIntoView({ block: "nearest", inline: "nearest" }); + const hint = node.current?.firstElementChild; + if (!props.revealOverflow || transform?.y === undefined || !hint) return; + // Only nudge a hint the viewport already clips; scrolling to an off-screen one jumps the list. + const viewport = getScrollableAncestors(hint, 1)[0]?.getBoundingClientRect(); + const rect = hint.getBoundingClientRect(); + if (viewport && rect.top < viewport.bottom && rect.bottom > viewport.top) { + hint.scrollIntoView({ block: "nearest", inline: "nearest" }); } }, [node, props.revealOverflow, transform?.y]); return ( From f8bd812f011b9e5b7b3f69313744454deb2e412a Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 16:15:50 +0000 Subject: [PATCH 3/5] fix(web): follow the settled drop hint from its resting slot The drag transform can push an on-screen hint fully below the viewport in one step, as when the only settled row is lifted into Active. Judge visibility from the slot the hint rests in, so that hint is still revealed while a far-off hint is still left alone. Co-Authored-By: Claude Opus 5.5 --- .../SidebarListMarkers.browser.test.tsx | 54 +++++++++++++++++-- .../components/sidebar/SidebarListMarkers.tsx | 6 ++- 2 files changed, 53 insertions(+), 7 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx index 62d51fc8d4525..db2f2f5a14df6 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx @@ -1,5 +1,16 @@ -import { DndContext, PointerSensor, useSensor, useSensors } from "@dnd-kit/core"; -import { SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { + type CollisionDetection, + DndContext, + PointerSensor, + useSensor, + useSensors, +} from "@dnd-kit/core"; +import { + SortableContext, + type SortingStrategy, + useSortable, + verticalListSortingStrategy, +} from "@dnd-kit/sortable"; import { describe, expect, it, vi } from "vite-plus/test"; import { render } from "../../../test/render"; import { sidebarMarkerId } from "../Sidebar.logic"; @@ -16,14 +27,21 @@ function Row(props: { id: string }) { ); } -function SortableList() { +function SortableList(props: { + strategy?: SortingStrategy; + collisionDetection?: CollisionDetection; +}) { const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 3 } })); return (
    - +
      {ROW_IDS.map((id) => ( @@ -90,4 +108,30 @@ describe("SidebarSectionPlaceholder", () => { ); document.dispatchEvent(pointer("pointerup", row, -6)); }); + + it("reveals an on-screen settled drop hint the drag pushes below the viewport", async () => { + // A row lifted into a taller section moves the hint down by a card's height in one step. + const pushHintDown: SortingStrategy = (args) => + args.index === ROW_IDS.length + ? { x: 0, y: 80, scaleX: 1, scaleY: 1 } + : verticalListSortingStrategy(args); + await using screen = await render( + [{ id: "thread-0" }]} />, + ); + const scroller = screen.getByTestId("scroller").element(); + const placeholder = screen.getByTestId("sidebar-settled-placeholder").element(); + const box = placeholder.firstElementChild!; + scroller.scrollTop += + box.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom + 4; + const row = screen.getByTestId("thread-38").element(); + + row.dispatchEvent(pointer("pointerdown", row, 0)); + document.dispatchEvent(pointer("pointermove", row, -6)); + await vi.waitFor(() => expect(placeholder.getAttribute("style")).toContain("translate")); + + expect(box.getBoundingClientRect().bottom).toBeLessThanOrEqual( + scroller.getBoundingClientRect().bottom, + ); + document.dispatchEvent(pointer("pointerup", row, -6)); + }); }); diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 8239be0b08ec4..04f221968bfa4 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -27,10 +27,12 @@ function SortableSidebarMarker(props: { useLayoutEffect(() => { const hint = node.current?.firstElementChild; if (!props.revealOverflow || transform?.y === undefined || !hint) return; - // Only nudge a hint the viewport already clips; scrolling to an off-screen one jumps the list. + // Only follow a hint whose resting slot is on screen; scrolling to an off-screen one jumps the list. const viewport = getScrollableAncestors(hint, 1)[0]?.getBoundingClientRect(); const rect = hint.getBoundingClientRect(); - if (viewport && rect.top < viewport.bottom && rect.bottom > viewport.top) { + const restingTop = rect.top - transform.y; + const restingBottom = rect.bottom - transform.y; + if (viewport && restingTop < viewport.bottom && restingBottom > viewport.top) { hint.scrollIntoView({ block: "nearest", inline: "nearest" }); } }, [node, props.revealOverflow, transform?.y]); From b4d1d8c8e46e493d7d676e4aae69d00e07f0c533 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Fri, 30 Oct 2026 16:26:19 +0000 Subject: [PATCH 4/5] fix(web): reveal a settled drop hint that is or rests on screen A drag can also lift a hint resting just below the viewport across its edge, so follow the hint when either its current box or its resting slot is on screen. Default the test harness's collision detection so it typechecks. Co-Authored-By: Claude Opus 5.5 --- .../SidebarListMarkers.browser.test.tsx | 24 +++++++++++++------ .../components/sidebar/SidebarListMarkers.tsx | 12 ++++++---- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx index db2f2f5a14df6..328f99bad2cc9 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx @@ -2,6 +2,7 @@ import { type CollisionDetection, DndContext, PointerSensor, + rectIntersection, useSensor, useSensors, } from "@dnd-kit/core"; @@ -37,7 +38,7 @@ function SortableList(props: { { document.dispatchEvent(pointer("pointerup", row, -6)); }); - it("reveals an on-screen settled drop hint the drag pushes below the viewport", async () => { - // A row lifted into a taller section moves the hint down by a card's height in one step. - const pushHintDown: SortingStrategy = (args) => + // Lifting a row into a taller section moves the hint a card's height down in one step; into a + // shorter one, up. + it.each([ + { + motion: "pushes a visible hint below", + shift: 80, + restingTop: (height: number) => -height - 4, + }, + { motion: "lifts a hint resting below into", shift: -46, restingTop: () => 35 }, + ])("reveals the settled drop hint when the drag $motion the viewport", async (fixture) => { + const shiftHint: SortingStrategy = (args) => args.index === ROW_IDS.length - ? { x: 0, y: 80, scaleX: 1, scaleY: 1 } + ? { x: 0, y: fixture.shift, scaleX: 1, scaleY: 1 } : verticalListSortingStrategy(args); await using screen = await render( - [{ id: "thread-0" }]} />, + [{ id: "thread-0" }]} />, ); const scroller = screen.getByTestId("scroller").element(); const placeholder = screen.getByTestId("sidebar-settled-placeholder").element(); const box = placeholder.firstElementChild!; + const resting = box.getBoundingClientRect(); scroller.scrollTop += - box.getBoundingClientRect().bottom - scroller.getBoundingClientRect().bottom + 4; + resting.top - scroller.getBoundingClientRect().bottom - fixture.restingTop(resting.height); const row = screen.getByTestId("thread-38").element(); row.dispatchEvent(pointer("pointerdown", row, 0)); diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index 04f221968bfa4..fd20a1ae7df30 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -27,12 +27,16 @@ function SortableSidebarMarker(props: { useLayoutEffect(() => { const hint = node.current?.firstElementChild; if (!props.revealOverflow || transform?.y === undefined || !hint) return; - // Only follow a hint whose resting slot is on screen; scrolling to an off-screen one jumps the list. const viewport = getScrollableAncestors(hint, 1)[0]?.getBoundingClientRect(); + if (!viewport) return; const rect = hint.getBoundingClientRect(); - const restingTop = rect.top - transform.y; - const restingBottom = rect.bottom - transform.y; - if (viewport && restingTop < viewport.bottom && restingBottom > viewport.top) { + const onScreen = (top: number, bottom: number) => + top < viewport.bottom && bottom > viewport.top; + // Follow a hint that is or rests on screen; scrolling to an off-screen one jumps the list. + if ( + onScreen(rect.top, rect.bottom) || + onScreen(rect.top - transform.y, rect.bottom - transform.y) + ) { hint.scrollIntoView({ block: "nearest", inline: "nearest" }); } }, [node, props.revealOverflow, transform?.y]); From 49babe117e5180bbf47d84ded270c7d66630ab23 Mon Sep 17 00:00:00 2001 From: Theo Browne Date: Fri, 6 Nov 2026 02:13:57 -0800 Subject: [PATCH 5/5] refactor(web): drop settled hint guard now covered by #30346 #30346 already limits the settled hint's scrollIntoView to drags that target Settled, so the on-screen guard and its tests are redundant and the tests fail against current main. Keep the autoscroll tuning, which still fixes the bottom-edge runaway. Co-Authored-By: Claude Opus 5.5 --- .../SidebarListMarkers.browser.test.tsx | 147 ------------------ .../components/sidebar/SidebarListMarkers.tsx | 16 +- 2 files changed, 2 insertions(+), 161 deletions(-) delete mode 100644 apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx deleted file mode 100644 index 328f99bad2cc9..0000000000000 --- a/apps/web/src/components/sidebar/SidebarListMarkers.browser.test.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { - type CollisionDetection, - DndContext, - PointerSensor, - rectIntersection, - useSensor, - useSensors, -} from "@dnd-kit/core"; -import { - SortableContext, - type SortingStrategy, - useSortable, - verticalListSortingStrategy, -} from "@dnd-kit/sortable"; -import { describe, expect, it, vi } from "vite-plus/test"; -import { render } from "../../../test/render"; -import { sidebarMarkerId } from "../Sidebar.logic"; -import { SidebarSectionPlaceholder } from "./SidebarListMarkers"; - -const ROW_IDS = Array.from({ length: 40 }, (_, index) => `thread-${index}`); - -function Row(props: { id: string }) { - const { setNodeRef, listeners } = useSortable({ id: props.id }); - return ( -
    • - {props.id} -
    • - ); -} - -function SortableList(props: { - strategy?: SortingStrategy; - collisionDetection?: CollisionDetection; -}) { - const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 3 } })); - return ( -
      - - -
        - {ROW_IDS.map((id) => ( - - ))} - -
      -
      -
      -
      - ); -} - -function pointer(type: string, target: Element, deltaY: number) { - const rect = target.getBoundingClientRect(); - return new PointerEvent(type, { - bubbles: true, - cancelable: true, - isPrimary: true, - pointerId: 1, - button: 0, - buttons: 1, - clientX: rect.left + 10, - clientY: rect.top + rect.height / 2 + deltaY, - }); -} - -describe("SidebarSectionPlaceholder", () => { - it("does not scroll the list to an off-screen settled drop hint when a drag starts", async () => { - await using screen = await render(); - const scroller = screen.getByTestId("scroller").element(); - const row = screen.getByTestId("thread-1").element(); - - row.dispatchEvent(pointer("pointerdown", row, 0)); - document.dispatchEvent(pointer("pointermove", row, -6)); - const hint = screen.getByTestId("sidebar-settled-placeholder").element(); - await vi.waitFor(() => expect(hint.getAttribute("style")).toContain("translate")); - - expect(scroller.scrollTop, "drag start scrolled the list to the settled drop hint").toBe(0); - document.dispatchEvent(pointer("pointerup", row, -6)); - }); - - it("reveals a settled drop hint the viewport clips at its bottom edge", async () => { - await using screen = await render(); - const scroller = screen.getByTestId("scroller").element(); - const placeholder = screen.getByTestId("sidebar-settled-placeholder").element(); - const box = placeholder.firstElementChild!; - const clipped = box.getBoundingClientRect(); - scroller.scrollTop += - clipped.top + clipped.height / 2 - scroller.getBoundingClientRect().bottom; - const row = screen.getByTestId("thread-38").element(); - - row.dispatchEvent(pointer("pointerdown", row, 0)); - document.dispatchEvent(pointer("pointermove", row, -6)); - await vi.waitFor(() => expect(placeholder.getAttribute("style")).toContain("translate")); - - expect(box.getBoundingClientRect().bottom).toBeLessThanOrEqual( - scroller.getBoundingClientRect().bottom, - ); - document.dispatchEvent(pointer("pointerup", row, -6)); - }); - - // Lifting a row into a taller section moves the hint a card's height down in one step; into a - // shorter one, up. - it.each([ - { - motion: "pushes a visible hint below", - shift: 80, - restingTop: (height: number) => -height - 4, - }, - { motion: "lifts a hint resting below into", shift: -46, restingTop: () => 35 }, - ])("reveals the settled drop hint when the drag $motion the viewport", async (fixture) => { - const shiftHint: SortingStrategy = (args) => - args.index === ROW_IDS.length - ? { x: 0, y: fixture.shift, scaleX: 1, scaleY: 1 } - : verticalListSortingStrategy(args); - await using screen = await render( - [{ id: "thread-0" }]} />, - ); - const scroller = screen.getByTestId("scroller").element(); - const placeholder = screen.getByTestId("sidebar-settled-placeholder").element(); - const box = placeholder.firstElementChild!; - const resting = box.getBoundingClientRect(); - scroller.scrollTop += - resting.top - scroller.getBoundingClientRect().bottom - fixture.restingTop(resting.height); - const row = screen.getByTestId("thread-38").element(); - - row.dispatchEvent(pointer("pointerdown", row, 0)); - document.dispatchEvent(pointer("pointermove", row, -6)); - await vi.waitFor(() => expect(placeholder.getAttribute("style")).toContain("translate")); - - expect(box.getBoundingClientRect().bottom).toBeLessThanOrEqual( - scroller.getBoundingClientRect().bottom, - ); - document.dispatchEvent(pointer("pointerup", row, -6)); - }); -}); diff --git a/apps/web/src/components/sidebar/SidebarListMarkers.tsx b/apps/web/src/components/sidebar/SidebarListMarkers.tsx index f427a38b7abee..da9444a5c1b24 100644 --- a/apps/web/src/components/sidebar/SidebarListMarkers.tsx +++ b/apps/web/src/components/sidebar/SidebarListMarkers.tsx @@ -1,5 +1,4 @@ import { CollapsibleSectionHeader } from "../ui/collapsible-section-header"; -import { getScrollableAncestors } from "@dnd-kit/core"; import { useSortable } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; import { type ReactNode, useLayoutEffect } from "react"; @@ -25,19 +24,8 @@ function SortableSidebarMarker(props: { }); // oxlint-disable-next-line t3code/no-unreasoned-state-sync -- keeps the projected drop box inside the scroll viewport as drag transforms move it useLayoutEffect(() => { - const hint = node.current?.firstElementChild; - if (!props.revealOverflow || transform?.y === undefined || !hint) return; - const viewport = getScrollableAncestors(hint, 1)[0]?.getBoundingClientRect(); - if (!viewport) return; - const rect = hint.getBoundingClientRect(); - const onScreen = (top: number, bottom: number) => - top < viewport.bottom && bottom > viewport.top; - // Follow a hint that is or rests on screen; scrolling to an off-screen one jumps the list. - if ( - onScreen(rect.top, rect.bottom) || - onScreen(rect.top - transform.y, rect.bottom - transform.y) - ) { - hint.scrollIntoView({ block: "nearest", inline: "nearest" }); + if (props.revealOverflow && transform?.y !== undefined) { + node.current?.firstElementChild?.scrollIntoView({ block: "nearest", inline: "nearest" }); } }, [node, props.revealOverflow, transform?.y]); return (