Skip to content

Commit

Permalink
feat: add experimental SSR component
Browse files Browse the repository at this point in the history
  • Loading branch information
igordanchenko committed Jun 27, 2024
1 parent 0ea7a26 commit fbd8819
Show file tree
Hide file tree
Showing 11 changed files with 160 additions and 11 deletions.
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,10 @@ on the client only after hydration. Please note that unless your photo album is
of constant width that always matches the `defaultContainerWidth` value, you
will most likely see a layout shift immediately after hydration. Alternatively,
you can provide a fallback skeleton in the `skeleton` prop that will be rendered
in SSR and swapped with the actual photo album markup after hydration.
in SSR and swapped with the actual photo album markup after hydration. Please
also refer to the
[Server-Side Rendering](<https://react-photo-album.com/documentation#Server-SideRendering(SSR)>)
documentation for a comprehensive list of available solutions.

## Credits

Expand Down
68 changes: 63 additions & 5 deletions docs/documentation.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,16 +687,23 @@ the content container padding and the left-hand side navigation menu:

## Server-Side Rendering (SSR)

By default, [React Photo Album](/) produces an empty markup on the server, but
several alternative solutions are available.
By default, [React Photo Album](/) produces an empty markup in SSR because the
actual container width is usually unknown during server-side rendering. This
default behavior causes content layout shift after hydration. As a workaround,
you can specify the `defaultContainerWidth` prop to enable photo album markup
rendering in SSR. However, that will likely result in the photo album layout
shift once the photo album re-calculates its layout on the client. With this
being said, there isn't a perfect solution for SSR, but there are several
options to choose from, depending on your use case.

### Default Container Width

To render photo album markup on the server, you can specify the
`defaultContainerWidth` value. It is a perfect SSR solution if your photo album
has a constant width in all viewports (e.g., an image picker in a fixed-size
sidebar). However, if the client-side photo album width doesn't match the
`defaultContainerWidth`, you are almost guaranteed to see a layout shift.
`defaultContainerWidth`, you are almost guaranteed to see a layout shift after
hydration.

```tsx
<RowsPhotoAlbum photos={photos} defaultContainerWidth={800} />
Expand All @@ -706,8 +713,11 @@ sidebar). However, if the client-side photo album width doesn't match the

Alternatively, you can provide a fallback skeleton in the `skeleton` prop that
will be rendered in SSR and swapped with the actual photo album markup after
hydration. This approach allows you to reserve a blank space for the photo album
markup and avoid a flash of below-the-fold content during hydration.
hydration. This approach allows you to reserve a blank space on the page for the
photo album markup and avoid a flash of the below-the-fold content during
hydration. The downside of this approach is that images don't start downloading
until after hydration unless you manually add prefetch links to the document
`<head>`.

```tsx
<RowsPhotoAlbum
Expand All @@ -716,6 +726,54 @@ markup and avoid a flash of below-the-fold content during hydration.
/>
```

### Visibility Hidden

Another option is to render the photo album on the server with
`visibility: hidden`. This way, you can avoid a flash of the below-the-fold
content and allow the browser to start downloading images before hydration.

```tsx
<RowsPhotoAlbum
photos={photos}
defaultContainerWidth={800}
componentsProps={(containerWidth) =>
containerWidth === undefined
? {
container: { style: { visibility: "hidden" } },
}
: {}
}
/>
```

### Multiple Layouts

The ultimate zero-CLS solution requires pre-rendering multiple layouts on the
server and displaying the correct one on the client using CSS `@container`
queries. [React Photo Album](/) provides an experimental `SSR` component
implementing this approach (the component is currently exported as
`UnstableSSR`). The downside of this approach is the overhead in SSR-generated
markup and the hydration of multiple photo album instances on the client (which
may be a reasonable compromise if zero CLS is a must-have requirement).

```tsx
import { RowsPhotoAlbum, UnstableSSR as SSR } from "react-photo-album";
import "react-photo-album/rows.css";

import photos from "./photos";

export default function Gallery() {
return (
<SSR breakpoints={[300, 600, 900, 1200]}>
<RowsPhotoAlbum photos={photos} />
</SSR>
);
}
```

Please share your feedback if you have successfully used this component in your
project or encountered any issues.

## Previous Versions

Are you looking for documentation for one of the previous versions?
Expand Down
1 change: 1 addition & 0 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ export default {
"client/columns": "src/client/columns/index.ts",
"client/masonry": "src/client/masonry/index.ts",
"client/aggregate": "src/client/aggregate/index.ts",
"client/ssr": "src/client/ssr/index.ts",
},
output: { dir: "dist" },
plugins: [dts()],
Expand Down
5 changes: 1 addition & 4 deletions src/client/hooks/useContainerWidth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,7 @@ function resolveContainerWidth(el: HTMLElement | null, breakpoints: readonly num
return width;
}

export default function useContainerWidth(
breakpointsArray: number[] | undefined,
defaultContainerWidth: number | undefined,
) {
export default function useContainerWidth(breakpointsArray: number[] | undefined, defaultContainerWidth?: number) {
const [[containerWidth], dispatch] = useReducer(containerWidthReducer, [defaultContainerWidth]);
const breakpoints = useArray(breakpointsArray);
const observerRef = useRef<ResizeObserver>();
Expand Down
59 changes: 59 additions & 0 deletions src/client/ssr/SSR.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type React from "react";
import { cloneElement, isValidElement, useId, useState } from "react";

import { useContainerWidth } from "../hooks";
import { cssClass } from "../../core/utils";
import { CommonPhotoAlbumProps } from "../../types";

export type SSRProps = {
/** Photo album layout breakpoints. */
breakpoints: number[];
/** Photo album instance, which must be the only child. */
children: React.ReactElement<Pick<CommonPhotoAlbumProps, "breakpoints" | "defaultContainerWidth">>;
};

export default function SSR({ breakpoints, children }: SSRProps) {
const uid = `ssr-${useId().replace(/:/g, "")}`;
const { containerRef, containerWidth } = useContainerWidth(breakpoints);
const [hydratedBreakpoint, setHydratedBreakpoint] = useState<number>();

if (!Array.isArray(breakpoints) || breakpoints.length === 0 || !isValidElement(children)) return null;

if (containerWidth !== undefined && hydratedBreakpoint === undefined) {
setHydratedBreakpoint(containerWidth);
}

const containerClass = cssClass(uid);
const breakpointClass = (breakpoint: number) => cssClass(`${uid}-${breakpoint}`);

const allBreakpoints = [Math.min(...breakpoints) / 2, ...breakpoints];
allBreakpoints.sort((a, b) => a - b);

return (
<>
{hydratedBreakpoint === undefined && (
<style>
{[
`.${containerClass}{container-type:inline-size}`,
`${allBreakpoints.map((breakpoint) => `.${breakpointClass(breakpoint)}`).join()}{display:none}`,
...allBreakpoints.map(
(breakpoint, index, array) =>
`@container(min-width:${index > 0 ? breakpoint : 0}px)${index < array.length - 1 ? ` and (max-width:${array[index + 1] - 1}px)` : ""}{.${breakpointClass(breakpoint)}{display:block}}`,
),
].join("\n")}
</style>
)}

<div ref={containerRef} className={containerClass}>
{allBreakpoints.map(
(breakpoint) =>
(hydratedBreakpoint === undefined || hydratedBreakpoint === breakpoint) && (
<div key={breakpoint} className={breakpointClass(breakpoint)}>
{cloneElement(children, { breakpoints, defaultContainerWidth: breakpoint })}
</div>
),
)}
</div>
</>
);
}
2 changes: 2 additions & 0 deletions src/client/ssr/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from "./SSR";
export { default } from "./SSR";
2 changes: 1 addition & 1 deletion src/core/static/StaticPhotoAlbum.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import PhotoComponent from "./PhotoComponent";
import { srcSetAndSizes, unwrap } from "../utils";
import { CommonPhotoAlbumProps, ComponentsProps, LayoutModel, Photo, Render } from "../../types";

type StaticPhotoAlbumProps<TPhoto extends Photo> = Pick<
export type StaticPhotoAlbumProps<TPhoto extends Photo> = Pick<
CommonPhotoAlbumProps<TPhoto>,
"sizes" | "onClick" | "skeleton"
> & {
Expand Down
1 change: 1 addition & 0 deletions src/core/static/index.ts
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from "./StaticPhotoAlbum";
export { default } from "./StaticPhotoAlbum";
6 changes: 6 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@ export { default as ColumnsPhotoAlbum } from "./client/columns";
export { default as MasonryPhotoAlbum } from "./client/masonry";

// experimental exports (no semver coverage)

export { default as UnstableSSR } from "./client/ssr";
export type { SSRProps as UnstableSSRProps } from "./client/ssr";

export { default as UnstableStaticPhotoAlbum } from "./core/static";
export type { StaticPhotoAlbumProps as UnstableStaticPhotoAlbumProps } from "./core/static";

export { default as unstable_computeRowsLayout } from "./layouts/rows";
export { default as unstable_computeColumnsLayout } from "./layouts/columns";
export { default as unstable_computeMasonryLayout } from "./layouts/masonry";
21 changes: 21 additions & 0 deletions test/SSR.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { RowsPhotoAlbum, UnstableSSR as SSR } from "../src";
import { render } from "./test-utils";
import photos from "./photos";

describe("SSR", () => {
it("works as expected", () => {
const { getTracks, rerender } = render(
<SSR breakpoints={[]}>
<RowsPhotoAlbum photos={photos} />
</SSR>,
);
expect(getTracks().length).toBe(0);

rerender(
<SSR breakpoints={[300, 600, 900]}>
<RowsPhotoAlbum photos={photos.slice(0, 1)} />
</SSR>,
);
expect(getTracks().length).toBe(1);
});
});
1 change: 1 addition & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ export default defineConfig({
"client/columns": "src/client/columns/index.ts",
"client/masonry": "src/client/masonry/index.ts",
"client/aggregate": "src/client/aggregate/index.ts",
"client/ssr": "src/client/ssr/index.ts",
},
formats: ["es"],
},
Expand Down

0 comments on commit fbd8819

Please sign in to comment.