Skip to content

Commit

Permalink
feat: support spa mode
Browse files Browse the repository at this point in the history
  • Loading branch information
sanyuan0704 committed Sep 16, 2022
1 parent f8f71e0 commit 9aafda9
Show file tree
Hide file tree
Showing 17 changed files with 109 additions and 46 deletions.
7 changes: 6 additions & 1 deletion docs/.island/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { defineConfig } from '../../dist';
export default defineConfig({
lang: 'en-US',
icon: '/icon.png',
enableSpa: true,
themeConfig: {
socialLinks: [
{
Expand Down Expand Up @@ -30,8 +31,12 @@ function getTutorialSidebar() {
return [
{
text: 'Guide',
items: [{ text: 'Getting Started', link: '/guide/getting-started' }]
items: [
{ text: 'Getting Started', link: '/guide/getting-started' },
{ text: 'Fresh', link: '/fresh' }
]
}

// {
// text: 'Advance',
// items: []
Expand Down
5 changes: 5 additions & 0 deletions scripts/pre-bundle.cts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,11 @@ async function preBundle(deps: PreBundleItem[]) {
minify: true,
splitting: true,
format: 'esm',
define: {
'process.env.NODE_ENV': JSON.stringify(
process.env.NODE_ENV || 'production'
)
},
platform: 'browser',
plugins: [
{
Expand Down
1 change: 1 addition & 0 deletions src/client/runtime/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { HelmetProvider } from 'react-helmet-async';
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();
return {
siteData,
Expand Down
40 changes: 29 additions & 11 deletions src/client/runtime/client-entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,15 @@ import { ComponentType } 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 {
interface Window {
ISLANDS: Record<string, ComponentType<any>>;
// The state for island.
ISLAND_PROPS: any;
ISLAND_PAGE_DATA: any;
}
}

Expand All @@ -18,26 +20,42 @@ async function renderInBrowser() {
if (!containerEl) {
throw new Error('#root element not found');
}
// TODO: add SPA mode support
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 production

const enhancedApp = async () => {
const { waitForApp, App } = await import('./app');
const pageData = await waitForApp(window.location.pathname);
createRoot(containerEl).render(
return (
<DataContext.Provider value={pageData}>
<BrowserRouter>
<App />
</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());
} else {
const islands = document.querySelectorAll('[__island]');
for (let i = 0; i < islands.length; i++) {
const island = islands[i];
const [id, index] = island.getAttribute('__island')!.split(':');
const Element = window.ISLANDS[id];
hydrateRoot(island, <Element {...window.ISLAND_PROPS[index]}></Element>);
// In production
// SPA mode
if (import.meta.env.ENABLE_SPA) {
const rootApp = await enhancedApp();
loadableReady(() => {
hydrateRoot(containerEl, rootApp);
});
} else {
// MPA mode or island mode
const islands = document.querySelectorAll('[__island]');
for (let i = 0; i < islands.length; i++) {
const island = islands[i];
const [id, index] = island.getAttribute('__island')!.split(':');
const Element = window.ISLANDS[id];
hydrateRoot(
island,
<Element {...window.ISLAND_PROPS[index]}></Element>
);
}
}
}
}
Expand Down
5 changes: 4 additions & 1 deletion src/client/runtime/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { createContext, useContext } from 'react';
import { PageData } from '../../shared/types';
import { inBrowser } from './utils';

export const DataContext = createContext({});
export const DataContext = createContext(
inBrowser() ? window?.ISLAND_PAGE_DATA : null
);

export const usePageData = () => {
return useContext(DataContext) as PageData;
Expand Down
2 changes: 1 addition & 1 deletion src/client/runtime/sideEffects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { inBrowser } from './utils';
const DEFAULT_NAV_HEIGHT = 72;

// Control the scroll behavior of the browser when user clicks on a link
if (inBrowser) {
if (inBrowser()) {
function scrollTo(el: HTMLElement, hash: string, smooth = false) {
let target: HTMLElement | null = null;
try {
Expand Down
8 changes: 6 additions & 2 deletions src/client/runtime/ssr-entry.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import { DataContext } from './hooks';
// For ssr component render
export async function render(
pagePath: string,
helmetContext: object
helmetContext: object,
enableSpa: boolean
): Promise<{
appHtml: string;
propsData: any[];
islandToPathMap: Record<string, string>;
pageData: any;
}> {
const pageData = await waitForApp(pagePath);
const { data } = await import('island/jsx-runtime');
Expand All @@ -30,7 +32,9 @@ export async function render(
return {
appHtml,
islandToPathMap,
propsData: islandProps
propsData: islandProps,
// Only spa need the hydrate data on window
pageData: enableSpa ? pageData : null
};
}

Expand Down
2 changes: 1 addition & 1 deletion src/client/runtime/utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
export const inBrowser = typeof window !== undefined;
export const inBrowser = () => typeof window !== 'undefined';

export const omit = (obj: Record<string, any>, keys: string[]) => {
const ret = { ...obj };
Expand Down
3 changes: 2 additions & 1 deletion src/client/theme-default/logic/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,5 +11,6 @@ export function normalizeHref(url?: string) {
if (!isProduction() || url.startsWith('http')) {
return url;
}
return addLeadingSlash(`${url}.html`);
const suffix = import.meta.env.ENABLE_SPA ? '' : '.html';
return addLeadingSlash(`${url}${suffix}`);
}
7 changes: 7 additions & 0 deletions src/client/type.d.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
/// <reference types="vite/client" />

interface ImportMetaEnv {
readonly ENABLE_SPA: boolean;
}

interface ImportMeta {
readonly env: ImportMetaEnv
}

declare module 'island/theme*' {
import { ComponentType, Component } from 'react';
Expand Down
47 changes: 28 additions & 19 deletions src/node/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,13 @@ export const failMark = '\x1b[31m✖\x1b[0m';

export type RenderFn = (
url: string,
helmetContext: object
helmetContext: object,
enableSpa: boolean
) => Promise<{
appHtml: string;
propsData: string;
islandToPathMap: Record<string, string>;
pageData: any;
}>;

interface ServerEntryExports {
Expand Down Expand Up @@ -115,17 +117,16 @@ class SSGBuilder {
const { default: ora } = await dynamicImport('ora');
const spinner = ora();
spinner.start('Rendering page in server side...');
const clientChunkInfo = {
code: clientChunkCode,
fileName: clientEntryChunk!.fileName
};
await Promise.all(
routes.map((route) =>
this.#renderPage(render, route.path, clientChunkCode, styleAssets)
this.#renderPage(render, route.path, clientChunkInfo, styleAssets)
)
);
await this.#render404Page(render, clientChunkCode, styleAssets);
// await fs.copy(
// join(this.#root, 'public'),
// join(this.#root, DIST_PATH),
// 'public'
// );
await this.#render404Page(render, clientChunkInfo, styleAssets);
spinner.stopAndPersist({
symbol: okMark
});
Expand Down Expand Up @@ -187,19 +188,21 @@ class SSGBuilder {
async #renderPage(
render: RenderFn,
routePath: string,
clientChunkCode: string,
clientChunk: { code?: string; fileName?: string },
styleAssets: (OutputChunk | OutputAsset)[]
) {
const helmetContext: HelmetData = {
context: {}
} as HelmetData;
const { appHtml, propsData, islandToPathMap } = await render(
const { appHtml, propsData, islandToPathMap, pageData } = await render(
routePath,
helmetContext.context
helmetContext.context,
this.#config.enableSpa!
);
const hasIsland = Object.keys(islandToPathMap).length > 0;
let injectIslandsCode = '';
if (hasIsland) {
// In island mode, we will bundle and inject island components code to html
if (hasIsland && !this.#config.enableSpa) {
const islandHash = createHash(JSON.stringify(islandToPathMap));
let injectBundlePromise = this.#islandsInjectCache.get(islandHash);

Expand All @@ -223,7 +226,6 @@ class SSGBuilder {
<link rel="icon" href="${
this.#config.siteData!.icon
}" type="image/svg+xml"></link>
${helmet?.title.toString() || ''}
${helmet?.meta.toString() || ''}
${helmet?.link.toString() || ''}
Expand All @@ -245,16 +247,23 @@ class SSGBuilder {
<body>
<div id="root">${appHtml}</div>
${
hasIsland
this.#config.enableSpa
? `<script>window.ISLAND_PAGE_DATA=${JSON.stringify(
pageData
)};</script>`
: ''
}
${
!this.#config.enableSpa && hasIsland
? `<script id="island-props">${JSON.stringify(
propsData
)}</script><script type="module">${injectIslandsCode}</script>`
: ''
}
${
clientChunkCode && hasIsland
? `<script type="module">${clientChunkCode}</script>`
: ''
this.#config.enableSpa
? `<script type="module" src="/${clientChunk.fileName}"></script>`
: `<script type="module">${clientChunk.code}</script>`
}
</body>
</html>`.trim();
Expand All @@ -269,10 +278,10 @@ class SSGBuilder {

#render404Page(
render: RenderFn,
clientChunkCode: string,
clientChunk: { code: string; fileName: string },
styleAssets: (OutputChunk | OutputAsset)[]
) {
return this.#renderPage(render, '/404', clientChunkCode, styleAssets);
return this.#renderPage(render, '/404', clientChunk, styleAssets);
}

#generateIslandInjectCode(islandToPathMap: Record<string, string>) {
Expand Down
4 changes: 2 additions & 2 deletions src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export async function resolveConfig(
const themeDir = pathExistsSync(userThemeDir)
? userThemeDir
: DEFAULT_THEME_PATH;

const siteConfig: SiteConfig<DefaultTheme.Config> = {
root,
srcDir,
Expand All @@ -90,7 +89,8 @@ export async function resolveConfig(
tempDir: resolve(root, 'node_modules', '.island'),
vite: userConfig.vite || {},
allowDeadLinks: userConfig.allowDeadLinks || false,
siteData: resolveSiteData(userConfig)
siteData: resolveSiteData(userConfig),
enableSpa: userConfig.enableSpa || false
};

return siteConfig;
Expand Down
3 changes: 3 additions & 0 deletions src/node/plugin-island/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,9 @@ export function pluginConfig(config: SiteConfig): Plugin {
)
}
},
define: {
'import.meta.env.ENABLE_SPA': config.enableSpa
},
css: {
modules: {
localsConvention: 'camelCaseOnly'
Expand Down
2 changes: 1 addition & 1 deletion src/node/plugin-island/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,6 @@ export function pluginIsland(
pluginSiteData(config),
pluginConfig(config),
pluginIndexHtml(config),
pluginIslandTransform(isServer)
pluginIslandTransform(config, isServer)
];
}
9 changes: 7 additions & 2 deletions src/node/plugin-island/islandTransform.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@ import {
import { Plugin, transformWithEsbuild } from 'vite';
import { transformAsync } from '@babel/core';
import babelPluginIsland from '../babel-plugin-island';
import { SiteConfig } from 'shared/types/index';

export function pluginIslandTransform(isServer: boolean): Plugin {
export function pluginIslandTransform(
config: SiteConfig,
isServer: boolean
): Plugin {
return {
name: 'island:vite-plugin-internal',
enforce: 'pre',
Expand All @@ -17,7 +21,8 @@ export function pluginIslandTransform(isServer: boolean): Plugin {
if (
options?.ssr &&
TS_REGEX.test(id) &&
id.includes(DEFAULT_THEME_PATH)
id.includes(DEFAULT_THEME_PATH) &&
!config.enableSpa
) {
let strippedTypes = await transformWithEsbuild(code, id, {
jsx: 'preserve'
Expand Down
6 changes: 4 additions & 2 deletions src/node/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ export async function createIslandPlugins(
// React hmr support
pluginReact({
jsxRuntime: 'automatic',
jsxImportSource: isServer ? ISLAND_JSX_RUNTIME_PATH : 'react',
jsxImportSource:
isServer && !config.enableSpa ? ISLAND_JSX_RUNTIME_PATH : 'react',
babel: {
plugins: [babelPluginIsland]
// Babel plugin for island(mpa) mode
plugins: [...(config.enableSpa ? [] : [babelPluginIsland])]
}
}),
// Svg component support
Expand Down
4 changes: 2 additions & 2 deletions src/shared/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,9 +70,9 @@ export interface UserConfig<ThemeConfig = any> {
*/
vite?: ViteConfiguration;
/**
* Enable island architecture.
* Enable single page application in production.
*/
mpa?: boolean;
enableSpa?: boolean;
/**
* Whether to fail builds when there are dead links.
*/
Expand Down

0 comments on commit 9aafda9

Please sign in to comment.