diff --git a/app/(app)/home/course-search.tsx b/app/(app)/home/course-search.tsx index 66246f97..5ae8cb40 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 { addTransitionType } 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"; diff --git a/app/components/command-palette.tsx b/app/components/command-palette.tsx index 12752495..036840d0 100644 --- a/app/components/command-palette.tsx +++ b/app/components/command-palette.tsx @@ -2,7 +2,6 @@ import React, { Activity, - addTransitionType, startTransition, useEffect, useEffectEvent, @@ -18,6 +17,7 @@ import { invalidateAuthSessionCache, useGuestPrompt, } from "@/app/components/auth-gate"; +import { addTransitionType } from "@/app/components/common/react-transition"; import { getCommandCatalogAction, getCommandSessionAction, 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..2d5bc7ce --- /dev/null +++ b/app/components/common/react-transition.tsx @@ -0,0 +1,35 @@ +"use client"; + +import * as React from "react"; +import type { ViewTransitionClass } from "react"; + +type ReactTransitionRuntime = typeof React & { + addTransitionType?: (type: string) => void; + ViewTransition?: React.ComponentType; +}; + +type OptionalViewTransitionProps = { + children: React.ReactNode; + name?: string; + enter?: ViewTransitionClass; + exit?: ViewTransitionClass; + update?: ViewTransitionClass; + share?: ViewTransitionClass | string; + default?: string; +}; + +const reactTransitionRuntime = React as ReactTransitionRuntime; + +export function addTransitionType(type: string) { + reactTransitionRuntime.addTransitionType?.(type); +} + +export function OptionalViewTransition(props: OptionalViewTransitionProps) { + const ViewTransition = reactTransitionRuntime.ViewTransition; + + if (typeof ViewTransition !== "function") { + return <>{props.children}; + } + + return ; +} diff --git a/app/components/mobile-tab-bar.tsx b/app/components/mobile-tab-bar.tsx index ac43cac0..4f4baf63 100644 --- a/app/components/mobile-tab-bar.tsx +++ b/app/components/mobile-tab-bar.tsx @@ -3,7 +3,8 @@ 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 { addTransitionType } from "@/app/components/common/react-transition"; import { APP_NAV_LINKS } from "@/lib/app-nav-links"; import c2cStyles from "./mobile-tab-bar.module.css"; diff --git a/app/components/nav-bar.tsx b/app/components/nav-bar.tsx index e555c038..309573bf 100644 --- a/app/components/nav-bar.tsx +++ b/app/components/nav-bar.tsx @@ -1,10 +1,11 @@ "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 { addTransitionType } from "@/app/components/common/react-transition"; import ThemeToggleSwitch from "@/app/components/common/theme-toggle"; import { SignOut } from "@/app/components/sign-out"; import VoiceAgentButton from "@/app/components/voice/voice-agent-button"; diff --git a/app/components/notes/notes-course-search.tsx b/app/components/notes/notes-course-search.tsx index 4cea6988..b09411c0 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 { addTransitionType } 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"; diff --git a/app/components/past_papers/answer-key-toggle.tsx b/app/components/past_papers/answer-key-toggle.tsx index b4f756c1..81d3f1eb 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 { addTransitionType } from "@/app/components/common/react-transition"; type Props = { basePath: string; diff --git a/app/components/past_papers/course-paper-grid.tsx b/app/components/past_papers/course-paper-grid.tsx index 31a47cb8..2903111e 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 { @@ -13,6 +13,7 @@ import { X, } from "lucide-react"; import { useToast } from "@/app/components/ui/use-toast"; +import { OptionalViewTransition } from "@/app/components/common/react-transition"; import CoursePaperCard from "./course-paper-card"; import type { CoursePaperListItem } from "@/lib/data/course-papers"; import { downloadPdfFile, downloadPdfZip } from "@/lib/downloads/browser-downloads"; @@ -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..2760b10a 100644 --- a/app/components/past_papers/filter-bar.tsx +++ b/app/components/past_papers/filter-bar.tsx @@ -1,7 +1,8 @@ "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 { addTransitionType } from "@/app/components/common/react-transition"; import { examTypeLabel, examTypeToSlug, examSlugToType } from "@/lib/exam-slug"; import type { Campus, ExamType, Semester } from "@/db"; diff --git a/app/components/past_papers/past-papers-course-search.tsx b/app/components/past_papers/past-papers-course-search.tsx index 7fb5ceb4..fce808ca 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 { addTransitionType } 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"; diff --git a/app/components/past_papers/sort-dropdown.tsx b/app/components/past_papers/sort-dropdown.tsx index 9cfe57c1..0fb533e5 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 { addTransitionType } from "@/app/components/common/react-transition"; const OPTIONS = [ { value: "seasonal", label: "Current exam first" }, diff --git a/app/components/voice/voice-agent-provider.tsx b/app/components/voice/voice-agent-provider.tsx index 01a36f68..b5cd6131 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 { addTransitionType } from "@/app/components/common/react-transition"; import { createVoiceControlController, defineVoiceTool, diff --git a/scripts/test-react-transition-runtime-imports.ts b/scripts/test-react-transition-runtime-imports.ts new file mode 100644 index 00000000..1dd72d60 --- /dev/null +++ b/scripts/test-react-transition-runtime-imports.ts @@ -0,0 +1,57 @@ +import { readFileSync } from "node:fs"; +import { join, relative } from "node:path"; +import React from "react"; + +const repoRoot = process.cwd(); + +const filesToCheck = [ + "app/(app)/home/course-search.tsx", + "app/components/command-palette.tsx", + "app/components/common/directional-transition.tsx", + "app/components/mobile-tab-bar.tsx", + "app/components/nav-bar.tsx", + "app/components/notes/notes-course-search.tsx", + "app/components/past_papers/answer-key-toggle.tsx", + "app/components/past_papers/course-paper-grid.tsx", + "app/components/past_papers/filter-bar.tsx", + "app/components/past_papers/past-papers-course-search.tsx", + "app/components/past_papers/sort-dropdown.tsx", + "app/components/voice/voice-agent-provider.tsx", +]; + +const unsafeRuntimeImportPattern = + /import\s+(?!type\b)(?:[\s\S]*?\{[\s\S]*?\b(?:addTransitionType|ViewTransition)\b[\s\S]*?\}[\s\S]*?|(?:addTransitionType|ViewTransition))\s+from\s+["']react["'];?/m; + +const reactRuntime = React as typeof React & { + addTransitionType?: unknown; + ViewTransition?: unknown; +}; + +const failures: string[] = []; + +for (const file of filesToCheck) { + const absolutePath = join(repoRoot, file); + const source = readFileSync(absolutePath, "utf8"); + + if (unsafeRuntimeImportPattern.test(source)) { + failures.push(relative(repoRoot, absolutePath)); + } +} + +console.log( + [ + `addTransitionType=${typeof reactRuntime.addTransitionType}`, + `ViewTransition=${typeof reactRuntime.ViewTransition}`, + `Activity=${typeof React.Activity}`, + ].join(" "), +); + +if (failures.length > 0) { + throw new Error( + `Unsafe React transition runtime imports found:\n${failures + .map((file) => `- ${file}`) + .join("\n")}`, + ); +} + +console.log("No unsafe React transition runtime imports found.");