From d048ed203f7f8fa1cf1857cca6e6b5f1734c65ab Mon Sep 17 00:00:00 2001 From: bubacho <52525721+bubacho@users.noreply.github.com> Date: Fri, 15 May 2026 19:47:06 +0300 Subject: [PATCH 1/2] fix(web): fallback when requestIdleCallback is unavailable --- .../components/core/render-if-visible-HOC.tsx | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) diff --git a/apps/web/core/components/core/render-if-visible-HOC.tsx b/apps/web/core/components/core/render-if-visible-HOC.tsx index bd8a77a04a2..7adc9c56873 100644 --- a/apps/web/core/components/core/render-if-visible-HOC.tsx +++ b/apps/web/core/components/core/render-if-visible-HOC.tsx @@ -23,6 +23,14 @@ type Props = { forceRender?: boolean; }; +const runIdleTask = (callback: () => void) => { + if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") { + window.requestIdleCallback(callback, { timeout: 300 }); + return; + } + globalThis.setTimeout(callback, 0); +}; + function RenderIfVisible(props: Props) { const { defaultHeight = "300px", @@ -47,14 +55,13 @@ function RenderIfVisible(props: Props) { // Set visibility with intersection observer useEffect(() => { - if (intersectionRef.current) { + const target = intersectionRef.current; + if (target) { const observer = new IntersectionObserver( (entries) => { //DO no remove comments for future - if (typeof window !== undefined && window.requestIdleCallback && useIdletime) { - window.requestIdleCallback(() => setShouldVisible(entries[entries.length - 1].isIntersecting), { - timeout: 300, - }); + if (typeof window !== "undefined" && useIdletime) { + runIdleTask(() => setShouldVisible(entries[entries.length - 1].isIntersecting)); } else { setShouldVisible(entries[entries.length - 1].isIntersecting); } @@ -64,20 +71,17 @@ function RenderIfVisible(props: Props) { rootMargin: `${verticalOffset}% ${horizontalOffset}% ${verticalOffset}% ${horizontalOffset}%`, } ); - observer.observe(intersectionRef.current); + observer.observe(target); return () => { - if (intersectionRef.current) { - // eslint-disable-next-line react-hooks/exhaustive-deps - observer.unobserve(intersectionRef.current); - } + observer.unobserve(target); }; } - }, [intersectionRef, children, root, verticalOffset, horizontalOffset]); + }, [intersectionRef, root, verticalOffset, horizontalOffset, useIdletime]); //Set height after render useEffect(() => { if (intersectionRef.current && isVisible && shouldRecordHeights) { - window.requestIdleCallback(() => { + runIdleTask(() => { if (intersectionRef.current) placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`; }); } From eb0edcdbaefcb64542135d1dd4b086316783f6c7 Mon Sep 17 00:00:00 2001 From: bubacho <52525721+bubacho@users.noreply.github.com> Date: Tue, 19 May 2026 16:45:37 +0300 Subject: [PATCH 2/2] refactor: improve idle task scheduling safety in render-if-visible --- .../components/core/render-if-visible-HOC.tsx | 33 +++++++++++-------- .../issues/issue-layouts/kanban/default.tsx | 2 +- apps/web/core/lib/idle-task.ts | 27 +++++++++++++++ 3 files changed, 47 insertions(+), 15 deletions(-) create mode 100644 apps/web/core/lib/idle-task.ts diff --git a/apps/web/core/components/core/render-if-visible-HOC.tsx b/apps/web/core/components/core/render-if-visible-HOC.tsx index 7adc9c56873..03ba7b52b08 100644 --- a/apps/web/core/components/core/render-if-visible-HOC.tsx +++ b/apps/web/core/components/core/render-if-visible-HOC.tsx @@ -7,6 +7,7 @@ import type { ReactNode, MutableRefObject } from "react"; import React, { useState, useRef, useEffect } from "react"; import { cn } from "@plane/utils"; +import { runIdleTask } from "@/lib/idle-task"; type Props = { defaultHeight?: string; @@ -19,18 +20,10 @@ type Props = { placeholderChildren?: ReactNode; defaultValue?: boolean; shouldRecordHeights?: boolean; - useIdletime?: boolean; + useIdleTime?: boolean; forceRender?: boolean; }; -const runIdleTask = (callback: () => void) => { - if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") { - window.requestIdleCallback(callback, { timeout: 300 }); - return; - } - globalThis.setTimeout(callback, 0); -}; - function RenderIfVisible(props: Props) { const { defaultHeight = "300px", @@ -44,12 +37,14 @@ function RenderIfVisible(props: Props) { //placeholder children placeholderChildren = null, //placeholder children defaultValue = false, - useIdletime = false, + useIdleTime = false, forceRender = false, } = props; const [shouldVisible, setShouldVisible] = useState(defaultValue); const placeholderHeight = useRef(defaultHeight); const intersectionRef = useRef(null); + const visibilityIdleTaskRef = useRef | null>(null); + const heightIdleTaskRef = useRef | null>(null); const isVisible = shouldVisible || forceRender; @@ -60,8 +55,11 @@ function RenderIfVisible(props: Props) { const observer = new IntersectionObserver( (entries) => { //DO no remove comments for future - if (typeof window !== "undefined" && useIdletime) { - runIdleTask(() => setShouldVisible(entries[entries.length - 1].isIntersecting)); + if (typeof window !== "undefined" && useIdleTime) { + visibilityIdleTaskRef.current?.cancel(); + visibilityIdleTaskRef.current = runIdleTask(() => + setShouldVisible(entries[entries.length - 1].isIntersecting) + ); } else { setShouldVisible(entries[entries.length - 1].isIntersecting); } @@ -73,18 +71,25 @@ function RenderIfVisible(props: Props) { ); observer.observe(target); return () => { + visibilityIdleTaskRef.current?.cancel(); + visibilityIdleTaskRef.current = null; observer.unobserve(target); }; } - }, [intersectionRef, root, verticalOffset, horizontalOffset, useIdletime]); + }, [intersectionRef, root, verticalOffset, horizontalOffset, useIdleTime]); //Set height after render useEffect(() => { if (intersectionRef.current && isVisible && shouldRecordHeights) { - runIdleTask(() => { + heightIdleTaskRef.current?.cancel(); + heightIdleTaskRef.current = runIdleTask(() => { if (intersectionRef.current) placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`; }); } + return () => { + heightIdleTaskRef.current?.cancel(); + heightIdleTaskRef.current = null; + }; }, [isVisible, intersectionRef, shouldRecordHeights]); const child = isVisible ? <>{children} : placeholderChildren; diff --git a/apps/web/core/components/issues/issue-layouts/kanban/default.tsx b/apps/web/core/components/issues/issue-layouts/kanban/default.tsx index b7326ee0f9c..cff862d8ca9 100644 --- a/apps/web/core/components/issues/issue-layouts/kanban/default.tsx +++ b/apps/web/core/components/issues/issue-layouts/kanban/default.tsx @@ -204,7 +204,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) { /> } defaultValue={groupIndex < 5 && subGroupIndex < 2} - useIdletime + useIdleTime > void; +}; + +/** + * Schedule lightweight work for idle time and return a cancel handle. + * Falls back to setTimeout when requestIdleCallback is unavailable. + */ +export const runIdleTask = (callback: () => void): IdleTaskHandle => { + if (typeof window !== "undefined" && typeof window.requestIdleCallback === "function") { + const idleId = window.requestIdleCallback(callback, { timeout: 300 }); + return { + cancel: () => window.cancelIdleCallback(idleId), + }; + } + + const timeoutId = window.setTimeout(callback, 0); + return { + cancel: () => window.clearTimeout(timeoutId), + }; +};