migrated to web workers for client side components

This commit is contained in:
Youwen Wu 2024-02-12 20:50:00 -08:00
parent 7dc55799b8
commit a074952678
6 changed files with 476 additions and 214 deletions

View file

@ -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)) - 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 - 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: `<img src="/eecs-wordmark.png" />`. - 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: `<img src="/eecs-wordmark.png" />`.
- 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.

View file

@ -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 (
<>
<span>{mainPosition} at </span>
<Link href={`/affiliation/${mainAffiliationShort}`}>
{mainAffiliation.name}
</Link>
{website ? (
<div className='mt-2'>
Visit {name.nickname ? name.nickname : name.first} at:{' '}
<a href={website} target='_blank'>
{website}
</a>
</div>
) : null}
<div className='my-4 max-h-12 flex flex-wrap'>
{affiliation.map((a: string) => (
<Link key={a} href={`/affiliation/${a.split('@')[1]}`}>
<img
src={affiliations[a.split('@')[1]].image}
alt={affiliations[a.split('@')[1]].name}
className='h-12 mr-2'
/>
</Link>
))}
</div>
</>
)
}
const OtherPositions = () => {
if (affiliation.length === 1) return
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>
Other Positions:
</h1>
{affiliation.slice(1).map((a: string, i: number) => {
const position = a.split('@')[0]
const affiliation = affiliations[a.split('@')[1]].name
return (
<Fragment key={`${position}@${affiliation}`}>
<span className='text-slate-500 text-lg'>
{position} at{' '}
<Link href={`/affiliation/${a.split('@')[1]}`}>
{affiliation}
</Link>
</span>
</Fragment>
)
})}
</>
)
}
const FormerPositions = () => {
if (!formerAffiliations) return null
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>
Former Positions:
</h1>
{formerAffiliations?.map((a: string, i: number) => {
const position = a.split('@')[0]
const affiliation = affiliations[a.split('@')[1]].name
return (
<Fragment key={`${position}@${affiliation}`}>
<span className='text-slate-500 text-lg'>
{position} at{' '}
<Link href={`/affiliation/${a.split('@')[1]}`}>
{affiliation}
</Link>
</span>
</Fragment>
)
})}
</>
)
}
const NationalityDisplay = ({
nationality,
}: Readonly<{ nationality: string }>) => {
const nationalityData = nationalities[nationality]
const { demonym, flag } = nationalityData
return (
<div className='flex items-center'>
<img
src={flag}
className='w-10 border-2 border-slate-200'
alt={`${demonym} flag`}
/>
<span className='mx-3 font-semibold'>{demonym}</span>
</div>
)
}
const Bio = () => {
const { bio } = data
if (!bio) return null
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>Bio:</h1>
<p>{bio}</p>
</>
)
}
return (
<div>
<div className='grid grid-cols-1 md:grid-cols-2 items-center max-w-3xl mx-auto'>
<div className='aspect-square w-[60vw] md:w-[30vw] lg:w-[20vw] 2xl:w-[15vw] overflow-hidden mx-auto mb-4'>
<img
alt='profile'
className='rounded-full mx-auto object-cover w-full h-full border-slate-800 border-4'
src={image}
/>
</div>
<div>
<div>
<span
className={`${zillaSlab.className} font-bold text-5xl text-center md:text-left`}
>
{name.first}
{name.nickname ? ` "${name.nickname}"` : null} {name.last}
</span>
<div className='text-slate-600 text-md sm:text-lg mt-4'>
<MainPosition />
</div>
</div>
</div>
</div>
<div className='max-w-3xl mx-auto grid grid-cols-1 justify-center'>
<hr className='mx-auto w-full h-1 border-0 bg-slate-200 my-2 rounded-md' />
<OtherPositions />
<FormerPositions />
<h1 className='text-3xl md:my-6 my-4 font-serif'>
Ethnicity and Nationality:
</h1>
<div className='flex gap-2 flex-wrap'>
{nationality.map((n: string) => (
<Fragment key={n}>
<NationalityDisplay nationality={n} />
</Fragment>
))}
</div>
<Bio />
</div>
</div>
)
}

View file

