Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(react,server-runtime): Support for v2 array syntax for route meta #4610

Merged
merged 10 commits into from
Nov 19, 2022
7 changes: 7 additions & 0 deletions .changeset/tricky-bobcats-nail.md
Original file line number Diff line number Diff line change
@@ -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).
129 changes: 129 additions & 0 deletions integration/meta-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<Scripts />
</body>
</html>
);
}
`,

"app/routes/index.jsx": js`
export const meta = ({ data, matches }) => [
...matches.map((match) => match.meta),
];
export default function Index() {
return <div>This is the index file</div>;
}
`,

"app/routes/no-meta.jsx": js`
export default function NoMeta() {
return <div>No meta here!</div>;
}
`,

"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 <h1>Music</h1>;
}
`,
},
});
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 <meta charset='utf-8' />", async ({ page }) => {
let app = new PlaywrightFixture(appFixture, page);
await app.goto("/");
expect(await app.getHtml('meta[charset="utf-8"]')).toBeTruthy();
});

test("{ title } adds a <title />", 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();
});
});
117 changes: 113 additions & 4 deletions packages/remix-react/components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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)}</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 (
<meta
key="charset"
charSet={metaProps.charSet || (metaProps as any).charset}
/>
);
}
return <meta key={JSON.stringify(metaProps)} {...metaProps} />;
})}
</>
);
}

export function Meta() {
let { future } = useRemixEntryContext();
return future.v2_meta ? <V2Meta /> : <V1Meta />;
}

/**
* Tracks whether Remix has finished hydrating or not, so scripts can be skipped
* during client-side updates.
Expand Down
41 changes: 37 additions & 4 deletions packages/remix-react/routeModules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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;
}

Expand Down Expand Up @@ -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<Route> extends BaseRouteMatch<Route> {
meta: V2_HtmlMetaDescriptor[];
}

export interface V2_MetaFunction {
(args: {
data: AppData;
parentsData: RouteData;
params: Params;
location: Location;
matches: RouteMatchWithMeta<ClientRoute>[];
}): V2_HtmlMetaDescriptor[] | undefined;
}

/**
Expand All @@ -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;
Expand All @@ -82,6 +104,17 @@ export interface HtmlMetaDescriptor {
| Array<Record<string, string> | 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.
Expand Down
3 changes: 2 additions & 1 deletion packages/remix-react/routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@ export interface RouteManifest<Route> {

// 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
Expand Down
Loading