Skip to content

Commit

Permalink
Merge branch 'pr/aleclarson/11'
Browse files Browse the repository at this point in the history
feat: efficient HMR updates
  • Loading branch information
csr632 committed Mar 13, 2021
2 parents 347c567 + 969a234 commit e4b4abb
Show file tree
Hide file tree
Showing 14 changed files with 307 additions and 184 deletions.
1 change: 0 additions & 1 deletion packages/react-pages/client.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import React from 'react'
export type Theme = React.ComponentType<ThemeProps>

export interface ThemeProps {
readonly staticData: PagesStaticData
readonly loadedData: PagesLoaded
readonly loadState: LoadState
}
Expand Down
2 changes: 2 additions & 0 deletions packages/react-pages/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,12 @@
"@rollup/plugin-babel": "^5.3.0",
"chalk": "^4.1.0",
"chokidar": "^3.5.1",
"dequal": "^2.0.2",
"enhanced-resolve": "^5.7.0",
"fs-extra": "^9.1.0",
"gray-matter": "^4.0.2",
"jest-docblock": "^26.0.0",
"jotai": "^0.15.1",
"mini-debounce": "^1.0.8",
"minimist": "^1.2.5",
"read-pkg-up": "^7.0.1",
Expand Down
37 changes: 10 additions & 27 deletions packages/react-pages/src/client/App.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import React from 'react'
import { Switch, Route } from 'react-router-dom'
import { usePagePaths } from './state'
import PageLoader from './PageLoader'

import pages from '@!virtual-modules/pages'
import Theme from '@!virtual-modules/theme'

