Skip to content

Commit

Permalink
feat: support router lazy with preload
Browse files Browse the repository at this point in the history
  • Loading branch information
sanyuan0704 committed Sep 16, 2022
1 parent 9aafda9 commit d2e978c
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 35 deletions.
5 changes: 3 additions & 2 deletions src/client/runtime/Content.tsx
@@ -1,7 +1,8 @@
import { useRoutes } from 'react-router-dom';
import { routes } from 'virtual:routes';
import { ReactElement, Suspense } from 'react';

export const Content = () => {
export const Content = ({ fallback }: { fallback: ReactElement }) => {
const routesElement = useRoutes(routes);
return <>{routesElement}</>;
return <Suspense fallback={fallback}>{routesElement}</Suspense>;
};
20 changes: 18 additions & 2 deletions src/client/runtime/app.tsx
@@ -1,17 +1,19 @@
import { Layout } from 'island/theme';
import { routes } from 'virtual:routes';
import { matchRoutes } from 'react-router-dom';
import { matchRoutes, useLocation } from 'react-router-dom';
import siteData from 'island:site-data';
import { Route } from '../../node/plugin-routes';
import { omit } from './utils';
import { PageData } from '../../shared/types';
import { HelmetProvider } from 'react-helmet-async';
import { useEffect } from 'react';

export async function waitForApp(path: string): Promise<PageData> {
const matched = matchRoutes(routes, path)!;
if (matched) {
// Preload route component
const mod = await (matched[0].route as Route).preload();
console.log(mod);
return {
siteData,
pagePath: (matched[0].route as Route).filePath,
Expand All @@ -26,7 +28,21 @@ export async function waitForApp(path: string): Promise<PageData> {
}
}

export function App({ helmetContext }: { helmetContext?: object }) {
export function App({
helmetContext,
setPageData
}: {
helmetContext?: object;
setPageData?: React.Dispatch<React.SetStateAction<PageData>>;
}) {
const { pathname } = useLocation();
useEffect(() => {
async function refetchData() {
setPageData && setPageData(await waitForApp(pathname));
}
refetchData();
}, [pathname]);

return (
<HelmetProvider context={helmetContext}>
<Layout />
Expand Down
32 changes: 17 additions & 15 deletions src/client/runtime/client-entry.tsx
@@ -1,9 +1,8 @@
import { hydrateRoot, createRoot } from 'react-dom/client';
import { ComponentType } from 'react';
import { ComponentType, useState } from 'react';
import { BrowserRouter } from 'react-router-dom';
import './sideEffects';
import { DataContext } from './hooks';
import { loadableReady } from '@loadable/component';

// Type shim for window.ISLANDS
declare global {
Expand All @@ -23,27 +22,30 @@ async function renderInBrowser() {

const enhancedApp = async () => {
const { waitForApp, App } = await import('./app');
const pageData = await waitForApp(window.location.pathname);
return (
<DataContext.Provider value={pageData}>
<BrowserRouter>
<App />
</BrowserRouter>
</DataContext.Provider>
);
const initialPageData = await waitForApp(window.location.pathname);
return () => {
const [pageData, setPageData] = useState(initialPageData);

return (
<DataContext.Provider value={pageData}>
<BrowserRouter>
<App setPageData={setPageData} />
</BrowserRouter>
</DataContext.Provider>
);
};
};
if (import.meta.env.DEV) {
// The App code will will be tree-shaking in production
// So there is no need to worry that the complete hydration will be executed in island mode
createRoot(containerEl).render(await enhancedApp());
const RootApp = await enhancedApp();
createRoot(containerEl).render(<RootApp />);
} else {
// In production
// SPA mode
if (import.meta.env.ENABLE_SPA) {
const rootApp = await enhancedApp();
loadableReady(() => {
hydrateRoot(containerEl, rootApp);
});
const RootApp = await enhancedApp();
hydrateRoot(containerEl, <RootApp />);
} else {
// MPA mode or island mode
const islands = document.querySelectorAll('[__island]');
Expand Down
2 changes: 1 addition & 1 deletion src/client/runtime/ssr-entry.tsx
Expand Up @@ -33,7 +33,7 @@ export async function render(
appHtml,
islandToPathMap,
propsData: islandProps,
// Only spa need the hydrate data on window
// Only spa need the data on window
pageData: enableSpa ? pageData : null
};
}
Expand Down
22 changes: 17 additions & 5 deletions src/client/theme-default/components/Link/index.tsx
@@ -1,5 +1,6 @@
import React from 'react';
import styles from './index.module.scss';
import { Link as RouterLink } from 'react-router-dom';

interface LinkProps {
href?: string;
Expand All @@ -13,9 +14,20 @@ export function Link(props: LinkProps) {
const isExternal = EXTERNAL_URL_RE.test(href);
const target = isExternal ? '_blank' : '';
const rel = isExternal ? 'noopener noreferrer' : undefined;
return (
<a href={href} target={target} rel={rel} className={styles.link}>
{children}
</a>
);

if (import.meta.env.ENABLE_SPA && !isExternal) {
return (
<div className={styles.link}>
<RouterLink to={href} rel={rel} target={target}>
{children}
</RouterLink>
</div>
);
} else {
return (
<a href={href} target={target} rel={rel} className={styles.link}>
{children}
</a>
);
}
}
2 changes: 1 addition & 1 deletion src/client/theme-default/layout/DocLayout/index.tsx
Expand Up @@ -26,7 +26,7 @@ export function DocLayout() {
<div className={styles.contentContainer}>
<main className={styles.main}>
<div className="island-doc">
<Content />
<Content fallback={<div>Loading...</div>} />
</div>
<DocFooter />
</main>
Expand Down
38 changes: 33 additions & 5 deletions src/node/plugin-routes/RouteService.ts
@@ -1,5 +1,6 @@
import fastGlob from 'fast-glob';
import path from 'path';
import { lazyWithPreload } from './lazyWithPreload';

export interface RouteMeta {
routePath: string;
Expand Down Expand Up @@ -58,21 +59,48 @@ export class RouteService {

generateRoutesCode(ssr?: boolean) {
return `
${ssr ? '' : `import loadable from '@loadable/component'`};
${
ssr
? ''
: `import loadable from '@loadable/component';
import { ComponentType, forwardRef, lazy, useRef } from 'react';
import { jsx } from 'react/jsx-runtime';
${lazyWithPreload.toString()};`
};
import React from 'react';
${this.#routeData
.map((route, index) => {
return ssr
? `import Route${index} from '${route.absolutePath}';`
: `const Route${index} = loadable(() => import('${route.absolutePath}'))`;
? `import * as Route${index} from '${route.absolutePath}';`
: `const Route${index} = lazyWithPreload(() => import('${route.absolutePath}'))`;
})
.join('\n')}
export const routes = [
${this.#routeData
.map((route, index) => {
return `{ path: '${route.routePath}', element: React.createElement(Route${index}), filePath: '${route.absolutePath}', preload: () => import('${route.absolutePath}') },`;
// In ssr, we don't need to import component dynamically.
const preload = ssr ? `() => Route${index}` : `Route${index}.preload`;
const component = ssr ? `Route${index}.default` : `Route${index}`;
/**
* For SSR, example:
* {
* route: '/',
* element: React.createElement(Route0),
* preload: Route0.preload,
* filePath: '/Users/xxx/xxx/index.md'
* }
*
* For client render, example:
* {
* route: '/',
* element: React.createElement(Route0.default),
* preload: Route0.preload,
* filePath: '/Users/xxx/xxx/index.md'
* }
*/
return `{ path: '${route.routePath}', element: React.createElement(${component}), filePath: '${route.absolutePath}', preload: ${preload} }`;
})
.join('\n')}
.join(',\n')}
];
`;
}
Expand Down
2 changes: 1 addition & 1 deletion src/node/plugin-routes/index.ts
Expand Up @@ -31,7 +31,7 @@ export interface Route {
path: string;
element: React.ReactElement;
filePath: string;
preload: () => Promise<PageModule>;
preload: () => Promise<PageModule<any>>;
}

export const CONVENTIONAL_ROUTE_ID = 'virtual:routes';
Expand Down
37 changes: 37 additions & 0 deletions src/node/plugin-routes/lazyWithPreload.tsx
@@ -0,0 +1,37 @@
import { ComponentType, forwardRef, lazy, useRef } from 'react';
import { PageModule } from 'shared/types';

export type PreloadableComponent<T extends ComponentType<any>> = T & {
preload: () => Promise<PageModule<T>>;
};

// Runtime code
// Inspired by https://github.com/ianschmitz/react-lazy-with-preload/blob/master/src/index.ts
export function lazyWithPreload<T extends ComponentType<any>>(
factory: () => Promise<{ default: T }>
): PreloadableComponent<T> {
const ReactLazyComponent = lazy(factory);
let PreloadedComponent: T | undefined;
let factoryPromise: Promise<PageModule<T>> | undefined;

const Component = forwardRef(function LazyWithPreload(props, ref) {
const ComponentToRender = useRef(PreloadedComponent ?? ReactLazyComponent);
const Element = ComponentToRender.current as React.ComponentPropsWithRef<T>;
return <Element ref={ref} {...props} />;
});

const LazyWithPreload = Component as any as PreloadableComponent<T>;

LazyWithPreload.preload = () => {
if (!factoryPromise) {
factoryPromise = factory().then((mod) => {
PreloadedComponent = mod.default;
return mod;
});
}

return factoryPromise;
};

return LazyWithPreload;
}
6 changes: 3 additions & 3 deletions src/shared/types/index.ts
@@ -1,4 +1,4 @@
import { ReactElement } from 'react';
import { ComponentType, ReactElement } from 'react';
import { UserConfig as ViteConfiguration } from 'vite';
import { DefaultTheme } from './default-theme';

Expand Down Expand Up @@ -138,8 +138,8 @@ export interface SiteConfig<ThemeConfig = any>

export type ComponentPropsWithIsland<T = any> = T & { __island: boolean };

export interface PageModule {
default: ReactElement;
export interface PageModule<T extends ComponentType<any>> {
default: T;
[key: string]: unknown;
}

Expand Down

0 comments on commit d2e978c

Please sign in to comment.