From a074952678d44553894bd4acf24d8b194e9aa9da Mon Sep 17 00:00:00 2001 From: Youwen Wu Date: Mon, 12 Feb 2024 20:50:00 -0800 Subject: [PATCH] migrated to web workers for client side components --- README.md | 14 ++ src/app/author/[author]/AuthorDisplay.tsx | 170 +++++++++++++++ src/app/author/[author]/page.tsx | 178 +--------------- src/app/components/DataDisplay.tsx | 49 +++-- src/app/db/data.ts | 38 ++-- src/app/db/loaders.ts | 241 +++++++++++++++++++++- 6 files changed, 476 insertions(+), 214 deletions(-) create mode 100644 src/app/author/[author]/AuthorDisplay.tsx diff --git a/README.md b/README.md index 0d4c5e9..413f291 100644 --- a/README.md +++ b/README.md @@ -27,3 +27,17 @@ The dummies guide to maintaining a Next.js project: - Important: THIS IS NOT JAVASCRIPT! You CANNOT use global variables, window variables, etc, or even stateful variables that are meant to persist beyond a single refresh cycle (which can happen many times per second). Use the STATE for this (see [the module we are using for state management](https://github.com/pmndrs/zustand)) - Try to define modules for your components instead of putting everything in one file to avoid JANK - You should put all static assets like images, icons, sound bytes, etc, in the `public` folder. These assets will be made available at the root directory `/`. For example, if `eecs-wordmark.png` is in `public`, then I can access it from an image element like so: ``. +- VERY IMPORTANT for performance and UI jank purposes: + - As you may have noticed, we store all of our data in a super large TypeScript file at `db/data.ts`. This module contains exports for all of our 5 main "databases." + - In order to access these databases from your components, there are two **critical** conventions to follow: + - If your component is **server side**, then simply import the components like normal (eg `import { documents } from '@/app/db/data`) and use them in your code. + - If your component is **client side**, then you should import the data using the utilities available in `db/loaders.ts`. The `loaders` library contains + functions which return a Promise that resolve to the requested data. The function will load the very large objects in a separate thread via a Web Worker, which + avoids blocking the main UI thread and freezing everything. You can then use the `useSuspenseQuery` hook from `@tanstack/react-query` to load this data in the background while + triggering a React Suspense (if you don't set one up yourself, the default site wide loading bar will be used). This helps vastly reduce UI jank when trying to load the entire + mega data object directly into memory. + - Not sure whether you're in a client or server side component? If your component has the `'use client'` directive at the top of its file, then it's a client side component. + Otherwise, by default, it should be a server side component. + - Footnote: why don't I have to use the utilities in `db/loaders.ts` to asynchronously load the data in server side components? + - Next.js will automatically pre-render all server side components into static HTML, which means there will be no performance impact (and in fact performance loss at build time) + to loading the entire objects into memory. diff --git a/src/app/author/[author]/AuthorDisplay.tsx b/src/app/author/[author]/AuthorDisplay.tsx new file mode 100644 index 0000000..1c32ba5 --- /dev/null +++ b/src/app/author/[author]/AuthorDisplay.tsx @@ -0,0 +1,170 @@ +import Link from 'next/link' +import { Fragment } from 'react' +import { affiliations, nationalities, authors } from '../../db/data' +import { Zilla_Slab } from 'next/font/google' + +const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500'] }) + +export default function AuthorDisplay({ + author, +}: Readonly<{ author: string }>) { + const data = authors[author] + const { name, affiliation, image, nationality, formerAffiliations } = data + + const MainPosition = () => { + const mainAffiliationShort = affiliation[0].split('@')[1] + const mainPosition = affiliation[0].split('@')[0] + const mainAffiliation = affiliations[mainAffiliationShort] + const { website } = data + return ( + <> + {mainPosition} at + + {mainAffiliation.name} + + {website ? ( +
+ Visit {name.nickname ? name.nickname : name.first} at:{' '} + + {website} + +
+ ) : null} +
+ {affiliation.map((a: string) => ( + + {affiliations[a.split('@')[1]].name} + + ))} +
+ + ) + } + + const OtherPositions = () => { + if (affiliation.length === 1) return + return ( + <> +

+ Other Positions: +

+ {affiliation.slice(1).map((a: string, i: number) => { + const position = a.split('@')[0] + const affiliation = affiliations[a.split('@')[1]].name + return ( + + + {position} at{' '} + + {affiliation} + + + + ) + })} + + ) + } + + const FormerPositions = () => { + if (!formerAffiliations) return null + return ( + <> +

+ Former Positions: +

+ {formerAffiliations?.map((a: string, i: number) => { + const position = a.split('@')[0] + const affiliation = affiliations[a.split('@')[1]].name + + return ( + + + {position} at{' '} + + {affiliation} + + + + ) + })} + + ) + } + + const NationalityDisplay = ({ + nationality, + }: Readonly<{ nationality: string }>) => { + const nationalityData = nationalities[nationality] + const { demonym, flag } = nationalityData + return ( +
+ {`${demonym} + {demonym} +
+ ) + } + + const Bio = () => { + const { bio } = data + if (!bio) return null + + return ( + <> +

Bio:

+

{bio}

+ + ) + } + + return ( +
+
+
+ profile +
+
+
+ + {name.first} + {name.nickname ? ` "${name.nickname}"` : null} {name.last} + +
+ +
+
+
+
+
+
+ + +

+ Ethnicity and Nationality: +

+
+ {nationality.map((n: string) => ( + + + + ))} +
+ +
+
+ ) +} diff --git a/src/app/author/[author]/page.tsx b/src/app/author/[author]/page.tsx index 48f02e2..0942ac5 100644 --- a/src/app/author/[author]/page.tsx +++ b/src/app/author/[author]/page.tsx @@ -1,10 +1,5 @@ -import { authors, affiliations, nationalities } from '../../db/data' -import { Zilla_Slab } from 'next/font/google' -import Link from 'next/link' -import { notFound } from 'next/navigation' -import { Fragment, Suspense } from 'react' - -const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500'] }) +import AuthorDisplay from './AuthorDisplay' +import { authors } from '@/app/db/data' export function generateStaticParams() { const authorsList = Object.keys(authors) @@ -16,172 +11,5 @@ export default function Page({ }: Readonly<{ params: { author: string } }>) { - const authorData = authors[params.author] - // console.log(authorData) - if (!authorData) { - notFound() - } - - const { name, affiliation, image, nationality, formerAffiliations } = - authorData - - const MainPosition = () => { - const mainAffiliationShort = affiliation[0].split('@')[1] - const mainPosition = affiliation[0].split('@')[0] - const mainAffiliation = affiliations[mainAffiliationShort] - return ( - <> - {mainPosition} at - - {mainAffiliation.name} - - {authorData.website ? ( -
- Visit{' '} - {authorData.name.nickname - ? authorData.name.nickname - : authorData.name.first}{' '} - at:{' '} - - {authorData.website} - -
- ) : null} -
- {affiliation.map((a: string) => ( - - {affiliations[a.split('@')[1]].name} - - ))} -
- - ) - } - - const OtherPositions = () => { - if (affiliation.length === 1) return - return ( - <> -

- Other Positions: -

- {affiliation.slice(1).map((a: string, i: number) => { - const position = a.split('@')[0] - const affiliation = affiliations[a.split('@')[1]].name - return ( - - - {position} at{' '} - - {affiliation} - - - - ) - })} - - ) - } - - const FormerPositions = () => { - if (!formerAffiliations) return null - return ( - <> -

- Former Positions: -

- {formerAffiliations?.map((a: string, i: number) => { - const position = a.split('@')[0] - const affiliation = affiliations[a.split('@')[1]].name - - return ( - - - {position} at{' '} - - {affiliation} - - - - ) - })} - - ) - } - - const NationalityDisplay = ({ - nationality, - }: Readonly<{ nationality: string }>) => { - const nationalityData = nationalities[nationality] - const { demonym, flag } = nationalityData - return ( -
- {`${demonym} - {demonym} -
- ) - } - - const Bio = () => { - const { bio } = authorData - if (!bio) return null - - return ( - <> -

Bio:

-

{authorData.bio}

- - ) - } - - return ( -
-
-
- profile -
-
-
- - {name.first} - {name.nickname ? ` "${name.nickname}"` : null} {name.last} - -
- -
-
-
-
-
-
- - -

- Ethnicity and Nationality: -

-
- {nationality.map((n: string) => ( - - - - ))} -
- -
-
- ) + return } diff --git a/src/app/components/DataDisplay.tsx b/src/app/components/DataDisplay.tsx index 16a6868..ce800b5 100644 --- a/src/app/components/DataDisplay.tsx +++ b/src/app/components/DataDisplay.tsx @@ -1,10 +1,8 @@ import { Fragment } from 'react' import Link from 'next/link' -import { - topics as topicList, - authors as authorList, - reviewer, -} from '@/app/db/data' +import { reviewer } from '@/app/db/data' +import { loadAllTopics, loadAllAuthors } from '@/app/db/loaders' +import { useSuspenseQuery } from '@tanstack/react-query' export const Code = ({ code, @@ -54,6 +52,18 @@ export const Topics = ({ topics, showTitle = true, }: Readonly<{ topics: string[]; showTitle?: boolean }>) => { + 'use client' + + const { data, error } = useSuspenseQuery({ + queryKey: ['topics_all'], + queryFn: () => { + const data = loadAllTopics() + return data + }, + }) + if (error) throw error + const topicList = data + return ( <> {showTitle ? Topics: : null} @@ -72,6 +82,19 @@ export const Topics = ({ export const Authors = ({ authors, }: Readonly<{ authors: string[]; noLink?: boolean }>) => { + 'use client' + + const { data, error } = useSuspenseQuery({ + queryKey: ['authors_all'], + queryFn: () => { + const data = loadAllAuthors() + return data + }, + }) + if (error) throw error + + const authorList = data + return ( <> {authors.map((a: string, i) => ( @@ -95,20 +118,16 @@ export const Reviewers = ({ const ReviewerDisplay = ({ r }: Readonly<{ r: reviewer }>) => { if (r.profile) { return ( - <> - - {r.first} {r.last} - - + + {r.first} {r.last} + ) } if (r.url) { return ( - <> - - {r.first} {r.last} - - + + {r.first} {r.last} + ) } return ( diff --git a/src/app/db/data.ts b/src/app/db/data.ts index 5bec8e0..4ab5ce5 100644 --- a/src/app/db/data.ts +++ b/src/app/db/data.ts @@ -282,14 +282,12 @@ On Day 1 we will discuss the ins and outs of the robot. The Electrical Sub team }, } -export interface Topics { - [key: string]: { - name: string - description: string - wiki: string - } +export interface Topic { + name: string + description: string + wiki: string } -export const topics: Topics = { +export const topics: { [key: string]: Topic } = { frc: { name: 'FIRST Robotics Competition', description: @@ -499,16 +497,14 @@ export const authors: { [key: string]: Author } = { }, } -export interface Affiliations { - [key: string]: { - name: string - short: string - image: string - description: string - } +export interface Affiliation { + name: string + short: string + image: string + description: string } -export const affiliations: Affiliations = { +export const affiliations: { [key: string]: Affiliation } = { '1280-mech': { name: "Team 1280, the Ragin' C Biscuits, Mechanical Subteam", short: '1280 Mech', @@ -659,14 +655,12 @@ Raid Zero's influence extends beyond the technical achievements in robotics comp }, } -export interface Nationalities { - [key: string]: { - name: string - demonym: string - flag: string - } +export interface Nationality { + name: string + demonym: string + flag: string } -export const nationalities: Nationalities = { +export const nationalities: { [key: string]: Nationality } = { pak: { name: 'Pakistan', demonym: 'Pakistani', diff --git a/src/app/db/loaders.ts b/src/app/db/loaders.ts index af4bcce..2af5727 100644 --- a/src/app/db/loaders.ts +++ b/src/app/db/loaders.ts @@ -1,4 +1,4 @@ -import { Document, Author } from './data' +import { Document, Author, Affiliation, Topic, Nationality } from './data' export const loadDocument = (id: string): Promise => { return new Promise((resolve, reject) => { @@ -67,7 +67,7 @@ export const loadAllAuthors = (): Promise<{ [key: string]: Author }> => { return new Promise((resolve, reject) => { if (typeof Worker !== 'undefined') { const worker = new Worker( - new URL('./workers/documentLoader.worker.ts', import.meta.url), + new URL('./workers/authorLoader.worker.ts', import.meta.url), { type: 'module' } ) @@ -91,3 +91,240 @@ export const loadAllAuthors = (): Promise<{ [key: string]: Author }> => { } }) } + +export const loadAuthor = (id: string): Promise => { + return new Promise((resolve, reject) => { + if (typeof Worker !== 'undefined') { + const worker = new Worker( + new URL('./workers/authorLoader.worker.ts', import.meta.url), + { type: 'module' } + ) + + worker.onmessage = (e: MessageEvent<{ [key: string]: Author }>) => { + const data = e.data + const author: Author | undefined = data[id] + if (!author) { + return reject(new Error('404')) + } else { + resolve(author) + } + worker.terminate() + } + + worker.onerror = (error) => { + reject(error) + worker.terminate() + } + + worker.postMessage('LOAD') + } else { + reject( + new Error( + `Web Workers are not supported in this environment. Please avoid using a prehistoric browser. + If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.` + ) + ) + } + }) +} + +export const loadAffiliation = (id: string): Promise => { + return new Promise((resolve, reject) => { + if (typeof Worker !== 'undefined') { + const worker = new Worker( + new URL('./workers/affiliationLoader.worker.ts', import.meta.url), + { type: 'module' } + ) + + worker.onmessage = (e: MessageEvent<{ [key: string]: Affiliation }>) => { + const data = e.data + const affiliation: Affiliation | undefined = data[id] + if (!affiliation) { + return reject(new Error('404')) + } else { + resolve(affiliation) + } + worker.terminate() + } + + worker.onerror = (error) => { + reject(error) + worker.terminate() + } + + worker.postMessage('LOAD') + } else { + reject( + new Error( + `Web Workers are not supported in this environment. Please avoid using a prehistoric browser. + If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.` + ) + ) + } + }) +} + +export const loadAllAffiliations = (): Promise<{ + [key: string]: Affiliation +}> => { + return new Promise((resolve, reject) => { + if (typeof Worker !== 'undefined') { + const worker = new Worker( + new URL('./workers/affiliationLoader.worker.ts', import.meta.url), + { type: 'module' } + ) + + worker.onmessage = (e: MessageEvent<{ [key: string]: Affiliation }>) => { + resolve(e.data) + worker.terminate() + } + + worker.onerror = (error) => { + reject(error) + worker.terminate() + } + + worker.postMessage('LOAD') + } else { + reject( + new Error( + 'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.' + ) + ) + } + }) +} + +export const loadTopic = (id: string): Promise => { + return new Promise((resolve, reject) => { + if (typeof Worker !== 'undefined') { + const worker = new Worker( + new URL('./workers/topicLoader.worker.ts', import.meta.url), + { type: 'module' } + ) + + worker.onmessage = (e: MessageEvent<{ [key: string]: Topic }>) => { + const data = e.data + const topic: Topic | undefined = data[id] + if (!topic) { + return reject(new Error('404')) + } else { + resolve(topic) + } + worker.terminate() + } + + worker.onerror = (error) => { + reject(error) + worker.terminate() + } + + worker.postMessage('LOAD') + } else { + reject( + new Error( + `Web Workers are not supported in this environment. Please avoid using a prehistoric browser. + If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.` + ) + ) + } + }) +} + +export const loadAllTopics = (): Promise<{ + [key: string]: Topic +}> => { + return new Promise((resolve, reject) => { + if (typeof Worker !== 'undefined') { + const worker = new Worker( + new URL('./workers/topicLoader.worker.ts', import.meta.url), + { type: 'module' } + ) + + worker.onmessage = (e: MessageEvent<{ [key: string]: Topic }>) => { + resolve(e.data) + worker.terminate() + } + + worker.onerror = (error) => { + reject(error) + worker.terminate() + } + + worker.postMessage('LOAD') + } else { + reject( + new Error( + 'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.' + ) + ) + } + }) +} + +export const loadNationality = (id: string): Promise => { + return new Promise((resolve, reject) => { + if (typeof Worker !== 'undefined') { + const worker = new Worker( + new URL('./workers/nationalityLoader.worker.ts', import.meta.url), + { type: 'module' } + ) + + worker.onmessage = (e: MessageEvent<{ [key: string]: Nationality }>) => { + const data = e.data + const nationality: Nationality | undefined = data[id] + if (!nationality) { + return reject(new Error('404')) + } else { + resolve(nationality) + } + worker.terminate() + } + + worker.onerror = (error) => { + reject(error) + worker.terminate() + } + + worker.postMessage('LOAD') + } else { + reject( + new Error( + `Web Workers are not supported in this environment. Please avoid using a prehistoric browser. + If nothing else seems wrong, this error message is probably showing up due to ghosts in your browser.` + ) + ) + } + }) +} + +export const loadAllNationalities = (): Promise<{ + [key: string]: Nationality +}> => { + return new Promise((resolve, reject) => { + if (typeof Worker !== 'undefined') { + const worker = new Worker( + new URL('./workers/topicLoader.worker.ts', import.meta.url), + { type: 'module' } + ) + + worker.onmessage = (e: MessageEvent<{ [key: string]: Nationality }>) => { + resolve(e.data) + worker.terminate() + } + + worker.onerror = (error) => { + reject(error) + worker.terminate() + } + + worker.postMessage('LOAD') + } else { + reject( + new Error( + 'Web Workers are not supported in this environment. Please avoid using a prehistoric browser.' + ) + ) + } + }) +}