Server-side rendering

In Modern.js, SSR also works out of the box. Developers do not need to write complex server level logic for SSR, nor do they need to care about the operation and maintenance of SSR, or create services. Modern.js have a comprehensive SSR degradation strategy to ensure that pages can run safely.

Enabling SSR is very easy, just set 'server.ssr' to true:

modern.config.ts
import { defineConfig } from '@modern-js/app-tools';

export default defineConfig({
  "server": {
    "ssr": true,
  },
})

SSR Data Fetch

Modern.js provides Data Loader, which is convenient for developers to fetch data under SSR and CSR. Each routing module, such as layout.tsx and page.tsx, can define its own Data Loader:

src/routes/page.tsx
export const loader = () => {
  return {
    message: 'Hello World',
  };
};

in the component, the data returned by the loader function can be get data through the Hooks API:

export default () => {
  const data = useLoaderData();
  return <div>{data.message}</div>;
};

Modern.js break the traditional SSR development model and provide users with a user-friendly SSR development experience.

And it provides elegant degradation processing. Once the SSR request fails, it will automatically downgrade and restart the request on the browser side.

However, developers still need to pay attention to the fallback of data, such as null values or data returns that do not as expect. Avoid React rendering errors or messy rendering results when SSR.

INFO

  1. When you request the page on client-side page transitions, Modern.js sends an API request to the server, which runs Data Loader function.

  2. When using Data Loader, data fetching happens before rendering, Modern.js still supports fetching data when the component is rendered. See Data Fetch. :::

Keep Rendering Consistent

In some businesses, it is usually necessary to display different UI displays according to the current operating container environment characteristics, such as UA information.

If the processing is not careful enough, the rendering results may do not meet the expectations at this time.

Here is an example to show the problem when SSR and CSR rendering are inconsistent, add the following code to the component:

{
  typeof window !== 'undefined' ? <div>browser content</div> : null;
}

After starting the app, visit the page and will find that the browser console throws a warning message:

Warning: Expected server HTML to contain a matching <div> in <div>.

This is caused by the inconsistency between the rendering result and the SSR rendering result when React executes the hydrate logic on the client side. Although the page performs normally, in complex applications, it is likely to cause problems such as DOM hierarchy confusion and style confusion.

:::info For hydrate logic, please refer to here.

Applications need to maintain the consistency of SSR and CSR rendering results. If there are inconsistencies, it means that this part of the content does not need to be rendered in SSR.

Modern.js provide <NoSSR> for such content that does not need to be rendered in SSR:

import { NoSSR } from '@modern-js/runtime/ssr';

Outside of elements that do not require SSR, wrap with a NoSSR component:

<NoSSR>
  <div>client content</div>
</NoSSR>

After modifying the code, refresh page found that the previous Waring disappeared. Open the Network window of the browser developer tool to see that the returned HTML document does not contain the content of the NoSSR component package.

INFO

'useRuntimeContext' can get complete request information, which can be used to ensure that the rendering results of SSR and CSR are consistent.

Concerned Memory Leaks

WARNING

In the SSR, developers need to pay special attention to the problem of memory leaks. Even small memory leaks can affect services..

In SSR, every request triggers the component rendering. So, you need to avoid defining any potentially growing global data, or subscribing to events globally, or creating streams that will not be destroyed.

For example, the following code, when using redux-observable, developers used to code like this:

import { createEpicMiddleware, combineEpics } from 'redux-observable';

const epicMiddleware = createEpicMiddleware();
const rootEpic = combineEpics();

export default function Test() {
  epicMiddleware.run(rootEpic);
  return <div>Hello Modern.js</div>;
}

Create a Middleware instance epicMiddleware outside the component and call epicMiddleware.run inside the component.

On the browser side, this code does not cause any problems. But in SSR, the Middleware instance will never be destroyed. Every time the component is rendered and rootEpic is called, new event bindings are added internally, causing the entire object to continue to grow larger, which ultimately affects application performance.

Such problems in CSR are not easy to detect, so when switching from CSR to SSR, if you are not sure whether the application has such hidden dangers, you can press the application.

Crop SSR Data

In order to keep the data requested in the SSR phase, it can be used directly on the browser side, Modern.js inject the data and state that collected during the rendering process into the HTML.

As a result, CSR applications often have a large amount of interface data and the state of the components is not crop. If SSR is used directly, the rendered HTML size may be too large.

At this time, SSR not only cannot bring an improvement in the user experience, but may have the opposite effect.

