-
Notifications
You must be signed in to change notification settings - Fork 5
perf: lazy-load term catalogs + vendor code splitting (8.2 MB → ~1.0 MB initial JS) #164
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
d0394da
06f49ee
00a6cc3
b88d8f0
21339b8
0fc9466
9e6810e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: WorkspaceSearch Fzf instance recreated every render At Was this helpful? React with 👍 or 👎 to provide feedback. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,4 +1,4 @@ | ||
| import { use, useState } from "react"; | ||
| import { use, useEffect, useState } from "react"; | ||
| import Modal, { useModal } from "./Modal"; | ||
| import Select from "react-select"; | ||
| import { SingleValue } from "react-select"; | ||
|
|
@@ -419,6 +419,12 @@ function WorkspaceSearch() { | |
| options = courses; | ||
| } | ||
|
|
||
| // catalogs load asynchronously; re-sync the options whenever the catalog | ||
| // changes so the dropdown never shows stale or empty results | ||
| useEffect(() => { | ||
| setOptions(Object.values(indexedCourses)); | ||
| }, [indexedCourses]); | ||
|
Comment on lines
+424
to
+426
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: useEffect resetting options may briefly override active search filter The new Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — as noted, the catalog loads once before a user can realistically have an active query, and typing immediately re-filters; leaving as is. |
||
|
|
||
| const [selectedCourse, setCourse] = useState<Maybe<CourseData>>(null); | ||
|
|
||
| const handleSelect = (courseData: SingleValue<CourseData>) => { | ||
|
|
@@ -447,7 +453,9 @@ function WorkspaceSearch() { | |
| <Select | ||
| isClearable | ||
| className="my-3" | ||
| placeholder="Add a course..." | ||
| placeholder={ | ||
| courses.length === 0 ? "Loading courses..." : "Add a course..." | ||
| } | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
devin-ai-integration[bot] marked this conversation as resolved.
|
||
| options={options} | ||
| value={selectedCourse} | ||
| getOptionLabel={(course) => `${course?.number} - ${course?.name}`} | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,33 +1,39 @@ | ||
| import DATA_FA2023 from "./data/IndexedTotalFA2022-23.json"; | ||
| import DATA_WI2023 from "./data/IndexedTotalWI2022-23.json"; | ||
| import DATA_SP2023 from "./data/IndexedTotalSP2022-23.json"; | ||
| import DATA_FA2024 from "./data/IndexedTotalFA2023-24.json"; | ||
| import DATA_WI2024 from "./data/IndexedTotalWI2023-24.json"; | ||
| import DATA_SP2024 from "./data/IndexedTotalSP2023-24.json"; | ||
| import DATA_FA2025 from "./data/IndexedTotalFA2024-25.json"; | ||
| import DATA_WI2025 from "./data/IndexedTotalWI2024-25.json"; | ||
| import DATA_SP2025 from "./data/IndexedTotalSP2024-25.json"; | ||
| import DATA_FA2026 from "./data/IndexedTotalFA2025-26.json"; | ||
| import DATA_WI2026 from "./data/IndexedTotalWI2025-26.json"; | ||
| import DATA_SP2026 from "./data/IndexedTotalSP2025-26.json"; | ||
| import DATA_FA2027 from "./data/IndexedTotalFA2026-27.json"; | ||
|
|
||
| export const CURRENT_TERM = "/fa2027"; | ||
|
|
||
| export const courseDataSources: { | ||
| [key: string]: { [key: string]: CourseData }; | ||
| const courseDataLoaders: { | ||
| [key: string]: () => Promise<{ default: CourseIndex }>; | ||
| } = { | ||
| "/fa2023": DATA_FA2023, | ||
| "/wi2023": DATA_WI2023, | ||
| "/sp2023": DATA_SP2023, | ||
| "/fa2024": DATA_FA2024, | ||
| "/wi2024": DATA_WI2024, | ||
| "/sp2024": DATA_SP2024, | ||
| "/fa2025": DATA_FA2025, | ||
| "/wi2025": DATA_WI2025, | ||
| "/sp2025": DATA_SP2025, | ||
| "/fa2026": DATA_FA2026, | ||
| "/wi2026": DATA_WI2026, | ||
| "/sp2026": DATA_SP2026, | ||
| "/fa2027": DATA_FA2027, | ||
| "/fa2023": () => import("./data/IndexedTotalFA2022-23.json"), | ||
| "/wi2023": () => import("./data/IndexedTotalWI2022-23.json"), | ||
| "/sp2023": () => import("./data/IndexedTotalSP2022-23.json"), | ||
| "/fa2024": () => import("./data/IndexedTotalFA2023-24.json"), | ||
| "/wi2024": () => import("./data/IndexedTotalWI2023-24.json"), | ||
| "/sp2024": () => import("./data/IndexedTotalSP2023-24.json"), | ||
| "/fa2025": () => import("./data/IndexedTotalFA2024-25.json"), | ||
| "/wi2025": () => import("./data/IndexedTotalWI2024-25.json"), | ||
| "/sp2025": () => import("./data/IndexedTotalSP2024-25.json"), | ||
| "/fa2026": () => import("./data/IndexedTotalFA2025-26.json"), | ||
| "/wi2026": () => import("./data/IndexedTotalWI2025-26.json"), | ||
| "/sp2026": () => import("./data/IndexedTotalSP2025-26.json"), | ||
| "/fa2027": () => import("./data/IndexedTotalFA2026-27.json"), | ||
|
Comment on lines
+15
to
+18
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Invalid terms return When Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| }; | ||
|
|
||
| const courseIndexCache: { [key: string]: CourseIndex } = {}; | ||
|
|
||
| export function getCachedCourseIndex(term: string): CourseIndex | undefined { | ||
| return courseIndexCache[term]; | ||
| } | ||
|
|
||
| export async function loadCourseIndex(term: string): Promise<CourseIndex> { | ||
| const cached = courseIndexCache[term]; | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
| const loader = courseDataLoaders[term]; | ||
| if (!loader) { | ||
| return {}; | ||
| } | ||
| const module = await loader(); | ||
| courseIndexCache[term] = module.default; | ||
| return module.default; | ||
| } | ||
|
Comment on lines
+27
to
+39
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Duplicate concurrent loads possible in loadCourseIndex The Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — concurrent duplicate fetches are harmless (idempotent cache write) and the |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -14,7 +14,11 @@ import { | |
| shortenCourses, | ||
| } from "./appContext"; | ||
| import { generateCourseSections } from "./scheduler"; | ||
| import { CURRENT_TERM, courseDataSources } from "./courseData"; | ||
| import { | ||
| CURRENT_TERM, | ||
| getCachedCourseIndex, | ||
| loadCourseIndex, | ||
| } from "./courseData"; | ||
|
|
||
| function setArrayIdx<T>(arr: Array<T>, idx: number, element: T) { | ||
| return arr.map((value, i) => { | ||
|
|
@@ -57,10 +61,32 @@ export function useAppState(): { | |
| // really basic routing | ||
| const pathname = useReactPath(); | ||
| const realPath = pathname === "/" ? CURRENT_TERM : pathname; | ||
| const indexedCourses: CourseIndex = useMemo( | ||
| () => courseDataSources[realPath] ?? {}, | ||
| [realPath], | ||
| const [indexedCourses, setIndexedCourses] = useState<CourseIndex>( | ||
| () => getCachedCourseIndex(realPath) ?? {}, | ||
| ); | ||
| // sync from the cache before paint so indexedCourses never lags behind | ||
| // the workspace data when the term changes | ||
| useLayoutEffect(() => { | ||
| let cancelled = false; | ||
| const cached = getCachedCourseIndex(realPath); | ||
| if (cached) { | ||
| setIndexedCourses(cached); | ||
| return; | ||
| } | ||
| setIndexedCourses({}); | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: setIndexedCourses({}) in useLayoutEffect triggers an unnecessary re-render on initial mount At Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — the extra pre-paint re-render only happens on the first visit to an uncached term and is not user-visible; keeping the code simple here. |
||
| loadCourseIndex(realPath) | ||
| .then((index) => { | ||
| if (!cancelled) { | ||
| setIndexedCourses(index); | ||
| } | ||
| }) | ||
| .catch((error: unknown) => { | ||
| console.error(`Failed to load course data for ${realPath}:`, error); | ||
| }); | ||
| return () => { | ||
| cancelled = true; | ||
| }; | ||
| }, [realPath]); | ||
|
devin-ai-integration[bot] marked this conversation as resolved.
devin-ai-integration[bot] marked this conversation as resolved.
Comment on lines
+69
to
+89
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: useLayoutEffect with async operation is intentional and correct Using Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — yes, the pre-paint sync for cache hits/clears is intentional; the async load completing post-paint is the unavoidable loading state. |
||
|
|
||
| // 5 blank workspaces by default bc I'm too lazy to implement dynamic tabs and stuff | ||
| const defaultWorkspaces = useMemo( | ||
|
|
@@ -98,6 +124,8 @@ export function useAppState(): { | |
| setWorkspaceIdx(storedIdx ? JSON.parse(storedIdx) : 0); | ||
| }, [realPath, defaultWorkspaces, setWorkspaces, setWorkspaceIdx]); | ||
|
|
||
| const catalogReady = Object.keys(indexedCourses).length > 0; | ||
|
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: catalogReady blocks all operations during initial async load The Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — the guard is intentional to prevent crashes/data loss during the load window. The search Select shows "Loading courses..." as feedback; the load typically completes in well under a second.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: catalogReady conflates 'not yet loaded' with 'empty catalog' At Was this helpful? React with 👍 or 👎 to provide feedback.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Acknowledged — invalid terms previously also produced a broken state ( |
||
|
|
||
| const courses = workspaces[workspaceIdx].courses; | ||
| const availableTimes: Date[][] = useMemo( | ||
| () => | ||
|
|
@@ -113,6 +141,9 @@ export function useAppState(): { | |
| /** Helper functions to be sent sent through Context */ | ||
| const addCourse = useCallback( | ||
| (newCourse: CourseStorage) => { | ||
| if (!catalogReady) { | ||
| return; | ||
| } | ||
| const result = courses.find( | ||
| (course) => course.courseData.id === newCourse.courseData.id, | ||
| ); | ||
|
|
@@ -157,6 +188,7 @@ export function useAppState(): { | |
| ); | ||
| }, | ||
| [ | ||
| catalogReady, | ||
| courses, | ||
| availableTimes, | ||
| workspaces, | ||
|
|
@@ -168,6 +200,9 @@ export function useAppState(): { | |
|
|
||
| const removeCourse = useCallback( | ||
| (course: CourseStorage) => { | ||
| if (!catalogReady) { | ||
| return; | ||
| } | ||
| let newCourses = courses.filter((currCourse) => currCourse !== course); | ||
| const newArrangements = generateCourseSections( | ||
| newCourses, | ||
|
|
@@ -199,6 +234,7 @@ export function useAppState(): { | |
| ); | ||
| }, | ||
| [ | ||
| catalogReady, | ||
| courses, | ||
| availableTimes, | ||
| workspaces, | ||
|
|
@@ -210,6 +246,9 @@ export function useAppState(): { | |
|
|
||
| const toggleCourse = useCallback( | ||
| (newCourse: CourseStorage) => { | ||
| if (!catalogReady) { | ||
| return; | ||
| } | ||
| let newCourses = courses.map((course) => { | ||
| if (course.courseData.id === newCourse.courseData.id) { | ||
| newCourse.enabled = !newCourse.enabled; | ||
|
|
@@ -253,6 +292,7 @@ export function useAppState(): { | |
| ); | ||
| }, | ||
| [ | ||
| catalogReady, | ||
| courses, | ||
| availableTimes, | ||
| arrangementIdx, | ||
|
|
@@ -265,6 +305,9 @@ export function useAppState(): { | |
|
|
||
| const toggleSectionLock = useCallback( | ||
| (newCourse: CourseStorage) => { | ||
| if (!catalogReady) { | ||
| return; | ||
| } | ||
| let newCourses = courses.map((course) => { | ||
| if (course.courseData.id === newCourse.courseData.id) { | ||
| newCourse.locked = !newCourse.locked; | ||
|
|
@@ -304,6 +347,7 @@ export function useAppState(): { | |
| ); | ||
| }, | ||
| [ | ||
| catalogReady, | ||
| courses, | ||
| availableTimes, | ||
| arrangementIdx, | ||
|
|
@@ -315,6 +359,9 @@ export function useAppState(): { | |
| ); | ||
|
|
||
| const nextArrangement = useCallback(() => { | ||
| if (!catalogReady) { | ||
| return; | ||
| } | ||
| const workspace = workspaces[workspaceIdx]; | ||
| let newIdx = workspace.arrangementIdx; | ||
| if (workspace.arrangements.length === 0) { | ||
|
|
@@ -336,9 +383,12 @@ export function useAppState(): { | |
| arrangementIdx: newIdx, | ||
| }), | ||
| ); | ||
| }, [workspaces, workspaceIdx, indexedCourses, setWorkspaces]); | ||
| }, [catalogReady, workspaces, workspaceIdx, indexedCourses, setWorkspaces]); | ||
|
|
||
| const prevArrangement = useCallback(() => { | ||
| if (!catalogReady) { | ||
| return; | ||
| } | ||
| const workspace = workspaces[workspaceIdx]; | ||
| let newIdx = workspace.arrangementIdx; | ||
| if (workspace.arrangements.length === 0) { | ||
|
|
@@ -362,10 +412,13 @@ export function useAppState(): { | |
| arrangementIdx: newIdx, | ||
| }), | ||
| ); | ||
| }, [workspaces, workspaceIdx, indexedCourses, setWorkspaces]); | ||
| }, [catalogReady, workspaces, workspaceIdx, indexedCourses, setWorkspaces]); | ||
|
|
||
| const setCourses = useCallback( | ||
| (courses: CourseStorage[]) => { | ||
| if (!catalogReady) { | ||
| return; | ||
| } | ||
| let newCourses = courses; | ||
| const newArrangements = generateCourseSections( | ||
| newCourses, | ||
|
|
@@ -396,7 +449,14 @@ export function useAppState(): { | |
| }), | ||
| ); | ||
| }, | ||
| [availableTimes, workspaces, workspaceIdx, indexedCourses, setWorkspaces], | ||
| [ | ||
| catalogReady, | ||
| availableTimes, | ||
| workspaces, | ||
| workspaceIdx, | ||
| indexedCourses, | ||
| setWorkspaces, | ||
| ], | ||
| ); | ||
|
|
||
| const setWorkspace = useCallback( | ||
|
|
@@ -413,6 +473,9 @@ export function useAppState(): { | |
|
|
||
| const updateAvailableTimes = useCallback( | ||
| (dayIdx: number, isStart: boolean, day: Date) => { | ||
| if (!catalogReady) { | ||
| return; | ||
| } | ||
| const newAvailableTimes = setArrayIdx(availableTimes, dayIdx, [ | ||
| isStart ? day : availableTimes[dayIdx][0], | ||
| isStart ? availableTimes[dayIdx][1] : day, | ||
|
|
@@ -456,6 +519,7 @@ export function useAppState(): { | |
| ); | ||
| }, | ||
| [ | ||
| catalogReady, | ||
| courses, | ||
| availableTimes, | ||
| arrangements, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,6 +15,24 @@ export default defineConfig({ | |
| build: { | ||
| sourcemap: true, | ||
| outDir: "dist", | ||
| chunkSizeWarningLimit: 700, | ||
| rollupOptions: { | ||
| output: { | ||
| manualChunks(id: string) { | ||
| if (!id.includes("node_modules")) return; | ||
| if (/node_modules\/(react|react-dom|scheduler)\//.test(id)) | ||
| return "react"; | ||
| if (id.includes("node_modules/@mui/")) return "mui"; | ||
| if (id.includes("node_modules/@schedule-x/")) return "schedulex"; | ||
| if ( | ||
| /node_modules\/(motion|framer-motion|motion-dom|motion-utils)\//.test( | ||
| id, | ||
| ) | ||
| ) | ||
| return "motion"; | ||
| }, | ||
| }, | ||
| }, | ||
|
Comment on lines
+18
to
+35
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📝 Info: Vite manual chunks configuration looks correct The Was this helpful? React with 👍 or 👎 to provide feedback. |
||
| }, | ||
| server: { | ||
| port: 3000, | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.