From 3f878a82eedb57e02685ab62b6cba08d9919ede7 Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Fri, 18 Nov 2022 16:31:30 -0800 Subject: [PATCH] feat(`react`,`server-runtime`): Support for v2 array syntax for route `meta` (#4610) The v2 `meta` API can now be supported behind a future flag. See https://github.com/orgs/remix-run/projects/5/views/1?filterQuery=meta for details. --- .changeset/tricky-bobcats-nail.md | 7 + integration/meta-test.ts | 129 ++++++++++++++++++ packages/remix-react/components.tsx | 117 +++++++++++++++- packages/remix-react/routeModules.ts | 41 +++++- packages/remix-react/routes.tsx | 3 +- packages/remix-server-runtime/routeModules.ts | 55 +++++++- packages/remix-server-runtime/routes.ts | 3 +- 7 files changed, 343 insertions(+), 12 deletions(-) create mode 100644 .changeset/tricky-bobcats-nail.md diff --git a/.changeset/tricky-bobcats-nail.md b/.changeset/tricky-bobcats-nail.md new file mode 100644 index 00000000000..6be9b308ee6 --- /dev/null +++ b/.changeset/tricky-bobcats-nail.md @@ -0,0 +1,7 @@ +--- +"@remix-run/dev": minor +"@remix-run/react": minor +"@remix-run/server-runtime": minor +--- + +Added support for a new route `meta` API to handle arrays of tags instead of an object. For details, check out the [RFC](https://github.com/remix-run/remix/discussions/4462). diff --git a/integration/meta-test.ts b/integration/meta-test.ts index 91c154fc394..2662b050a70 100644 --- a/integration/meta-test.ts +++ b/integration/meta-test.ts @@ -382,3 +382,132 @@ test.describe("meta", () => { }); }); }); + +test.describe("v2_meta", () => { + let fixture: Fixture; + let appFixture: AppFixture; + + // disable JS for all tests in this file + // to only disable them for some, add another test.describe() + // and move the following line there + test.use({ javaScriptEnabled: false }); + + test.beforeAll(async () => { + fixture = await createFixture({ + files: { + "remix.config.js": js` + module.exports = { + ignoredRouteFiles: ["**/.*"], + future: { + v2_meta: true, + }, + }; + `, + + "app/root.jsx": js` + import { json } from "@remix-run/node"; + import { Meta, Links, Outlet, Scripts } from "@remix-run/react"; + + export const loader = async () => + json({ + description: "This is a meta page", + title: "Meta Page", + }); + + export const meta = ({ data }) => [ + { charSet: "utf-8" }, + { name: "description", content: data.description }, + { property: "og:image", content: "https://picsum.photos/200/200" }, + { property: "og:type", content: data.contentType }, // undefined + { title: data.title }, + ]; + + export default function Root() { + return ( + + + + + + + + + + + ); + } + `, + + "app/routes/index.jsx": js` + export const meta = ({ data, matches }) => [ + ...matches.map((match) => match.meta), + ]; + export default function Index() { + return
This is the index file
; + } + `, + + "app/routes/no-meta.jsx": js` + export default function NoMeta() { + return
No meta here!
; + } + `, + + "app/routes/music.jsx": js` + export function meta({ data, matches }) { + let rootModule = matches.find(match => match.route.id === "root"); + let rootCharSet = rootModule.meta.find(meta => meta.charSet); + return [ + rootCharSet, + { title: "What's My Age Again?" }, + { property: "og:type", content: "music.song" }, + { property: "music:musician", content: "https://www.blink182.com/" }, + { property: "music:duration", content: 182 }, + ]; + } + + export default function Music() { + return

Music

; + } + `, + }, + }); + appFixture = await createAppFixture(fixture); + }); + + test.afterAll(() => appFixture.close()); + + test("empty meta does not render a tag", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/no-meta"); + await expect(app.getHtml("title")).rejects.toThrowError( + 'No element matches selector "title"' + ); + }); + + test("meta from `matches` renders meta tags", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/music"); + expect(await app.getHtml('meta[charset="utf-8"]')).toBeTruthy(); + }); + + test("{ charSet } adds a ", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml('meta[charset="utf-8"]')).toBeTruthy(); + }); + + test("{ title } adds a ", async ({ page }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml("title")).toBeTruthy(); + }); + + test("{ property: 'og:*', content: '*' } adds a <meta property='og:*' />", async ({ + page, + }) => { + let app = new PlaywrightFixture(appFixture, page); + await app.goto("/"); + expect(await app.getHtml('meta[property="og:image"]')).toBeTruthy(); + }); +}); diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index ced5b54c778..45e4a9efc1d 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -48,7 +48,12 @@ import { createClientRoutes } from "./routes"; import type { RouteData } from "./routeData"; import type { RouteMatch as BaseRouteMatch } from "./routeMatching"; import { matchClientRoutes } from "./routeMatching"; -import type { RouteModules, HtmlMetaDescriptor } from "./routeModules"; +import type { + RouteModules, + RouteMatchWithMeta, + V1_HtmlMetaDescriptor, + V2_HtmlMetaDescriptor, +} from "./routeModules"; import { createTransitionManager } from "./transition"; import type { Transition, @@ -695,11 +700,11 @@ function PrefetchPageLinksImpl({ * * @see https://remix.run/api/remix#meta-links-scripts */ -export function Meta() { +function V1Meta() { let { matches, routeData, routeModules } = useRemixEntryContext(); let location = useLocation(); - let meta: HtmlMetaDescriptor = {}; + let meta: V1_HtmlMetaDescriptor = {}; let parentsData: { [routeId: string]: AppData } = {}; for (let match of matches) { @@ -712,8 +717,26 @@ export function Meta() { if (routeModule.meta) { let routeMeta = typeof routeModule.meta === "function" - ? routeModule.meta({ data, parentsData, params, location }) + ? routeModule.meta({ + data, + parentsData, + params, + location, + matches: undefined as any, + }) : routeModule.meta; + if (routeMeta && Array.isArray(routeMeta)) { + throw new Error( + "The route at " + + match.route.path + + " returns an array. This is only supported with the `v2_meta` future flag " + + "in the Remix config. Either set the flag to `true` or update the route's " + + "meta function to return an object." + + "\n\nTo reference the v1 meta function API, see https://remix.run/api/conventions#meta" + // TODO: Add link to the docs once they are written + // + "\n\nTo reference future flags and the v2 meta API, see https://remix.run/api/remix#future-v2-meta." + ); + } Object.assign(meta, routeMeta); } @@ -775,6 +798,92 @@ export function Meta() { ); } +function V2Meta() { + let { matches, routeData, routeModules } = useRemixEntryContext(); + let location = useLocation(); + + let meta: V2_HtmlMetaDescriptor[] = []; + let parentsData: { [routeId: string]: AppData } = {}; + + let matchesWithMeta: RouteMatchWithMeta<ClientRoute>[] = matches.map( + (match) => ({ ...match, meta: [] }) + ); + + let index = -1; + for (let match of matches) { + index++; + let routeId = match.route.id; + let data = routeData[routeId]; + let params = match.params; + + let routeModule = routeModules[routeId]; + + let routeMeta: V2_HtmlMetaDescriptor[] | V1_HtmlMetaDescriptor | undefined = + []; + + if (routeModule?.meta) { + routeMeta = + typeof routeModule.meta === "function" + ? routeModule.meta({ + data, + parentsData, + params, + location, + matches: matchesWithMeta, + }) + : routeModule.meta; + } + + routeMeta = routeMeta || []; + if (!Array.isArray(routeMeta)) { + throw new Error( + "The `v2_meta` API is enabled in the Remix config, but the route at " + + match.route.path + + " returns an invalid value. In v2, all route meta functions must " + + "return an array of meta objects." + + // TODO: Add link to the docs once they are written + // "\n\nTo reference future flags and the v2 meta API, see https://remix.run/api/remix#future-v2-meta." + + "\n\nTo reference the v1 meta function API, see https://remix.run/api/conventions#meta" + ); + } + + matchesWithMeta[index].meta = routeMeta; + meta = routeMeta; + parentsData[routeId] = data; + } + + return ( + <> + {meta.flat().map((metaProps) => { + if (!metaProps) { + return null; + } + + if ("title" in metaProps) { + return <title key="title">{String(metaProps.title)}; + } + + if ("charSet" in metaProps || "charset" in metaProps) { + // TODO: We normalize this for the user in v1, but should we continue + // to do that? Seems like a nice convenience IMO. + return ( + + ); + } + return ; + })} + + ); +} + +export function Meta() { + let { future } = useRemixEntryContext(); + return future.v2_meta ? : ; +} + /** * Tracks whether Remix has finished hydrating or not, so scripts can be skipped * during client-side updates. diff --git a/packages/remix-react/routeModules.ts b/packages/remix-react/routeModules.ts index ab163a7ddff..88ae3254e86 100644 --- a/packages/remix-react/routeModules.ts +++ b/packages/remix-react/routeModules.ts @@ -8,6 +8,7 @@ import type { AppData } from "./data"; import type { LinkDescriptor } from "./links"; import type { ClientRoute, EntryRoute } from "./routes"; import type { RouteData } from "./routeData"; +import type { RouteMatch as BaseRouteMatch } from "./routeMatching"; import type { Submission } from "./transition"; export interface RouteModules { @@ -20,7 +21,11 @@ export interface RouteModule { default: RouteComponent; handle?: RouteHandle; links?: LinksFunction; - meta?: MetaFunction | HtmlMetaDescriptor; + meta?: + | V1_MetaFunction + | V1_HtmlMetaDescriptor + | V2_MetaFunction + | V2_HtmlMetaDescriptor[]; unstable_shouldReload?: ShouldReloadFunction; } @@ -55,13 +60,30 @@ export interface LinksFunction { * * @see https://remix.run/api/remix#meta-links-scripts */ -export interface MetaFunction { +export interface V1_MetaFunction { (args: { data: AppData; parentsData: RouteData; params: Params; location: Location; - }): HtmlMetaDescriptor | undefined; + }): HtmlMetaDescriptor; +} + +// TODO: Replace in v2 +export type MetaFunction = V1_MetaFunction; + +export interface RouteMatchWithMeta extends BaseRouteMatch { + meta: V2_HtmlMetaDescriptor[]; +} + +export interface V2_MetaFunction { + (args: { + data: AppData; + parentsData: RouteData; + params: Params; + location: Location; + matches: RouteMatchWithMeta[]; + }): V2_HtmlMetaDescriptor[] | undefined; } /** @@ -70,7 +92,7 @@ export interface MetaFunction { * tag, or an array of strings that will render multiple tags with the same * `name` attribute. */ -export interface HtmlMetaDescriptor { +export interface V1_HtmlMetaDescriptor { charset?: "utf-8"; charSet?: "utf-8"; title?: string; @@ -82,6 +104,17 @@ export interface HtmlMetaDescriptor { | Array | string>; } +// TODO: Replace in v2 +export type HtmlMetaDescriptor = V1_HtmlMetaDescriptor; + +export type V2_HtmlMetaDescriptor = + | { charSet: "utf-8" } + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { httpEquiv: string; content: string } + | { [name: string]: string }; + /** * During client side transitions Remix will optimize reloading of routes that * are currently on the page by avoiding loading routes that aren't changing. diff --git a/packages/remix-react/routes.tsx b/packages/remix-react/routes.tsx index af9361dd94c..023dac0559c 100644 --- a/packages/remix-react/routes.tsx +++ b/packages/remix-react/routes.tsx @@ -21,10 +21,11 @@ export interface RouteManifest { // NOTE: make sure to change the Route in server-runtime if you change this interface Route { + index?: boolean; caseSensitive?: boolean; id: string; + parentId?: string; path?: string; - index?: boolean; } // NOTE: make sure to change the EntryRoute in server-runtime if you change this diff --git a/packages/remix-server-runtime/routeModules.ts b/packages/remix-server-runtime/routeModules.ts index ec228386866..813685729f3 100644 --- a/packages/remix-server-runtime/routeModules.ts +++ b/packages/remix-server-runtime/routeModules.ts @@ -5,6 +5,7 @@ import type { Params } from "react-router-dom"; import type { AppLoadContext, AppData } from "./data"; import type { LinkDescriptor } from "./links"; import type { RouteData } from "./routeData"; +import type { Route } from "./routes"; import type { SerializeFrom } from "./serialize"; export interface RouteModules { @@ -131,7 +132,7 @@ export interface LoaderFunction { * } * ``` */ -export interface MetaFunction< +export interface V1_MetaFunction< Loader extends LoaderFunction | unknown = unknown, ParentsLoaders extends Record = {} > { @@ -145,13 +146,52 @@ export interface MetaFunction< }): HtmlMetaDescriptor; } +// TODO: Replace in v2 +export type MetaFunction< + Loader extends LoaderFunction | unknown = unknown, + ParentsLoaders extends Record = {} +> = V1_MetaFunction; + +interface RouteMatchWithMeta extends BaseRouteMatch { + meta: V2_HtmlMetaDescriptor[]; +} + +interface BaseRouteMatch { + params: Params; + pathname: string; + route: Route; +} + +interface ClientRoute extends Route { + loader?: LoaderFunction; + action: ActionFunction; + children?: ClientRoute[]; + module: string; + hasLoader: boolean; +} + +export interface V2_MetaFunction< + Loader extends LoaderFunction | unknown = unknown, + ParentsLoaders extends Record = {} +> { + (args: { + data: Loader extends LoaderFunction ? SerializeFrom : AppData; + parentsData: { + [k in keyof ParentsLoaders]: SerializeFrom; + } & RouteData; + params: Params; + location: Location; + matches: RouteMatchWithMeta[]; + }): HtmlMetaDescriptor; +} + /** * A name/content pair used to render `` tags in a meta function for a * route. The value can be either a string, which will render a single `` * tag, or an array of strings that will render multiple tags with the same * `name` attribute. */ -export interface HtmlMetaDescriptor { +export interface V1_HtmlMetaDescriptor { charset?: "utf-8"; charSet?: "utf-8"; title?: string; @@ -163,8 +203,19 @@ export interface HtmlMetaDescriptor { | Array | string>; } +// TODO: Replace in v2 +export type HtmlMetaDescriptor = V1_HtmlMetaDescriptor; + export type MetaDescriptor = HtmlMetaDescriptor; +export type V2_HtmlMetaDescriptor = + | { charSet: "utf-8" } + | { title: string } + | { name: string; content: string } + | { property: string; content: string } + | { httpEquiv: string; content: string } + | { [name: string]: string }; + /** * A React component that is rendered for a route. */ diff --git a/packages/remix-server-runtime/routes.ts b/packages/remix-server-runtime/routes.ts index e1bb2daf0d8..dce4e19efdd 100644 --- a/packages/remix-server-runtime/routes.ts +++ b/packages/remix-server-runtime/routes.ts @@ -15,7 +15,7 @@ export interface RouteManifest { export type ServerRouteManifest = RouteManifest>; // NOTE: make sure to change the Route in remix-react if you change this -interface Route { +export interface Route { index?: boolean; caseSensitive?: boolean; id: string; @@ -31,6 +31,7 @@ export interface EntryRoute extends Route { hasErrorBoundary: boolean; imports?: string[]; module: string; + parentId?: string; } export interface ServerRoute extends Route {