fix(web): add requestIdleCallback fallback for Safari/iOS (#9094)

* fix(web): fallback when requestIdleCallback is unavailable

* refactor: improve idle task scheduling safety in render-if-visible
This commit is contained in:
bubacho
2026-05-24 22:31:11 +03:00
committed by GitHub
parent 039d582fbb
commit fd613dc738
3 changed files with 51 additions and 15 deletions
@@ -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,7 +20,7 @@ type Props = {
placeholderChildren?: ReactNode;
defaultValue?: boolean;
shouldRecordHeights?: boolean;
useIdletime?: boolean;
useIdleTime?: boolean;
forceRender?: boolean;
};
@@ -36,25 +37,29 @@ function RenderIfVisible(props: Props) {
//placeholder children
placeholderChildren = null, //placeholder children
defaultValue = false,
useIdletime = false,
useIdleTime = false,
forceRender = false,
} = props;
const [shouldVisible, setShouldVisible] = useState<boolean>(defaultValue);
const placeholderHeight = useRef<string>(defaultHeight);
const intersectionRef = useRef<HTMLElement | null>(null);
const visibilityIdleTaskRef = useRef<ReturnType<typeof runIdleTask> | null>(null);
const heightIdleTaskRef = useRef<ReturnType<typeof runIdleTask> | null>(null);
const isVisible = shouldVisible || forceRender;
// 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) {
visibilityIdleTaskRef.current?.cancel();
visibilityIdleTaskRef.current = runIdleTask(() =>
setShouldVisible(entries[entries.length - 1].isIntersecting)
);
} else {
setShouldVisible(entries[entries.length - 1].isIntersecting);
}
@@ -64,23 +69,27 @@ 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);
}
visibilityIdleTaskRef.current?.cancel();
visibilityIdleTaskRef.current = null;
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(() => {
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;
@@ -204,7 +204,7 @@ export const KanBan = observer(function KanBan(props: IKanBan) {
/>
}
defaultValue={groupIndex < 5 && subGroupIndex < 2}
useIdletime
useIdleTime
>
<KanbanGroup
groupId={subList.id}
+27
View File
@@ -0,0 +1,27 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
export type IdleTaskHandle = {
cancel: () => 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),
};
};