From fbd8819b28a0ce3e638d7ca5d0530692bce24ffb Mon Sep 17 00:00:00 2001 From: Igor Danchenko <64441155+igordanchenko@users.noreply.github.com> Date: Thu, 27 Jun 2024 12:08:39 -0400 Subject: [PATCH] feat: add experimental SSR component --- README.md | 5 +- docs/documentation.md | 68 +++++++++++++++++++++++++-- rollup.config.js | 1 + src/client/hooks/useContainerWidth.ts | 5 +- src/client/ssr/SSR.tsx | 59 +++++++++++++++++++++++ src/client/ssr/index.ts | 2 + src/core/static/StaticPhotoAlbum.tsx | 2 +- src/core/static/index.ts | 1 + src/index.ts | 6 +++ test/SSR.spec.tsx | 21 +++++++++ vite.config.ts | 1 + 11 files changed, 160 insertions(+), 11 deletions(-) create mode 100644 src/client/ssr/SSR.tsx create mode 100644 src/client/ssr/index.ts create mode 100644 test/SSR.spec.tsx diff --git a/README.md b/README.md index 1533fce..0e37645 100644 --- a/README.md +++ b/README.md @@ -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]() +documentation for a comprehensive list of available solutions. ## Credits diff --git a/docs/documentation.md b/docs/documentation.md index cb72311..951fb5c 100644 --- a/docs/documentation.md +++ b/docs/documentation.md @@ -687,8 +687,14 @@ 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 @@ -696,7 +702,8 @@ 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 @@ -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 +``. ```tsx ``` +### 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 + + 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 ( + + + + ); +} +``` + +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? diff --git a/rollup.config.js b/rollup.config.js index a61912e..e339539 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -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()], diff --git a/src/client/hooks/useContainerWidth.ts b/src/client/hooks/useContainerWidth.ts index 5373bb2..883dc02 100644 --- a/src/client/hooks/useContainerWidth.ts +++ b/src/client/hooks/useContainerWidth.ts @@ -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(); diff --git a/src/client/ssr/SSR.tsx b/src/client/ssr/SSR.tsx new file mode 100644 index 0000000..c54c32e --- /dev/null +++ b/src/client/ssr/SSR.tsx @@ -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>; +}; + +export default function SSR({ breakpoints, children }: SSRProps) { + const uid = `ssr-${useId().replace(/:/g, "")}`; + const { containerRef, containerWidth } = useContainerWidth(breakpoints); + const [hydratedBreakpoint, setHydratedBreakpoint] = useState(); + + 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 && ( + + )} + +
+ {allBreakpoints.map( + (breakpoint) => + (hydratedBreakpoint === undefined || hydratedBreakpoint === breakpoint) && ( +
+ {cloneElement(children, { breakpoints, defaultContainerWidth: breakpoint })} +
+ ), + )} +
+ + ); +} diff --git a/src/client/ssr/index.ts b/src/client/ssr/index.ts new file mode 100644 index 0000000..388ee75 --- /dev/null +++ b/src/client/ssr/index.ts @@ -0,0 +1,2 @@ +export * from "./SSR"; +export { default } from "./SSR"; diff --git a/src/core/static/StaticPhotoAlbum.tsx b/src/core/static/StaticPhotoAlbum.tsx index 4dd1882..c256eec 100644 --- a/src/core/static/StaticPhotoAlbum.tsx +++ b/src/core/static/StaticPhotoAlbum.tsx @@ -6,7 +6,7 @@ import PhotoComponent from "./PhotoComponent"; import { srcSetAndSizes, unwrap } from "../utils"; import { CommonPhotoAlbumProps, ComponentsProps, LayoutModel, Photo, Render } from "../../types"; -type StaticPhotoAlbumProps = Pick< +export type StaticPhotoAlbumProps = Pick< CommonPhotoAlbumProps, "sizes" | "onClick" | "skeleton" > & { diff --git a/src/core/static/index.ts b/src/core/static/index.ts index d6f2f88..3455ab4 100644 --- a/src/core/static/index.ts +++ b/src/core/static/index.ts @@ -1 +1,2 @@ +export * from "./StaticPhotoAlbum"; export { default } from "./StaticPhotoAlbum"; diff --git a/src/index.ts b/src/index.ts index 829d117..78a04bf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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"; diff --git a/test/SSR.spec.tsx b/test/SSR.spec.tsx new file mode 100644 index 0000000..eb56d5e --- /dev/null +++ b/test/SSR.spec.tsx @@ -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( + + + , + ); + expect(getTracks().length).toBe(0); + + rerender( + + + , + ); + expect(getTracks().length).toBe(1); + }); +}); diff --git a/vite.config.ts b/vite.config.ts index 0ef13f2..fd6ab85 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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"], },