From c86e2de088610e3a1a35b9633c36cd4e9a2b501a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Tue, 1 Sep 2026 11:07:07 +0000 Subject: [PATCH] fix: guard React transition runtime APIs Co-authored-by: theg1239 --- app/(app)/home/course-search.tsx | 7 +- app/components/command-palette.tsx | 4 +- .../common/directional-transition.tsx | 7 +- app/components/common/react-transition.tsx | 25 ++++++ app/components/mobile-tab-bar.tsx | 5 +- app/components/nav-bar.tsx | 5 +- app/components/notes/notes-course-search.tsx | 13 ++-- .../past_papers/answer-key-toggle.tsx | 5 +- .../past_papers/course-paper-grid.tsx | 7 +- app/components/past_papers/filter-bar.tsx | 5 +- .../past_papers/past-papers-course-search.tsx | 9 ++- app/components/past_papers/sort-dropdown.tsx | 5 +- app/components/voice/voice-agent-provider.tsx | 4 +- .../test-react-transition-runtime-imports.ts | 77 +++++++++++++++++++ 14 files changed, 145 insertions(+), 33 deletions(-) create mode 100644 app/components/common/react-transition.tsx create mode 100644 scripts/test-react-transition-runtime-imports.ts diff --git a/app/(app)/home/course-search.tsx b/app/(app)/home/course-search.tsx index 66246f97..27960be0 100644 --- a/app/(app)/home/course-search.tsx +++ b/app/(app)/home/course-search.tsx @@ -1,11 +1,12 @@ 'use client'; -import React, { Activity, addTransitionType, startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; +import React, { Activity, startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react'; import Image from "@/app/components/common/app-image"; import Link from "next/link"; import { useRouter } from "next/navigation"; import SearchIcon from "@/app/components/assets/seacrh.svg"; import VoiceAgentButton from "@/app/components/voice/voice-agent-button"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; import { getAliasCourseCodes } from "@/lib/course-aliases"; import { createCourseFuse } from "@/lib/course-search-fuse"; import { normalizeCourseCode } from "@/lib/course-tags"; @@ -74,14 +75,14 @@ export default function CourseSearch({ courses }: CourseSearchProps) { const navigateForward = (href: string, beforeNavigate?: () => void) => { startTransition(() => { beforeNavigate?.(); - addTransitionType("nav-forward"); + addOptionalTransitionType("nav-forward"); push(href); }); }; const navigateToSearch = (href: string) => { startTransition(() => { - addTransitionType("filter-results"); + addOptionalTransitionType("filter-results"); push(href); }); }; diff --git a/app/components/command-palette.tsx b/app/components/command-palette.tsx index 12752495..13e12200 100644 --- a/app/components/command-palette.tsx +++ b/app/components/command-palette.tsx @@ -2,7 +2,6 @@ import React, { Activity, - addTransitionType, startTransition, useEffect, useEffectEvent, @@ -14,6 +13,7 @@ import { Command } from "cmdk"; import * as Dialog from "@radix-ui/react-dialog"; import { useAgent } from "agents/react"; import { usePathname, useRouter } from "next/navigation"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; import { invalidateAuthSessionCache, useGuestPrompt, @@ -1460,7 +1460,7 @@ function CommandPaletteSession({ if (href === pathname) return; startTransition(() => { - addTransitionType(href.startsWith("/past_papers/") ? "nav-forward" : "nav-lateral"); + addOptionalTransitionType(href.startsWith("/past_papers/") ? "nav-forward" : "nav-lateral"); push(href); }); }; diff --git a/app/components/common/directional-transition.tsx b/app/components/common/directional-transition.tsx index 2da2575f..7cf454ec 100644 --- a/app/components/common/directional-transition.tsx +++ b/app/components/common/directional-transition.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { ViewTransition, useEffect, useState } from "react"; +import React, { useEffect, useState } from "react"; import type { ViewTransitionClass } from "react"; +import { OptionalViewTransition } from "@/app/components/common/react-transition"; // `` is an experimental React API. It used to be keyed on // `pathname`, which forced the whole page subtree to remount on every @@ -70,12 +71,12 @@ export default function DirectionalTransition({ } return ( - {children} - + ); } diff --git a/app/components/common/react-transition.tsx b/app/components/common/react-transition.tsx new file mode 100644 index 00000000..91f622f4 --- /dev/null +++ b/app/components/common/react-transition.tsx @@ -0,0 +1,25 @@ +"use client"; + +import React from "react"; +import type { ViewTransitionProps } from "react"; + +type ReactWithOptionalTransitions = typeof React & { + addTransitionType?: (type: string) => void; + ViewTransition?: React.ExoticComponent; +}; + +const reactWithOptionalTransitions = React as ReactWithOptionalTransitions; + +export function addOptionalTransitionType(type: string) { + reactWithOptionalTransitions.addTransitionType?.(type); +} + +export function OptionalViewTransition(props: ViewTransitionProps) { + const ViewTransition = reactWithOptionalTransitions.ViewTransition; + + if (!ViewTransition) { + return <>{props.children}; + } + + return ; +} diff --git a/app/components/mobile-tab-bar.tsx b/app/components/mobile-tab-bar.tsx index ac43cac0..9a9f591f 100644 --- a/app/components/mobile-tab-bar.tsx +++ b/app/components/mobile-tab-bar.tsx @@ -3,8 +3,9 @@ import Link from "next/link"; import Image from "@/app/components/common/app-image"; import { usePathname, useRouter } from "next/navigation"; -import { addTransitionType, startTransition, useEffect, useReducer, type MouseEvent } from "react"; +import { startTransition, useEffect, useReducer, type MouseEvent } from "react"; import { APP_NAV_LINKS } from "@/lib/app-nav-links"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; import c2cStyles from "./mobile-tab-bar.module.css"; const C2C_EVENT_URL = @@ -96,7 +97,7 @@ export default function MobileTabBar({ toolsSheetOpen = false }: Props) { event.preventDefault(); setNavTransitionOrigin(event.currentTarget); startTransition(() => { - addTransitionType("nav-lateral"); + addOptionalTransitionType("nav-lateral"); push(href); }); }; diff --git a/app/components/nav-bar.tsx b/app/components/nav-bar.tsx index e555c038..27b90135 100644 --- a/app/components/nav-bar.tsx +++ b/app/components/nav-bar.tsx @@ -1,11 +1,12 @@ "use client"; -import React, { addTransitionType, startTransition, useCallback, useEffect, useEffectEvent, useReducer, useRef, useSyncExternalStore } from "react"; +import React, { startTransition, useCallback, useEffect, useEffectEvent, useReducer, useRef, useSyncExternalStore } from "react"; import dynamic from "next/dynamic"; import Link from "next/link"; import { createPortal } from "react-dom"; import Image from "@/app/components/common/app-image"; import { usePathname, useRouter } from "next/navigation"; import ThemeToggleSwitch from "@/app/components/common/theme-toggle"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; import { SignOut } from "@/app/components/sign-out"; import VoiceAgentButton from "@/app/components/voice/voice-agent-button"; import { useGuestPrompt } from "@/app/components/auth-gate"; @@ -296,7 +297,7 @@ const NavBar: React.FC = ({ event.preventDefault(); setNavTransitionOrigin(event.currentTarget); startTransition(() => { - addTransitionType("nav-lateral"); + addOptionalTransitionType("nav-lateral"); push(href); }); }; diff --git a/app/components/notes/notes-course-search.tsx b/app/components/notes/notes-course-search.tsx index 4cea6988..1c083126 100644 --- a/app/components/notes/notes-course-search.tsx +++ b/app/components/notes/notes-course-search.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { Activity, addTransitionType, startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import React, { Activity, startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import Image from "@/app/components/common/app-image"; import SearchIcon from "@/app/components/assets/seacrh.svg"; import { useRouter } from "next/navigation"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; import { getAliasCourseCodes } from "@/lib/course-aliases"; import { createCourseFuse } from "@/lib/course-search-fuse"; import { normalizeCourseCode } from "@/lib/course-tags"; @@ -156,7 +157,7 @@ export default function NotesCourseSearch({ }); startTransition(() => { - addTransitionType("nav-forward"); + addOptionalTransitionType("nav-forward"); push(`/notes/course/${encodeURIComponent(course.code)}`); }); setIsOpen(false); @@ -180,7 +181,7 @@ export default function NotesCourseSearch({ return; } startTransition(() => { - addTransitionType("filter-results"); + addOptionalTransitionType("filter-results"); push(`/notes?search=${encodeURIComponent(trimmed)}`); }); setIsOpen(false); @@ -246,7 +247,7 @@ export default function NotesCourseSearch({ hasSyllabus: false, }); startTransition(() => { - addTransitionType("nav-forward"); + addOptionalTransitionType("nav-forward"); push(`/notes/course/${encodeURIComponent(course.code)}`); }); return; @@ -277,13 +278,13 @@ export default function NotesCourseSearch({ hasSyllabus: false, }); startTransition(() => { - addTransitionType("nav-forward"); + addOptionalTransitionType("nav-forward"); push(`/notes/course/${encodeURIComponent(exact.code)}`); }); return; } startTransition(() => { - addTransitionType("filter-results"); + addOptionalTransitionType("filter-results"); push(`/notes?search=${encodeURIComponent(trimmed)}`); }); } catch { diff --git a/app/components/past_papers/answer-key-toggle.tsx b/app/components/past_papers/answer-key-toggle.tsx index b4f756c1..7d2265c0 100644 --- a/app/components/past_papers/answer-key-toggle.tsx +++ b/app/components/past_papers/answer-key-toggle.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { addTransitionType, useTransition } from "react"; +import React, { useTransition } from "react"; import { useRouter } from "next/navigation"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; type Props = { basePath: string; @@ -23,7 +24,7 @@ export default function AnswerKeyToggle({ basePath, count, searchString }: Props const qs = params.toString(); startTransition(() => { - addTransitionType("filter-results"); + addOptionalTransitionType("filter-results"); replace(qs ? `${basePath}?${qs}` : basePath); }); }; diff --git a/app/components/past_papers/course-paper-grid.tsx b/app/components/past_papers/course-paper-grid.tsx index 31a47cb8..54897a0d 100644 --- a/app/components/past_papers/course-paper-grid.tsx +++ b/app/components/past_papers/course-paper-grid.tsx @@ -1,6 +1,6 @@ "use client"; -import React, { ViewTransition, useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef } from "react"; +import React, { useCallback, useEffect, useEffectEvent, useMemo, useReducer, useRef } from "react"; import { createPortal } from "react-dom"; import { useRouter } from "next/navigation"; import { @@ -36,6 +36,7 @@ import { MOBILE_SELECT_ALL_HOST_ID, } from "./course-paper-grid-controls"; import { getPastPaperDetailPath } from "@/lib/seo"; +import { OptionalViewTransition } from "@/app/components/common/react-transition"; type Props = { papers: CoursePaperListItem[]; @@ -669,7 +670,7 @@ export default function CoursePaperGrid({
{papers.map((paper, index) => ( -
-
+ ))} diff --git a/app/components/past_papers/filter-bar.tsx b/app/components/past_papers/filter-bar.tsx index 7cbf5236..3ea03a65 100644 --- a/app/components/past_papers/filter-bar.tsx +++ b/app/components/past_papers/filter-bar.tsx @@ -1,9 +1,10 @@ "use client"; -import React, { addTransitionType, memo, useCallback, useMemo, useTransition } from "react"; +import React, { memo, useCallback, useMemo, useTransition } from "react"; import { useRouter } from "next/navigation"; import { examTypeLabel, examTypeToSlug, examSlugToType } from "@/lib/exam-slug"; import type { Campus, ExamType, Semester } from "@/db"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; type Props = { basePath: string; @@ -102,7 +103,7 @@ export default function FilterBar({ next.delete("page"); const qs = next.toString(); startTransition(() => { - addTransitionType("filter-results"); + addOptionalTransitionType("filter-results"); replace(qs ? `${basePath}?${qs}` : basePath); }); }, [basePath, replace, startTransition]); diff --git a/app/components/past_papers/past-papers-course-search.tsx b/app/components/past_papers/past-papers-course-search.tsx index 7fb5ceb4..87e2578c 100644 --- a/app/components/past_papers/past-papers-course-search.tsx +++ b/app/components/past_papers/past-papers-course-search.tsx @@ -1,10 +1,11 @@ "use client"; -import React, { Activity, addTransitionType, startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; +import React, { Activity, startTransition, useDeferredValue, useEffect, useMemo, useRef, useState } from "react"; import Image from "@/app/components/common/app-image"; import Link from "next/link"; import SearchIcon from "@/app/components/assets/seacrh.svg"; import { useRouter } from "next/navigation"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; import { getAliasCourseCodes } from "@/lib/course-aliases"; import { createCourseFuse } from "@/lib/course-search-fuse"; import { normalizeCourseCode } from "@/lib/course-tags"; @@ -204,7 +205,7 @@ export default function PastPapersCourseSearch({ ) => { recordSelection(course, options); startTransition(() => { - addTransitionType("nav-forward"); + addOptionalTransitionType("nav-forward"); push(getCoursePastPapersPath(course.code)); }); }; @@ -288,7 +289,7 @@ export default function PastPapersCourseSearch({ hasSyllabus: false, }); startTransition(() => { - addTransitionType("nav-forward"); + addOptionalTransitionType("nav-forward"); push(`/past_papers/${encodeURIComponent(course.code)}`); }); return; @@ -322,7 +323,7 @@ export default function PastPapersCourseSearch({ hasSyllabus: false, }); startTransition(() => { - addTransitionType("nav-forward"); + addOptionalTransitionType("nav-forward"); push(`/past_papers/${encodeURIComponent(exact.code)}`); }); return; diff --git a/app/components/past_papers/sort-dropdown.tsx b/app/components/past_papers/sort-dropdown.tsx index 9cfe57c1..d7b69ac9 100644 --- a/app/components/past_papers/sort-dropdown.tsx +++ b/app/components/past_papers/sort-dropdown.tsx @@ -1,7 +1,8 @@ "use client"; -import React, { addTransitionType, useTransition } from "react"; +import React, { useTransition } from "react"; import { useRouter } from "next/navigation"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; const OPTIONS = [ { value: "seasonal", label: "Current exam first" }, @@ -34,7 +35,7 @@ export default function SortDropdown({ params.delete("page"); const qs = params.toString(); startTransition(() => { - addTransitionType("filter-results"); + addOptionalTransitionType("filter-results"); replace(qs ? `${basePath}?${qs}` : basePath); }); }; diff --git a/app/components/voice/voice-agent-provider.tsx b/app/components/voice/voice-agent-provider.tsx index 01a36f68..11c2276f 100644 --- a/app/components/voice/voice-agent-provider.tsx +++ b/app/components/voice/voice-agent-provider.tsx @@ -1,7 +1,6 @@ "use client"; import React, { - addTransitionType, createContext, startTransition, useCallback, @@ -12,6 +11,7 @@ import React, { useState, } from "react"; import { useRouter } from "next/navigation"; +import { addOptionalTransitionType } from "@/app/components/common/react-transition"; import { createVoiceControlController, defineVoiceTool, @@ -428,7 +428,7 @@ export default function VoiceAgentProvider({ } startTransition(() => { - addTransitionType("filter-results"); + addOptionalTransitionType("filter-results"); replace(nextPath); }); await settleUi({ targetPath: nextPath }); diff --git a/scripts/test-react-transition-runtime-imports.ts b/scripts/test-react-transition-runtime-imports.ts new file mode 100644 index 00000000..54142b8e --- /dev/null +++ b/scripts/test-react-transition-runtime-imports.ts @@ -0,0 +1,77 @@ +import { readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; +import React from "react"; + +const repoRoot = process.cwd(); +const sourceRoots = ["app"]; +const allowedDirectImportFiles = new Set([ + "app/components/common/react-transition.tsx", +]); + +function walkFiles(directory: string): string[] { + const entries = readdirSync(directory); + const files: string[] = []; + + for (const entry of entries) { + if (entry === "node_modules" || entry === ".next") continue; + + const fullPath = path.join(directory, entry); + const stats = statSync(fullPath); + + if (stats.isDirectory()) { + files.push(...walkFiles(fullPath)); + continue; + } + + if (/\.(?:ts|tsx)$/.test(entry)) { + files.push(fullPath); + } + } + + return files; +} + +function findUnsafeReactTransitionImports() { + const unsafeFiles: string[] = []; + const importFromReactPattern = /import\s+[\s\S]*?\s+from\s+["']react["'];?/g; + const unsafeImportNamePattern = /\b(?:addTransitionType|ViewTransition)\b/; + + for (const sourceRoot of sourceRoots) { + for (const file of walkFiles(path.join(repoRoot, sourceRoot))) { + const relativePath = path.relative(repoRoot, file).split(path.sep).join("/"); + if (allowedDirectImportFiles.has(relativePath)) continue; + + const source = readFileSync(file, "utf8"); + const importBlocks = source.match(importFromReactPattern) ?? []; + const hasUnsafeImport = importBlocks.some((importBlock) => ( + !/^\s*import\s+type\b/.test(importBlock) && + unsafeImportNamePattern.test(importBlock) + )); + + if (hasUnsafeImport) { + unsafeFiles.push(relativePath); + } + } + } + + return unsafeFiles; +} + +const addTransitionTypeType = typeof (React as { addTransitionType?: unknown }).addTransitionType; +const viewTransitionType = typeof (React as { ViewTransition?: unknown }).ViewTransition; +const activityType = typeof (React as { Activity?: unknown }).Activity; +const unsafeFiles = findUnsafeReactTransitionImports(); + +console.log( + `React runtime: addTransitionType=${addTransitionTypeType} ViewTransition=${viewTransitionType} Activity=${activityType}`, +); + +if (unsafeFiles.length > 0) { + console.error("Unsafe direct React transition imports found:"); + for (const file of unsafeFiles) { + console.error(`- ${file}`); + } + process.exit(1); +} + +console.log("No unsafe direct React transition imports found.");