Skip to content

Commit

Permalink
feat(react-18): Add streaming implementation
Browse files Browse the repository at this point in the history
  • Loading branch information
damassi committed Nov 24, 2024
1 parent fc1ab3a commit ea097ab
Show file tree
Hide file tree
Showing 11 changed files with 232 additions and 85 deletions.
18 changes: 11 additions & 7 deletions src/Components/FlashBanner/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ import { useRouter } from "System/Hooks/useRouter"
import { FlashBanner_me$data } from "__generated__/FlashBanner_me.graphql"

interface FlashBannerProps {
me?: FlashBanner_me$data
me?: FlashBanner_me$data | null
}

/**
Expand Down Expand Up @@ -80,10 +80,16 @@ export const FlashBannerFragmentContainer = createFragmentContainer(
}
)

export const FlashBannerQueryRenderer: FC<React.PropsWithChildren<unknown>> = () => {
export const FlashBannerQueryRenderer: FC<React.PropsWithChildren<
unknown
>> = () => {
const { user } = useSystemContext()

return user ? (
if (!user) {
return <FlashBannerFragmentContainer me={null} />
}

return (
<SystemQueryRenderer<FlashBannerQuery>
query={graphql`
query FlashBannerQuery {
Expand All @@ -95,17 +101,15 @@ export const FlashBannerQueryRenderer: FC<React.PropsWithChildren<unknown>> = ()
render={({ props, error }) => {
if (error) {
console.error(error)
return <FlashBannerFragmentContainer />
return <FlashBannerFragmentContainer me={null} />
}

if (!props?.me) {
return <FlashBannerFragmentContainer />
return <FlashBannerFragmentContainer me={null} />
}

return <FlashBannerFragmentContainer me={props.me} />
}}
/>
) : (
<FlashBannerFragmentContainer />
)
}
1 change: 1 addition & 0 deletions src/Server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ export const EDITORIAL_PATHS: any =
export const ENABLE_CONVERSATIONS_MESSAGES_AUTO_REFRESH: any = true
export const ENABLE_NEW_AUCTIONS_FILTER: any = false
export const ENABLE_QUERY_BATCHING: any = false
export const ENABLE_SSR_STREAMING: any = false
export const ENABLE_WEB_CRAWLING: any = false
export const ENABLE_WEB_VITALS_LOGGING: any = false
export const GRAPHQL_CACHE_TTL: any = 1000000000
Expand Down
1 change: 1 addition & 0 deletions src/Server/setup_sharify.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ sharify.data = _.extend(
"ENABLE_CONVERSATIONS_MESSAGES_AUTO_REFRESH",
"ENABLE_NEW_AUCTIONS_FILTER",
"ENABLE_QUERY_BATCHING",
"ENABLE_SSR_STREAMING",
"ENABLE_WEB_CRAWLING",
"ENABLE_WEB_VITALS_LOGGING",
"ERROR",
Expand Down
24 changes: 10 additions & 14 deletions src/System/Boot.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { Theme, injectGlobalStyles, ToastsProvider } from "@artsy/palette"
import { RouteProps } from "System/Router/Route"
import { FC, useEffect } from "react"
import * as React from "react"
import { FC, Suspense, useEffect } from "react"
import { HeadProvider } from "react-head"
import { Environment, RelayEnvironmentProvider } from "react-relay"
import Events from "Utils/Events"
Expand Down Expand Up @@ -35,12 +34,11 @@ export interface BootProps extends React.PropsWithChildren {

const { GlobalStyles } = injectGlobalStyles()

export const Boot: React.FC<React.PropsWithChildren<React.PropsWithChildren<BootProps>>> = track(
undefined,
{
dispatch: Events.postEvent,
}
)((props: BootProps) => {
export const Boot: React.FC<React.PropsWithChildren<
React.PropsWithChildren<BootProps>
>> = track(undefined, {
dispatch: Events.postEvent,
})((props: BootProps) => {
/**
* Let our end-to-end tests know that the app is hydrated and ready to go; and
* if in prod, initialize Sentry.
Expand Down Expand Up @@ -82,8 +80,7 @@ export const Boot: React.FC<React.PropsWithChildren<React.PropsWithChildren<Boot
>
<CookieConsentManager>
<SiftContainer />

{children}
<Suspense fallback={null}>{children}</Suspense>
</CookieConsentManager>
</DismissibleProvider>
</AuthDialogProvider>
Expand All @@ -100,10 +97,9 @@ export const Boot: React.FC<React.PropsWithChildren<React.PropsWithChildren<Boot
)
})

const EnvironmentProvider: FC<React.PropsWithChildren<{ environment: Environment }>> = ({
children,
environment,
}) => {
const EnvironmentProvider: FC<React.PropsWithChildren<{
environment: Environment
}>> = ({ children, environment }) => {
if (process.env.NODE_ENV === "test") return <>{children}</>

return (
Expand Down
124 changes: 73 additions & 51 deletions src/System/Router/Utils/collectAssets.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,9 @@ import RelayServerSSR, {
SSRCache,
} from "react-relay-network-modern-ssr/lib/server"
import { RelayNetworkLayerResponse } from "react-relay-network-modern"
import { ENABLE_SSR_STREAMING } from "Server/config"
import { renderToStream } from "System/Router/Utils/renderToStream"
import { ArtsyResponse } from "Server/middleware/artsyExpress"

const STATS = "loadable-stats.json"

Expand All @@ -19,11 +22,13 @@ const ASSET_PATH = "/assets"
interface CollectAssetsProps {
ServerRouter: React.FC<React.PropsWithChildren<unknown>>
relayEnvironment: Environment
res: ArtsyResponse
}

export const collectAssets = async ({
ServerRouter,
relayEnvironment,
res,
}: CollectAssetsProps) => {
/**
* This is the stats file generated by Force's webpack setup, via
Expand Down Expand Up @@ -52,71 +57,88 @@ export const collectAssets = async ({

const jsx = extractor.collectChunks(sheet.collectStyles(<ServerRouter />))

const html = renderToString(jsx)
const styleTags = sheet.getStyleTags()
let stream
let html
let styleTags

if (ENABLE_SSR_STREAMING) {
stream = renderToStream(jsx, sheet, res)
} else {
html = renderToString(jsx)
styleTags = sheet.getStyleTags()
}

const relaySSRMiddleware = (relayEnvironment as any)
.relaySSRMiddleware as RelayServerSSR

const initialRelayData = await relaySSRMiddleware.getCache()

const initialScripts: string[] = []

const bundleScriptTags = extractor.getScriptTags()

initialScripts.push(
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,
`src="${assetPublicPath}`
)
return scriptTagWithCDN
} else {
return script
}
})
.filter(script => {
return !(
/**
* Extract scripts from loadable components
* NOTE: When streaming is enabled we need to lazily execute script extraction
*/
const extractScriptTags = () => {
const initialScripts: string[] = []

const bundleScriptTags = extractor.getScriptTags()

initialScripts.push(
bundleScriptTags
.split("\n")
.map(script => {
/**
* Since these files are already embedded in our main
* layout file, omit them from the scripts array.
*
* TODO: Don't include these files in the main layout
* and instead relay on dynamically including them here.
* Will require some shuffling in the jade template,
* however.
* In production, prefix injected script src with CDN endpoint.
* @see https://github.com/artsy/force/blob/main/src/lib/middleware/asset.ts#L23
*/
(
script.includes("/assets/runtime.") ||
script.includes("/assets/artsy.") ||
script.includes("/assets/common-artsy.") ||
script.includes("/assets/common-react.") ||
script.includes("/assets/common-utility.") ||
script.includes("/assets/common.")
if (getENV("CDN_URL")) {
const scriptTagWithCDN = script.replace(
/src="\/assets/g,
`src="${assetPublicPath}`
)
return scriptTagWithCDN
} else {
return script
}
})
.filter(script => {
return !(
/**
* Since these files are already embedded in our main
* layout file, omit them from the scripts array.
*
* TODO: Don't include these files in the main layout
* and instead relay on dynamically including them here.
* Will require some shuffling in the jade template,
* however.
*/
(
script.includes("/assets/runtime.") ||
script.includes("/assets/artsy.") ||
script.includes("/assets/common-artsy.") ||
script.includes("/assets/common-react.") ||
script.includes("/assets/common-utility.") ||
script.includes("/assets/common.")
)
)
)
})
.join("\n")
)
})
.join("\n")
)

