From b496b56a909050fc5d170df3773705521f4a19ea Mon Sep 17 00:00:00 2001 From: Simon Boudrias Date: Mon, 19 Jan 2026 13:54:16 -0500 Subject: [PATCH] fix: wrap tether instance creation and updates in requestAnimationFrame This prevents positioning issues that can occur when the DOM hasn't fully settled during rapid updates. By deferring tether operations to the next animation frame, the browser has time to complete layout calculations before positioning is computed. The fix addresses flickering and incorrect positioning that can happen when components mount or update quickly, particularly in complex UI scenarios with nested tethered elements. Tests have been updated to use async/await with waitFor to accommodate the now-asynchronous tether initialization. --- src/react-tether.tsx | 31 +++++--- tests/unit/component.test.tsx | 136 ++++++++++++++++++++++------------ 2 files changed, 109 insertions(+), 58 deletions(-) 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(); + }); }); });