From a78508ad022216dcaa5eeedaaeb85460970a8211 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 15 Feb 2024 13:29:03 -0800 Subject: [PATCH 01/27] Update prerender path to be URL --- packages/prerender/src/runPrerender.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/prerender/src/runPrerender.tsx b/packages/prerender/src/runPrerender.tsx index e9a2f702037e..d96372221dbb 100644 --- a/packages/prerender/src/runPrerender.tsx +++ b/packages/prerender/src/runPrerender.tsx @@ -124,8 +124,16 @@ async function recursivelyRender( }) ) + // `renderPath` is *just* a path, but the LocationProvider needs a full URL + // object so if you need the domain to be something specific when + // pre-rendering (because you're showing it in HTML output or the og:image + // uses useLocation().host) you can set the RWJS_PRERENDER_ORIGIN env variable + // so that it doesn't just default to localhost + const prerenderUrl = + process.env.RWJS_PRERENDER_ORIGIN || 'http://localhost' + renderPath + const componentAsHtml = ReactDOMServer.renderToString( - + From 413294f400316fdbb65360a1d57dd8d21f0c40e2 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 15 Feb 2024 13:29:24 -0800 Subject: [PATCH 02/27] useLocation will return a full URL object instead of just three parts --- packages/router/src/location.tsx | 31 +++++-------------- .../streaming/createReactStreamingHandler.ts | 6 ++-- packages/vite/src/streaming/streamHelpers.ts | 13 +++----- 3 files changed, 16 insertions(+), 34 deletions(-) diff --git a/packages/router/src/location.tsx b/packages/router/src/location.tsx index 0623d109e0b9..a794731bbba7 100644 --- a/packages/router/src/location.tsx +++ b/packages/router/src/location.tsx @@ -4,19 +4,12 @@ import { createNamedContext } from './createNamedContext' import { gHistory } from './history' import type { TrailingSlashesTypes } from './util' -export interface LocationContextType { - pathname: string - search?: string - hash?: string -} +export interface LocationContextType extends URL {} const LocationContext = createNamedContext('Location') -interface Location { - pathname: string - search?: string - hash?: string -} +interface Location extends URL {} + interface LocationProviderProps { location?: Location trailingSlashes?: TrailingSlashesTypes @@ -24,7 +17,7 @@ interface LocationProviderProps { } interface LocationProviderState { - context: Location + context: Location | undefined } class LocationProvider extends React.Component< @@ -75,18 +68,10 @@ class LocationProvider extends React.Component< break } - windowLocation = window.location - } else { - windowLocation = { - pathname: this.context?.pathname || '', - search: this.context?.search || '', - hash: this.context?.hash || '', - } + windowLocation = new URL(window.location.href) } - const { pathname, search, hash } = this.props.location || windowLocation - - return { pathname, search, hash } + return this.props.location || this.context || windowLocation } componentDidMount() { @@ -94,8 +79,8 @@ class LocationProvider extends React.Component< const context = this.getContext() this.setState((lastState) => { if ( - context.pathname !== lastState.context.pathname || - context.search !== lastState.context.search + context?.pathname !== lastState?.context?.pathname || + context?.search !== lastState?.context?.search ) { globalThis?.scrollTo(0, 0) } diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts index e01768193610..449492f043c7 100644 --- a/packages/vite/src/streaming/createReactStreamingHandler.ts +++ b/packages/vite/src/streaming/createReactStreamingHandler.ts @@ -102,10 +102,10 @@ export const createReactStreamingHandler = async ( const FallbackDocument = fallbackDocumentImport.Document || fallbackDocumentImport.default - const { pathname: currentPathName } = new URL(req.url) + const currentUrl = new URL(req.url) // @TODO validate this is correct - const parsedParams = matchPath(route.pathDefinition, currentPathName) + const parsedParams = matchPath(route.pathDefinition, currentUrl.pathname) let metaTags: TagDescriptor[] = [] @@ -147,7 +147,7 @@ export const createReactStreamingHandler = async ( { ServerEntry, FallbackDocument, - currentPathName, + currentUrl, metaTags, cssLinks, isProd, diff --git a/packages/vite/src/streaming/streamHelpers.ts b/packages/vite/src/streaming/streamHelpers.ts index 53f9082cf5f6..e0a52ff1f25c 100644 --- a/packages/vite/src/streaming/streamHelpers.ts +++ b/packages/vite/src/streaming/streamHelpers.ts @@ -26,7 +26,7 @@ import { createServerInjectionTransform } from './transforms/serverInjectionTran interface RenderToStreamArgs { ServerEntry: any FallbackDocument: any - currentPathName: string + currentUrl: URL metaTags: TagDescriptor[] cssLinks: string[] isProd: boolean @@ -48,7 +48,7 @@ export async function reactRenderToStreamResponse( const { ServerEntry, FallbackDocument, - currentPathName, + currentUrl, metaTags, cssLinks, isProd, @@ -91,7 +91,7 @@ export async function reactRenderToStreamResponse( // @ts-expect-error Something in React's packages mean types dont come through const { renderToReadableStream } = await import('react-dom/server.edge') - const renderRoot = (path: string) => { + const renderRoot = (url: URL) => { return React.createElement( ServerAuthProvider, { @@ -100,9 +100,7 @@ export async function reactRenderToStreamResponse( React.createElement( LocationProvider, { - location: { - pathname: path, - }, + location: url, }, React.createElement( ServerHtmlProvider, @@ -110,7 +108,6 @@ export async function reactRenderToStreamResponse( value: injectToPage, }, ServerEntry({ - url: path, css: cssLinks, meta: metaTags, }) @@ -146,7 +143,7 @@ export async function reactRenderToStreamResponse( }, } - const root = renderRoot(currentPathName) + const root = renderRoot(currentUrl) const reactStream: ReactDOMServerReadableStream = await renderToReadableStream(root, renderToStreamOptions) From 52cb90311c0c1595891362699ed69f450e6a0aab Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 15 Feb 2024 13:31:41 -0800 Subject: [PATCH 03/27] Include additional options when calling middleware --- packages/vite/src/middleware/invokeMiddleware.ts | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index c7b70f362893..a62a9cb79907 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -5,7 +5,8 @@ import { MiddlewareResponse } from './MiddlewareResponse' type Middleware = ( req: MiddlewareRequest, - res?: MiddlewareResponse + res?: MiddlewareResponse, + route?: any ) => Promise | Response | void /** @@ -18,7 +19,8 @@ type Middleware = ( */ export const invoke = async ( req: Request, - middleware?: Middleware + middleware?: Middleware, + options?: any ): Promise<[MiddlewareResponse, ServerAuthState]> => { if (typeof middleware !== 'function') { return [MiddlewareResponse.next(), defaultAuthProviderState] @@ -28,13 +30,19 @@ export const invoke = async ( let mwRes: MiddlewareResponse = MiddlewareResponse.next() try { - const output = (await middleware(mwReq)) || MiddlewareResponse.next() + const output = + (await middleware(mwReq, MiddlewareResponse.next(), options)) || + MiddlewareResponse.next() if (output instanceof MiddlewareResponse) { mwRes = output - } else { + } else if (typeof output === 'object' && output instanceof Response) { // If it was a WebAPI Response mwRes = MiddlewareResponse.fromResponse(output) + } else { + throw new Error( + 'Middleware must return a MiddlewareResponse or a Response' + ) } } catch (e) { console.error('Error executing middleware > \n') From 7891b353e0e6b2acb129749aff21ab29ae376b57 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 15 Feb 2024 13:32:17 -0800 Subject: [PATCH 04/27] Add server route for .png files --- packages/vite/src/devFeServer.ts | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts index 286e58b37759..c180071b5239 100644 --- a/packages/vite/src/devFeServer.ts +++ b/packages/vite/src/devFeServer.ts @@ -82,6 +82,21 @@ async function createServer() { app.get(expressPathDef, createServerAdapter(routeHandler)) + app.get( + createPngRouteDef(route.matchRegexString), + createServerAdapter(async (req: Request) => { + const entryServerImport = await vite.ssrLoadModule( + rwPaths.web.entryServer as string // already validated in dev server + ) + + const middleware = entryServerImport.middleware + + const [mwRes] = await invoke(req, middleware, { route }) + + return mwRes.toResponse() + }) + ) + app.post( '*', createServerAdapter(async (req: Request) => { @@ -103,6 +118,16 @@ async function createServer() { return await app.listen(port) } +function createPngRouteDef(matchRegexString: string): any { + if (matchRegexString.endsWith('/$')) { + console.log(1) + return new RegExp(matchRegexString.replace('$', 'index.png$')) + } else if (matchRegexString.endsWith('$')) { + console.log(2) + return new RegExp(matchRegexString.replace('$', '.png$')) + } +} + let devApp = createServer() process.stdin.on('data', async (data) => { From 68b9ad0a06ddf063abc645638881e6d87d395d8a Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 28 Feb 2024 20:48:57 -0800 Subject: [PATCH 05/27] Update App template to render children if present --- packages/create-redwood-app/templates/js/web/src/App.jsx | 4 ++-- packages/create-redwood-app/templates/ts/web/src/App.tsx | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/create-redwood-app/templates/js/web/src/App.jsx b/packages/create-redwood-app/templates/js/web/src/App.jsx index 97fb5e02520d..ad3ce3697d95 100644 --- a/packages/create-redwood-app/templates/js/web/src/App.jsx +++ b/packages/create-redwood-app/templates/js/web/src/App.jsx @@ -6,11 +6,11 @@ import Routes from 'src/Routes' import './index.css' -const App = () => ( +const App = ({ children }) => ( - + {children ? children : } diff --git a/packages/create-redwood-app/templates/ts/web/src/App.tsx b/packages/create-redwood-app/templates/ts/web/src/App.tsx index 97fb5e02520d..ad3ce3697d95 100644 --- a/packages/create-redwood-app/templates/ts/web/src/App.tsx +++ b/packages/create-redwood-app/templates/ts/web/src/App.tsx @@ -6,11 +6,11 @@ import Routes from 'src/Routes' import './index.css' -const App = () => ( +const App = ({ children }) => ( - + {children ? children : } From 5be594a66e5f7b31b7b0ee9a4ca4731cdeb88ba7 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 28 Feb 2024 20:49:26 -0800 Subject: [PATCH 06/27] Tie into all extensions, not just png --- packages/vite/src/devFeServer.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts index c180071b5239..2d7cd844a5f9 100644 --- a/packages/vite/src/devFeServer.ts +++ b/packages/vite/src/devFeServer.ts @@ -83,7 +83,7 @@ async function createServer() { app.get(expressPathDef, createServerAdapter(routeHandler)) app.get( - createPngRouteDef(route.matchRegexString), + createExtensionRouteDef(route.matchRegexString), createServerAdapter(async (req: Request) => { const entryServerImport = await vite.ssrLoadModule( rwPaths.web.entryServer as string // already validated in dev server @@ -118,13 +118,13 @@ async function createServer() { return await app.listen(port) } -function createPngRouteDef(matchRegexString: string): any { +function createExtensionRouteDef(matchRegexString: string): any { if (matchRegexString.endsWith('/$')) { - console.log(1) - return new RegExp(matchRegexString.replace('$', 'index.png$')) + // url is something like / + return new RegExp(matchRegexString.replace('$', 'index.*$')) } else if (matchRegexString.endsWith('$')) { - console.log(2) - return new RegExp(matchRegexString.replace('$', '.png$')) + // url is something like /about + return new RegExp(matchRegexString.replace('$', '.*$')) } } From 4f97bb46f3e0c7ac68a792a1f3d83b2d00b4bc08 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 28 Feb 2024 21:20:35 -0800 Subject: [PATCH 07/27] Move createServerAdapter calls for middleware to shared function, use in prod server --- packages/vite/src/devFeServer.ts | 59 +++++++------------ .../vite/src/middleware/extensionRouteDef.ts | 9 +++ packages/vite/src/runFeServer.ts | 35 +++++++---- 3 files changed, 54 insertions(+), 49 deletions(-) create mode 100644 packages/vite/src/middleware/extensionRouteDef.ts diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts index 2d7cd844a5f9..2b149e3f009d 100644 --- a/packages/vite/src/devFeServer.ts +++ b/packages/vite/src/devFeServer.ts @@ -9,6 +9,7 @@ import type { Paths } from '@redwoodjs/project-config' import { getConfig, getPaths } from '@redwoodjs/project-config' import { registerFwGlobals } from './lib/registerGlobals' +import { createExtensionRouteDef } from './middleware/extensionRouteDef' import { invoke } from './middleware/invokeMiddleware' import { collectCssPaths, componentsModules } from './streaming/collectCss' import { createReactStreamingHandler } from './streaming/createReactStreamingHandler' @@ -17,13 +18,14 @@ import { ensureProcessDirWeb } from './utils' // TODO (STREAMING) Just so it doesn't error out. Not sure how to handle this. globalThis.__REDWOOD__PRERENDER_PAGES = {} +const rwPaths = getPaths() + async function createServer() { ensureProcessDirWeb() registerFwGlobals() const app = express() - const rwPaths = getPaths() // ~~~ Dev time validations ~~~~ // TODO (STREAMING) When Streaming is released Vite will be the only bundler, @@ -55,6 +57,21 @@ async function createServer() { appType: 'custom', }) + // create a handler that will invoke middleware with or without a route + const handleWithMiddleware = (route?: RouteSpec) => { + return createServerAdapter(async (req: Request) => { + const entryServerImport = await vite.ssrLoadModule( + rwPaths.web.entryServer as string // already validated in dev server + ) + + const middleware = entryServerImport.middleware + + const [mwRes] = await invoke(req, middleware, route ? { route } : {}) + + return mwRes.toResponse() + }) + } + // use vite's connect instance as middleware app.use(vite.middlewares) @@ -84,50 +101,18 @@ async function createServer() { app.get( createExtensionRouteDef(route.matchRegexString), - createServerAdapter(async (req: Request) => { - const entryServerImport = await vite.ssrLoadModule( - rwPaths.web.entryServer as string // already validated in dev server - ) - - const middleware = entryServerImport.middleware - - const [mwRes] = await invoke(req, middleware, { route }) - - return mwRes.toResponse() - }) - ) - - app.post( - '*', - createServerAdapter(async (req: Request) => { - const entryServerImport = await vite.ssrLoadModule( - rwPaths.web.entryServer as string // already validated in dev server - ) - - const middleware = entryServerImport.middleware - - const [mwRes] = await invoke(req, middleware) - - return mwRes.toResponse() - }) + handleWithMiddleware(route) ) } + // invokes middleware for any POST request for auth + app.post('*', handleWithMiddleware()) + const port = getConfig().web.port console.log(`Started server on http://localhost:${port}`) return await app.listen(port) } -function createExtensionRouteDef(matchRegexString: string): any { - if (matchRegexString.endsWith('/$')) { - // url is something like / - return new RegExp(matchRegexString.replace('$', 'index.*$')) - } else if (matchRegexString.endsWith('$')) { - // url is something like /about - return new RegExp(matchRegexString.replace('$', '.*$')) - } -} - let devApp = createServer() process.stdin.on('data', async (data) => { diff --git a/packages/vite/src/middleware/extensionRouteDef.ts b/packages/vite/src/middleware/extensionRouteDef.ts new file mode 100644 index 000000000000..b5e251486fa8 --- /dev/null +++ b/packages/vite/src/middleware/extensionRouteDef.ts @@ -0,0 +1,9 @@ +export const createExtensionRouteDef = (matchRegexString: string): RegExp => { + if (matchRegexString.endsWith('/$')) { + // url is something like / + return new RegExp(matchRegexString.replace('$', 'index.*$')) + } else { + // url is something like /about + return new RegExp(matchRegexString.replace('$', '.*$')) + } +} diff --git a/packages/vite/src/runFeServer.ts b/packages/vite/src/runFeServer.ts index 0e7bb767ff00..c62b06466a45 100644 --- a/packages/vite/src/runFeServer.ts +++ b/packages/vite/src/runFeServer.ts @@ -9,15 +9,18 @@ import path from 'node:path' import url from 'node:url' import { createServerAdapter } from '@whatwg-node/server' + // @ts-expect-error We will remove dotenv-defaults from this package anyway import { config as loadDotEnv } from 'dotenv-defaults' import express from 'express' import { createProxyMiddleware } from 'http-proxy-middleware' import type { Manifest as ViteBuildManifest } from 'vite' +import type { RWRouteManifestItem } from '@redwoodjs/internal/dist/routes' import { getConfig, getPaths } from '@redwoodjs/project-config' import { registerFwGlobals } from './lib/registerGlobals' +import { createExtensionRouteDef } from './middleware/extensionRouteDef' import { invoke } from './middleware/invokeMiddleware' import { createRscRequestHandler } from './rsc/rscRequestHandler' import { setClientEntries } from './rsc/rscWorkerCommunication' @@ -84,6 +87,18 @@ export async function runFeServer() { return manifestItem.isEntry }) + const handleWithMiddleware = (route?: RWRouteManifestItem) => { + return createServerAdapter(async (req: Request) => { + const entryServerImport = await import(rwPaths.web.entryServer as string) + + const middleware = entryServerImport.middleware + + const [mwRes] = await invoke(req, middleware, route ? { route } : {}) + + return mwRes.toResponse() + }) + } + if (!indexEntry) { throw new Error('Could not find index.html in build manifest') } @@ -156,24 +171,20 @@ export async function runFeServer() { return express.static(rwPaths.web.dist)(req, res, next) }) } + + // add express routes to capture extension requests and give them to middleware + // ie. /about.json, /about.png, etc + app.get( + createExtensionRouteDef(route.matchRegexString), + handleWithMiddleware(route) + ) } // Mounting middleware at /rw-rsc will strip /rw-rsc from req.url app.use('/rw-rsc', createRscRequestHandler()) // @MARK: put this after rw-rsc! - app.post( - '*', - createServerAdapter(async (req: Request) => { - const entryServerImport = await import(rwPaths.web.distEntryServer) - - const { middleware } = entryServerImport - - const [mwRes] = await invoke(req, middleware) - - return mwRes.toResponse() - }) - ) + app.post('*', handleWithMiddleware()) app.listen(rwConfig.web.port) console.log( From 1d62b7423c9a2c4aa554d834f05c5aefc7351ac4 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 28 Feb 2024 21:20:40 -0800 Subject: [PATCH 08/27] Update types --- packages/vite/src/middleware/invokeMiddleware.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index a62a9cb79907..ae2cb6f4efb7 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -1,4 +1,5 @@ import { defaultAuthProviderState, type ServerAuthState } from '@redwoodjs/auth' +import type { RWRouteManifestItem } from '@redwoodjs/internal/dist/routes' import { MiddlewareRequest } from './MiddlewareRequest' import { MiddlewareResponse } from './MiddlewareResponse' @@ -20,7 +21,7 @@ type Middleware = ( export const invoke = async ( req: Request, middleware?: Middleware, - options?: any + options?: { route?: RWRouteManifestItem } ): Promise<[MiddlewareResponse, ServerAuthState]> => { if (typeof middleware !== 'function') { return [MiddlewareResponse.next(), defaultAuthProviderState] From 2e587819817323bdc2f49a4910b1e81fbd0ec6cc Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 29 Feb 2024 11:22:47 -0800 Subject: [PATCH 09/27] Updates App.tsx for test project fixture and rsc template --- __fixtures__/empty-project/web/src/App.tsx | 4 ++-- .../src/commands/experimental/templates/rsc/App.tsx.template | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/__fixtures__/empty-project/web/src/App.tsx b/__fixtures__/empty-project/web/src/App.tsx index 97fb5e02520d..ad3ce3697d95 100644 --- a/__fixtures__/empty-project/web/src/App.tsx +++ b/__fixtures__/empty-project/web/src/App.tsx @@ -6,11 +6,11 @@ import Routes from 'src/Routes' import './index.css' -const App = () => ( +const App = ({ children }) => ( - + {children ? children : } diff --git a/packages/cli/src/commands/experimental/templates/rsc/App.tsx.template b/packages/cli/src/commands/experimental/templates/rsc/App.tsx.template index 27cb83121ff7..0ed4f990c777 100644 --- a/packages/cli/src/commands/experimental/templates/rsc/App.tsx.template +++ b/packages/cli/src/commands/experimental/templates/rsc/App.tsx.template @@ -6,11 +6,11 @@ import Routes from './Routes' import './index.css' -const App = () => ( +const App = ({ children }) => ( - + {children ? children : } From 53c53e9c80ee262586fa9cc53d9af14257aa7526 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 29 Feb 2024 13:42:40 -0800 Subject: [PATCH 10/27] Adds useOgImageUrl() hook to return the URL to generate for the og:image URL --- .../src/components/__tests__/ogImage.test.ts | 103 ++++++++++++++++++ packages/web/src/components/ogImage.tsx | 38 +++++++ packages/web/src/index.ts | 2 + 3 files changed, 143 insertions(+) create mode 100644 packages/web/src/components/__tests__/ogImage.test.ts create mode 100644 packages/web/src/components/ogImage.tsx diff --git a/packages/web/src/components/__tests__/ogImage.test.ts b/packages/web/src/components/__tests__/ogImage.test.ts new file mode 100644 index 000000000000..d3937f53343c --- /dev/null +++ b/packages/web/src/components/__tests__/ogImage.test.ts @@ -0,0 +1,103 @@ +import '@testing-library/jest-dom/jest-globals' + +import { useOgImageUrl } from '../ogImage' + +const mockLocation = jest.fn() + +jest.mock('@redwoodjs/router', () => { + return { + useLocation: () => mockLocation(), + } +}) + +describe('useOgImageUrl', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + it('returns a plain URL with a default extension', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams(), + }) + + const url = useOgImageUrl() + + expect(url).toBe('http://localhost/user/1.png') + }) + + it('returns index.png if at the root', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost', + pathname: '/', + searchParams: new URLSearchParams(), + }) + + const url = useOgImageUrl() + + expect(url).toBe('http://localhost/index.png') + }) + + it('preserves existing query variables', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/about', + pathname: '/about', + searchParams: new URLSearchParams('foo=bar'), + }) + + const url = useOgImageUrl() + + expect(url).toBe('http://localhost/about.png?foo=bar') + }) + + it('allows setting a custom extension', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1/edit', + pathname: '/user/1/edit', + searchParams: new URLSearchParams(), + }) + + const url = useOgImageUrl({ extension: 'jpg' }) + + expect(url).toBe('http://localhost/user/1/edit.jpg') + }) + + it('allows setting a custom width', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams(), + }) + + const url = useOgImageUrl({ width: 1000 }) + + expect(url).toBe('http://localhost/user/1.png?width=1000') + }) + + it('allows setting a custom height', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams(), + }) + + const url = useOgImageUrl({ height: 500 }) + + expect(url).toBe('http://localhost/user/1.png?height=500') + }) + + it('merges existing query variables with custom ones', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams('foo=bar'), + }) + + const url = useOgImageUrl({ extension: 'gif', width: 1024, height: 768 }) + + expect(url).toBe( + 'http://localhost/user/1.gif?foo=bar&width=1024&height=768' + ) + }) +}) diff --git a/packages/web/src/components/ogImage.tsx b/packages/web/src/components/ogImage.tsx new file mode 100644 index 000000000000..389735b409be --- /dev/null +++ b/packages/web/src/components/ogImage.tsx @@ -0,0 +1,38 @@ +import { useLocation } from '@redwoodjs/router' + +export type OgImageUrlOptions = { + extension?: 'png' | 'jpg' | 'jpeg' | 'gif' + width?: number + height?: number +} + +const OGIMAGE_DEFAULTS = { + extension: 'png', + width: 1200, + height: 630, +} + +export const useOgImageUrl = (options?: OgImageUrlOptions) => { + const { origin, pathname, searchParams } = useLocation() + const ext = options?.extension || OGIMAGE_DEFAULTS.extension + const output = [origin, `.${ext}`] + + // special case if we're at the root, image is available at /index.ext + if (pathname === '/') { + output.splice(1, 0, '/index') + } + + if (options?.width && options.width !== OGIMAGE_DEFAULTS.width) { + searchParams.append('width', options.width.toString()) + } + if (options?.height && options.height !== OGIMAGE_DEFAULTS.height) { + searchParams.append('height', options.height.toString()) + } + + // only append search params if there are any, so we don't up with a trailing `?` + if (searchParams.size) { + output.push(`?${searchParams}`) + } + + return output.join('') +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 90c870aefb9a..55c7f68518a1 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -39,4 +39,6 @@ export * from './routeHooks.types' export * from './components/ServerInject' +export * from './components/ogImage' + export type { TypedDocumentNode } from './components/GraphQLHooksProvider' From c0200901f206e40ed1ba5772ac2de5cf6ab2d301 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 29 Feb 2024 13:42:51 -0800 Subject: [PATCH 11/27] Reorganize other web tests into __tests__ dir --- .../components/{ => __tests__}/FetchConfigProvider.test.tsx | 2 +- .../components/{ => __tests__}/GraphQLHooksProvider.test.tsx | 2 +- packages/web/src/components/{ => __tests__}/Metadata.test.tsx | 2 +- .../{portalHead.test.tsx => __tests__/PortalHead.test.tsx} | 4 ++-- 4 files changed, 5 insertions(+), 5 deletions(-) rename packages/web/src/components/{ => __tests__}/FetchConfigProvider.test.tsx (95%) rename packages/web/src/components/{ => __tests__}/GraphQLHooksProvider.test.tsx (99%) rename packages/web/src/components/{ => __tests__}/Metadata.test.tsx (99%) rename packages/web/src/components/{portalHead.test.tsx => __tests__/PortalHead.test.tsx} (93%) diff --git a/packages/web/src/components/FetchConfigProvider.test.tsx b/packages/web/src/components/__tests__/FetchConfigProvider.test.tsx similarity index 95% rename from packages/web/src/components/FetchConfigProvider.test.tsx rename to packages/web/src/components/__tests__/FetchConfigProvider.test.tsx index db47d6523911..f369e5ab0b61 100644 --- a/packages/web/src/components/FetchConfigProvider.test.tsx +++ b/packages/web/src/components/__tests__/FetchConfigProvider.test.tsx @@ -11,7 +11,7 @@ import '@testing-library/jest-dom/jest-globals' globalThis.RWJS_API_GRAPHQL_URL = 'https://api.example.com/graphql' -import { FetchConfigProvider, useFetchConfig } from './FetchConfigProvider' +import { FetchConfigProvider, useFetchConfig } from '../FetchConfigProvider' const FetchConfigToString: React.FunctionComponent = () => { const c = useFetchConfig() diff --git a/packages/web/src/components/GraphQLHooksProvider.test.tsx b/packages/web/src/components/__tests__/GraphQLHooksProvider.test.tsx similarity index 99% rename from packages/web/src/components/GraphQLHooksProvider.test.tsx rename to packages/web/src/components/__tests__/GraphQLHooksProvider.test.tsx index eaf75aa2897b..4766f56ff0f0 100644 --- a/packages/web/src/components/GraphQLHooksProvider.test.tsx +++ b/packages/web/src/components/__tests__/GraphQLHooksProvider.test.tsx @@ -10,7 +10,7 @@ import { useQuery, useMutation, useSubscription, -} from './GraphQLHooksProvider' +} from '../GraphQLHooksProvider' const TestUseQueryHook: React.FunctionComponent = () => { // @ts-expect-error - Purposefully not passing in a DocumentNode type here. diff --git a/packages/web/src/components/Metadata.test.tsx b/packages/web/src/components/__tests__/Metadata.test.tsx similarity index 99% rename from packages/web/src/components/Metadata.test.tsx rename to packages/web/src/components/__tests__/Metadata.test.tsx index 9e3174fa8da8..088af278986f 100644 --- a/packages/web/src/components/Metadata.test.tsx +++ b/packages/web/src/components/__tests__/Metadata.test.tsx @@ -1,7 +1,7 @@ import { render } from '@testing-library/react' import '@testing-library/jest-dom' -import { Metadata } from './Metadata' +import { Metadata } from '../Metadata' // DOCS: can return a structured object from the database and just give it to `og` and it works diff --git a/packages/web/src/components/portalHead.test.tsx b/packages/web/src/components/__tests__/PortalHead.test.tsx similarity index 93% rename from packages/web/src/components/portalHead.test.tsx rename to packages/web/src/components/__tests__/PortalHead.test.tsx index 74f62e218ec2..0a997a8dcc79 100644 --- a/packages/web/src/components/portalHead.test.tsx +++ b/packages/web/src/components/__tests__/PortalHead.test.tsx @@ -3,8 +3,8 @@ import React from 'react' import '@testing-library/jest-dom/jest-globals' import { render } from '@testing-library/react' -import PortalHead from './PortalHead' -import * as ServerInject from './ServerInject' +import PortalHead from '../PortalHead' +import * as ServerInject from '../ServerInject' const serverInsertionHookSpy = jest .spyOn(ServerInject, 'useServerInsertedHTML') From 4b9d39cf658abca166d5220c2354cbb996a0248c Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 29 Feb 2024 14:08:11 -0800 Subject: [PATCH 12/27] Modified to return separate width, height and ogProps in case you want to build all the meta tags in one go --- .../src/components/__tests__/ogImage.test.ts | 64 ++++++++++++++++--- packages/web/src/components/ogImage.tsx | 27 ++++++-- 2 files changed, 77 insertions(+), 14 deletions(-) diff --git a/packages/web/src/components/__tests__/ogImage.test.ts b/packages/web/src/components/__tests__/ogImage.test.ts index d3937f53343c..f06af4136c94 100644 --- a/packages/web/src/components/__tests__/ogImage.test.ts +++ b/packages/web/src/components/__tests__/ogImage.test.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom/jest-globals' -import { useOgImageUrl } from '../ogImage' +import { useOgImageUrl, OGIMAGE_DEFAULTS } from '../ogImage' const mockLocation = jest.fn() @@ -22,11 +22,49 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const url = useOgImageUrl() + const { url } = useOgImageUrl() expect(url).toBe('http://localhost/user/1.png') }) + it('returns the default width of the image', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams(), + }) + + const { width } = useOgImageUrl() + + expect(width).toBe(OGIMAGE_DEFAULTS.width) + }) + + it('returns the default height of the image', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams(), + }) + + const { height } = useOgImageUrl() + + expect(height).toBe(OGIMAGE_DEFAULTS.height) + }) + + it('returns all the props necessary to build the og:image meta tags', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams(), + }) + + const { ogProps } = useOgImageUrl() + + expect(ogProps).toEqual({ + image: ['http://localhost/user/1.png', { width: 1200, height: 630 }], + }) + }) + it('returns index.png if at the root', () => { mockLocation.mockReturnValue({ origin: 'http://localhost', @@ -34,7 +72,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const url = useOgImageUrl() + const { url } = useOgImageUrl() expect(url).toBe('http://localhost/index.png') }) @@ -46,7 +84,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams('foo=bar'), }) - const url = useOgImageUrl() + const { url } = useOgImageUrl() expect(url).toBe('http://localhost/about.png?foo=bar') }) @@ -58,7 +96,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const url = useOgImageUrl({ extension: 'jpg' }) + const { url } = useOgImageUrl({ extension: 'jpg' }) expect(url).toBe('http://localhost/user/1/edit.jpg') }) @@ -70,9 +108,11 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const url = useOgImageUrl({ width: 1000 }) + const { url, width, height } = useOgImageUrl({ width: 1000 }) expect(url).toBe('http://localhost/user/1.png?width=1000') + expect(width).toBe(1000) + expect(height).toBe(OGIMAGE_DEFAULTS.height) }) it('allows setting a custom height', () => { @@ -82,9 +122,11 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const url = useOgImageUrl({ height: 500 }) + const { url, width, height } = useOgImageUrl({ height: 500 }) expect(url).toBe('http://localhost/user/1.png?height=500') + expect(width).toBe(OGIMAGE_DEFAULTS.width) + expect(height).toBe(500) }) it('merges existing query variables with custom ones', () => { @@ -94,10 +136,16 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams('foo=bar'), }) - const url = useOgImageUrl({ extension: 'gif', width: 1024, height: 768 }) + const { url, width, height } = useOgImageUrl({ + extension: 'gif', + width: 1024, + height: 768, + }) expect(url).toBe( 'http://localhost/user/1.gif?foo=bar&width=1024&height=768' ) + expect(width).toBe(1024) + expect(height).toBe(768) }) }) diff --git a/packages/web/src/components/ogImage.tsx b/packages/web/src/components/ogImage.tsx index 389735b409be..d52c86b51329 100644 --- a/packages/web/src/components/ogImage.tsx +++ b/packages/web/src/components/ogImage.tsx @@ -6,7 +6,7 @@ export type OgImageUrlOptions = { height?: number } -const OGIMAGE_DEFAULTS = { +export const OGIMAGE_DEFAULTS = { extension: 'png', width: 1200, height: 630, @@ -15,6 +15,8 @@ const OGIMAGE_DEFAULTS = { export const useOgImageUrl = (options?: OgImageUrlOptions) => { const { origin, pathname, searchParams } = useLocation() const ext = options?.extension || OGIMAGE_DEFAULTS.extension + const width = options?.width + const height = options?.height const output = [origin, `.${ext}`] // special case if we're at the root, image is available at /index.ext @@ -22,11 +24,11 @@ export const useOgImageUrl = (options?: OgImageUrlOptions) => { output.splice(1, 0, '/index') } - if (options?.width && options.width !== OGIMAGE_DEFAULTS.width) { - searchParams.append('width', options.width.toString()) + if (width) { + searchParams.append('width', width.toString()) } - if (options?.height && options.height !== OGIMAGE_DEFAULTS.height) { - searchParams.append('height', options.height.toString()) + if (height) { + searchParams.append('height', height.toString()) } // only append search params if there are any, so we don't up with a trailing `?` @@ -34,5 +36,18 @@ export const useOgImageUrl = (options?: OgImageUrlOptions) => { output.push(`?${searchParams}`) } - return output.join('') + return { + url: output.join(''), + width: width || OGIMAGE_DEFAULTS.width, + height: height || OGIMAGE_DEFAULTS.height, + ogProps: { + image: [ + output.join(''), + { + width: width || OGIMAGE_DEFAULTS.width, + height: height || OGIMAGE_DEFAULTS.height, + }, + ], + }, + } } From cc8ad07b8721ad70f47b89fbd69329009f44e100 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Fri, 1 Mar 2024 11:28:36 -0800 Subject: [PATCH 13/27] Adds quality setting to useOgImage() --- .../src/components/__tests__/ogImage.test.ts | 55 ++++++++++++++----- packages/web/src/components/ogImage.tsx | 12 +++- 2 files changed, 51 insertions(+), 16 deletions(-) diff --git a/packages/web/src/components/__tests__/ogImage.test.ts b/packages/web/src/components/__tests__/ogImage.test.ts index f06af4136c94..b09517b0f471 100644 --- a/packages/web/src/components/__tests__/ogImage.test.ts +++ b/packages/web/src/components/__tests__/ogImage.test.ts @@ -1,6 +1,6 @@ import '@testing-library/jest-dom/jest-globals' -import { useOgImageUrl, OGIMAGE_DEFAULTS } from '../ogImage' +import { useOgImage, OGIMAGE_DEFAULTS } from '../ogImage' const mockLocation = jest.fn() @@ -10,7 +10,7 @@ jest.mock('@redwoodjs/router', () => { } }) -describe('useOgImageUrl', () => { +describe('useOgImage', () => { afterEach(() => { jest.clearAllMocks() }) @@ -22,7 +22,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const { url } = useOgImageUrl() + const { url } = useOgImage() expect(url).toBe('http://localhost/user/1.png') }) @@ -34,7 +34,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const { width } = useOgImageUrl() + const { width } = useOgImage() expect(width).toBe(OGIMAGE_DEFAULTS.width) }) @@ -46,11 +46,23 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const { height } = useOgImageUrl() + const { height } = useOgImage() expect(height).toBe(OGIMAGE_DEFAULTS.height) }) + it('returns the default quality of the image', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams(), + }) + + const { quality } = useOgImage() + + expect(quality).toBe(OGIMAGE_DEFAULTS.quality) + }) + it('returns all the props necessary to build the og:image meta tags', () => { mockLocation.mockReturnValue({ origin: 'http://localhost/user/1', @@ -58,7 +70,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const { ogProps } = useOgImageUrl() + const { ogProps } = useOgImage() expect(ogProps).toEqual({ image: ['http://localhost/user/1.png', { width: 1200, height: 630 }], @@ -72,7 +84,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const { url } = useOgImageUrl() + const { url } = useOgImage() expect(url).toBe('http://localhost/index.png') }) @@ -84,7 +96,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams('foo=bar'), }) - const { url } = useOgImageUrl() + const { url } = useOgImage() expect(url).toBe('http://localhost/about.png?foo=bar') }) @@ -96,7 +108,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const { url } = useOgImageUrl({ extension: 'jpg' }) + const { url } = useOgImage({ extension: 'jpg' }) expect(url).toBe('http://localhost/user/1/edit.jpg') }) @@ -108,7 +120,7 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const { url, width, height } = useOgImageUrl({ width: 1000 }) + const { url, width, height } = useOgImage({ width: 1000 }) expect(url).toBe('http://localhost/user/1.png?width=1000') expect(width).toBe(1000) @@ -122,13 +134,26 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams(), }) - const { url, width, height } = useOgImageUrl({ height: 500 }) + const { url, width, height } = useOgImage({ height: 500 }) expect(url).toBe('http://localhost/user/1.png?height=500') expect(width).toBe(OGIMAGE_DEFAULTS.width) expect(height).toBe(500) }) + it('allows setting a custom quality', () => { + mockLocation.mockReturnValue({ + origin: 'http://localhost/user/1', + pathname: '/user/1', + searchParams: new URLSearchParams(), + }) + + const { url, quality } = useOgImage({ quality: 50 }) + + expect(url).toBe('http://localhost/user/1.png?quality=50') + expect(quality).toBe(50) + }) + it('merges existing query variables with custom ones', () => { mockLocation.mockReturnValue({ origin: 'http://localhost/user/1', @@ -136,16 +161,18 @@ describe('useOgImageUrl', () => { searchParams: new URLSearchParams('foo=bar'), }) - const { url, width, height } = useOgImageUrl({ - extension: 'gif', + const { url, width, height, quality } = useOgImage({ + extension: 'png', width: 1024, height: 768, + quality: 75, }) expect(url).toBe( - 'http://localhost/user/1.gif?foo=bar&width=1024&height=768' + 'http://localhost/user/1.png?foo=bar&width=1024&height=768&quality=75' ) expect(width).toBe(1024) expect(height).toBe(768) + expect(quality).toBe(75) }) }) diff --git a/packages/web/src/components/ogImage.tsx b/packages/web/src/components/ogImage.tsx index d52c86b51329..40c8b6f3c02a 100644 --- a/packages/web/src/components/ogImage.tsx +++ b/packages/web/src/components/ogImage.tsx @@ -1,22 +1,25 @@ import { useLocation } from '@redwoodjs/router' export type OgImageUrlOptions = { - extension?: 'png' | 'jpg' | 'jpeg' | 'gif' + extension?: 'png' | 'jpg' width?: number height?: number + quality?: number } export const OGIMAGE_DEFAULTS = { extension: 'png', width: 1200, height: 630, + quality: 100, } -export const useOgImageUrl = (options?: OgImageUrlOptions) => { +export const useOgImage = (options?: OgImageUrlOptions) => { const { origin, pathname, searchParams } = useLocation() const ext = options?.extension || OGIMAGE_DEFAULTS.extension const width = options?.width const height = options?.height + const quality = options?.quality const output = [origin, `.${ext}`] // special case if we're at the root, image is available at /index.ext @@ -30,6 +33,9 @@ export const useOgImageUrl = (options?: OgImageUrlOptions) => { if (height) { searchParams.append('height', height.toString()) } + if (quality) { + searchParams.append('quality', quality.toString()) + } // only append search params if there are any, so we don't up with a trailing `?` if (searchParams.size) { @@ -40,6 +46,8 @@ export const useOgImageUrl = (options?: OgImageUrlOptions) => { url: output.join(''), width: width || OGIMAGE_DEFAULTS.width, height: height || OGIMAGE_DEFAULTS.height, + quality: quality || OGIMAGE_DEFAULTS.quality, + extension: ext, ogProps: { image: [ output.join(''), From 05e33d2664dee9e415afa1defeaf29c1d7e095b1 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Wed, 6 Mar 2024 14:09:18 +0700 Subject: [PATCH 14/27] Update invokeMiddleware comments --- packages/vite/src/middleware/invokeMiddleware.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index ae2cb6f4efb7..d3ee4d88f0a6 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -35,14 +35,18 @@ export const invoke = async ( (await middleware(mwReq, MiddlewareResponse.next(), options)) || MiddlewareResponse.next() + // Error out early, incase user returns something else from the middleware + // returning nothing is still fine! if (output instanceof MiddlewareResponse) { mwRes = output } else if (typeof output === 'object' && output instanceof Response) { // If it was a WebAPI Response mwRes = MiddlewareResponse.fromResponse(output) } else { + console.error('Return from middleware >> ', output) + console.error('\n----\n') throw new Error( - 'Middleware must return a MiddlewareResponse or a Response' + 'Invalid return type from middleware. You must return a MiddlewareResponse or a Response object, or nothing at all' ) } } catch (e) { From e15fb10cd8f62eeb7c0daed1a08cea679532fbed Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Wed, 6 Mar 2024 14:37:53 +0700 Subject: [PATCH 15/27] Lint --- packages/vite/src/runFeServer.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/vite/src/runFeServer.ts b/packages/vite/src/runFeServer.ts index 37b305e0c199..5ef63c5437bb 100644 --- a/packages/vite/src/runFeServer.ts +++ b/packages/vite/src/runFeServer.ts @@ -9,7 +9,6 @@ import path from 'node:path' import url from 'node:url' import { createServerAdapter } from '@whatwg-node/server' - // @ts-expect-error We will remove dotenv-defaults from this package anyway import { config as loadDotEnv } from 'dotenv-defaults' import express from 'express' From 5afcec2291d2e88154fb63ee392b5b85cc63011d Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Wed, 6 Mar 2024 14:40:24 +0700 Subject: [PATCH 16/27] Fix type linting warnings in skipNav --- packages/router/src/skipNav.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/router/src/skipNav.tsx b/packages/router/src/skipNav.tsx index 61f8f89d83ef..62ef8cabed13 100644 --- a/packages/router/src/skipNav.tsx +++ b/packages/router/src/skipNav.tsx @@ -10,7 +10,7 @@ import * as React from 'react' // Original Code Source @reach/polymorphic // https://github.com/reach/reach-ui/blob/dev/packages/polymorphic/src/reach-polymorphic.ts -type Merge = Omit & P2 +type Merge = Omit & P2 type ForwardRefExoticComponent = React.ForwardRefExoticComponent< Merge< @@ -21,7 +21,7 @@ type ForwardRefExoticComponent = React.ForwardRefExoticComponent< interface ForwardRefComponent< IntrinsicElementString, - OwnProps = {} + OwnProps = object /* * Extends original type to ensure built in React types play nice with * polymorphic components still e.g. `React.ElementRef` etc. From 2be1e63dc137f08cdc7957bbad108b04de190978 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Wed, 6 Mar 2024 15:59:18 +0700 Subject: [PATCH 17/27] update fixture --- __fixtures__/test-project/web/src/App.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/__fixtures__/test-project/web/src/App.tsx b/__fixtures__/test-project/web/src/App.tsx index 65419d60c7d6..e2e20e4bec05 100644 --- a/__fixtures__/test-project/web/src/App.tsx +++ b/__fixtures__/test-project/web/src/App.tsx @@ -9,12 +9,12 @@ import { AuthProvider, useAuth } from './auth' import './scaffold.css' import './index.css' -const App = () => ( +const App = ({ children }) => ( - + {children ? children : } From dcdb43a0287777a001c39b244c80f8a60d40ee19 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Wed, 6 Mar 2024 20:51:36 -0800 Subject: [PATCH 18/27] Add cssPaths to options --- packages/vite/src/devFeServer.ts | 6 +++++- packages/vite/src/middleware/invokeMiddleware.ts | 7 ++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/vite/src/devFeServer.ts b/packages/vite/src/devFeServer.ts index 2b149e3f009d..38a5b4f42712 100644 --- a/packages/vite/src/devFeServer.ts +++ b/packages/vite/src/devFeServer.ts @@ -66,7 +66,11 @@ async function createServer() { const middleware = entryServerImport.middleware - const [mwRes] = await invoke(req, middleware, route ? { route } : {}) + const [mwRes] = await invoke( + req, + middleware, + route ? { route, cssPaths: getCssLinks(rwPaths, route, vite) } : {} + ) return mwRes.toResponse() }) diff --git a/packages/vite/src/middleware/invokeMiddleware.ts b/packages/vite/src/middleware/invokeMiddleware.ts index d3ee4d88f0a6..6673c65046be 100644 --- a/packages/vite/src/middleware/invokeMiddleware.ts +++ b/packages/vite/src/middleware/invokeMiddleware.ts @@ -10,6 +10,11 @@ type Middleware = ( route?: any ) => Promise | Response | void +type MiddlewareInvokeOptions = { + route?: RWRouteManifestItem + cssPaths?: Array +} + /** * * Invokes the middleware function, and guarantees and MWResponse object is returned @@ -21,7 +26,7 @@ type Middleware = ( export const invoke = async ( req: Request, middleware?: Middleware, - options?: { route?: RWRouteManifestItem } + options?: MiddlewareInvokeOptions ): Promise<[MiddlewareResponse, ServerAuthState]> => { if (typeof middleware !== 'function') { return [MiddlewareResponse.next(), defaultAuthProviderState] From 04e7c49a39e1742d9af879bc39a29678eff18ff9 Mon Sep 17 00:00:00 2001 From: Rob Cameron Date: Thu, 7 Mar 2024 15:42:20 -0800 Subject: [PATCH 19/27] Removes useOgImage hook code --- .../src/components/__tests__/ogImage.test.ts | 178 ------------------ packages/web/src/components/ogImage.tsx | 61 ------ packages/web/src/index.ts | 2 - 3 files changed, 241 deletions(-) delete mode 100644 packages/web/src/components/__tests__/ogImage.test.ts delete mode 100644 packages/web/src/components/ogImage.tsx diff --git a/packages/web/src/components/__tests__/ogImage.test.ts b/packages/web/src/components/__tests__/ogImage.test.ts deleted file mode 100644 index b09517b0f471..000000000000 --- a/packages/web/src/components/__tests__/ogImage.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import '@testing-library/jest-dom/jest-globals' - -import { useOgImage, OGIMAGE_DEFAULTS } from '../ogImage' - -const mockLocation = jest.fn() - -jest.mock('@redwoodjs/router', () => { - return { - useLocation: () => mockLocation(), - } -}) - -describe('useOgImage', () => { - afterEach(() => { - jest.clearAllMocks() - }) - - it('returns a plain URL with a default extension', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams(), - }) - - const { url } = useOgImage() - - expect(url).toBe('http://localhost/user/1.png') - }) - - it('returns the default width of the image', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams(), - }) - - const { width } = useOgImage() - - expect(width).toBe(OGIMAGE_DEFAULTS.width) - }) - - it('returns the default height of the image', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams(), - }) - - const { height } = useOgImage() - - expect(height).toBe(OGIMAGE_DEFAULTS.height) - }) - - it('returns the default quality of the image', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams(), - }) - - const { quality } = useOgImage() - - expect(quality).toBe(OGIMAGE_DEFAULTS.quality) - }) - - it('returns all the props necessary to build the og:image meta tags', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams(), - }) - - const { ogProps } = useOgImage() - - expect(ogProps).toEqual({ - image: ['http://localhost/user/1.png', { width: 1200, height: 630 }], - }) - }) - - it('returns index.png if at the root', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost', - pathname: '/', - searchParams: new URLSearchParams(), - }) - - const { url } = useOgImage() - - expect(url).toBe('http://localhost/index.png') - }) - - it('preserves existing query variables', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/about', - pathname: '/about', - searchParams: new URLSearchParams('foo=bar'), - }) - - const { url } = useOgImage() - - expect(url).toBe('http://localhost/about.png?foo=bar') - }) - - it('allows setting a custom extension', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1/edit', - pathname: '/user/1/edit', - searchParams: new URLSearchParams(), - }) - - const { url } = useOgImage({ extension: 'jpg' }) - - expect(url).toBe('http://localhost/user/1/edit.jpg') - }) - - it('allows setting a custom width', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams(), - }) - - const { url, width, height } = useOgImage({ width: 1000 }) - - expect(url).toBe('http://localhost/user/1.png?width=1000') - expect(width).toBe(1000) - expect(height).toBe(OGIMAGE_DEFAULTS.height) - }) - - it('allows setting a custom height', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams(), - }) - - const { url, width, height } = useOgImage({ height: 500 }) - - expect(url).toBe('http://localhost/user/1.png?height=500') - expect(width).toBe(OGIMAGE_DEFAULTS.width) - expect(height).toBe(500) - }) - - it('allows setting a custom quality', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams(), - }) - - const { url, quality } = useOgImage({ quality: 50 }) - - expect(url).toBe('http://localhost/user/1.png?quality=50') - expect(quality).toBe(50) - }) - - it('merges existing query variables with custom ones', () => { - mockLocation.mockReturnValue({ - origin: 'http://localhost/user/1', - pathname: '/user/1', - searchParams: new URLSearchParams('foo=bar'), - }) - - const { url, width, height, quality } = useOgImage({ - extension: 'png', - width: 1024, - height: 768, - quality: 75, - }) - - expect(url).toBe( - 'http://localhost/user/1.png?foo=bar&width=1024&height=768&quality=75' - ) - expect(width).toBe(1024) - expect(height).toBe(768) - expect(quality).toBe(75) - }) -}) diff --git a/packages/web/src/components/ogImage.tsx b/packages/web/src/components/ogImage.tsx deleted file mode 100644 index 40c8b6f3c02a..000000000000 --- a/packages/web/src/components/ogImage.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useLocation } from '@redwoodjs/router' - -export type OgImageUrlOptions = { - extension?: 'png' | 'jpg' - width?: number - height?: number - quality?: number -} - -export const OGIMAGE_DEFAULTS = { - extension: 'png', - width: 1200, - height: 630, - quality: 100, -} - -export const useOgImage = (options?: OgImageUrlOptions) => { - const { origin, pathname, searchParams } = useLocation() - const ext = options?.extension || OGIMAGE_DEFAULTS.extension - const width = options?.width - const height = options?.height - const quality = options?.quality - const output = [origin, `.${ext}`] - - // special case if we're at the root, image is available at /index.ext - if (pathname === '/') { - output.splice(1, 0, '/index') - } - - if (width) { - searchParams.append('width', width.toString()) - } - if (height) { - searchParams.append('height', height.toString()) - } - if (quality) { - searchParams.append('quality', quality.toString()) - } - - // only append search params if there are any, so we don't up with a trailing `?` - if (searchParams.size) { - output.push(`?${searchParams}`) - } - - return { - url: output.join(''), - width: width || OGIMAGE_DEFAULTS.width, - height: height || OGIMAGE_DEFAULTS.height, - quality: quality || OGIMAGE_DEFAULTS.quality, - extension: ext, - ogProps: { - image: [ - output.join(''), - { - width: width || OGIMAGE_DEFAULTS.width, - height: height || OGIMAGE_DEFAULTS.height, - }, - ], - }, - } -} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index 55c7f68518a1..90c870aefb9a 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -39,6 +39,4 @@ export * from './routeHooks.types' export * from './components/ServerInject' -export * from './components/ogImage' - export type { TypedDocumentNode } from './components/GraphQLHooksProvider' From a44f28f5529e96bd2061e619c16514fb248464fe Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Fri, 29 Mar 2024 16:30:15 +0700 Subject: [PATCH 20/27] Bad merge --- packages/web/src/components/__tests__/Metadata.test.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/web/src/components/__tests__/Metadata.test.tsx b/packages/web/src/components/__tests__/Metadata.test.tsx index 96f533d82e57..93646a770d38 100644 --- a/packages/web/src/components/__tests__/Metadata.test.tsx +++ b/packages/web/src/components/__tests__/Metadata.test.tsx @@ -3,7 +3,6 @@ import React from 'react' import { render } from '@testing-library/react' import { describe, beforeAll, it, expect } from 'vitest' -import '@testing-library/jest-dom' import { Metadata } from '../Metadata' // DOCS: can return a structured object from the database and just give it to `og` and it works From 4b9f06874e454398bf27831b772a5d8b3ec979f4 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Fri, 29 Mar 2024 16:52:35 +0700 Subject: [PATCH 21/27] Update types in app template --- packages/create-redwood-app/templates/ts/web/src/App.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/create-redwood-app/templates/ts/web/src/App.tsx b/packages/create-redwood-app/templates/ts/web/src/App.tsx index ad3ce3697d95..4df315c062f0 100644 --- a/packages/create-redwood-app/templates/ts/web/src/App.tsx +++ b/packages/create-redwood-app/templates/ts/web/src/App.tsx @@ -5,8 +5,12 @@ import FatalErrorPage from 'src/pages/FatalErrorPage' import Routes from 'src/Routes' import './index.css' +import { ReactNode } from 'react' +interface AppProps { + children?: ReactNode +} -const App = ({ children }) => ( +const App = ({ children }: AppProps) => ( From 787fa561c7e697415078da98b8bbe400cd58b16f Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Fri, 29 Mar 2024 17:07:36 +0700 Subject: [PATCH 22/27] Lint fixes, Update project fixture --- __fixtures__/test-project/web/src/App.tsx | 18 +++++++++--------- .../templates/ts/web/src/App.tsx | 3 ++- 2 files changed, 11 insertions(+), 10 deletions(-) diff --git a/__fixtures__/test-project/web/src/App.tsx b/__fixtures__/test-project/web/src/App.tsx index e2e20e4bec05..235df87826da 100644 --- a/__fixtures__/test-project/web/src/App.tsx +++ b/__fixtures__/test-project/web/src/App.tsx @@ -1,22 +1,22 @@ +import type { ReactNode } from 'react' + import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' import FatalErrorPage from 'src/pages/FatalErrorPage' import Routes from 'src/Routes' -import { AuthProvider, useAuth } from './auth' - -import './scaffold.css' import './index.css' +interface AppProps { + children?: ReactNode +} -const App = ({ children }) => ( +const App = ({ children }: AppProps) => ( - - - {children ? children : } - - + + {children ? children : } + ) diff --git a/packages/create-redwood-app/templates/ts/web/src/App.tsx b/packages/create-redwood-app/templates/ts/web/src/App.tsx index 4df315c062f0..235df87826da 100644 --- a/packages/create-redwood-app/templates/ts/web/src/App.tsx +++ b/packages/create-redwood-app/templates/ts/web/src/App.tsx @@ -1,3 +1,5 @@ +import type { ReactNode } from 'react' + import { FatalErrorBoundary, RedwoodProvider } from '@redwoodjs/web' import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' @@ -5,7 +7,6 @@ import FatalErrorPage from 'src/pages/FatalErrorPage' import Routes from 'src/Routes' import './index.css' -import { ReactNode } from 'react' interface AppProps { children?: ReactNode } From 183a7d9d97ec9ad531ded9f9a944e24ee6627963 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Thu, 11 Apr 2024 16:54:41 +0700 Subject: [PATCH 23/27] Add changeset --- .changesets/10441.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 .changesets/10441.md diff --git a/.changesets/10441.md b/.changesets/10441.md new file mode 100644 index 000000000000..de04d1ae4272 --- /dev/null +++ b/.changesets/10441.md @@ -0,0 +1,10 @@ +- feat(og-gen): Update implementation of useLocation | Update App template (#10441) by @dac09 +**Updated App.tsx template** +We modified the `App.tsx` template to accept possible children, and render them if present. This lets the og:image handler inject your component into the Document tree, without including the entire Router, but still style your og:image component using whatever you used to style the rest of your app (Tailwind, perhaps?) + +**Updated useLocation implementation** +We also modified the `useLocation()` hook to now return everything that the [URL API](https://developer.mozilla.org/en-US/docs/Web/API/URL) returns. Previously it only returned three attributes of the url (pathname, search, hash), now it returns everything available to a call to `new URL()` (origin, href, searchParams, etc.). + +The reason for this is now that we have SSR, we can get access to more details in the hook - in this case we needed origin + +Both changes should be non-breaking! From 3767eb8f1578f508a25727092a9be8ab0a92b963 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Thu, 11 Apr 2024 16:56:51 +0700 Subject: [PATCH 24/27] Remove file thats no longer needed --- packages/vite/src/middleware/extensionRouteDef.ts | 9 --------- 1 file changed, 9 deletions(-) delete mode 100644 packages/vite/src/middleware/extensionRouteDef.ts diff --git a/packages/vite/src/middleware/extensionRouteDef.ts b/packages/vite/src/middleware/extensionRouteDef.ts deleted file mode 100644 index b5e251486fa8..000000000000 --- a/packages/vite/src/middleware/extensionRouteDef.ts +++ /dev/null @@ -1,9 +0,0 @@ -export const createExtensionRouteDef = (matchRegexString: string): RegExp => { - if (matchRegexString.endsWith('/$')) { - // url is something like / - return new RegExp(matchRegexString.replace('$', 'index.*$')) - } else { - // url is something like /about - return new RegExp(matchRegexString.replace('$', '.*$')) - } -} From 1a81e232d258949e1722b24211c3299792c6a843 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Thu, 11 Apr 2024 18:50:33 +0700 Subject: [PATCH 25/27] Pass currentUrl in streaming handler instead of pathname --- .../src/streaming/createReactStreamingHandler.ts | 6 +++--- packages/vite/src/streaming/streamHelpers.ts | 12 +++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/packages/vite/src/streaming/createReactStreamingHandler.ts b/packages/vite/src/streaming/createReactStreamingHandler.ts index ef7abfb82873..05494596bb0b 100644 --- a/packages/vite/src/streaming/createReactStreamingHandler.ts +++ b/packages/vite/src/streaming/createReactStreamingHandler.ts @@ -64,13 +64,13 @@ export const createReactStreamingHandler = async ( let currentRoute: RWRouteManifestItem | undefined let parsedParams: any = {} - const { pathname: currentPathName } = new URL(req.url) + const currentUrl = new URL(req.url) // @TODO validate this is correct for (const route of routes) { const { match, ...rest } = matchPath( route.pathDefinition, - currentPathName, + currentUrl.pathname, ) if (match) { currentRoute = route @@ -174,7 +174,7 @@ export const createReactStreamingHandler = async ( { ServerEntry, FallbackDocument, - currentPathName, + currentUrl, metaTags, cssLinks, isProd, diff --git a/packages/vite/src/streaming/streamHelpers.ts b/packages/vite/src/streaming/streamHelpers.ts index 65878a9e8116..9ae897e0ab89 100644 --- a/packages/vite/src/streaming/streamHelpers.ts +++ b/packages/vite/src/streaming/streamHelpers.ts @@ -28,7 +28,7 @@ import { createServerInjectionTransform } from './transforms/serverInjectionTran interface RenderToStreamArgs { ServerEntry: ServerEntryType FallbackDocument: React.FunctionComponent - currentPathName: string + currentUrl: URL metaTags: TagDescriptor[] cssLinks: string[] isProd: boolean @@ -64,7 +64,7 @@ export async function reactRenderToStreamResponse( const { ServerEntry, FallbackDocument, - currentPathName, + currentUrl, metaTags, cssLinks, isProd, @@ -103,7 +103,7 @@ export async function reactRenderToStreamResponse( const timeoutTransform = createTimeoutTransform(timeoutHandle) - const renderRoot = (path: string) => { + const renderRoot = (url: URL) => { return React.createElement( ServerAuthProvider, { @@ -112,9 +112,7 @@ export async function reactRenderToStreamResponse( React.createElement( LocationProvider, { - location: { - pathname: path, - }, + location: url, }, React.createElement( ServerHtmlProvider, @@ -157,7 +155,7 @@ export async function reactRenderToStreamResponse( }, } - const root = renderRoot(currentPathName) + const root = renderRoot(currentUrl) const reactStream: ReactDOMServerReadableStream = await renderToReadableStream(root, renderToStreamOptions) From 18f38f72eaa453b5ff7c9b80103d4bda5373a4c3 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Fri, 12 Apr 2024 10:02:50 +0700 Subject: [PATCH 26/27] Update App.tsx fixture to put back auth provider and useAuth --- __fixtures__/test-project/web/src/App.tsx | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/__fixtures__/test-project/web/src/App.tsx b/__fixtures__/test-project/web/src/App.tsx index 235df87826da..0c5a48d728bf 100644 --- a/__fixtures__/test-project/web/src/App.tsx +++ b/__fixtures__/test-project/web/src/App.tsx @@ -6,6 +6,9 @@ import { RedwoodApolloProvider } from '@redwoodjs/web/apollo' import FatalErrorPage from 'src/pages/FatalErrorPage' import Routes from 'src/Routes' +import { AuthProvider, useAuth } from './auth' + +import './scaffold.css' import './index.css' interface AppProps { children?: ReactNode @@ -14,9 +17,11 @@ interface AppProps { const App = ({ children }: AppProps) => ( - - {children ? children : } - + + + {children ? children : } + + ) From df1386cf7a2b9754248fbf0064b341ec0404d8b5 Mon Sep 17 00:00:00 2001 From: Daniel Choudhury Date: Fri, 12 Apr 2024 10:28:48 +0700 Subject: [PATCH 27/27] Update pkg json --- packages/ogimage-gen/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/ogimage-gen/package.json b/packages/ogimage-gen/package.json index ea11714f82a0..798ec6b1e4c4 100644 --- a/packages/ogimage-gen/package.json +++ b/packages/ogimage-gen/package.json @@ -19,7 +19,6 @@ "files": [ "dist", "cjsWrappers" - ], "scripts": { "build": "tsx ./build.mts && yarn build:types",