diff --git a/src/System/Router/Utils/__tests__/renderToStream.jest.tsx b/src/System/Router/Utils/__tests__/renderToStream.jest.tsx new file mode 100644 index 00000000000..c9e69618f5d --- /dev/null +++ b/src/System/Router/Utils/__tests__/renderToStream.jest.tsx @@ -0,0 +1,127 @@ +import { flushPromiseQueue } from "DevTools/flushPromiseQueue" +import { renderToPipeableStream } from "react-dom/server" +import { ArtsyResponse } from "Server/middleware/artsyExpress" +import { ServerStyleSheet } from "styled-components" +import { renderToStream } from "System/Router/Utils/renderToStream" + +jest.mock("react-dom/server", () => ({ + renderToPipeableStream: jest.fn(), +})) + +describe("renderToStream", () => { + const mockRenderToPipeableStream = renderToPipeableStream as jest.Mock + + const res = ({ + statusCode: 0, + setHeader: jest.fn(), + } as unknown) as ArtsyResponse + + const sheet = ({ + _emitSheetCSS: jest.fn(() => "mock-css"), + instance: { + clearTag: jest.fn(), + }, + } as unknown) as ServerStyleSheet + + afterEach(() => { + jest.clearAllMocks() + }) + + it("should set the response status and content type on shell ready", async () => { + const mockPipe = jest.fn() + const mockAbort = jest.fn() + + mockRenderToPipeableStream.mockImplementation((_, options) => { + setTimeout(() => { + options.onShellReady() + }) + + return { pipe: mockPipe, abort: mockAbort } + }) + + const jsx =
Hello World
+ renderToStream({ jsx, sheet, res }) + + await flushPromiseQueue() + + expect(res.statusCode).toBe(200) + expect(res.setHeader).toHaveBeenCalledWith( + "Content-Type", + "text/html; charset=utf-8" + ) + expect(mockPipe).toHaveBeenCalled() + }) + + it("should handle onError and set didError to true", async () => { + const mockPipe = jest.fn() + const mockAbort = jest.fn() + + mockRenderToPipeableStream.mockImplementation((_, options) => { + options.onError(new Error("Test error")) + + setTimeout(() => { + options.onShellReady() + }) + + return { pipe: mockPipe, abort: mockAbort } + }) + + const jsx =
Hello Error
+ renderToStream({ jsx, sheet, res }) + + await flushPromiseQueue() + + expect(res.statusCode).toBe(500) + expect(mockPipe).toHaveBeenCalled() + }) + + it("should call abort if STREAM_TIMEOUT is reached", () => { + jest.useFakeTimers() + const mockAbort = jest.fn() + + mockRenderToPipeableStream.mockImplementation((_, options) => { + return { pipe: jest.fn(), abort: mockAbort } + }) + + const jsx =
Timeout Test
+ renderToStream({ jsx, sheet, res }) + + jest.advanceTimersByTime(5000) + + expect(mockAbort).toHaveBeenCalled() + jest.useRealTimers() + }) + + // eslint-disable-next-line jest/no-done-callback + it("should transform the stream and inject CSS into the HTML", done => { + const mockPipe = jest.fn() + const mockAbort = jest.fn() + + mockRenderToPipeableStream.mockImplementation((_, options) => { + setTimeout(() => { + options.onShellReady() + }) + return { pipe: mockPipe, abort: mockAbort } + }) + + const jsx =
Stream Test
+ const stream = renderToStream({ jsx, sheet, res }) + + stream.write("") + + const chunks: string[] = [] + stream.on("data", chunk => { + chunks.push(chunk) + }) + + stream.on("end", () => { + const result = chunks.join("") + expect(result).toContain( + "mock-css" + ) + done() + }) + + stream.end() + }) +}) diff --git a/src/System/Router/Utils/collectAssets.tsx b/src/System/Router/Utils/collectAssets.tsx index 54ff430f234..4c489e85290 100644 --- a/src/System/Router/Utils/collectAssets.tsx +++ b/src/System/Router/Utils/collectAssets.tsx @@ -9,6 +9,7 @@ import { ENABLE_SSR_STREAMING } from "Server/config" import { renderToStream } from "System/Router/Utils/renderToStream" import { ArtsyResponse } from "Server/middleware/artsyExpress" import { serializeRelayHydrationData } from "System/Router/Utils/serializeRelayHydrationData" +import { Transform } from "stream" const STATS = "loadable-stats.json" @@ -59,7 +60,7 @@ export const collectAssets = async ({ let styleTags if (ENABLE_SSR_STREAMING) { - stream = renderToStream(jsx, sheet, res) + stream = renderToStream({ jsx, sheet, res }) } else { html = renderToString(jsx) styleTags = sheet.getStyleTags() @@ -79,10 +80,6 @@ export const collectAssets = async ({ bundleScriptTags .split("\n") .map(script => { - /** - * In production, prefix injected script src with CDN endpoint. - * @see https://github.com/artsy/force/blob/main/src/lib/middleware/asset.ts#L23 - */ if (getENV("CDN_URL")) { const scriptTagWithCDN = script.replace( /src="\/assets/g, diff --git a/src/System/Router/Utils/renderToStream.tsx b/src/System/Router/Utils/renderToStream.tsx index 680ca79f26c..7337adb5daf 100644 --- a/src/System/Router/Utils/renderToStream.tsx +++ b/src/System/Router/Utils/renderToStream.tsx @@ -1,10 +1,22 @@ +import { ReactNode } from "react" import { renderToPipeableStream } from "react-dom/server" import { ArtsyResponse } from "Server/middleware/artsyExpress" import { Transform } from "stream" +import { ServerStyleSheet } from "styled-components" const STREAM_TIMEOUT = 5000 -export function renderToStream(jsx, sheet, res: ArtsyResponse) { +interface RenderToStreamProps { + jsx: ReactNode + sheet: ServerStyleSheet + res: ArtsyResponse +} + +export const renderToStream = ({ + jsx, + sheet, + res, +}: RenderToStreamProps): Transform => { let didError = false const decoder = new TextDecoder("utf-8") @@ -49,11 +61,11 @@ export function renderToStream(jsx, sheet, res: ArtsyResponse) { const { pipe, abort } = renderToPipeableStream(jsx, { onError: error => { didError = true - console.error("error", error) + console.error("[renderToStream] onError:", error) }, onShellError: error => { didError = true - console.log("shell error", error) + console.error("[renderToStream] onShellError:", error) }, onShellReady: () => { res.statusCode = didError ? 500 : 200 diff --git a/src/server.ts b/src/server.ts index 4710d4007ec..2c7bbde70d4 100644 --- a/src/server.ts +++ b/src/server.ts @@ -24,9 +24,7 @@ initializeMiddleware(app) const { routes, routePaths } = getRoutes() -/** - * Mount routes that will connect to global SSR router - */ +// React app routes app.get( routePaths, async (req: ArtsyRequest, res: ArtsyResponse, next: NextFunction) => { @@ -51,10 +49,7 @@ app.get( } ) -/** - * Mount server-side Express routes - */ - +// Common express routes app .use(appPreferencesServerRoutes) .use(cookieConsentManagerServerRoutes)