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 ", 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[] = 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 {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 {