const App = () => {
const pageRoutes = Object.keys(pages)
const pageRoutes = usePagePaths()
.filter((path) => path !== '/404')
.map((path) => getPageRoute(path, pages[path].staticData))
.map((path) => (
// avoid re-mount layout component
// https://github.com/ReactTraining/react-router/issues/3928#issuecomment-284152397
<Route key="same" exact path={path}>
<PageLoader routePath={path} />
</Route>
))

return (
<Switch>
Expand All @@ -18,32 +22,11 @@ const App = () => {
path="*"
render={({ match }) => {
// 404
return (
<PageLoader Theme={Theme} pages={pages} routePath={match.url} />
)
return <PageLoader routePath={match.url} />
}}
/>
</Switch>
)
}

export default App

function getPageRoute(path: string, staticData: any) {
if (!pages[path]) {
throw new Error(`page not exist. route path: ${path}`)
}
return (
<Route
// avoid re-mount layout component
// https://github.com/ReactTraining/react-router/issues/3928#issuecomment-284152397
key="same"
exact
path={path}
// not used for now
{...staticData._routeConfig}
>
<PageLoader Theme={Theme} pages={pages} routePath={path} />
</Route>
)
}
32 changes: 8 additions & 24 deletions packages/react-pages/src/client/PageLoader.tsx
Original file line number Diff line number Diff line change
@@ -1,34 +1,18 @@
import React, { useContext, useMemo } from 'react'
import type { PagesStaticData, PagesInternal, Theme } from '../../client'
import React, { useContext } from 'react'
import { dataCacheCtx } from './ssr/ctx'
import { useTheme } from './state'
import useAppState from './useAppState'

interface Props {
readonly Theme: Theme
readonly pages: PagesInternal
readonly routePath: string
routePath: string
}

const PageLoader = ({ pages, routePath: routePathFromProps, Theme }: Props) => {
const PageLoader = React.memo(({ routePath }: Props) => {
const Theme = useTheme()
const loadState = useAppState(routePath)
const dataCache = useContext(dataCacheCtx)
const loadState = useAppState(pages, routePathFromProps)

const pagesStaticData = useMemo(() => getPublicPages(pages), [pages])

return (
<Theme
loadState={loadState}
loadedData={dataCache}
staticData={pagesStaticData}
/>
)
}
return <Theme loadState={loadState} loadedData={dataCache} />
})

export default PageLoader

// filter out internal fields inside pages
function getPublicPages(pages: PagesInternal): PagesStaticData {
return Object.fromEntries(
Object.entries(pages).map(([path, { staticData }]) => [path, staticData])
)
}
2 changes: 1 addition & 1 deletion packages/react-pages/src/client/declare.d.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
declare module '@!virtual-modules/*'
declare module '/@react-pages/*'
1 change: 1 addition & 0 deletions packages/react-pages/src/client/index.tsx
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export type { Theme } from '../../client'
export { useStaticData } from './state'
10 changes: 7 additions & 3 deletions packages/react-pages/src/client/main.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
import React from 'react'
import ReactDOM from 'react-dom'
import { Provider as Jotai } from 'jotai'
import SSRContextProvider from './SSRContextProvider'
import App from './App'

let app = <App />
if (import.meta.hot) {
app = <Jotai>{app}</Jotai>
}

ReactDOM.render(
<SSRContextProvider>
<App />
</SSRContextProvider>,
<SSRContextProvider>{app}</SSRContextProvider>,
document.getElementById('root')
)
2 changes: 1 addition & 1 deletion packages/react-pages/src/client/ssr/clientRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import React from 'react'
import ReactDOM from 'react-dom'
import App from '../App'
import SSRContextProvider from '../SSRContextProvider'
import pages from '@!virtual-modules/pages'
import pages from '/@react-pages/pages'

declare global {
interface Window {
Expand Down
2 changes: 1 addition & 1 deletion packages/react-pages/src/client/ssr/serverRender.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react'
import ReactDOM from 'react-dom/server'
import { StaticRouter } from 'react-router-dom'

import ssrData from '@!virtual-modules/ssrData'
import ssrData from '/@react-pages/ssrData'
import App from '../App'
import { dataCacheCtx } from './ctx'
import type { PagesLoaded } from '../../../client'
Expand Down
154 changes: 154 additions & 0 deletions packages/react-pages/src/client/state.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { useMemo } from 'react'
import { dequal } from 'dequal'
import type { SetAtom } from 'jotai/core/types'
import { atom, useAtom } from 'jotai'
import { atomFamily, useAtomValue, useUpdateAtom } from 'jotai/utils'
import type { PageLoaded, PagesStaticData, Theme } from '../../client'

export let useTheme: () => Theme
export let usePagePaths: () => string[]
export let usePageModule: (path: string) => Promise<PageModule> | undefined
export let useStaticData: UseStaticData

interface PageModule {
['default']: PageLoaded
}

interface UseStaticData {
(): PagesStaticData
(path: string): Record<string, any>
<T>(path: string, selector: (staticData: Record<string, any>) => T): T
}

import initialPages from '/@react-pages/pages'
import initialTheme from '/@react-pages/theme'

const initialPagePaths = Object.keys(initialPages)

// This HMR code assumes that our Jotai atoms are always managed
// by the same Provider. It also mutates during render, which is
// generally discouraged, but in this case it's okay.
if (import.meta.hot) {
let setTheme: SetAtom<{ Theme: Theme }> | undefined
import.meta.hot!.accept('/@react-pages/theme', (module) => {
setTheme?.({ Theme: module.default })
})

const themeAtom = atom({ Theme: initialTheme })
useTheme = () => {
const [{ Theme }, set] = useAtom(themeAtom)
setTheme = set
return Theme
}

let setPages: SetAtom<any> | undefined
import.meta.hot!.accept('/@react-pages/pages', (module) => {
setPages?.(module.default)
})

const pagesAtom = atom(initialPages)
const pagePathsAtom = atom(initialPagePaths.sort())
const staticDataAtom = atom(toStaticData(initialPages))

const setPagesAtom = atom(null, (get, set, newPages: any) => {
let newStaticData: Record<string, any> | undefined

const pages = get(pagesAtom)
for (const path in newPages) {
const page = pages[path]
const newPage = newPages[path]

// Avoid changing the identity of `page.staticData` unless
// a change is detected. This prevents unnecessary renders
// of components that depend on `useStaticData(path)` call.
if (page && dequal(page.staticData, newPage.staticData)) {
newPage.staticData = page.staticData
} else {
newStaticData ??= {}
newStaticData[path] = newPage.staticData
}
}

// Update the `pagesAtom` every time, since no hook uses it directly.
set(pagesAtom, newPages)

// Avoid re-rendering `useStaticData()` callers if no data changed.
if (newStaticData) {
set(staticDataAtom, {
...get(staticDataAtom),
...newStaticData,
})
}

// Avoid re-rendering `usePagePaths()` callers if no paths were added/deleted.
const newPagePaths = Object.keys(newPages).sort()
if (!dequal(get(pagePathsAtom), newPagePaths)) {
set(pagePathsAtom, newPagePaths)
}
})

const dataPathAtoms = atomFamily((path: string) => (get) => {
const pages = get(pagesAtom)
const page = pages[path] || pages['/404']
return page?.dataPath || null
})

const emptyData: any = {}
const staticDataAtoms = atomFamily((path: string) => (get) => {
const pages = get(pagesAtom)
const page = pages[path] || pages['/404']
return page?.staticData || emptyData
})

usePagePaths = () => {
setPages = useUpdateAtom(setPagesAtom)
return useAtomValue(pagePathsAtom)
}

// This hook uses dynamic import with a variable, which is not supported
// by Rollup, but that's okay since HMR is for development only.
usePageModule = (pagePath) => {
const dataPath = useAtomValue(dataPathAtoms(pagePath))
return useMemo(() => {
return dataPath ? import(dataPath /* @vite-ignore */) : void 0
}, [dataPath])
}

useStaticData = (pagePath?: string, selector?: Function) => {
const staticData = pagePath ? staticDataAtoms(pagePath) : staticDataAtom
if (selector) {
const selection = useMemo(
() => atom((get) => selector(get(staticData))),
[staticData]
)
return useAtomValue(selection)
}
return useAtomValue(staticData)
}
}

// Static mode
else {
useTheme = () => initialTheme
usePagePaths = () => initialPagePaths
usePageModule = (path) => {
const page = initialPages[path] || initialPages['/404']
return useMemo(() => page?.data(), [page])
}
useStaticData = (path?: string, selector?: Function) => {
if (path) {
const page = initialPages[path] || initialPages['/404']
const staticData = page?.staticData || {}
return selector ? selector(staticData) : staticData
}
return toStaticData(initialPages)
}
}

function toStaticData(pages: Record<string, any>) {
const staticData: Record<string, any> = {}
for (const path in pages) {
staticData[path] = pages[path].staticData
}
return staticData
}
Loading

0 comments on commit e4b4abb

Please sign in to comment.