@ -1,10 +1,5 @@
import { authors, affiliations, nationalities } from '../../db/data' import AuthorDisplay from './AuthorDisplay'
import { Zilla_Slab } from 'next/font/google' import { authors } from '@/app/db/data'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import { Fragment, Suspense } from 'react'
const zillaSlab = Zilla_Slab({ subsets: ['latin'], weight: ['500'] })
export function generateStaticParams() { export function generateStaticParams() {
const authorsList = Object.keys(authors) const authorsList = Object.keys(authors)
@ -16,172 +11,5 @@ export default function Page({
}: Readonly<{ }: Readonly<{
params: { author: string } params: { author: string }
}>) { }>) {
const authorData = authors[params.author] return <AuthorDisplay author={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 (
<>
<span>{mainPosition} at </span>
<Link href={`/affiliation/${mainAffiliationShort}`}>
{mainAffiliation.name}
</Link>
{authorData.website ? (
<div className='mt-2'>
Visit{' '}
{authorData.name.nickname
? authorData.name.nickname
: authorData.name.first}{' '}
at:{' '}
<a href={authorData.website} target='_blank'>
{authorData.website}
</a>
</div>
) : null}
<div className='my-4 max-h-12 flex flex-wrap'>
{affiliation.map((a: string) => (
<Link key={a} href={`/affiliation/${a.split('@')[1]}`}>
<img
src={affiliations[a.split('@')[1]].image}
alt={affiliations[a.split('@')[1]].name}
className='h-12 mr-2'
/>
</Link>
))}
</div>
</>
)
}
const OtherPositions = () => {
if (affiliation.length === 1) return
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>
Other Positions:
</h1>
{affiliation.slice(1).map((a: string, i: number) => {
const position = a.split('@')[0]
const affiliation = affiliations[a.split('@')[1]].name
return (
<Fragment key={`${position}@${affiliation}`}>
<span className='text-slate-500 text-lg'>
{position} at{' '}
<Link href={`/affiliation/${a.split('@')[1]}`}>
{affiliation}
</Link>
</span>
</Fragment>
)
})}
</>
)
}
const FormerPositions = () => {
if (!formerAffiliations) return null
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>
Former Positions:
</h1>
{formerAffiliations?.map((a: string, i: number) => {
const position = a.split('@')[0]
const affiliation = affiliations[a.split('@')[1]].name
return (
<Fragment key={`${position}@${affiliation}`}>
<span className='text-slate-500 text-lg'>
{position} at{' '}
<Link href={`/affiliation/${a.split('@')[1]}`}>
{affiliation}
</Link>
</span>
</Fragment>
)
})}
</>
)
}
const NationalityDisplay = ({
nationality,
}: Readonly<{ nationality: string }>) => {
const nationalityData = nationalities[nationality]
const { demonym, flag } = nationalityData
return (
<div className='flex items-center'>
<img
src={flag}
className='w-10 border-2 border-slate-200'
alt={`${demonym} flag`}
/>
<span className='mx-3 font-semibold'>{demonym}</span>
</div>
)
}
const Bio = () => {
const { bio } = authorData
if (!bio) return null
return (
<>
<h1 className='text-3xl md:mt-6 mt-4 mb-2 font-serif'>Bio:</h1>
<p>{authorData.bio}</p>
</>
)
}
return (
<div>
<div className='grid grid-cols-1 md:grid-cols-2 items-center max-w-3xl mx-auto'>
<div className='aspect-square w-[60vw] md:w-[30vw] lg:w-[20vw] 2xl:w-[15vw] overflow-hidden mx-auto mb-4'>
<img
alt='profile'
className='rounded-full mx-auto object-cover w-full h-full border-slate-800 border-4'
src={image}
/>
</div>
<div>
<div>
<span
className={`${zillaSlab.className} font-bold text-5xl text-center md:text-left`}
>
{name.first}
{name.nickname ? ` "${name.nickname}"` : null} {name.last}
</span>
<div className='text-slate-600 text-md sm:text-lg mt-4'>
<MainPosition />
</div>
</div>
</div>
</div>
<div className='max-w-3xl mx-auto grid grid-cols-1 justify-center'>
<hr className='mx-auto w-full h-1 border-0 bg-slate-200 my-2 rounded-md' />
<OtherPositions />
<FormerPositions />
<h1 className='text-3xl md:my-6 my-4 font-serif'>
Ethnicity and Nationality:
</h1>
<div className='flex gap-2 flex-wrap'>
{nationality.map((n: string) => (
<Fragment key={n}>
<NationalityDisplay nationality={n} />
</Fragment>
))}
</div>
<Bio />
</div>
</div>
)
} }

View file

