From 485586d2d1710d840446c2dce16b0c2b5a37c8a2 Mon Sep 17 00:00:00 2001 From: Jose Antonio Martinez <257598434+jamartineztelecoengineer84-dotcom@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:10:09 +0000 Subject: [PATCH 1/2] fix: use polyfilled requestIdleCallback in render-if-visible HOC MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The polyfill from #5689 is only applied as a side-effect when app/provider.tsx is evaluated. Lazy-loaded chunks like gantt-layout-loader can execute before the provider tree finishes hydrating, causing window.requestIdleCallback to be undefined on Safari (the only major browser without native support) and crashing the entire UI with a TypeError. Add safeRequestIdleCallback / safeCancelIdleCallback wrappers to apps/web/core/lib/polyfills and use them at the call sites in render-if-visible-HOC.tsx, making the call defensive at use rather than relying on global side-effect ordering. The existing side-effect is preserved for backward compatibility. Also cleans up a typo in the original guard at line 54 (typeof window !== undefined → effectively always truthy). Closes #8904 Closes #8871 --- .../components/core/render-if-visible-HOC.tsx | 19 ++++++------ apps/web/core/lib/polyfills/index.ts | 29 ++++++++++++++++++- 2 files changed, 37 insertions(+), 11 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..3ed521e5d89 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 { safeRequestIdleCallback } from "@/lib/polyfills"; type Props = { defaultHeight?: string; @@ -47,12 +48,13 @@ function RenderIfVisible(props: Props) { // Set visibility with intersection observer useEffect(() => { - if (intersectionRef.current) { + const node = intersectionRef.current; + if (node) { 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), { + if (useIdletime) { + safeRequestIdleCallback(() => setShouldVisible(entries[entries.length - 1].isIntersecting), { timeout: 300, }); } else { @@ -64,20 +66,17 @@ function RenderIfVisible(props: Props) { rootMargin: `${verticalOffset}% ${horizontalOffset}% ${verticalOffset}% ${horizontalOffset}%`, } ); - observer.observe(intersectionRef.current); + observer.observe(node); return () => { - if (intersectionRef.current) { - // eslint-disable-next-line react-hooks/exhaustive-deps - observer.unobserve(intersectionRef.current); - } + observer.unobserve(node); }; } - }, [intersectionRef, children, root, verticalOffset, horizontalOffset]); + }, [intersectionRef, children, root, verticalOffset, horizontalOffset, useIdletime]); //Set height after render useEffect(() => { if (intersectionRef.current && isVisible && shouldRecordHeights) { - window.requestIdleCallback(() => { + safeRequestIdleCallback(() => { if (intersectionRef.current) placeholderHeight.current = `${intersectionRef.current.offsetHeight}px`; }); } diff --git a/apps/web/core/lib/polyfills/index.ts b/apps/web/core/lib/polyfills/index.ts index 2243dc620c2..1d7e735f420 100644 --- a/apps/web/core/lib/polyfills/index.ts +++ b/apps/web/core/lib/polyfills/index.ts @@ -27,4 +27,31 @@ if (typeof window !== "undefined" && window) { }; } -export {}; +// Defensive wrappers for use at call sites that may run before the side-effect +// above is applied (e.g., lazy-loaded chunks like gantt-layout-loader that can +// execute before app/provider.tsx finishes evaluating). +export const safeRequestIdleCallback: typeof window.requestIdleCallback = (cb, options) => { + if (typeof window !== "undefined" && window.requestIdleCallback) { + return window.requestIdleCallback(cb, options); + } + const start = Date.now(); + // setTimeout's return type is `number | NodeJS.Timeout` depending on the resolved + // typings; in a browser context (the only path that reaches this fallback) it is + // always `number`. Cast to satisfy the DOM-shaped IdleCallbackHandle return. + return setTimeout( + () => + cb({ + didTimeout: false, + timeRemaining: () => Math.max(0, 50 - (Date.now() - start)), + }), + 1 + ) as unknown as number; +}; + +export const safeCancelIdleCallback: typeof window.cancelIdleCallback = (id) => { + if (typeof window !== "undefined" && window.cancelIdleCallback) { + window.cancelIdleCallback(id); + return; + } + clearTimeout(id); +}; From 35d3e46b7ceba2e708f3cb85cf980f1b69ed6a13 Mon Sep 17 00:00:00 2001 From: Jose Antonio Martinez <257598434+jamartineztelecoengineer84-dotcom@users.noreply.github.com> Date: Mon, 27 Apr 2026 11:22:27 +0000 Subject: [PATCH 2/2] fix: cancel pending idle callback on cleanup and effect re-run Address review feedback from coderabbitai: the handle returned by safeRequestIdleCallback was discarded, allowing setShouldVisible to fire on unmounted components or after the effect's deps changed. Capture the handle, cancel any pending callback before scheduling a new one, and cancel on cleanup. --- apps/web/core/components/core/render-if-visible-HOC.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 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 3ed521e5d89..4e40869c65c 100644 --- a/apps/web/core/components/core/render-if-visible-HOC.tsx +++ b/apps/web/core/components/core/render-if-visible-HOC.tsx @@ -7,7 +7,7 @@ import type { ReactNode, MutableRefObject } from "react"; import React, { useState, useRef, useEffect } from "react"; import { cn } from "@plane/utils"; -import { safeRequestIdleCallback } from "@/lib/polyfills"; +import { safeCancelIdleCallback, safeRequestIdleCallback } from "@/lib/polyfills"; type Props = { defaultHeight?: string; @@ -50,11 +50,13 @@ function RenderIfVisible(props: Props) { useEffect(() => { const node = intersectionRef.current; if (node) { + let idleHandle: number | undefined; const observer = new IntersectionObserver( (entries) => { //DO no remove comments for future if (useIdletime) { - safeRequestIdleCallback(() => setShouldVisible(entries[entries.length - 1].isIntersecting), { + if (idleHandle !== undefined) safeCancelIdleCallback(idleHandle); + idleHandle = safeRequestIdleCallback(() => setShouldVisible(entries[entries.length - 1].isIntersecting), { timeout: 300, }); } else { @@ -69,6 +71,7 @@ function RenderIfVisible(props: Props) { observer.observe(node); return () => { observer.unobserve(node); + if (idleHandle !== undefined) safeCancelIdleCallback(idleHandle); }; } }, [intersectionRef, children, root, verticalOffset, horizontalOffset, useIdletime]);