Skip to content

Commit

Permalink
feat(react,server-runtime): Support for v2 array syntax for route…
Browse files Browse the repository at this point in the history
… `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.
  • Loading branch information
chaance authored Nov 19, 2022
1 parent 95ce9ce commit 3f878a8
Show file tree
Hide file tree
Showing 7 changed files with 343 additions and 12 deletions.
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

0 comments on commit 3f878a8

Please sign in to comment.