fix: prevent BookingDetailsSheet flicker when switching bookings (#27894)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Eunjae Lee
2026-02-13 11:53:22 +01:00
committed by GitHub
co-authored by Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
parent e2119879ae
commit 896dfd50fd
3 changed files with 165 additions and 14 deletions
@@ -0,0 +1,144 @@
import { act, renderHook } from "@testing-library/react";
import React from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { createStore, useStore } from "zustand";
import type { BookingOutput } from "../types";
type BookingDetailsSheetStore = {
selectedBookingUid: string | null;
bookings: BookingOutput[];
setSelectedBookingUid: (uid: string | null) => void;
setBookings: (bookings: BookingOutput[]) => void;
getSelectedBooking: () => BookingOutput | null;
};
function createTestStore(initialBookings: BookingOutput[] = []) {
return createStore<BookingDetailsSheetStore>((set, get) => ({
selectedBookingUid: null,
bookings: initialBookings,
setSelectedBookingUid: (uid) => set({ selectedBookingUid: uid }),
setBookings: (bookings) => set({ bookings }),
getSelectedBooking: () => {
const state = get();
if (!state.selectedBookingUid) return null;
return state.bookings.find((booking) => booking.uid === state.selectedBookingUid) ?? null;
},
}));
}
const makeBooking = (uid: string, title: string): BookingOutput =>
({
uid,
id: Number(uid),
title,
startTime: new Date().toISOString(),
endTime: new Date().toISOString(),
status: "ACCEPTED",
attendees: [],
metadata: null,
location: null,
rescheduled: false,
recurringEventId: null,
fromReschedule: null,
responses: {},
payment: [],
eventType: null,
user: null,
assignmentReasonSortedByCreatedAt: [],
}) as unknown as BookingOutput;
describe("BookingDetailsSheet transition behavior", () => {
const bookingA = makeBooking("uid-a", "Booking A");
const bookingB = makeBooking("uid-b", "Booking B");
it("getSelectedBooking returns null when no uid is selected", () => {
const store = createTestStore([bookingA, bookingB]);
expect(store.getState().getSelectedBooking()).toBeNull();
});
it("getSelectedBooking returns the correct booking when uid is set", () => {
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-a");
expect(store.getState().getSelectedBooking()?.uid).toBe("uid-a");
});
it("getSelectedBooking returns null when uid does not match any booking", () => {
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-nonexistent");
expect(store.getState().getSelectedBooking()).toBeNull();
});
it("switching between bookings updates getSelectedBooking immediately", () => {
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-a");
expect(store.getState().getSelectedBooking()?.title).toBe("Booking A");
store.getState().setSelectedBookingUid("uid-b");
expect(store.getState().getSelectedBooking()?.title).toBe("Booking B");
});
describe("lastBookingRef fallback logic", () => {
it("preserves last booking when getSelectedBooking returns null during transition", () => {
let lastBooking: BookingOutput | null = null;
let selectedBookingUid: string | null = null;
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-a");
const booking = store.getState().getSelectedBooking();
if (booking) {
lastBooking = booking;
}
store.getState().setSelectedBookingUid("uid-nonexistent");
selectedBookingUid = store.getState().selectedBookingUid;
const currentBooking = store.getState().getSelectedBooking();
const displayBooking = currentBooking ?? lastBooking;
expect(currentBooking).toBeNull();
expect(selectedBookingUid).toBe("uid-nonexistent");
expect(displayBooking?.uid).toBe("uid-a");
});
it("clears lastBooking when selectedBookingUid is explicitly null", () => {
let lastBooking: BookingOutput | null = null;
const store = createTestStore([bookingA]);
store.getState().setSelectedBookingUid("uid-a");
const booking = store.getState().getSelectedBooking();
if (booking) {
lastBooking = booking;
}
store.getState().setSelectedBookingUid(null);
const selectedBookingUid = store.getState().selectedBookingUid;
if (!selectedBookingUid) {
lastBooking = null;
}
const currentBooking = store.getState().getSelectedBooking();
const displayBooking = currentBooking ?? lastBooking;
expect(displayBooking).toBeNull();
});
it("updates lastBooking when new booking is found", () => {
let lastBooking: BookingOutput | null = null;
const store = createTestStore([bookingA, bookingB]);
store.getState().setSelectedBookingUid("uid-a");
const bookingAResult = store.getState().getSelectedBooking();
if (bookingAResult) lastBooking = bookingAResult;
expect(lastBooking?.uid).toBe("uid-a");
store.getState().setSelectedBookingUid("uid-b");
const bookingBResult = store.getState().getSelectedBooking();
if (bookingBResult) lastBooking = bookingBResult;
expect(lastBooking?.uid).toBe("uid-b");
});
});
});
@@ -34,7 +34,7 @@ import { ExternalLinkIcon, RepeatIcon } from "@coss/ui/icons";
import { BookingHistory } from "@calcom/web/modules/booking-audit/components/BookingHistory";
import assignmentReasonBadgeTitleMap from "@lib/booking/assignmentReasonBadgeTitleMap";
import Link from "next/link";
import { useMemo } from "react";
import { useMemo, useRef } from "react";
import type { z } from "zod";
import { AcceptBookingButton } from "../../../components/booking/AcceptBookingButton";
import { BookingActionsDropdown } from "../../../components/booking/actions/BookingActionsDropdown";
@@ -64,14 +64,25 @@ export function BookingDetailsSheet({
bookingAuditEnabled = false,
}: BookingDetailsSheetProps) {
const booking = useBookingDetailsSheetStore((state) => state.getSelectedBooking());
const selectedBookingUid = useBookingDetailsSheetStore((state) => state.selectedBookingUid);
const lastBookingRef = useRef<BookingOutput | null>(null);
// Return null if no booking is selected (sheet is closed)
if (!booking) return null;
if (booking) {
lastBookingRef.current = booking;
}
if (!selectedBookingUid) {
lastBookingRef.current = null;
}
const displayBooking = booking ?? lastBookingRef.current;
if (!displayBooking) return null;
return (
<BookingActionsStoreProvider>
<BookingDetailsSheetInner
booking={booking}
booking={displayBooking}
userTimeZone={userTimeZone}
userTimeFormat={userTimeFormat}
userId={userId}
@@ -224,16 +235,13 @@ function BookingDetailsSheetInner({
className="overflow-y-auto pb-0 sm:pb-0"
hideOverlay
onInteractOutside={(e) => {
// Check if the click is on a booking list item
const target = e.target as HTMLElement;
const isBookingListItem = target.closest("[data-booking-list-item]");
const isBookingItem =
target.closest("[data-booking-list-item]") || target.closest("[data-booking-calendar-event]");
if (isBookingListItem) {
// Prevent closing when clicking a booking list item
// The item's onClick will handle opening the sheet with the new booking
if (isBookingItem) {
e.preventDefault();
}
// If clicking elsewhere, allow the default behavior (close the sheet)
}}>
<SheetHeader showCloseButton={false} className="mt-0 w-full">
<div className="flex items-center justify-between gap-x-4">
@@ -1,11 +1,9 @@
import { cva } from "class-variance-authority";
import dayjs from "@calcom/dayjs";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import type { BookingStatus } from "@calcom/prisma/enums";
import classNames from "@calcom/ui/classNames";
import { Tooltip } from "@calcom/ui/components/tooltip";
import type { CalendarEvent } from "@calcom/features/calendars/weeklyview/types/events";
import { cva } from "class-variance-authority";
type EventProps = {
event: CalendarEvent;
@@ -111,6 +109,7 @@ export function Event({
return (
<Tooltip content={tooltipContent} className="max-w-none" side={tooltipSide}>
<Component
data-booking-calendar-event="true"
onClick={() => onEventClick?.(event)} // Note this is not the button event. It is the calendar event.
className={classNames(
eventClasses({