Therefore, when using SSR, developers need to do reasonable weight loss for the application:

  1. Pay attention to the first screen, you can only request the data needed for the first screen in SSR, and render the rest on the browser side.
  2. Removes the data independent with render from the data returned by the interface.

Serverless Pre-render

Modern.js provide Serverless Pre-rendering(SPR) to improve SSR performance.

SPR uses pre-rendering and caching to provide the responsive performance of static Web for SSR pages. It allows SSR applications to have the responsiveness and stability of static Web pages, while keeping data dynamically updated.

Using SPR in the Modern.js is very simple, just add the <PreRender> component, and the page where the component is located will automatically open SPR.

This mock a component that uses the useLoaderData API, and the request in the Data Loader takes 2s.

import { useLoaderData } from '@modern-js/runtime/router';

export const loader = async () => {
  await new Promise((resolve, reject) => {
    setTimeout(() => {
      resolve(null);
    }, 2000);
  });

  return {
    message: 'Hello Modern.js',
  };
};

export default () => {
  const data = useLoaderData();
  return <div>{data?.message}</div>;
};

After executing the dev command and opening the page, it is obvious that the page needs to wait 2s before returning.

The next is to use the <PreRender> component, which can be exported directly from @modern-js/runtime/ssr :

import { PreRender } from '@modern-js/runtime/ssr';

Use the PreRender component in the routing component and set the parameter interval to indicate that the expiration time of the rendering result is 5s:

<PreRender interval={5} />

After modification, execute pnpm run build && pnpm run serve to start the application and open the page.

When open page for the first time, it is no different from the previous rendering, and there is also a 2s delay.

Refresh page, and the page opens instantly, but at this time, the page data has not changed due to the refresh, because the cache has not expired.

Wait 5s, refresh the page again, the data of the page is still unchanged. Refresh the page again The data changes, but the page still responds almost instantaneously.

This is because in the previous request, the SPR has asynchronously obtained the new rendering result in the background, and the page requested this time is the version that has been cached in the server.

It is conceivable that when interval is set to 1, users can have the responsive experience of a static page.

INFO

For more detail, see <PreRender>.

Treeshaking

When SSR is enabled, Modern.js will use the same entry to build both the SSR Bundle and the CSR Bundle. Therefore, the Web API in the SSR Bundle, or the Node API in the CSR Bundle, can lead to runtime errors.

Web API into a component is usually to do some global listening, or to get browser-related data, such as:

document.addEventListener('load', () => {
  console.log('document load');
});
const App = () => {
  return <div>Hello World</div>;
};
export default App;

The Node API is introduced in the component file, usually because of the use of Data Loader, for example:

import fse from 'fs-extra';
export const loader = () => {
  const file = fse.readFileSync('./myfile');
  return {
    ...
  };
};

Use Environment Variables

For the first case, we can directly use Modern.js built-in environment variables MODERN_TARGET to remove useless code at build time:

if (process.env.MODERN_TARGET === 'browser') {
  document.addEventListener('load', () => {
    console.log('document load');
  });
}

NOTE

For more information, see environment variables.

Use File Suffix

In the second case, the Treeshaking method does not guarantee that the code is completely separated. Modern.js also supports the packaging file of SSR Bundle and CSR Bundle products through the file suffixed with .node..

For example, the import of fs-extra in the code, when it is directly referenced to the component, will cause the CSR to load an error. You can create .ts and .node.ts files of the same name as a layer of proxy:

compat.ts
export const readFileSync: any = () => {};
compat.node.ts
export { readFileSync } from 'fs-extra';

use ./compat directly into the file. At this time, files with the .node.ts suffix will be used first in the SSR environment, and files with the .ts suffix will be used in the CSR environment.

App.tsx
import { readFileSync } from './compat'

export const loader = () => {
  const file = readFileSync('./myfile');
  return {
    ...
  };
};

Independent File

Both of the above methods will bring some burden to the developer. Modern.js based on Nested Routing developed and designed Data Fetch to separate CSR and SSR code.

Remote Request

When initiating remote requests in SSR, developers sometimes use request tools. Some interfaces need to pass user cookies, which developers can get through the 'useRuntimeContext' API to achieve.

It should be noted, the request header of the HTML request is obtained, which may not be applicable to remote requests, so must not pass through all request headers.

In addition, some backend interfaces, or general gateways, will verify according to the information in the request header, and full pass-through is prone to various problems that are difficult to debug. It is recommended that pass-through on demand.

Be sure to filter the host field if you really need to pass through all request headers.

Streaming SSR

Modern.js supports streaming rendering in React 18. Opt in it with the following configuration:

