diff --git a/templates/ords-remix-jwt-sample/app/entry.client.tsx b/templates/ords-remix-jwt-sample/app/entry.client.tsx index f9084b1..7436180 100644 --- a/templates/ords-remix-jwt-sample/app/entry.client.tsx +++ b/templates/ords-remix-jwt-sample/app/entry.client.tsx @@ -21,13 +21,30 @@ import { import { hydrateRoot } from 'react-dom/client'; import { MuiProvider } from './mui/MuiProvider'; +const SERVER_HEAD_PATTERN = /[\s\S]*?/; + +/** + * Removes the static server-rendered head so the client portal can own it. + */ +function removeServerHead() : void { + document.head.innerHTML = document.head.innerHTML.replace(SERVER_HEAD_PATTERN, ''); +} + startTransition(() => { + const rootElement = document.getElementById('root'); + + if (rootElement === null) { + throw new Error('Could not find the root element to hydrate.'); + } + hydrateRoot( - document, + rootElement, , ); + + removeServerHead(); }); diff --git a/templates/ords-remix-jwt-sample/app/entry.server.tsx b/templates/ords-remix-jwt-sample/app/entry.server.tsx index 058a20e..89037f2 100644 --- a/templates/ords-remix-jwt-sample/app/entry.server.tsx +++ b/templates/ords-remix-jwt-sample/app/entry.server.tsx @@ -11,126 +11,24 @@ /* eslint-disable jsdoc/require-param-description */ /* eslint-disable jsdoc/require-returns */ /* eslint-disable jsdoc/require-jsdoc */ -/* eslint-disable no-magic-numbers */ /* eslint-disable no-console */ -/* eslint-disable no-param-reassign */ -import { PassThrough } from 'node:stream'; - import type { AppLoadContext, EntryContext, } from '@remix-run/node'; -import { createReadableStreamFromReadable } from '@remix-run/node'; import { RemixServer } from '@remix-run/react'; -import { isbot } from 'isbot'; -import { renderToPipeableStream } from 'react-dom/server'; +import { renderToString } from 'react-dom/server'; import { MuiProvider } from './mui/MuiProvider'; +import createEmotionCache from './mui/createEmotionCache'; +import { Head } from './root'; -const ABORT_DELAY = 5_000; - -function handleBotRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { - pipe, abort, - } = renderToPipeableStream( - , - { - onAllReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set('Content-Type', 'text/html'); +const HEAD_START = ''; +const HEAD_END = ''; - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - }, - ); - - setTimeout(abort, ABORT_DELAY); - }); -} - -function handleBrowserRequest( - request: Request, - responseStatusCode: number, - responseHeaders: Headers, - remixContext: EntryContext, -) { - return new Promise((resolve, reject) => { - let shellRendered = false; - const { - pipe, abort, - } = renderToPipeableStream( - - - , - { - onShellReady() { - shellRendered = true; - const body = new PassThrough(); - const stream = createReadableStreamFromReadable(body); - - responseHeaders.set('Content-Type', 'text/html'); - - resolve( - new Response(stream, { - headers: responseHeaders, - status: responseStatusCode, - }), - ); - - pipe(body); - }, - onShellError(error: unknown) { - reject(error); - }, - onError(error: unknown) { - responseStatusCode = 500; - // Log streaming rendering errors from inside the shell. Don't log - // errors encountered during initial shell rendering since they'll - // reject and get logged in handleDocumentRequest. - if (shellRendered) { - console.error(error); - } - }, - }, - ); - - setTimeout(abort, ABORT_DELAY); - }); +function renderDocument( + head: string, + body: string, +): string { + return `${HEAD_START}${head}${HEAD_END}
${body}
`; } /** @@ -154,17 +52,33 @@ export default function handleRequest( // eslint-disable-next-line @typescript-eslint/no-unused-vars loadContext: AppLoadContext, ) { - return isbot(request.headers.get('user-agent') || '') - ? handleBotRequest( - request, - responseStatusCode, - responseHeaders, - remixContext, - ) - : handleBrowserRequest( - request, - responseStatusCode, - responseHeaders, - remixContext, - ); + const rootRouteModule = remixContext.routeModules.root; + const headContext: EntryContext = { + ...remixContext, + routeModules: { + ...remixContext.routeModules, + root: { + ...rootRouteModule, + default: Head, + ErrorBoundary: Head, + }, + }, + }; + + const head = renderToString( + , + ); + + const body = renderToString( + + + , + ); + + responseHeaders.set('Content-Type', 'text/html'); + + return new Response(renderDocument(head, body), { + headers: responseHeaders, + status: responseStatusCode, + }); } diff --git a/templates/ords-remix-jwt-sample/app/mui/MuiProvider.tsx b/templates/ords-remix-jwt-sample/app/mui/MuiProvider.tsx index 7fa0e31..7eb8301 100644 --- a/templates/ords-remix-jwt-sample/app/mui/MuiProvider.tsx +++ b/templates/ords-remix-jwt-sample/app/mui/MuiProvider.tsx @@ -7,28 +7,29 @@ import { CacheProvider } from '@emotion/react'; import React from 'react'; import { ThemeProvider } from '@mui/material'; -import createCache from '@emotion/cache'; import type { EmotionCache } from '@emotion/react'; +import createEmotionCache from './createEmotionCache'; import theme from './theme'; -/** - * Create an instance of the emotion cache on every request to make the style - * configuration available to all components in the component tree. - * @returns an instance of the emotion cache. - */ -function createEmotionCache(): EmotionCache { - return createCache({ key: 'css' }); +interface MuiProviderProps { + children: React.ReactNode; + emotionCache?: EmotionCache; } + /** * Provider used to wrap the App root component to make all of the * MUI style configurations available. * @param root0 the Page React Nodes. * @param root0.children the page React Node children's. + * @param root0.emotionCache the Emotion cache used for SSR and hydration. * @see {@link https://mui.com/material-ui/guides/server-rendering/} * @returns the react root component. */ -export function MuiProvider({ children }: { children: React.ReactNode }): React.ReactNode { - const cache = createEmotionCache(); +export function MuiProvider({ + children, + emotionCache, +}: MuiProviderProps): React.ReactNode { + const [cache] = React.useState(() => emotionCache ?? createEmotionCache()); return ( diff --git a/templates/ords-remix-jwt-sample/app/mui/createEmotionCache.tsx b/templates/ords-remix-jwt-sample/app/mui/createEmotionCache.tsx new file mode 100644 index 0000000..d00776e --- /dev/null +++ b/templates/ords-remix-jwt-sample/app/mui/createEmotionCache.tsx @@ -0,0 +1,18 @@ +/* +** +** Copyright (c) 2024, Oracle and/or its affiliates. +** All rights reserved +** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ +*/ +import createCache from '@emotion/cache'; +import type { EmotionCache } from '@emotion/react'; + +/** + * Creates the Emotion cache used by both the server and the client. + * @returns the Emotion cache instance. + */ +export default function createEmotionCache(): EmotionCache { + return createCache({ + key: 'css', + }); +} diff --git a/templates/ords-remix-jwt-sample/app/root.tsx b/templates/ords-remix-jwt-sample/app/root.tsx index 4adc44d..cc69a29 100644 --- a/templates/ords-remix-jwt-sample/app/root.tsx +++ b/templates/ords-remix-jwt-sample/app/root.tsx @@ -5,11 +5,13 @@ ** Licensed under the Universal Permissive License v 1.0 as shown at https://oss.oracle.com/licenses/upl/ */ import type { + LinksFunction, LoaderFunctionArgs, } from '@remix-run/node'; import { json, ErrorResponse } from '@remix-run/node'; import { Links, + LiveReload, Meta, Outlet, Scripts, @@ -18,8 +20,11 @@ import { useLoaderData, useRouteError, } from '@remix-run/react'; -import { ReactElement } from 'react'; -import { StyledEngineProvider } from '@mui/material'; +import { + ReactElement, ReactNode, useEffect, useState, +} from 'react'; +import { createPortal } from 'react-dom'; +import datepicker from 'react-datepicker/dist/react-datepicker.css'; import stylesheet from './tailwind.css?url'; import type { LoaderError } from './models/LoaderError'; import { @@ -27,6 +32,7 @@ import { } from './utils/auth.server'; import NavBar from './components/navbar/NavBar'; import { + BASIC_SCHEMA_AUTH, CITIES_ENDPOINT, } from './routes/constants/index.server'; import TooltipButton from './components/tooltips/TooltipButton'; @@ -38,12 +44,12 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { const userProfile = await auth.isAuthenticated(request); const profile = userProfile?.profile || null; const USER_CREDENTIALS = userProfile === null - ? null + ? BASIC_SCHEMA_AUTH : `${userProfile.tokenType} ${userProfile.accessToken}`; const session = await getSession(request.headers.get('Cookie')); const error = session.get(auth.sessionErrorKey) as LoaderError; - const cities = await ORDSFetcher(CITIES_ENDPOINT, USER_CREDENTIALS!); + const cities = await ORDSFetcher(CITIES_ENDPOINT, USER_CREDENTIALS); if (cities.items.length === 0) { const errorMessage = 'The cities endpoint has no elements. Review your database configuration and try again.'; throw new Response(errorMessage, { @@ -59,6 +65,64 @@ export const loader = async ({ request }: LoaderFunctionArgs) => { }); }; +export const links: LinksFunction = () => [ + { + rel: 'stylesheet', + href: stylesheet, + }, + { + rel: 'stylesheet', + href: datepicker, + }, +]; + +/** + * Renders the route metadata and stylesheet links for the document head. + * @returns the head contents. + */ +export function Head(): ReactElement { + return ( + <> + + + + + + ); +} + +/** + * Mounts the head contents into `document.head` after hydration finishes. + * @returns the client-only head portal. + */ +function HydratedHead() { + const [isHydrated, setIsHydrated] = useState(false); + + useEffect(() => { + setIsHydrated(true); + }, []); + + return isHydrated ? createPortal(, document.head) : null; +} + +/** + * Shared app shell used by both the main app and the root error boundary. + * @param root0 the shell children. + * @param root0.children the rendered route content. + * @returns the app shell. + */ +function AppShell({ children }: { children: ReactNode }): ReactElement { + return ( + <> + + {children} + + + + + ); +} + /** * * @returns Display the error page. @@ -67,22 +131,10 @@ export function ErrorBoundary() : ReactElement { const error = useRouteError(); if (isRouteErrorResponse(error)) { return ( - - - - - - - - - - - - - - - - + + + + ); } if (error instanceof Error) { const unknownError : ErrorResponse = { @@ -91,30 +143,19 @@ export function ErrorBoundary() : ReactElement { data: error.stack, }; return ( - - - - - - - - - - - - - - - - + + + + ); } - return

Unknown Error

; -} -export const links = () => [ - { rel: 'stylesheet', href: stylesheet }, -]; + return ( + +

Unknown Error

+
+ ); +} /** * @@ -126,23 +167,11 @@ export default function App() : ReactElement { cities, } = useLoaderData(); return ( - - - - - - - - - - - - -