From d21cdef0a9a50ab31c0ae30af14cfd2d80fe5ecf Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Tue, 15 Nov 2022 12:29:25 -0800 Subject: [PATCH 1/7] feat: Add support for v2 `meta` 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. --- packages/remix-react/components.tsx | 108 +++++++++++++++++- 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 +- 5 files changed, 198 insertions(+), 12 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index ced5b54c778..3f00192fae2 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,83 @@ 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[] = []; + for (let match of matches) { + 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.push({ ...match, meta: routeMeta }); + parentsData[routeId] = data; + } + + return ( + <> + {meta.map((metaProps) => { + if (!metaProps) { + return null; + } + + if ("title" in metaProps) { + return {String(metaProps.title)}; + } + + if ("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. + metaProps.charSet = metaProps.charset; + delete metaProps.charset; + } + + 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 5873e13bf17..d2c0f803acf 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 7a8b1162517..12a980b2ec2 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 { From 300d16d799fc0221e149251ffa15e2165f0d3bed Mon Sep 17 00:00:00 2001 From: Chance Strickland Date: Tue, 15 Nov 2022 12:30:56 -0800 Subject: [PATCH 2/7] add tests --- integration/meta-test.ts | 130 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/integration/meta-test.ts b/integration/meta-test.ts index 91c154fc394..3befb93ccc0 100644 --- a/integration/meta-test.ts +++ b/integration/meta-test.ts @@ -382,3 +382,133 @@ 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 + { name: httpEquiv: "refresh", content: "3;url=https://www.mozilla.org" }, + { 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.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(); + }); +}); From 66778f5b32c6e77c4d4292c277b71e269495984f Mon Sep 17 00:00:00 2001 From: Chance Strickland <hi@chance.dev> Date: Tue, 15 Nov 2022 12:33:19 -0800 Subject: [PATCH 3/7] add changeset --- .changeset/tricky-bobcats-nail.md | 7 +++++++ 1 file changed, 7 insertions(+) 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). From fa2b7d2435772980e1f161379274f0f05441ae10 Mon Sep 17 00:00:00 2001 From: Chance Strickland <hi@chance.dev> Date: Tue, 15 Nov 2022 12:38:15 -0800 Subject: [PATCH 4/7] fix test syntax error --- integration/meta-test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integration/meta-test.ts b/integration/meta-test.ts index 3befb93ccc0..64b97d8e015 100644 --- a/integration/meta-test.ts +++ b/integration/meta-test.ts @@ -419,7 +419,7 @@ test.describe("v2_meta", () => { { name: "description", content: data.description }, { property": "og:image", content: "https://picsum.photos/200/200" }, { property": "og:type", content: data.contentType }, // undefined - { name: httpEquiv: "refresh", content: "3;url=https://www.mozilla.org" }, + { httpEquiv: "refresh", content: "3;url=https://www.mozilla.org" }, { title: data.title }, ]; From 47e6453dfe0e3f04a215f6de9230cf588366128b Mon Sep 17 00:00:00 2001 From: Chance Strickland <hi@chance.dev> Date: Tue, 15 Nov 2022 12:51:12 -0800 Subject: [PATCH 5/7] fix syntax error --- integration/meta-test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/integration/meta-test.ts b/integration/meta-test.ts index 64b97d8e015..45aa1512935 100644 --- a/integration/meta-test.ts +++ b/integration/meta-test.ts @@ -417,8 +417,8 @@ test.describe("v2_meta", () => { 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 + { property: "og:image", content: "https://picsum.photos/200/200" }, + { property: "og:type", content: data.contentType }, // undefined { httpEquiv: "refresh", content: "3;url=https://www.mozilla.org" }, { title: data.title }, ]; From 1535a67af0ace21fa2f82787889b268bae167639 Mon Sep 17 00:00:00 2001 From: Chance Strickland <hi@chance.dev> Date: Wed, 16 Nov 2022 15:52:47 -0800 Subject: [PATCH 6/7] fix broken test --- integration/meta-test.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/integration/meta-test.ts b/integration/meta-test.ts index 45aa1512935..2662b050a70 100644 --- a/integration/meta-test.ts +++ b/integration/meta-test.ts @@ -419,7 +419,6 @@ test.describe("v2_meta", () => { { name: "description", content: data.description }, { property: "og:image", content: "https://picsum.photos/200/200" }, { property: "og:type", content: data.contentType }, // undefined - { httpEquiv: "refresh", content: "3;url=https://www.mozilla.org" }, { title: data.title }, ]; @@ -456,7 +455,7 @@ test.describe("v2_meta", () => { "app/routes/music.jsx": js` export function meta({ data, matches }) { - let rootModule = matches.find(match => match.id === "root"); + let rootModule = matches.find(match => match.route.id === "root"); let rootCharSet = rootModule.meta.find(meta => meta.charSet); return [ rootCharSet, From 8b1b030177fd070a0eecb00863626291e59c74fc Mon Sep 17 00:00:00 2001 From: Chance Strickland <hi@chance.dev> Date: Wed, 16 Nov 2022 15:53:22 -0800 Subject: [PATCH 7/7] fix meta resolution --- packages/remix-react/components.tsx | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx index 3f00192fae2..45e4a9efc1d 100644 --- a/packages/remix-react/components.tsx +++ b/packages/remix-react/components.tsx @@ -805,8 +805,13 @@ function V2Meta() { let meta: V2_HtmlMetaDescriptor[] = []; let parentsData: { [routeId: string]: AppData } = {}; - let matchesWithMeta: RouteMatchWithMeta<ClientRoute>[] = []; + 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; @@ -816,7 +821,7 @@ function V2Meta() { let routeMeta: V2_HtmlMetaDescriptor[] | V1_HtmlMetaDescriptor | undefined = []; - if (routeModule.meta) { + if (routeModule?.meta) { routeMeta = typeof routeModule.meta === "function" ? routeModule.meta({ @@ -842,13 +847,14 @@ function V2Meta() { ); } - matchesWithMeta.push({ ...match, meta: routeMeta }); + matchesWithMeta[index].meta = routeMeta; + meta = routeMeta; parentsData[routeId] = data; } return ( <> - {meta.map((metaProps) => { + {meta.flat().map((metaProps) => { if (!metaProps) { return null; } @@ -857,13 +863,16 @@ function V2Meta() { return <title key="title">{String(metaProps.title)}; } - if ("charset" in metaProps) { + 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. - metaProps.charSet = metaProps.charset; - delete metaProps.charset; + return ( + + ); } - return ; })}