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
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export default defineConfig({
"**/reminders-screenshots.spec.ts",
"**/virtualization-screenshots.spec.ts",
"**/scroll-history.spec.ts",
"**/stick-to-bottom-scroll-jump.spec.ts",
],
use: {
...devices["Desktop Chrome"],
Expand Down
27 changes: 26 additions & 1 deletion desktop/src/shared/hooks/useStickToBottom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,16 +11,40 @@ import { useCallback, useEffect, useRef } from "react";
*
* Scroll calls are batched via `requestAnimationFrame` so rapid streaming
* updates (e.g. token-by-token SSE) don't cause layout thrashing.
*
* `onScroll` is direction-aware: it only unsticks when the user moves
* the viewport *upward*. Intermediate scroll events emitted by the
* browser during the smooth scroll-to-bottom animation see scrollTop
* climbing toward the new bottom, so they leave the sticky bit alone.
* Without that guard, a smooth-scroll mid-animation would flip
* `isNearBottom` to false (the visible scrollTop is still well above
* scrollHeight - clientHeight - 100), and the next content update
* would refuse to follow — the user-visible symptom is the feed
* appearing to "jump back a couple lines" as new content pushes the
* old bottom out of view.
*/
export function useStickToBottom<T extends HTMLElement = HTMLDivElement>() {
const ref = useRef<T>(null);
const isNearBottomRef = useRef(true);
const lastScrollTopRef = useRef(0);

const onScroll = useCallback(() => {
const el = ref.current;
if (!el) return;
const { scrollTop, scrollHeight, clientHeight } = el;
isNearBottomRef.current = scrollHeight - scrollTop - clientHeight < 100;
const distance = scrollHeight - scrollTop - clientHeight;
if (distance < 100) {
// At (or very near) the bottom — always sticky, regardless of
// direction. Resticks the container once a smooth-scroll
// animation settles, and handles the initial state.
isNearBottomRef.current = true;
} else if (scrollTop < lastScrollTopRef.current) {
// User pulled the viewport upward. Detach.
isNearBottomRef.current = false;
}
// Otherwise: scrollTop is climbing toward the bottom (smooth-scroll
// in flight) or unchanged. Leave the sticky bit as-is.
lastScrollTopRef.current = scrollTop;
}, []);

useEffect(() => {
Expand All @@ -29,6 +53,7 @@ export function useStickToBottom<T extends HTMLElement = HTMLDivElement>() {

// Start at the bottom; the observer below only reacts to later changes.
el.scrollTop = el.scrollHeight;
lastScrollTopRef.current = el.scrollTop;

let rafId: number | null = null;

Expand Down
28 changes: 28 additions & 0 deletions desktop/src/testing/e2eBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -644,6 +644,25 @@ declare global {
__BUZZ_E2E_QUERY_CLIENT__?: {
invalidateQueries: (filters: { queryKey: readonly unknown[] }) => unknown;
};
// Lazy-installed by the stick-to-bottom-scroll-jump spec via
// __BUZZ_E2E_INSTALL_STICK_FIXTURE__; declared here so the rest of
// the bridge typechecks.
__BUZZ_E2E_INSTALL_STICK_FIXTURE__?: () => Promise<void>;
__BUZZ_E2E_MOUNT_STICK_FIXTURE__?: (options?: { seedItems?: number }) => {
push: (text?: string) => number;
scrollToBottom: () => void;
scrollToTop: () => void;
simulateScroll: (scrollTop: number) => void;
state: () => {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
isNearBottom: boolean;
distanceFromBottom: number;
itemCount: number;
};
unmount: () => void;
};
}
}

Expand Down Expand Up @@ -6899,5 +6918,14 @@ export function maybeInstallE2eTauriMocks() {
handleMockCommand(command, payload ?? null);
mockIPC(handleMockCommand);

// Lazy-installer for the stick-to-bottom test fixture. The fixture
// pulls in `react-dom/client` and the production hook, so we only
// import it on demand from the spec that needs it — avoids bloating
// the main e2e bundle for every other test.
window.__BUZZ_E2E_INSTALL_STICK_FIXTURE__ = async () => {
const mod = await import("./stickToBottomFixture");
mod.installStickFixtureBridge();
};

installed = true;
}
216 changes: 216 additions & 0 deletions desktop/src/testing/stickToBottomFixture.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
import * as React from "react";
import { createRoot, type Root } from "react-dom/client";

import { useStickToBottom } from "@/shared/hooks/useStickToBottom";

/**
* E2E test fixture: mounts a scroll container that uses the real
* `useStickToBottom` hook so a Playwright spec can drive it through the
* exact code path the activity feed uses. Lives under `src/testing/` so
* the production bundle ignores it unless the E2E bridge boots.
*/

const HOST_ID = "buzz-e2e-stick-fixture";

type FixtureHandle = {
push: (text?: string) => number;
scrollToBottom: () => void;
scrollToTop: () => void;
/**
* Manually set scrollTop and dispatch a `scroll` event so the
* React-attached `onScroll` handler observes it. Lets specs
* deterministically simulate a mid-animation intermediate scroll
* event without depending on Chromium's `behavior: "smooth"`
* timing in a tiny test container.
*/
simulateScroll: (scrollTop: number) => void;
state: () => {
scrollTop: number;
scrollHeight: number;
clientHeight: number;
isNearBottom: boolean;
distanceFromBottom: number;
itemCount: number;
};
unmount: () => void;
};

declare global {
interface Window {
__BUZZ_E2E_MOUNT_STICK_FIXTURE__?: (options?: {
seedItems?: number;
}) => FixtureHandle;
}
}

function StickToBottomFixture({
initialItems,
registerHandle,
}: {
initialItems: number;
registerHandle: (handle: Omit<FixtureHandle, "unmount">) => void;
}) {
const [items, setItems] = React.useState<string[]>(() =>
Array.from({ length: initialItems }, (_, i) => `seed-${i}`),
);
const { ref, onScroll, isNearBottomRef } = useStickToBottom<HTMLDivElement>();
const seqRef = React.useRef(initialItems);

React.useEffect(() => {
registerHandle({
push: (text?: string) => {
seqRef.current += 1;
const label = text ?? `item-${seqRef.current}`;
setItems((prev) => [...prev, label]);
return seqRef.current;
},
scrollToBottom: () => {
const el = ref.current;
if (!el) return;
el.scrollTop = el.scrollHeight;
// Manually fire onScroll so the hook updates its internal state
// — synthetic scrollTop changes don't dispatch a scroll event.
onScroll();
},
scrollToTop: () => {
const el = ref.current;
if (!el) return;
el.scrollTop = 0;
onScroll();
},
simulateScroll: (scrollTop: number) => {
const el = ref.current;
if (!el) return;
el.scrollTop = scrollTop;
// Dispatch a real scroll event so React's onScroll fires.
el.dispatchEvent(new Event("scroll", { bubbles: true }));
},
state: () => {
const el = ref.current;
if (!el) {
return {
scrollTop: 0,
scrollHeight: 0,
clientHeight: 0,
isNearBottom: isNearBottomRef.current,
distanceFromBottom: 0,
itemCount: 0,
};
}
return {
scrollTop: el.scrollTop,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
isNearBottom: isNearBottomRef.current,
distanceFromBottom: el.scrollHeight - el.scrollTop - el.clientHeight,
itemCount: el.querySelectorAll("[data-stick-item]").length,
};
},
});
}, [isNearBottomRef, onScroll, ref, registerHandle]);

return (
<div
data-testid="stick-to-bottom-fixture"
onScroll={onScroll}
ref={ref}
style={{
height: "240px",
width: "320px",
overflowY: "auto",
border: "1px solid #444",
fontFamily: "monospace",
fontSize: "14px",
lineHeight: "20px",
}}
>
{items.map((item) => (
<div data-stick-item key={item} style={{ padding: "0 8px" }}>
{item}
</div>
))}
</div>
);
}

let activeRoot: Root | null = null;
let activeHost: HTMLDivElement | null = null;

export function installStickFixtureBridge() {
if (window.__BUZZ_E2E_MOUNT_STICK_FIXTURE__) return;

window.__BUZZ_E2E_MOUNT_STICK_FIXTURE__ = (options) => {
if (activeRoot) {
activeRoot.unmount();
activeRoot = null;
}
if (activeHost) {
activeHost.remove();
activeHost = null;
}

const host = document.createElement("div");
host.id = HOST_ID;
host.style.position = "fixed";
host.style.top = "0";
host.style.left = "0";
host.style.zIndex = "2147483647";
host.style.background = "white";
host.style.color = "black";
document.body.appendChild(host);

const root = createRoot(host);
activeRoot = root;
activeHost = host;

let resolveHandle: (handle: Omit<FixtureHandle, "unmount">) => void;
const handlePromise = new Promise<Omit<FixtureHandle, "unmount">>(
(resolve) => {
resolveHandle = resolve;
},
);

root.render(
<StickToBottomFixture
initialItems={options?.seedItems ?? 60}
registerHandle={(handle) => resolveHandle(handle)}
/>,
);

// The bridge contract is synchronous — but we need React to have
// rendered and registered the handle before the caller proceeds.
// Spin the microtask queue once; if the handle isn't ready, fall
// back to a stub that retries each call. In practice React 19's
// createRoot + initial render registers within a microtask, so the
// promise is usually already resolved by the time the user reads
// the returned methods via await page.evaluateHandle.
let registered: Omit<FixtureHandle, "unmount"> | null = null;
void handlePromise.then((h) => {
registered = h;
});

const ensure = () => {
if (!registered) {
throw new Error(
"stick-to-bottom fixture not yet mounted — await an animation frame first",
);
}
return registered;
};

return {
push: (text) => ensure().push(text),
scrollToBottom: () => ensure().scrollToBottom(),
scrollToTop: () => ensure().scrollToTop(),
simulateScroll: (top) => ensure().simulateScroll(top),
state: () => ensure().state(),
unmount: () => {
root.unmount();
host.remove();
activeRoot = null;
activeHost = null;
delete (window as Window).__BUZZ_E2E_MOUNT_STICK_FIXTURE__;
},
};
};
}
Loading