@ -1,10 +1,8 @@
import { Fragment } from 'react' import { Fragment } from 'react'
import Link from 'next/link' import Link from 'next/link'
import { import { reviewer } from '@/app/db/data'
topics as topicList, import { loadAllTopics, loadAllAuthors } from '@/app/db/loaders'
authors as authorList, import { useSuspenseQuery } from '@tanstack/react-query'
reviewer,
} from '@/app/db/data'
export const Code = ({ export const Code = ({
code, code,
@ -54,6 +52,18 @@ export const Topics = ({
topics, topics,
showTitle = true, showTitle = true,
}: Readonly<{ topics: string[]; showTitle?: boolean }>) => { }: 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 ( return (
<> <>
{showTitle ? <span className='font-bold'>Topics: </span> : null} {showTitle ? <span className='font-bold'>Topics: </span> : null}
@ -72,6 +82,19 @@ export const Topics = ({
export const Authors = ({ export const Authors = ({
authors, authors,
}: Readonly<{ authors: string[]; noLink?: boolean }>) => { }: 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 ( return (
<> <>
{authors.map((a: string, i) => ( {authors.map((a: string, i) => (
@ -95,20 +118,16 @@ export const Reviewers = ({
const ReviewerDisplay = ({ r }: Readonly<{ r: reviewer }>) => { const ReviewerDisplay = ({ r }: Readonly<{ r: reviewer }>) => {
if (r.profile) { if (r.profile) {
return ( return (
<>
<Link href={`/author/${r.profile}`} target='_blank'> <Link href={`/author/${r.profile}`} target='_blank'>
{r.first} {r.last} {r.first} {r.last}
</Link> </Link>
</>
) )
} }
if (r.url) { if (r.url) {
return ( return (
<>
<a href={r.url} target='_blank'> <a href={r.url} target='_blank'>
{r.first} {r.last} {r.first} {r.last}
</a> </a>
</>
) )
} }
return ( return (

View file

@ -282,14 +282,12 @@ On Day 1 we will discuss the ins and outs of the robot. The Electrical Sub team
}, },
} }
export interface Topics { export interface Topic {
[key: string]: {
name: string name: string
description: string description: string
wiki: string wiki: string
}
} }
export const topics: Topics = { export const topics: { [key: string]: Topic } = {
frc: { frc: {
name: 'FIRST Robotics Competition', name: 'FIRST Robotics Competition',
description: description:
@ -499,16 +497,14 @@ export const authors: { [key: string]: Author } = {
}, },
} }
export interface Affiliations { export interface Affiliation {
[key: string]: {
name: string name: string
short: string short: string
image: string image: string
description: string description: string
}
} }
export const affiliations: Affiliations = { export const affiliations: { [key: string]: Affiliation } = {
'1280-mech': { '1280-mech': {
name: "Team 1280, the Ragin' C Biscuits, Mechanical Subteam", name: "Team 1280, the Ragin' C Biscuits, Mechanical Subteam",
short: '1280 Mech', short: '1280 Mech',
@ -659,14 +655,12 @@ Raid Zero's influence extends beyond the technical achievements in robotics comp
}, },
} }
export interface Nationalities { export interface Nationality {
[key: string]: {
name: string name: string
demonym: string demonym: string
flag: string flag: string
}
} }
export const nationalities: Nationalities = { export const nationalities: { [key: string]: Nationality } = {
pak: { pak: {
name: 'Pakistan', name: 'Pakistan',
demonym: 'Pakistani', demonym: 'Pakistani',

View file

@ -1,4 +1,4 @@
import { Document, Author } from './data' import { Document, Author, Affiliation, Topic, Nationality } from './data'
export const loadDocument = (id: string): Promise<Document> => { export const loadDocument = (id: string): Promise<Document> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@ -67,7 +67,7 @@ export const loadAllAuthors = (): Promise<{ [key: string]: Author }> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (typeof Worker !== 'undefined') { if (typeof Worker !== 'undefined') {
const worker = new Worker( const worker = new Worker(
new URL('./workers/documentLoader.worker.ts', import.meta.url), new URL('./workers/authorLoader.worker.ts', import.meta.url),
{ type: 'module' } { type: 'module' }
) )
@ -91,3 +91,240 @@ export const loadAllAuthors = (): Promise<{ [key: string]: Author }> => {
} }
}) })
} }
export const loadAuthor = (id: string): Promise<Author> => {
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<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 }>) => {
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<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 }>) => {
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<Nationality> => {
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.'
)
)
}
})
}