modern.config.ts
import { defineConfig } from '@modern-js/app-tools';

export default defineConfig({
  "server": {
    "ssr": {
      "mode": "stream",
    },
  },
})

The streaming SSR of Modern.js is implemented based on React Router, and the main APIs involved are:

  • defer: This utility allows you to defer values returned from loaders by passing promises instead of resolved values.
  • Await: Used to render deferred values with automatic error handling.
  • useAsyncValue: Returns the resolved data from the nearest <Await> ancestor component.

Return async data

page.loader.ts
import { defer, type LoaderFunctionArgs } from '@modern-js/runtime/router';

interface User {
  name: string;
  age: number;
}

export interface Data {
  data: User;
}

export default ({ params }: LoaderFunctionArgs) => {
  const userId = params.id;

  const user = new Promise<User>(resolve => {
    setTimeout(() => {
      resolve({
        name: `user-${userId}`,
        age: 18,
      });
    }, 200);
  });

  return defer({ data: user });
};

user is of Promise type, which means the data will be obtained asynchronously. Note that defer must accept an object type parameter, therefore, the parameter passed to defer is {data: user}.

defer can also receive asynchronous data and synchronous data at the same time. For example:

page.loader.ts

// skip some codes

export default ({ params }: LoaderFunctionArgs) => {
  const userId = params.id;

  const user = new Promise<User>(resolve => {
    setTimeout(() => {
      resolve({
        name: `user-${userId}`,
        age: 18,
      });
    }, 200);
  });

  const otherData = new Promise<string>(resolve => {
    setTimeout(() => {
      resolve('some sync data');
    }, 200);
  });

  return defer({
    data: user,
    other: await otherData
  });
};

await is added before otherData, so the data is obtained synchronously. It can be passed to defer with the data user at the same time.

Render async data

Use the Await component to render the data returned asynchronously from the Data Loader. For example:

page.tsx
import { Await, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';
import type { Data } from './page.loader';

const Page = () => {
  const data = useLoaderData() as Data;

  return (
    <div>
      User info:
      <Suspense fallback={<div id="loading">loading user data ...</div>}>
        <Await resolve={data.data}>
          {(user) => {
            return (
              <div id="data">
                name: {user.name}, age: {user.age}
              </div>
            );
          }}
        </Await>
      </Suspense>
    </div>
  );
};

export default Page;

Await needs to be wrapped inside the Suspense component. The resolve of Await passes in the data acquired asynchronously by the Data Loader. When the data acquisition is completed, the obtained data is rendered through the Render Props mode. When the data acquisition is in pending status, the content set by the fallback property of the Suspense component will display.

Warning

When importing a type from a Data Loader file, you need to use the import type syntax to ensure that only type information is imported, which can prevent the Data Loader code from being packaged into the client bundle.

So, here we import like this: import type { Data } from './page.loader'

;

You can also get the asynchronous data returned by Data Loader through useAsyncValue. For example:

page.tsx
import { useAsyncValue } from '@modern-js/runtime/router';

// skip some codes

const UserInfo = () => {
  const user = useAsyncValue();

  return (
     <div>
      name: {user.name}, age: {user.age}
    </div>
  )
}

const Page = () => {
  const data = useLoaderData() as Data;

  return (
    <div>
      User info:
      <Suspense fallback={<div id="loading">loading user data ...</div>}>
        <Await resolve={data.data}>
          <UserInfo />
        </Await>
      </Suspense>
    </div>
  );
};

export default Page;

Error handling

The errorElement property of the Await component can be used to handle errors thrown when the Data Loader executes or when a child component renders. For example, we intentionally throw an error in the Data Loader function:

page.loader.ts
import { defer } from '@modern-js/runtime/router';

export default () => {
  const data = new Promise((resolve, reject) => {
    setTimeout(() => {
      reject(new Error('error occurs'));
    }, 200);
  });

  return defer({ data });
};

Then use useAsyncError to get the error, and assign the component used to render the error to the errorElement property of the Await component:

page.ts
import { Await, useAsyncError, useLoaderData } from '@modern-js/runtime/router';
import { Suspense } from 'react';

export default function Page() {
  const data = useLoaderData();

  return (
    <div>
      Error page
      <Suspense fallback={<div>loading ...</div>}>
        <Await resolve={data.data} errorElement={<ErrorElement />}>
          {(data: any) => {
            return <div>never displayed</div>;
          }}
        </Await>
      </Suspense>
    </div>
  );
}

function ErrorElement() {
  const error = useAsyncError() as Error;
  return <p>Something went wrong! {error.message}</p>;
}
,