diff --git a/src/react-tether.tsx b/src/react-tether.tsx index 4d384a5..6e2c8ed 100644 --- a/src/react-tether.tsx +++ b/src/react-tether.tsx @@ -157,8 +157,13 @@ export default class ReactTether extends Component { this._destroy(); } - this._tetherInstance = new Tether(tetherOptions); - this._registerEventListeners(); + // Use requestAnimationFrame to defer tether instance creation until after + // the browser has completed layout. This prevents positioning issues that + // can occur when the DOM hasn't fully settled. + requestAnimationFrame(() => { + this._tetherInstance = new Tether(tetherOptions); + this._registerEventListeners(); + }); } _destroyTetherInstance() { @@ -277,15 +282,19 @@ export default class ReactTether extends Component { } } - this._addContainerToDOM(); - - if (this._tetherInstance) { - this._tetherInstance.setOptions(tetherOptions); - } else { - this._createTetherInstance(tetherOptions); - } - - this._tetherInstance?.position(); + // Use requestAnimationFrame to defer DOM mutations and tether updates until + // after the browser has completed layout. This prevents positioning issues + // and flickering that can occur during rapid updates. + requestAnimationFrame(() => { + this._addContainerToDOM(); + + if (this._tetherInstance) { + this._tetherInstance.setOptions(tetherOptions); + this._tetherInstance.position(); + } else { + this._createTetherInstance(tetherOptions); + } + }); } override render() { diff --git a/tests/unit/component.test.tsx b/tests/unit/component.test.tsx index 604b02e..fe90b8c 100644 --- a/tests/unit/component.test.tsx +++ b/tests/unit/component.test.tsx @@ -1,8 +1,16 @@ import React from "react"; -import { render, screen } from "@testing-library/react"; +import { render, screen, waitFor, cleanup } from "@testing-library/react"; import TetherComponent from "../../src/react-tether"; describe("TetherComponent", () => { + afterEach(() => { + cleanup(); + // Clean up any tether elements left in the DOM + document.querySelectorAll(".tether-element").forEach((el) => el.remove()); + document.querySelectorAll("[data-tether-id]").forEach((el) => el.remove()); + document.querySelectorAll("#test-container").forEach((el) => el.remove()); + document.querySelectorAll("#test-container2").forEach((el) => el.remove()); + }); it("should render the target", () => { render( { expect(screen.getByTestId("target")).toBeInTheDocument(); }); - it("should render the element", () => { + it("should render the element", async () => { render( { renderElement={(ref) =>
} /> ); - expect(screen.getByTestId("element")).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByTestId("element")).toBeInTheDocument(); + }); }); - it("should create a tether element", () => { + it("should create a tether element", async () => { render( { renderElement={(ref) =>
} /> ); - const tetherElement = document.querySelector(".tether-element"); - expect(tetherElement).toBeInTheDocument(); + await waitFor(() => { + const tetherElement = document.querySelector(".tether-element"); + expect(tetherElement).toBeInTheDocument(); + }); }); - it("should render the second child in the tether element", () => { + it("should render the second child in the tether element", async () => { render( { renderElement={(ref) =>
} /> ); - const element = document.querySelector( - ".tether-element [data-testid=element]" - ); - expect(element).toBeInTheDocument(); + await waitFor(() => { + const element = document.querySelector( + ".tether-element [data-testid=element]" + ); + expect(element).toBeInTheDocument(); + }); }); - it("should add className to tether element", () => { + it("should add className to tether element", async () => { render( { renderElement={(ref) =>
} /> ); - const tetherElement = document.querySelector(".tether-element"); - expect(tetherElement).toBeInTheDocument(); - expect(tetherElement?.classList.contains("custom-class-1")).toBe(true); - expect(tetherElement?.classList.contains("custom-class-2")).toBe(true); + await waitFor(() => { + const tetherElement = document.querySelector(".tether-element"); + expect(tetherElement).toBeInTheDocument(); + expect(tetherElement?.classList.contains("custom-class-1")).toBe(true); + expect(tetherElement?.classList.contains("custom-class-2")).toBe(true); + }); }); - it("should swap out classes when className changes", () => { + it("should swap out classes when className changes", async () => { let { rerender } = render( { renderElement={(ref) =>
} /> ); - let tetherElement = document.querySelector(".tether-element"); - expect(tetherElement?.classList.contains("custom-class-1")).toBe(true); - expect(tetherElement?.classList.contains("custom-class-2")).toBe(true); + await waitFor(() => { + let tetherElement = document.querySelector(".tether-element"); + expect(tetherElement?.classList.contains("custom-class-1")).toBe(true); + expect(tetherElement?.classList.contains("custom-class-2")).toBe(true); + }); rerender( { /> ); - tetherElement = document.querySelector(".tether-element"); - expect(tetherElement?.classList.contains("custom-class-1")).toBe(true); - expect(tetherElement?.classList.contains("custom-class-2")).toBe(false); - expect(tetherElement?.classList.contains("custom-class-3")).toBe(true); - expect(tetherElement?.classList.contains("custom-class-4")).toBe(true); + await waitFor(() => { + let tetherElement = document.querySelector(".tether-element"); + expect(tetherElement?.classList.contains("custom-class-1")).toBe(true); + expect(tetherElement?.classList.contains("custom-class-2")).toBe(false); + expect(tetherElement?.classList.contains("custom-class-3")).toBe(true); + expect(tetherElement?.classList.contains("custom-class-4")).toBe(true); + }); }); it("should render a just a target", () => { @@ -138,7 +158,7 @@ describe("TetherComponent", () => { expect(document.querySelector(".tether-element")).not.toBeInTheDocument(); }); - it("should destroy the tether element if the first/second child is unmounted", () => { + it("should destroy the tether element if the first/second child is unmounted", async () => { function ToggleComponent({ first, second, @@ -161,9 +181,11 @@ describe("TetherComponent", () => { let { rerender } = render(); expect(screen.getByTestId("target")).toBeInTheDocument(); - expect( - document.querySelector(".tether-element [data-testid=element]") - ).toBeInTheDocument(); + await waitFor(() => { + expect( + document.querySelector(".tether-element [data-testid=element]") + ).toBeInTheDocument(); + }); rerender(); @@ -180,7 +202,7 @@ describe("TetherComponent", () => { ).not.toBeInTheDocument(); }); - it("allows changing the tether element tag", () => { + it("allows changing the tether element tag", async () => { render( { renderElement={(ref) =>
} /> ); - expect(document.querySelector(".tether-element")?.nodeName).toBe("ASIDE"); + await waitFor(() => { + expect(document.querySelector(".tether-element")?.nodeName).toBe("ASIDE"); + }); }); - it("allows changing the tether element tag on the fly", () => { + it("allows changing the tether element tag on the fly", async () => { function DifferentTagsComponent({ isAside }: { isAside: boolean }) { return ( { } let { rerender } = render(); - expect(document.querySelector(".tether-element")?.nodeName).toBe("DIV"); + await waitFor(() => { + expect(document.querySelector(".tether-element")?.nodeName).toBe("DIV"); + }); rerender(); - expect(document.querySelector(".tether-element")?.nodeName).toBe("ASIDE"); + await waitFor(() => { + expect(document.querySelector(".tether-element")?.nodeName).toBe("ASIDE"); + }); }); - it("allows changing the tether element tag", () => { + it("allows changing the renderElementTo location", async () => { const container = document.createElement("div"); container.setAttribute("id", "test-container"); // Tether requires the container element to have position static @@ -229,12 +257,14 @@ describe("TetherComponent", () => { ); expect(document.querySelector("#test-container")).toBeInTheDocument(); - expect( - document.querySelector("#test-container .tether-element") - ).toBeInTheDocument(); + await waitFor(() => { + expect( + document.querySelector("#test-container .tether-element") + ).toBeInTheDocument(); + }); }); - it("allows changing the tether element tag on the fly", () => { + it("allows changing the renderElementTo on the fly", async () => { const container = document.createElement("div"); container.setAttribute("id", "test-container"); // Tether requires the container element to have position static @@ -261,16 +291,20 @@ describe("TetherComponent", () => { ); expect(document.querySelector("#test-container")).toBeInTheDocument(); - expect( - document.querySelector("#test-container .tether-element") - ).toBeInTheDocument(); + await waitFor(() => { + expect( + document.querySelector("#test-container .tether-element") + ).toBeInTheDocument(); + }); rerender(); expect(document.querySelector("#test-container2")).toBeInTheDocument(); - expect( - document.querySelector("#test-container2 .tether-element") - ).toBeInTheDocument(); + await waitFor(() => { + expect( + document.querySelector("#test-container2 .tether-element") + ).toBeInTheDocument(); + }); }); // Not sure how to properly test this @@ -290,7 +324,7 @@ describe("TetherComponent", () => { expect(onUpdate).toHaveBeenCalled(); }); - it("passes arguments when on onRepositioned() is called", () => { + it("passes arguments when on onRepositioned() is called", async () => { const onRepositioned = jest.fn(); let { rerender } = render( @@ -301,6 +335,12 @@ describe("TetherComponent", () => { renderElement={(ref) =>
} /> ); + + // Wait for initial tether setup + await waitFor(() => { + expect(document.querySelector(".tether-element")).toBeInTheDocument(); + }); + rerender( { /> ); - expect(onRepositioned).toHaveBeenCalled(); + await waitFor(() => { + expect(onRepositioned).toHaveBeenCalled(); + }); }); });