initialScripts.push(`
<script>
var __RELAY_BOOTSTRAP__ = ${serializeRelayData(initialRelayData)};
</script>
`)
initialScripts.push(`
<script>
var __RELAY_BOOTSTRAP__ = ${serializeRelayData(initialRelayData)};
</script>
`)

const scripts = initialScripts.join("\n")
const scripts = initialScripts.join("\n")

return scripts
}

return {
html,
scripts,
extractScriptTags,
stream,
styleTags,
}
}
Expand Down
74 changes: 74 additions & 0 deletions src/System/Router/Utils/renderToStream.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { renderToPipeableStream } from "react-dom/server"
import { ArtsyResponse } from "Server/middleware/artsyExpress"
import { Transform } from "stream"

const STREAM_TIMEOUT = 5000

export function renderToStream(jsx, sheet, res: ArtsyResponse) {
let didError = false

const decoder = new TextDecoder("utf-8")

const stream = new Transform({
objectMode: true,
transform(chunk, encoding, callback) {
const renderedHtml =
chunk instanceof Uint8Array
? decoder.decode(chunk, { stream: true })
: chunk.toString(encoding || "utf8")

const styledCSS = sheet._emitSheetCSS()
const CLOSING_TAG_R = /<\/[a-z]*>/i

sheet.instance.clearTag()

// Inject CSS into HTML
if (/<\/head>/.test(renderedHtml)) {
const replacedHtml = renderedHtml.replace(
"</head>",
`${styledCSS}</head>`
)
this.push(replacedHtml)
} else if (CLOSING_TAG_R.test(renderedHtml)) {
const execResult = CLOSING_TAG_R.exec(renderedHtml) as RegExpExecArray
const endOfClosingTag = execResult.index + execResult[0].length
const before = renderedHtml.slice(0, endOfClosingTag)
const after = renderedHtml.slice(endOfClosingTag)

this.push(before + styledCSS + after)
} else {
this.push(styledCSS + renderedHtml)
}

callback()
},
})

let streamTimeout: NodeJS.Timeout

const { pipe, abort } = renderToPipeableStream(jsx, {
onError: error => {
didError = true
console.error("error", error)
},
onShellError: error => {
didError = true
console.log("shell error", error)
},
onShellReady: () => {
res.statusCode = didError ? 500 : 200
res.setHeader("Content-Type", "text/html; charset=utf-8")
pipe(stream)
},
onAllReady: () => {
clearTimeout(streamTimeout)
},
})

// Abandon and switch to client rendering if enough time passes.
streamTimeout = setTimeout(() => {
abort()
}, STREAM_TIMEOUT)

return stream
}
18 changes: 12 additions & 6 deletions src/System/Router/__tests__/serverRouter.jest.enzyme.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,12 @@ jest.mock("@loadable/server", () => ({

jest.mock("react-tracking")

jest.mock("Server/config", () => {
return {
ENABLE_SSR_STREAMING: false,
}
})

const defaultComponent = () => <div>hi!</div>

describe("serverRouter", () => {
Expand Down Expand Up @@ -127,21 +133,21 @@ describe("serverRouter", () => {
})

it("bootstraps relay SSR data", async () => {
const { scripts } = await getWrapper()
expect(scripts).toContain("__RELAY_BOOTSTRAP__")
const { extractScriptTags } = await getWrapper()
expect(extractScriptTags?.()).toContain("__RELAY_BOOTSTRAP__")
})

it("does not prefix CDN_URL if not available", async () => {
const postScripts = `<script src="/assets/foo.js"></script> <script src="/assets/bar.js"></script>`
const { scripts } = await getWrapper()
expect(scripts).toContain(postScripts)
const { extractScriptTags } = await getWrapper()
expect(extractScriptTags?.()).toContain(postScripts)
})

it("prefixes CDN_URL to script tags if available", async () => {
process.env.CDN_URL = CDN_URL
const postScripts = `<script src="${CDN_URL}/assets/foo.js"></script> <script src="${CDN_URL}/assets/bar.js"></script>`
const { scripts } = await getWrapper()
expect(scripts).toContain(postScripts)
const { extractScriptTags } = await getWrapper()
expect(extractScriptTags?.()).toContain(postScripts)
})
})

Expand Down
Loading

0 comments on commit ea097ab

Please sign in to comment.