Updating mergeMeta
utility suggested in Remix docs for use in React Router V7
#12672
-
Whilst upgrading a Remix app to use React Router V7, the I've created an example repo here which demonstrates how the existing util throws TS errors; https://github.com/charlie-bud/rr-v7.x-meta-types-repro I've also tried updating // app/utils/meta.ts
export const mergeMeta =
<MetaFn extends MetaFunction<unknown, Record<string, unknown>>>(
leafMetaFn: MetaFn,
) =>
(arg: MetaArgs) => {
const leafMeta = leafMetaFn(arg);
return arg.matches.reduceRight((acc, match) => {
// eslint-disable-next-line no-restricted-syntax
for (const parentMeta of match.meta) {
const index = acc?.findIndex(
(meta) =>
("name" in meta &&
"name" in parentMeta &&
meta.name === parentMeta.name) ||
("property" in meta &&
"property" in parentMeta &&
meta.property === parentMeta.property) ||
("title" in meta && "title" in parentMeta),
);
if (index === -1) {
// Parent meta not found in acc, so add it
acc?.push(parentMeta);
}
}
return acc;
}, leafMeta);
};
// app/routes/my-route.ts
export const meta = mergeMeta<Route.MetaFunction>(({ data }) => {
// Do something here
}) The above correctly infers the arguments passed to the callback (e.g.
I appreciate that typing has changed a fair bit in React Router V7, so rather than suggesting as an issue, I'm wondering if there's anyone who has had any luck in migrating |
Beta Was this translation helpful? Give feedback.
Replies: 3 comments 2 replies
-
I've developed a solution for my project by creating a new merge function called import type { ClientLoaderFunction, LoaderFunction, MetaDescriptor, MetaFunction } from 'react-router';
import type { CreateMetaArgs, MetaDescriptors } from 'react-router/route-module';
/**
* Merging helper
*
* {@link https://remix.run/docs/en/main/route/meta#meta-merging-helper}
*
* If you can't avoid the merge problem with global meta or index routes, we've created
* a helper that you can put in your app that can override and append to parent meta easily.
*
* @example
* ```typescript
* import type { MetaFunction } from 'react-router';
*
* import { mergeMeta } from '~/utils/meta-utils';
*
* export const meta: MetaFunction<typeof loader> = mergeMeta(({ data }) => {
* return [
* { title: "My Leaf Route" },
* ];
* });
*
* // In a parent route:
* import type { MetaFunction } from 'react-router';
*
* export const meta: MetaFunction<typeof loader> = ({ data }) => {
* return [
* { title: "My Parent Route" },
* { name: 'description', content: "This is the parent route" },
* ];
* }
* ```
* The resulting meta will contain both `title: 'My Leaf Route'` and `description: 'This is the parent route'`.
*/
export function mergeMeta<Loader extends LoaderFunction | ClientLoaderFunction | unknown = unknown, ParentsLoaders extends Record<string, LoaderFunction | ClientLoaderFunction | unknown> = Record<string, unknown>>(
leafMetaFn: MetaFunction<Loader, ParentsLoaders>,
): MetaFunction<Loader, ParentsLoaders> {
return (args) => {
const leafMeta = leafMetaFn(args);
return args.matches.reduceRight((acc, match) => {
for (const parentMeta of match.meta) {
addUniqueMeta(acc, parentMeta);
}
return acc;
}, leafMeta);
};
}
/**
* Merging helper that works with Route Module Type Safety
*
* If you can't avoid the merge problem with global meta or index routes, we've created
* a helper that you can put in your app that can override and append to parent meta easily.
*
* @example
* ```typescript
* import type { Route } from './+types/leaf';
*
* import { mergeRouteModuleMeta } from '~/utils/meta-utils';
*
* export const meta: Route.MetaFunction = mergeRouteModuleMeta(({ data }) => {
* return [
* { title: "My Leaf Route" },
* ];
* });
*
* // In a parent route:
* import type { Route } from './+types/root';
*
* export const meta: Route.MetaFunction = ({ data }) => {
* return [
* { title: "My Parent Route" },
* { name: 'description', content: "This is the parent route" },
* ];
* }
* ```
* The resulting meta will contain both `title: 'My Leaf Route'` and `description: 'This is the parent route'`.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export function mergeRouteModuleMeta<TMetaArgs extends CreateMetaArgs<any>>(leafMetaFn: (args: TMetaArgs) => MetaDescriptors): (args: TMetaArgs) => MetaDescriptors {
return (args) => {
const leafMeta = leafMetaFn(args);
return args.matches.reduceRight((acc, match) => {
for (const parentMeta of match?.meta ?? []) {
addUniqueMeta(acc, parentMeta);
}
return acc;
}, leafMeta);
};
}
function addUniqueMeta(acc: MetaDescriptor[] | undefined, parentMeta: MetaDescriptor) {
if (acc?.findIndex((meta) => isMetaEqual(meta, parentMeta)) === -1) {
acc.push(parentMeta);
}
}
function isMetaEqual(meta1: MetaDescriptor, meta2: MetaDescriptor): boolean {
// prettier-ignore
return ('name' in meta1 && 'name' in meta2 && meta1.name === meta2.name) ||
('property' in meta1 && 'property' in meta2 && meta1.property === meta2.property) ||
('title' in meta1 && 'title' in meta2);
} |
Beta Was this translation helpful? Give feedback.
-
I've found a solution by copying over the necessary types in order to merge the titles for my titles. Parent export const loader = () => {
return { title: 'parent' };
}; Child export const loader = () => {
return { title: 'child' };
};
// This will result in: "child · parent"
export const meta: Route.MetaFunction = (args) => [{ title: mergeTitles(args) }]; Meta utility function // These types are copied over from React Router's route module as RouteInfo is not exported
type Func = (...args: any[]) => unknown;
type RouteModule = {
meta?: Func;
links?: Func;
headers?: Func;
loader?: Func;
clientLoader?: Func;
action?: Func;
clientAction?: Func;
HydrateFallback?: unknown;
default?: unknown;
ErrorBoundary?: unknown;
[key: string]: unknown;
};
type RouteInfo = {
parents: RouteInfo[];
module: RouteModule;
id: unknown;
file: string;
path: string;
params: unknown;
loaderData: unknown;
actionData: unknown;
};
export const mergeTitles = <T extends RouteInfo>({ matches }: CreateMetaArgs<T>) => {
return Object.values(matches)
.reduce<string[]>((titles, match) => {
if (!isLoaderDataWithTitle(match?.data)) {
return titles;
}
return [match.data.title, ...titles];
}, [])
.join(' · ');
}; |
Beta Was this translation helpful? Give feedback.
-
I feel like sharing my approach:
import type { MetaDescriptors } from "react-router/route-module";
type MetaPatch = {
replace?: MetaDescriptors;
add?: MetaDescriptors;
};
type HasMeta = {
meta: MetaDescriptors;
};
type HasMetaMatches = {
matches: Array<HasMeta | undefined>;
};
type MetaDescriptor = MetaDescriptors[number];
type MetaPatcher<Arg> = (args: Arg) => MetaPatch;
/**
* Merging helper that works with Route Module Type Safety
*
* Note that the modifications will be done over the last match that has meta,
* not over the full list of matches, so if you want to collect metas from
* all parts of the route, all the meta functions should have this helper.
*
* Also note that some meta tags should can only appear once. Place those
* tags in the `replace` array to make sure they are not duplicated. The type
* system will try to enforce this, but it's not perfect.
*
* @param leafMetaFn The meta function for the leaf route. It returns an object
* with any of `add` or `replace`, with MetaDescriptors that
* should be added or replaced.
* @example
* ```typescript
* import type { Route } from './+types/leaf';
*
* import { patchMeta } from '~/utils/meta-utils';
*
* export const meta = metaPatcher((args) => ({
* replace: [{ title: "License" }],
* add: [{ name: "author", content: "John Doe" }]
* })
* );
*
* // In a parent route:
* import type { Route } from './+types/my_page';
*
* export const meta = metaPatcher((args) => ({
* replace: [{ name: "description", content: "Legal Documents" }]
* })
* );
* ```
* The resulting meta will contain both `title: 'My Leaf Route'` and `description: 'This is the parent route'`.
*/
export function metaPatcher<Args extends HasMetaMatches>(
leafMetaFn: MetaPatcher<Args>
): (args: Args) => MetaDescriptors {
return (args: Args): MetaDescriptors => {
const leafMeta = leafMetaFn(args);
const last_match_with_meta = args.matches?.find((match) => (match?.meta.length ?? 0) > 0);
const last_meta = last_match_with_meta?.meta ?? [];
return patchMeta(last_meta, leafMeta.replace, leafMeta.add);
};
}
/**
* Patching function in case you need to implement your own meta merging logic.
*/
export function patchMeta(
original: MetaDescriptors,
replace?: MetaDescriptors,
add?: MetaDescriptors
): MetaDescriptors {
for (const addition of add ?? []) {
if (mustReplace(addition, addition)) {
console.warn(
`Adding the tag ${JSON.stringify(addition)} can potentially create duplicates, use replace.`
);
}
}
return original
.filter(
(originalMeta) => !replace?.some((replacement) => canReplace(originalMeta, replacement))
)
.concat(replace ?? [])
.concat(add ?? []);
}
function canReplace(original: MetaDescriptor, replacement: MetaDescriptor): boolean {
if (mustReplace(original, replacement)) {
return true;
} else if ("tagName" in original && "tagName" in replacement) {
const keys = Object.keys(original);
return (
original.tagName === replacement.tagName &&
keys.length >= 2 &&
keys.every((key) => key in replacement)
);
} else if ("name" in original && "name" in replacement) {
return original.name === replacement.name;
} else {
return false;
}
}
const uniqueMetaNames = [
"title",
"description",
"robots",
"viewport",
"application-name",
"theme-color",
"color-scheme",
];
function mustReplace(original: MetaDescriptor, replacement: MetaDescriptor): boolean {
if ("charSet" in original && "charSet" in replacement) {
return true;
} else if ("httpEquiv" in original && "httpEquiv" in replacement) {
return original.httpEquiv === replacement.httpEquiv;
} else if ("title" in original && "title" in replacement) {
return true;
} else if ("tagName" in original && "tagName" in replacement) {
return (
original.tagName === replacement.tagName &&
original.tagName == "link" &&
"rel" in original &&
"rel" in replacement &&
original.rel === replacement.rel &&
original.rel === "canonical"
);
} else if ("name" in original && "name" in replacement) {
if (typeof original.name != "string" || typeof replacement.name != "string") {
// Not replacing weird things that we don't understand
return false;
} else {
return original.name === replacement.name && uniqueMetaNames.includes(original.name);
}
} else {
return false;
}
} |
Beta Was this translation helpful? Give feedback.
I've developed a solution for my project by creating a new merge function called
mergeRouteModuleMeta
to ensure type safety with Route Module Safety. We're gradually refactoring our numerous routes to incorporate this Route Module Type Safety.