diff --git a/packages/vite/ambient.d.ts b/packages/vite/ambient.d.ts
index 64c58d8e3a00..da81612adf7a 100644
--- a/packages/vite/ambient.d.ts
+++ b/packages/vite/ambient.d.ts
@@ -1,16 +1,29 @@
/* eslint-disable no-var */
///
+import type { HelmetServerState } from 'react-helmet-async'
declare global {
+ // Provided by Vite.config, or Webpack in the user's project
+ // but "regsitered" in packages/vite/src/streaming/registerGlobals.ts
+ // for it to be available to framework code
var RWJS_ENV: {
- RWJS_API_GRAPHQL_URL?: string
+ RWJS_API_GRAPHQL_URL: string
/** URL or absolute path to serverless functions */
- RWJS_API_URL?: string
+ RWJS_API_URL: string
+ RWJS_EXP_STREAMING_SSR: boolean
+ RWJS_EXP_RSC: boolean
- __REDWOOD__APP_TITLE?: string
+ __REDWOOD__APP_TITLE: string
+ }
+
+ var RWJS_DEBUG_ENV: {
+ RWJS_SRC_ROOT: string
+ REDWOOD_ENV_EDITOR: string
}
var __REDWOOD__PRERENDER_PAGES: any
+
+ var __REDWOOD__HELMET_CONTEXT: { helmet?: HelmetServerState }
}
export {}
diff --git a/packages/vite/bins/rw-vite-build.mjs b/packages/vite/bins/rw-vite-build.mjs
index 158e57b75905..e3aabaf7773e 100755
--- a/packages/vite/bins/rw-vite-build.mjs
+++ b/packages/vite/bins/rw-vite-build.mjs
@@ -41,13 +41,14 @@ const buildWebSide = async (webDir) => {
throw new Error('Could not locate your web/vite.config.{js,ts} file')
}
- // @NOTE: necessary for keeping the cwd correct for postcss/tailwind
- process.chdir(webDir)
process.env.NODE_ENV = 'production'
if (getConfig().experimental?.streamingSsr?.enabled) {
- await buildFeServer({ verbose })
+ // Webdir checks handled in the rwjs/vite package in new build system
+ await buildFeServer({ verbose, webDir })
} else {
+ // Ensure cwd to be web: required for postcss/tailwind to work correctly
+ process.chdir(webDir)
// Right now, the buildWeb function looks up the config file from project-config
// In the future, if we have multiple web spaces we could pass in the cwd here
buildWeb({ verbose })
diff --git a/packages/vite/src/buildFeServer.ts b/packages/vite/src/buildFeServer.ts
index 2e070f0bdcd5..2cfaca286e94 100644
--- a/packages/vite/src/buildFeServer.ts
+++ b/packages/vite/src/buildFeServer.ts
@@ -13,12 +13,16 @@ import { getAppRouteHook, getConfig, getPaths } from '@redwoodjs/project-config'
import { buildRscFeServer } from './buildRscFeServer'
import { RWRouteManifest } from './types'
+import { ensureProcessDirWeb } from './utils'
export interface BuildOptions {
verbose?: boolean
+ webDir?: string
}
-export const buildFeServer = async ({ verbose }: BuildOptions) => {
+export const buildFeServer = async ({ verbose, webDir }: BuildOptions = {}) => {
+ ensureProcessDirWeb(webDir)
+
const rwPaths = getPaths()
const rwConfig = getConfig()
const viteConfigPath = rwPaths.web.viteConfig
@@ -143,7 +147,7 @@ export const buildFeServer = async ({ verbose }: BuildOptions) => {
acc[route.path] = {
name: route.name,
bundle: route.relativeFilePath
- ? clientBuildManifest[route.relativeFilePath].file
+ ? clientBuildManifest[route.relativeFilePath]?.file
: null,
matchRegexString: route.matchRegexString,
// @NOTE this is the path definition, not the actual path
diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts
index 9adc2519d8e4..462ff2a1b9d0 100644
--- a/packages/vite/src/devFeServer.ts
+++ b/packages/vite/src/devFeServer.ts
@@ -1,8 +1,6 @@
// TODO (STREAMING) Merge with runFeServer so we only have one file
-import path from 'path'
import express from 'express'
-import { renderToPipeableStream } from 'react-dom/server'
import { createServer as createViteServer } from 'vite'
import { getProjectRoutes } from '@redwoodjs/internal/dist/routes'
@@ -10,29 +8,18 @@ import { getAppRouteHook, getConfig, getPaths } from '@redwoodjs/project-config'
import { matchPath } from '@redwoodjs/router'
import type { TagDescriptor } from '@redwoodjs/web'
-import { loadAndRunRouteHooks } from './triggerRouteHooks'
-import { stripQueryStringAndHashFromPath } from './utils'
-
-// These values are defined in the vite.config.ts
-globalThis.RWJS_ENV = {}
+import { registerFwGlobals } from './streaming/registerGlobals'
+import { reactRenderToStream } from './streaming/streamHelpers'
+import { loadAndRunRouteHooks } from './streaming/triggerRouteHooks'
+import { ensureProcessDirWeb, stripQueryStringAndHashFromPath } from './utils'
// TODO (STREAMING) Just so it doesn't error out. Not sure how to handle this.
globalThis.__REDWOOD__PRERENDER_PAGES = {}
async function createServer() {
- // Check CWD: make sure its the web/ directory
- // Without this Postcss can misbehave, and its hard to trace why.
- if (process.cwd() !== getPaths().web.base) {
- console.error('⚠️ Warning: CWD is ', process.cwd())
- console.warn('~'.repeat(50))
- console.warn(
- 'The FE dev server cwd must be web/. Please use `yarn rw dev` or start the server from the web/ directory.'
- )
- console.log(`Changing cwd to ${getPaths().web.base}....`)
- console.log()
+ ensureProcessDirWeb()
- process.chdir(getPaths().web.base)
- }
+ registerFwGlobals()
const app = express()
const rwPaths = getPaths()
@@ -62,6 +49,7 @@ async function createServer() {
app.use('*', async (req, res, next) => {
const currentPathName = stripQueryStringAndHashFromPath(req.originalUrl)
+ globalThis.__REDWOOD__HELMET_CONTEXT = {}
try {
const routes = getProjectRoutes()
@@ -120,46 +108,17 @@ async function createServer() {
// required, and provides efficient invalidation similar to HMR.
const { ServerEntry } = await vite.ssrLoadModule(rwPaths.web.entryServer)
- // TODO (STREAMING) CSS is handled by Vite in dev mode, we don't need to
- // worry about it in dev but..... it causes a flash of unstyled content.
- // For now I'm just injecting index css here
- // We believe we saw a fix for this somewhere in the Waku sources. Maybe
- // it was called something like "Capture Css". And it's also mentioned
- // in the Vite issues on GitHub
- const FIXME_HardcodedIndexCss = ['index.css']
-
- const assetMap = JSON.stringify({
- css: FIXME_HardcodedIndexCss,
- meta: metaTags,
- })
-
- const bootstrapModules = [
- path.join(__dirname, '../inject', 'reactRefresh.js'),
- ]
-
const pageWithJs = currentRoute?.renderMode !== 'html'
- if (pageWithJs) {
- bootstrapModules.push(rwPaths.web.entryClient)
- }
+ res.setHeader('content-type', 'text/html; charset=utf-8')
- const { pipe } = renderToPipeableStream(
- ServerEntry({
- url: currentPathName,
- css: FIXME_HardcodedIndexCss,
- meta: metaTags,
- }),
- {
- bootstrapScriptContent: pageWithJs
- ? `window.__assetMap = function() { return ${assetMap} }`
- : undefined,
- bootstrapModules,
- onShellReady() {
- res.setHeader('content-type', 'text/html; charset=utf-8')
- pipe(res)
- },
- }
- )
+ reactRenderToStream({
+ ServerEntry,
+ currentPathName,
+ metaTags,
+ includeJs: pageWithJs,
+ res,
+ })
} catch (e) {
// TODO (STREAMING) Is this what we want to do?
// send back a SPA page
diff --git a/packages/vite/src/runFeServer.ts b/packages/vite/src/runFeServer.ts
index 1bad75e2f16c..3170de8eb25b 100644
--- a/packages/vite/src/runFeServer.ts
+++ b/packages/vite/src/runFeServer.ts
@@ -18,12 +18,11 @@ import { getConfig, getPaths } from '@redwoodjs/project-config'
import { matchPath } from '@redwoodjs/router'
import type { TagDescriptor } from '@redwoodjs/web'
-import { loadAndRunRouteHooks } from './triggerRouteHooks'
+import { registerFwGlobals } from './streaming/registerGlobals'
+import { loadAndRunRouteHooks } from './streaming/triggerRouteHooks'
import { RWRouteManifest } from './types'
import { stripQueryStringAndHashFromPath } from './utils'
-globalThis.RWJS_ENV = {}
-
/**
* TODO (STREAMING)
* We have this server in the vite package only temporarily.
@@ -50,6 +49,8 @@ export async function runFeServer() {
const rwPaths = getPaths()
const rwConfig = getConfig()
+ registerFwGlobals()
+
// TODO When https://github.com/tc39/proposal-import-attributes and
// https://github.com/microsoft/TypeScript/issues/53656 have both landed we
// should try to do this instead:
@@ -211,15 +212,16 @@ export async function runFeServer() {
const pageWithJs = currentRoute.renderMode !== 'html'
// @NOTE have to add slash so subpaths still pick up the right file
- // Vite is currently producing modules not scripts: https://vitejs.dev/config/build-options.html#build-target
const bootstrapModules = pageWithJs
- ? ['/' + indexEntry.file, '/' + currentRoute.bundle]
+ ? ([
+ '/' + indexEntry.file,
+ currentRoute.bundle && '/' + currentRoute.bundle,
+ ].filter(Boolean) as string[])
: undefined
const isSeoCrawler = checkUaForSeoCrawler(req.get('user-agent'))
const { pipe, abort } = renderToPipeableStream(
- // we should use the same shape as Remix or Next for the meta object
ServerEntry({
url: currentPathName,
css: indexEntry.css,
diff --git a/packages/vite/src/runRscFeServer.ts b/packages/vite/src/runRscFeServer.ts
index 1b62b7533996..af4cde8bb392 100644
--- a/packages/vite/src/runRscFeServer.ts
+++ b/packages/vite/src/runRscFeServer.ts
@@ -15,10 +15,9 @@ import type { Manifest as ViteBuildManifest } from 'vite'
import { getConfig, getPaths } from '@redwoodjs/project-config'
+import { registerFwGlobals } from './streaming/registerGlobals'
import { renderRSC, setClientEntries } from './waku-lib/rsc-handler-worker'
-globalThis.RWJS_ENV = {}
-
/**
* TODO (STREAMING)
* We have this server in the vite package only temporarily.
@@ -45,6 +44,8 @@ export async function runFeServer() {
const rwPaths = getPaths()
const rwConfig = getConfig()
+ registerFwGlobals()
+
await setClientEntries('load')
// TODO When https://github.com/tc39/proposal-import-attributes and
diff --git a/packages/vite/src/streaming/registerGlobals.ts b/packages/vite/src/streaming/registerGlobals.ts
new file mode 100644
index 000000000000..fd21a2855dc5
--- /dev/null
+++ b/packages/vite/src/streaming/registerGlobals.ts
@@ -0,0 +1,34 @@
+import path from 'node:path'
+
+import { getConfig, getPaths } from '@redwoodjs/project-config'
+
+/**
+ * Use this function on the web server
+ *
+ * Because although this is defined in Vite/index.ts
+ * They are only available in the user's code (and not in FW code)
+ * because define STATICALLY replaces it in user's code, not in node_modules
+ *
+ * It's still available on the client side though, probably because its processed by Vite
+ */
+export const registerFwGlobals = () => {
+ const rwConfig = getConfig()
+ const rwPaths = getPaths()
+
+ globalThis.RWJS_ENV = {
+ // @NOTE we're avoiding process.env here, unlike webpack
+ RWJS_API_GRAPHQL_URL:
+ rwConfig.web.apiGraphQLUrl ?? rwConfig.web.apiUrl + '/graphql',
+ RWJS_API_URL: rwConfig.web.apiUrl,
+ __REDWOOD__APP_TITLE: rwConfig.web.title || path.basename(rwPaths.base),
+ RWJS_EXP_STREAMING_SSR:
+ rwConfig.experimental.streamingSsr &&
+ rwConfig.experimental.streamingSsr.enabled,
+ RWJS_EXP_RSC: rwConfig.experimental?.rsc?.enabled,
+ }
+
+ globalThis.RWJS_DEBUG_ENV = {
+ RWJS_SRC_ROOT: rwPaths.web.src,
+ REDWOOD_ENV_EDITOR: JSON.stringify(process.env.REDWOOD_ENV_EDITOR),
+ }
+}
diff --git a/packages/vite/src/streaming/streamHelpers.ts b/packages/vite/src/streaming/streamHelpers.ts
new file mode 100644
index 000000000000..bed2ad5bccf0
--- /dev/null
+++ b/packages/vite/src/streaming/streamHelpers.ts
@@ -0,0 +1,146 @@
+import path from 'node:path'
+import { Writable } from 'node:stream'
+
+import React from 'react'
+
+import { renderToPipeableStream, renderToString } from 'react-dom/server'
+
+import { getPaths } from '@redwoodjs/project-config'
+import type { TagDescriptor } from '@redwoodjs/web'
+// @TODO (ESM), use exports field. Cannot import from web because of index exports
+import {
+ ServerHtmlProvider,
+ ServerInjectedHtml,
+ createInjector,
+ RenderCallback,
+} from '@redwoodjs/web/dist/components/ServerInject'
+
+interface RenderToStreamArgs {
+ ServerEntry: any
+ currentPathName: string
+ metaTags: TagDescriptor[]
+ includeJs: boolean
+ res: Writable
+}
+
+export function reactRenderToStream({
+ ServerEntry,
+ currentPathName,
+ metaTags,
+ includeJs,
+ res,
+}: RenderToStreamArgs) {
+ const rwPaths = getPaths()
+
+ const bootstrapModules = [
+ path.join(__dirname, '../../inject', 'reactRefresh.js'),
+ ]
+
+ if (includeJs) {
+ // type casting: guaranteed to have entryClient by this stage, because checks run earlier
+ bootstrapModules.push(rwPaths.web.entryClient as string)
+ }
+
+ // TODO (STREAMING) CSS is handled by Vite in dev mode, we don't need to
+ // worry about it in dev but..... it causes a flash of unstyled content.
+ // For now I'm just injecting index css here
+ // Looks at collectStyles in packages/vite/src/fully-react/find-styles.ts
+ const FIXME_HardcodedIndexCss = ['index.css']
+
+ const assetMap = JSON.stringify({
+ css: FIXME_HardcodedIndexCss,
+ meta: metaTags,
+ })
+
+ // This ensures an isolated state for each request
+ const { injectionState, injectToPage } = createInjector()
+
+ // This is effectively a transformer stream
+ const intermediateStream = createServerInjectionStream({
+ outputStream: res,
+ onFinal: () => {
+ res.end()
+ },
+ injectionState,
+ })
+
+ const { pipe } = renderToPipeableStream(
+ React.createElement(
+ ServerHtmlProvider,
+ {
+ value: injectToPage,
+ },
+ ServerEntry({
+ url: currentPathName,
+ css: FIXME_HardcodedIndexCss,
+ meta: metaTags,
+ })
+ ),
+ {
+ bootstrapScriptContent: includeJs
+ ? `window.__REDWOOD__ASSET_MAP = ${assetMap}`
+ : undefined,
+ bootstrapModules,
+ onShellReady() {
+ // Pass the react "input" stream to the injection stream
+ // This intermediate stream will interweave the injected html into the react stream's
+ pipe(intermediateStream)
+ },
+ }
+ )
+}
+function createServerInjectionStream({
+ outputStream,
+ onFinal,
+ injectionState,
+}: {
+ outputStream: Writable
+ onFinal: () => void
+ injectionState: Set
+}) {
+ return new Writable({
+ write(chunk, encoding, next) {
+ const chunkAsString = chunk.toString()
+ const split = chunkAsString.split('')
+
+ // If the closing tag exists
+ if (split.length > 1) {
+ const [beforeClosingHead, afterClosingHead] = split
+
+ const elementsInjectedToHead = renderToString(
+ React.createElement(ServerInjectedHtml, {
+ injectionState,
+ })
+ )
+
+ const outputBuffer = Buffer.from(
+ [
+ beforeClosingHead,
+ elementsInjectedToHead,
+ '',
+ afterClosingHead,
+ ].join('')
+ )
+
+ outputStream.write(outputBuffer, encoding)
+ } else {
+ outputStream.write(chunk, encoding)
+ }
+
+ next()
+ },
+ final() {
+ // Before finishing, make sure we flush anything else that has been added to the queue
+ // Because of the implementation in ServerRenderHtml, its safe to call this multiple times (I think!)
+ // This is really for the data fetching usecase, where the promise is resolved after is closed
+ const elementsAtTheEnd = renderToString(
+ React.createElement(ServerInjectedHtml, {
+ injectionState,
+ })
+ )
+
+ outputStream.write(elementsAtTheEnd)
+ onFinal()
+ },
+ })
+}
diff --git a/packages/vite/src/triggerRouteHooks.ts b/packages/vite/src/streaming/triggerRouteHooks.ts
similarity index 81%
rename from packages/vite/src/triggerRouteHooks.ts
rename to packages/vite/src/streaming/triggerRouteHooks.ts
index 42d402e7e6a3..285bcd0c6cd3 100644
--- a/packages/vite/src/triggerRouteHooks.ts
+++ b/packages/vite/src/streaming/triggerRouteHooks.ts
@@ -80,39 +80,38 @@ export const loadAndRunRouteHooks = async ({
}
let currentRouteHooks: RouteHooks
-
+ let rhOutput: RouteHookOutput = defaultRouteHookOutput
// Pull out the first path
// Remember shift will mutate the array
const routeHookPath = paths.shift()
- if (!routeHookPath) {
- return defaultRouteHookOutput
- } else {
- try {
+ try {
+ // Sometimes the appRouteHook is null, so we can skip it
+ if (routeHookPath) {
currentRouteHooks = await loadModule(routeHookPath)
// Step 2, run the route hooks
- const rhOutput = await triggerRouteHooks({
+ rhOutput = await triggerRouteHooks({
routeHooks: currentRouteHooks,
req: reqMeta.req,
parsedParams: reqMeta.parsedParams,
previousOutput,
})
+ }
- if (paths.length > 0) {
- // Step 3, recursively call this function
- return loadAndRunRouteHooks({
- paths,
- reqMeta,
- previousOutput: rhOutput,
- viteDevServer,
- })
- } else {
- return rhOutput
- }
- } catch (e) {
- console.error(`Error loading route hooks in ${routeHookPath}}`)
- throw new Error(e as any)
+ if (paths.length > 0) {
+ // Step 3, recursively call this function
+ return loadAndRunRouteHooks({
+ paths,
+ reqMeta,
+ previousOutput: rhOutput,
+ viteDevServer,
+ })
+ } else {
+ return rhOutput
}
+ } catch (e) {
+ console.error(`Error loading route hooks in ${routeHookPath}}`)
+ throw e
}
}
diff --git a/packages/vite/src/utils.ts b/packages/vite/src/utils.ts
index 32da87601cca..e721c32177f3 100644
--- a/packages/vite/src/utils.ts
+++ b/packages/vite/src/utils.ts
@@ -1,3 +1,21 @@
+import { getPaths } from '@redwoodjs/project-config'
+
export function stripQueryStringAndHashFromPath(url: string) {
return url.split('?')[0].split('#')[0]
}
+
+// Check CWD: make sure its the web/ directory
+// Without this Postcss can misbehave, and its hard to trace why.
+export function ensureProcessDirWeb(webDir: string = getPaths().web.base) {
+ if (process.cwd() !== webDir) {
+ console.error('⚠️ Warning: CWD is ', process.cwd())
+ console.warn('~'.repeat(50))
+ console.warn(
+ 'The cwd must be web/. Please use `yarn rw ` or run the command from the web/ directory.'
+ )
+ console.log(`Changing cwd to ${webDir}....`)
+ console.log()
+
+ process.chdir(webDir)
+ }
+}
diff --git a/packages/web/ambient.d.ts b/packages/web/ambient.d.ts
index e452d2016432..151c7972b58a 100644
--- a/packages/web/ambient.d.ts
+++ b/packages/web/ambient.d.ts
@@ -8,6 +8,11 @@ declare global {
var __REDWOOD__APP_TITLE: string
var __REDWOOD__APOLLO_STATE: NormalizedCacheObject
+ var __REDWOOD__ASSET_MAP: {
+ css?: string[]
+ meta?: TagDescriptor[]
+ }
+
// Provided by Vite.config, or Webpack in the user's project
var RWJS_ENV: {
RWJS_API_GRAPHQL_URL: string
diff --git a/packages/web/package.json b/packages/web/package.json
index 4d3a24b5775a..8c68dc235408 100644
--- a/packages/web/package.json
+++ b/packages/web/package.json
@@ -31,7 +31,7 @@
"build:types": "tsc --build --verbose",
"build:watch": "nodemon --watch src --ext \"js,jsx,ts,tsx\" --ignore dist --exec \"yarn build\"",
"prepublishOnly": "NODE_ENV=production yarn build",
- "test": "jest src",
+ "test": "jest",
"test:watch": "yarn test --watch"
},
"dependencies": {
diff --git a/packages/web/src/components/MetaTags.tsx b/packages/web/src/components/MetaTags.tsx
index 0f207a0f4dac..1b1c5ebd492f 100644
--- a/packages/web/src/components/MetaTags.tsx
+++ b/packages/web/src/components/MetaTags.tsx
@@ -1,4 +1,8 @@
-import { Head } from '../index'
+import { Head as HelmetHead } from '../index'
+
+// Ideally we wouldn't include this for non experiment builds
+// But.... not worth the effort to remove it from bundle atm
+import PortalHead from './PortalHead'
type RobotsParams =
| 'noindex'
@@ -78,6 +82,13 @@ export const MetaTags = (props: MetaTagsProps) => {
author,
children,
} = props
+
+ let Head: typeof HelmetHead | typeof PortalHead = HelmetHead
+
+ if (RWJS_ENV.RWJS_EXP_STREAMING_SSR) {
+ Head = PortalHead
+ }
+
return (
<>
{title && (
diff --git a/packages/web/src/components/PortalHead.tsx b/packages/web/src/components/PortalHead.tsx
new file mode 100644
index 000000000000..829c1ccc5687
--- /dev/null
+++ b/packages/web/src/components/PortalHead.tsx
@@ -0,0 +1,20 @@
+import { createPortal } from 'react-dom'
+
+import { useServerInsertedHTML } from './ServerInject'
+
+const PortalHead: React.FC = ({ children }) => {
+ useServerInsertedHTML(() => {
+ // @TODO this component should be wrapped in: document.head.append()
+ // because its possible for meta tags to be rendered after is closed
+ return children
+ })
+
+ if (typeof window === 'undefined') {
+ // Don't do anything on the server, handled by above callback
+ return null
+ } else {
+ return createPortal(<>{children}>, document.head)
+ }
+}
+
+export default PortalHead
diff --git a/packages/web/src/components/RedwoodProvider.tsx b/packages/web/src/components/RedwoodProvider.tsx
index bc84deeb17af..64a4a3308164 100644
--- a/packages/web/src/components/RedwoodProvider.tsx
+++ b/packages/web/src/components/RedwoodProvider.tsx
@@ -18,12 +18,9 @@ export const RedwoodProvider = ({
}
return ''
}
- // eslint-disable-next-line @typescript-eslint/no-unused-vars
- // TODO (STREAMING) need to disable helmet here, because it clashes with meta
- // routeHooks but can still leave helmet provider to make it easy to migrate
- // to new setup
- // TODO (STREAMING) Figure out how the Helmet stuff can live along side
- // streaming while streaming is still experimental
+
+ // @TODO (STREAMING): We can remove Helmet, HelmetProvider
+ // Once we've migrated to using the new PortalHead component
return (
diff --git a/packages/web/src/components/ServerInject.tsx b/packages/web/src/components/ServerInject.tsx
new file mode 100644
index 000000000000..1e1819243dfd
--- /dev/null
+++ b/packages/web/src/components/ServerInject.tsx
@@ -0,0 +1,82 @@
+import React, { Fragment, ReactNode, useContext, useId } from 'react'
+
+/**
+ *
+ * Inspired by Next's useServerInsertedHTML, originally designed for CSS-in-JS
+ * for now it seems the only way to inject html with streaming is to use a context
+ *
+ * We use this for tags, and for apollo cache hydration
+ *
+ * Until https://github.com/reactjs/rfcs/pull/219 makes it into react
+ *
+ */
+
+export type RenderCallback = () => ReactNode
+
+export const ServerHtmlContext = React.createContext<
+ ((things: RenderCallback) => void) | null
+>(null)
+
+/**
+ *
+ * Use this factory, once per request.
+ * This is to ensure that injectionState is isolated to the request
+ * and not shared between requests.
+ */
+export const createInjector = () => {
+ const injectionState: Set = new Set([])
+
+ const injectToPage = (renderCallback: RenderCallback) => {
+ injectionState.add(renderCallback)
+ }
+
+ return { injectToPage, injectionState }
+}
+
+// @NOTE do not instatiate the provider value here, so that we can ensure
+// context isolation. This is done in streamHelpers currently,
+// using the createInjector factory, once per request
+export const ServerHtmlProvider = ServerHtmlContext.Provider
+
+export const ServerInjectedHtml = ({
+ injectionState,
+}: {
+ injectionState: Set
+}) => {
+ const serverInsertedHtml = []
+ for (const callback of injectionState) {
+ serverInsertedHtml.push(callback())
+
+ // Remove it from the set so its not rendered again
+ injectionState.delete(callback)
+ }
+
+ const fragmentId = useId()
+
+ return serverInsertedHtml.map((html, i) => {
+ return (
+ {html}
+ )
+ })
+}
+
+// Exactly the same as Next's useServerInsertedHTML
+export function useServerInsertedHTML(callback: () => React.ReactNode): void {
+ const addInsertedServerHTMLCallback = useContext(ServerHtmlContext)
+
+ // Should have no effects on client where there's no flush effects provider
+ if (addInsertedServerHTMLCallback) {
+ addInsertedServerHTMLCallback(callback)
+ }
+}
+
+// @TODO use this in streamHelpers final block
+export const AppendToHead = ({ tagsToAppend }: { tagsToAppend: string }) => {
+ return (
+
+ )
+}
diff --git a/packages/web/src/components/htmlTags.tsx b/packages/web/src/components/htmlTags.tsx
index 57482830ece8..4763bfdd8d52 100644
--- a/packages/web/src/components/htmlTags.tsx
+++ b/packages/web/src/components/htmlTags.tsx
@@ -1,11 +1,13 @@
import { Fragment } from 'react'
/** CSS is a specialised metatag */
-export const Css = ({ css = [] }: { css: string[] }) => {
+export const Css = ({ css }: { css: string[] }) => {
+ const cssLinks = css || window?.__REDWOOD__ASSET_MAP?.css || []
+
return (
<>
- {css.map((cssLinks, index) => {
+ {cssLinks.map((cssLink, index) => {
return (
-
+
)
})}
>
@@ -77,10 +79,12 @@ interface MetaProps {
tags: TagDescriptor[] | undefined
}
-export const Meta = ({ tags = [] }: MetaProps) => {
+export const Meta = ({ tags }: MetaProps) => {
+ const metaTags = tags || window?.__REDWOOD__ASSET_MAP?.meta || []
+
return (
<>
- {tags.map((tag, index) => {
+ {metaTags.map((tag, index) => {
if (!tag) {
return null
}
diff --git a/packages/web/src/components/portalHead.test.tsx b/packages/web/src/components/portalHead.test.tsx
new file mode 100644
index 000000000000..74e8916c3d31
--- /dev/null
+++ b/packages/web/src/components/portalHead.test.tsx
@@ -0,0 +1,44 @@
+import React from 'react'
+
+import '@testing-library/jest-dom/extend-expect'
+import { render } from '@testing-library/react'
+
+import PortalHead from './PortalHead'
+import * as ServerInject from './ServerInject'
+
+const serverInsertionHookSpy = jest
+ .spyOn(ServerInject, 'useServerInsertedHTML')
+ .mockImplementation(jest.fn())
+
+describe('Portal head', () => {
+ const TestUsage = () => {
+ return (
+
+ Test title
+
+
+
+ )
+ }
+
+ it('Should add children to the on the client, and call serverInsertion hook', () => {
+ render()
+ // Actually doesn't do anything on the client underneath, but we
+ // still want to make sure its called
+ expect(serverInsertionHookSpy).toHaveBeenCalled()
+
+ const head = document.querySelector('head') as HTMLHeadElement
+
+ expect(head.childNodes).toHaveLength(3)
+ expect(head.childNodes[0]).toHaveTextContent('Test title')
+
+ expect(head.childNodes[1]).toHaveAttribute('rel', 'canonical')
+ expect(head.childNodes[1]).toHaveAttribute('href', 'https://example.com')
+
+ expect(head.childNodes[2]).toHaveAttribute('name', 'description')
+ expect(head.childNodes[2]).toHaveAttribute(
+ 'content',
+ 'Kittens are soft and cuddly'
+ )
+ })
+})
diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts
index baa1196cb448..019e25db6fbb 100644
--- a/packages/web/src/index.ts
+++ b/packages/web/src/index.ts
@@ -34,3 +34,5 @@ export { Helmet as Head, Helmet } from 'react-helmet-async'
export * from './components/htmlTags'
export * from './routeHooks.types'
+
+export * from './components/ServerInject'