Skip to content
Merged
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
13 changes: 13 additions & 0 deletions plugins/stewards/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1608,6 +1608,19 @@ effort and follows that project, and clears both when the page unmounts. A
project pick while the page is mounted moves it. A user who pinned bb's own
list sees bb's list on the Stewards page rather than the stewards one.

**Row transitions only while the list is on screen (BB-91).** Each effort,
ended and thread row carries a `view-transition-name`, and the list's poll
wraps a data commit in `document.startViewTransition` so a row moving between
sections glides. A view transition hoists every named element in the document
into the top layer for the crossfade, hidden or not, and the host keeps the
sidebar mounted when it is closed: on a phone the drawer sits under the page
content and is `inert`, on desktop the collapsed sidebar is `inert` (rail) or
`visibility: hidden` (offcanvas). So the poll animates only when the list's
root is on screen (no `[inert]` ancestor and `checkVisibility` true), and the
page and overview polls, which have no named rows, never start one. Otherwise
the sidebar's rows flashed over the Stewards page and thread pages on a phone
for the length of the crossfade.

**Grouping is the steward relationship, then the thread tree.** A worker
belongs to its binding through the plugin's `workers` table and a reviewer
through the worker the RPC names, so the grouping survives a worker spawned
Expand Down
18 changes: 18 additions & 0 deletions plugins/stewards/app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,24 @@ describe("the Stewards page's decide items and questions (BB-20)", () => {

// BB-29: an approve-pr or merge card links the pull request separately, while
// the entity key keeps opening the binding's ticket.
describe("the Stewards page's data commits (BB-91)", () => {
it("never starts a view transition of its own, since only the sidebar's rows are named", async () => {
const startViewTransition = vi.fn((update: () => void) => {
update();
return { finished: Promise.resolve(), ready: Promise.resolve(), updateCallbackDone: Promise.resolve(), skipTransition: () => {} } as unknown as ViewTransition;
});
(document as unknown as { startViewTransition: unknown }).startViewTransition = startViewTransition;
try {
const slot = await renderBoard([need({ choices: ["Merge"] })], []);
await waitFor(() => expect(slot.getByText("Merge")).toBeTruthy());
expect(startViewTransition).not.toHaveBeenCalled();
slot.lifecycle.unmount();
} finally {
delete (document as unknown as { startViewTransition?: unknown }).startViewTransition;
}
});
});

describe("the Stewards page's need links (BB-29)", () => {
const pr = "https://github.com/bitcomplete/bb-gate/pull/17";
const ticket = "https://linear.app/bit-complete/issue/BB-28";
Expand Down
47 changes: 40 additions & 7 deletions plugins/stewards/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,34 @@ function ago(at: number | null, now: number): string {
/** `document.startViewTransition`, where the browser supports it (Chromium). */
type ViewTransitionDocument = Document & { startViewTransition?: (update: () => void) => unknown };

/** What `load` returns, refetched every half minute while the page is visible, and on demand. */
function usePolled<T>(load: () => Promise<T>): { data: T | null; error: string | null; loaded: boolean; refetch: () => void } {
/** `Element.checkVisibility`, where the browser supports it. */
type CheckVisibilityElement = Element & { checkVisibility?: (options?: { visibilityProperty?: boolean }) => boolean };

/**
* Whether `el` is on screen for the user, so a view transition around it is
* worth starting (BB-91). The host keeps the sidebar mounted but hidden when
* it is closed: on a phone the drawer sits under the page content and is
* `inert`, and on desktop the collapsed sidebar is `inert` (rail) or
* `visibility: hidden` (offcanvas). A view transition hoists every element
* with a `view-transition-name` into the top layer for the crossfade, so one
* started while the list is hidden paints its rows over whatever page is
* showing. Without `checkVisibility` the element is taken as visible.
*/
function onScreen(el: Element | null): boolean {
if (el === null || !el.isConnected) return false;
if (el.closest("[inert]") !== null) return false;
const check = (el as CheckVisibilityElement).checkVisibility;
return typeof check === "function" ? check.call(el, { visibilityProperty: true }) : true;
}

/**
* What `load` returns, refetched every half minute while the page is visible,
* and on demand. With `animate`, a commit that it approves is wrapped in a view
* transition so the browser animates rows moving between sections (BB-91); the
* caller passes a check that its animated surface is on screen, since a
* transition captures every named element in the document, hidden or not.
*/
function usePolled<T>(load: () => Promise<T>, animate?: () => boolean): { data: T | null; error: string | null; loaded: boolean; refetch: () => void } {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
// True once a load has been on screen for a paint, so the first load's rows
Expand All @@ -88,9 +114,10 @@ function usePolled<T>(load: () => Promise<T>): { data: T | null; error: string |
setError(null);
};
// A view transition around the commit lets the browser animate a row
// moving between sections; without the API the commit is plain (BB-91).
// moving between sections; without the API, or when the caller's
// surface is off screen, the commit is plain (BB-91).
const start = (document as ViewTransitionDocument).startViewTransition;
if (typeof start !== "function") return commit();
if (typeof start !== "function" || animate === undefined || !animate()) return commit();
try {
start.call(document, () => flushSync(commit));
} catch {
Expand All @@ -101,7 +128,7 @@ function usePolled<T>(load: () => Promise<T>): { data: T | null; error: string |
if (mounted.current && call === latest.current) setError(message(e));
},
);
}, [load]);
}, [load, animate]);
// A paint after the first successful load, so rows that mount later animate.
useEffect(() => {
if (data !== null) setLoaded(true);
Expand Down Expand Up @@ -1736,7 +1763,13 @@ function StewardsSidebarCore({
}) {
const rpc = useRpc<typeof rpcContract>();
const { status, threads, projects } = experimental_useSidebarThreads();
const { data, error, loaded, refetch } = usePolled(useCallback(() => rpc.call("stewardsSidebar", null), [rpc]));
// The list's root, so a poll animates only while the list is on screen and
// not from under a closed drawer (BB-91).
const root = useRef<HTMLDivElement>(null);
const { data, error, loaded, refetch } = usePolled(
useCallback(() => rpc.call("stewardsSidebar", null), [rpc]),
useCallback(() => onScreen(root.current), []),
);
const tabs = useMemo(() => buildSidebar({ projects, threads, data: data?.projects ?? [], now: Date.now() }), [projects, threads, data]);
const needsByProject = useMemo(() => new Map((data?.projects ?? []).map((p) => [p.projectId, p.needs] as const)), [data]);
// The picker counts the yellow rows, so the pill and the sections cannot disagree (BB-76).
Expand Down Expand Up @@ -1794,7 +1827,7 @@ function StewardsSidebarCore({
/>
));
return (
<div className="flex h-full flex-col text-sm">
<div ref={root} className="flex h-full flex-col text-sm">
<ProjectTabs
tabs={tabs}
counts={counts}
Expand Down
63 changes: 63 additions & 0 deletions plugins/stewards/sidebar-app.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1197,6 +1197,69 @@ describe("the sidebar's row transitions (BB-91)", () => {
slot.lifecycle.unmount();
});

it("commits a data update plainly while the list is inside an inert ancestor, as under a closed mobile drawer", async () => {
const startViewTransition = vi.fn((update: () => void) => {
update();
return { finished: Promise.resolve(), ready: Promise.resolve(), updateCallbackDone: Promise.resolve(), skipTransition: () => {} } as unknown as ViewTransition;
});
(document as unknown as { startViewTransition: unknown }).startViewTransition = startViewTransition;
let calls = 0;
const slot = await renderSidebar({
sidebarThreads: { projects, threads: [thread({ id: "thr_steward", displayTitle: "ENG-1: Fix login" })] },
rpc: {
stewardsSidebar: () => {
calls += 1;
return sidebarData({ bindings: [binding({ phase: calls === 1 ? "In Progress" : "In Review" })] });
},
} as never,
});
await waitFor(() => expect(calls).toBe(1));
startViewTransition.mockClear();
// The host marks the closed drawer `inert` and keeps the list mounted under the page.
slot.container.setAttribute("inert", "");
await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p1" });
await waitFor(() => expect(calls).toBe(2));
await waitFor(() => expect(lineTwo(slot, "Fix login")).toContain("In Review"));
expect(startViewTransition).not.toHaveBeenCalled();
slot.container.removeAttribute("inert");
slot.lifecycle.unmount();
});

it("commits a data update plainly while the list is hidden by visibility, as in a collapsed desktop sidebar", async () => {
const startViewTransition = vi.fn((update: () => void) => {
update();
return { finished: Promise.resolve(), ready: Promise.resolve(), updateCallbackDone: Promise.resolve(), skipTransition: () => {} } as unknown as ViewTransition;
});
(document as unknown as { startViewTransition: unknown }).startViewTransition = startViewTransition;
const proto = Element.prototype as unknown as { checkVisibility?: unknown };
const had = proto.checkVisibility;
let visible = true;
proto.checkVisibility = () => visible;
try {
let calls = 0;
const slot = await renderSidebar({
sidebarThreads: { projects, threads: [thread({ id: "thr_steward", displayTitle: "ENG-1: Fix login" })] },
rpc: {
stewardsSidebar: () => {
calls += 1;
return sidebarData({ bindings: [binding({ phase: calls === 1 ? "In Progress" : "In Review" })] });
},
} as never,
});
await waitFor(() => expect(calls).toBe(1));
startViewTransition.mockClear();
visible = false;
await slot.emitRealtime(PROJECT_CHANGED, { projectId: "p1" });
await waitFor(() => expect(calls).toBe(2));
await waitFor(() => expect(lineTwo(slot, "Fix login")).toContain("In Review"));
expect(startViewTransition).not.toHaveBeenCalled();
slot.lifecycle.unmount();
} finally {
if (had === undefined) delete proto.checkVisibility;
else proto.checkVisibility = had;
}
});

it("still commits a data update when the document has no view transition API", async () => {
expect((document as unknown as { startViewTransition?: unknown }).startViewTransition).toBeUndefined();
let calls = 0;
Expand Down
Loading