diff --git a/packages/react-pages/client.d.ts b/packages/react-pages/client.d.ts index 60622e12..8aa105bc 100644 --- a/packages/react-pages/client.d.ts +++ b/packages/react-pages/client.d.ts @@ -4,7 +4,6 @@ import React from 'react' export type Theme = React.ComponentType export interface ThemeProps { - readonly staticData: PagesStaticData readonly loadedData: PagesLoaded readonly loadState: LoadState } diff --git a/packages/react-pages/package.json b/packages/react-pages/package.json index 7a240ab6..0ee99f2f 100644 --- a/packages/react-pages/package.json +++ b/packages/react-pages/package.json @@ -49,6 +49,7 @@ "@babel/preset-typescript": "^7.12.17", "@rollup/plugin-babel": "^5.3.0", "chalk": "^4.1.0", + "dequal": "^2.0.2", "enhanced-resolve": "^5.7.0", "find-up": "^5.0.0", "fs-extra": "^9.1.0", @@ -56,6 +57,7 @@ "globby": "^11.0.2", "gray-matter": "^4.0.2", "jest-docblock": "^26.0.0", + "jotai": "^0.15.1", "mini-debounce": "^1.0.8", "minimist": "^1.2.5", "ora": "^5.3.0", diff --git a/packages/react-pages/src/client/App.tsx b/packages/react-pages/src/client/App.tsx index 42459312..92b3328e 100644 --- a/packages/react-pages/src/client/App.tsx +++ b/packages/react-pages/src/client/App.tsx @@ -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 + + + + )) return ( @@ -18,9 +22,7 @@ const App = () => { path="*" render={({ match }) => { // 404 - return ( - - ) + return }} /> @@ -28,22 +30,3 @@ const App = () => { } export default App - -function getPageRoute(path: string, staticData: any) { - if (!pages[path]) { - throw new Error(`page not exist. route path: ${path}`) - } - return ( - - - - ) -} diff --git a/packages/react-pages/src/client/PageLoader.tsx b/packages/react-pages/src/client/PageLoader.tsx index 94d3fd09..0f3b775f 100644 --- a/packages/react-pages/src/client/PageLoader.tsx +++ b/packages/react-pages/src/client/PageLoader.tsx @@ -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 ( - - ) -} + return +}) 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]) - ) -} diff --git a/packages/react-pages/src/client/index.tsx b/packages/react-pages/src/client/index.tsx index d063d776..98dd55eb 100644 --- a/packages/react-pages/src/client/index.tsx +++ b/packages/react-pages/src/client/index.tsx @@ -1 +1,2 @@ export type { Theme } from '../../client' +export { useStaticData } from './state' diff --git a/packages/react-pages/src/client/main.tsx b/packages/react-pages/src/client/main.tsx index 39ccb1ea..fb1f19ee 100644 --- a/packages/react-pages/src/client/main.tsx +++ b/packages/react-pages/src/client/main.tsx @@ -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 = +if (import.meta.hot) { + app = {app} +} + ReactDOM.render( - - - , + {app}, document.getElementById('root') ) diff --git a/packages/react-pages/src/client/state.ts b/packages/react-pages/src/client/state.ts new file mode 100644 index 00000000..cd44e9a9 --- /dev/null +++ b/packages/react-pages/src/client/state.ts @@ -0,0 +1,155 @@ +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 | undefined +export let useStaticData: UseStaticData + +interface PageModule { + ['default']: PageLoaded +} + +interface UseStaticData { + (): PagesStaticData + (path: string): Record + (path: string, selector: (staticData: Record) => T): T +} + +import initialPages from '@!virtual-modules/pages' +import initialTheme from '@!virtual-modules/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 }> + let setPages: SetAtom + + // Without /@id/ prefix, Vite will resolve them relative to __dirname. + import.meta.hot!.accept('/@id/@!virtual-modules/theme', (module) => { + setTheme(module.default) + }) + import.meta.hot!.accept('/@id/@!virtual-modules/pages', (module) => { + setPages(module.default) + }) + + const themeAtom = atom({ Theme: initialTheme }) + useTheme = () => { + const [{ Theme }, set] = useAtom(themeAtom) + setTheme = set + return Theme + } + + 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 | 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) { + const staticData: Record = {} + for (const path in pages) { + staticData[path] = pages[path].staticData + } + return staticData +} diff --git a/packages/react-pages/src/client/useAppState.tsx b/packages/react-pages/src/client/useAppState.tsx index 5678f250..2b341bbe 100644 --- a/packages/react-pages/src/client/useAppState.tsx +++ b/packages/react-pages/src/client/useAppState.tsx @@ -1,90 +1,54 @@ -import { useState, useEffect, useContext, useCallback } from 'react' -import type { PagesInternal, LoadState } from '../../client' +import { useState, useLayoutEffect, useContext, useRef } from 'react' +import { unstable_batchedUpdates as batchedUpdates } from 'react-dom' +import type { LoadState } from '../../client' import { dataCacheCtx, setDataCacheCtx } from './ssr/ctx' +import { usePageModule } from './state' -export default function useAppState( - pages: PagesInternal, - latestRoutePath: string -) { +export default function useAppState(routePath: string) { const dataCache = useContext(dataCacheCtx) const setDataCache = useContext(setDataCacheCtx) - const [loadState, _setLoadState] = useState(() => { - return { - type: 'loading', - routePath: latestRoutePath, - } - }) - const setLoadState = (newLoadState: LoadState) => { - if ( - newLoadState.type === loadState.type && - newLoadState.routePath === loadState.routePath - ) { - // don't set state if prev is already what I want - } else { - _setLoadState(newLoadState) - } - } + const [loadState, setLoadState] = useState(() => ({ + type: 'loading', + routePath, + })) - const { routePath } = loadState + const onLoadState = ( + type: LoadState['type'], + routePath: string, + error?: any + ) => + (type !== loadState.type || routePath !== loadState.routePath) && + setLoadState({ type, routePath, error }) - if (!pages[latestRoutePath]) { - if (pages['/404'] && !dataCache['/404']) { - // load /404 page - setLoadState({ - type: 'loading', - routePath: latestRoutePath, - }) + const loading = usePageModule(routePath) + const loadingRef = useRef | undefined>() + useLayoutEffect(() => { + loadingRef.current = loading + if (!loading) { + onLoadState('404', routePath) } else { - setLoadState({ - type: '404', - routePath: latestRoutePath, - }) - } - } else if (routePath !== latestRoutePath) { - // if routePath has changed, - // update loadState and rerender this component - setLoadState({ - type: 'loading', - routePath: latestRoutePath, - }) - } else if (dataCache[routePath]) { - // if we have the data in cache (.e.g during ssr or loaded done) - // update loadState and rerender this component - setLoadState({ - type: 'loaded', - routePath, - }) - } - - useEffect(() => { - // FIXME handle race condition of this async setState - if (loadState.type === 'loading') { - let loadPath = routePath - if (!pages[routePath]) { - // loading a non-exist page - if (pages['/404']) { - loadPath = '/404' - } + if (dataCache[routePath]) { + onLoadState('loaded', routePath) + } else { + onLoadState('loading', routePath) + loading.then( + (page) => + loading === loadingRef.current && + batchedUpdates(() => { + onLoadState('loaded', routePath) + setDataCache((prev) => ({ + ...prev, + [routePath]: page.default, + })) + }), + (error) => + loading === loadingRef.current && + onLoadState('load-error', routePath, error) + ) } - const { data: dataImporter } = pages[loadPath] - dataImporter() - .then(({ default: pageLoaded }) => { - setDataCache((prev) => ({ - ...prev, - [loadPath]: pageLoaded, - })) - }) - .catch((error) => { - setLoadState({ - type: 'load-error', - routePath, - error, - }) - throw error - }) } - }, [routePath]) + }, [loading]) return loadState } diff --git a/packages/react-pages/src/node/dynamic-modules/pages.ts b/packages/react-pages/src/node/dynamic-modules/pages.ts index 659521cd..59c258a4 100644 --- a/packages/react-pages/src/node/dynamic-modules/pages.ts +++ b/packages/react-pages/src/node/dynamic-modules/pages.ts @@ -11,7 +11,7 @@ export interface PagesData { } } -export async function renderPageList(pagesData: PagesData) { +export async function renderPageList(pagesData: PagesData, isBuild: boolean) { const addPagesData = Object.entries(pagesData).map( ([pageId, { staticData }]) => { let subPath = pageId @@ -20,9 +20,12 @@ export async function renderPageList(pagesData: PagesData) { // so we change the sub path subPath = '/__index' } + const dataProperty = isBuild + ? `data = () => import("@!virtual-modules/pages${subPath}")` + : `dataPath = "/@id/@!virtual-modules/pages${subPath}"` const code = ` pages["${pageId}"] = {}; -pages["${pageId}"].data = () => import("@!virtual-modules/pages${subPath}"); +pages["${pageId}"].${dataProperty}; pages["${pageId}"].staticData = ${JSON.stringify(staticData)};` return code } diff --git a/packages/react-pages/src/node/index.ts b/packages/react-pages/src/node/index.ts index 41b26238..0c554dde 100644 --- a/packages/react-pages/src/node/index.ts +++ b/packages/react-pages/src/node/index.ts @@ -32,6 +32,7 @@ export default function pluginFactory( staticSiteGeneration, } = opts + let isBuild: boolean let pagesDir: string let pageStrategy: PageStrategy @@ -55,14 +56,23 @@ export default function pluginFactory( }, }), configResolved(config) { + isBuild = config.command === 'build' pagesDir = opts.pagesDir ?? path.resolve(config.root, 'pages') pageStrategy = new PageStrategy(pagesDir, findPages, loadPageData) }, - configureServer({ watcher }) { + configureServer({ watcher, moduleGraph }) { + const reloadVirtualModule = (moduleId: string) => { + const module = moduleGraph.getModuleById(moduleId) + if (module) { + moduleGraph.invalidateModule(module) + watcher.emit('change', '/@id/' + moduleId) + } + } + pageStrategy - .on('promise', () => watcher.emit('change', pagesModuleId)) + .on('promise', () => reloadVirtualModule(pagesModuleId)) .on('change', (pageId: string) => - watcher.emit('change', pagesModuleId + pageId) + reloadVirtualModule(pagesModuleId + pageId) ) }, resolveId(id) { @@ -76,7 +86,7 @@ export default function pluginFactory( async load(id) { // page list if (id === pagesModuleId) { - return renderPageList(await pageStrategy.getPages()) + return renderPageList(await pageStrategy.getPages(), isBuild) } // one page data if (id.startsWith(pagesModuleId + '/')) { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index cd24a99a..81bc429d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -341,6 +341,7 @@ importers: '@babel/preset-typescript': 7.12.17 '@rollup/plugin-babel': 5.3.0_rollup@2.39.0 chalk: 4.1.0 + dequal: 2.0.2 enhanced-resolve: 5.7.0 find-up: 5.0.0 fs-extra: 9.1.0 @@ -348,6 +349,7 @@ importers: globby: 11.0.2 gray-matter: 4.0.2 jest-docblock: 26.0.0 + jotai: 0.15.1_react@17.0.1 mini-debounce: 1.0.8 minimist: 1.2.5 ora: 5.3.0 @@ -384,6 +386,7 @@ importers: chalk: ^4.1.0 chokidar: ^3.5.1 concurrently: ^6.0.0 + dequal: ^2.0.2 enhanced-resolve: ^5.7.0 find-up: ^5.0.0 fs-extra: ^9.1.0 @@ -391,6 +394,7 @@ importers: globby: ^11.0.2 gray-matter: ^4.0.2 jest-docblock: ^26.0.0 + jotai: ^0.15.1 mini-debounce: ^1.0.8 minimist: ^1.2.5 ora: ^5.3.0 @@ -2293,6 +2297,12 @@ packages: node: '>= 0.4' resolution: integrity: sha512-3MqfYKj2lLzdMSf8ZIZE/V+Zuy+BgD6f164e8K2w7dgnpKArBDerGYpM46IYYcjnkdPNMjPk9A6VFB8+3SKlXQ== + /dequal/2.0.2: + dev: false + engines: + node: '>=6' + resolution: + integrity: sha512-q9K8BlJVxK7hQYqa6XISGmBZbtQQWVXSrRrWreHC94rMt1QL/Impruc+7p2CYSYuVIUr+YCt6hjrs1kkdJRTug== /detab/2.0.4: dependencies: repeat-string: 1.6.1 @@ -3175,6 +3185,27 @@ packages: node: '>= 10.14.2' resolution: integrity: sha512-RDZ4Iz3QbtRWycd8bUEPxQsTlYazfYn/h5R65Fc6gOfwozFhoImx+affzky/FFBuqISPTqjXomoIGJVKBWoo0w== + /jotai/0.15.1_react@17.0.1: + dependencies: + react: 17.0.1 + dev: false + peerDependencies: + immer: '*' + optics-ts: '*' + react: '>=16.8' + react-query: '*' + xstate: '*' + peerDependenciesMeta: + immer: + optional: true + optics-ts: + optional: true + react-query: + optional: true + xstate: + optional: true + resolution: + integrity: sha512-ffC+CGYLyV6CiWZ2Pm2XcySzH3+AhaIdGN2LTmpOrbFQqH+JBPHQTRqw0G9ys7/6xhzhWDvBASigTaQQGEBubA== /js-tokens/4.0.0: resolution: integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==