Skip to content

Instantly share code, notes, and snippets.

@ryanflorence
Last active April 23, 2024 18:29
Show Gist options
  • Star 14 You must be signed in to star a gist
  • Fork 5 You must be signed in to fork a gist
  • Save ryanflorence/ec1849c6d690cfbffcb408ecd633e069 to your computer and use it in GitHub Desktop.
Save ryanflorence/ec1849c6d690cfbffcb408ecd633e069 to your computer and use it in GitHub Desktop.
import type { V2_HtmlMetaDescriptor, V2_MetaFunction } from "@remix-run/node";
export const mergeMeta = (
overrideFn: V2_MetaFunction,
appendFn?: V2_MetaFunction,
): V2_MetaFunction => {
return arg => {
// get meta from parent routes
let mergedMeta = arg.matches.reduce((acc, match) => {
return acc.concat(match.meta || []);
}, [] as V2_HtmlMetaDescriptor[]);
// replace any parent meta with the same name or property with the override
let overrides = overrideFn(arg);
for (let override of overrides) {
let index = mergedMeta.findIndex(
meta =>
("name" in meta &&
"name" in override &&
meta.name === override.name) ||
("property" in meta &&
"property" in override &&
meta.property === override.property) ||
("title" in meta && "title" in override),
);
if (index !== -1) {
mergedMeta.splice(index, 1, override);
}
}
// append any additional meta
if (appendFn) {
mergedMeta = mergedMeta.concat(appendFn(arg));
}
return mergedMeta;
};
};
import { mergeMeta } from "./merge-meta";
export const meta = mergeMeta(
// these will override the parent meta
({ data }) => {
return [{ title: data.project.name }];
},
// these will be appended to the parent meta
({ matches }) => {
return [{ name: "author", content: "Ryan Florence" }];
},
);
// both functions get the same arguments as a normal meta function
@sinitsa
Copy link

sinitsa commented May 20, 2023

How would I go about using this similar to the loader data to have the types available?

This works in my IDE, but throws an error on the server: You cannot useLoaderData in an errorElement (routeId: root)

	({ data }: V2_ServerRuntimeMetaArgs<typeof loader>) => {

@sergiocarneiro
Copy link

This is an important feature, at least for me. Is this likely to be included in the core library? If not, I may publish an improved version in remix-utils.

@cairin
Copy link

cairin commented Sep 28, 2023

The above mergeMeta will run into issues when you get to deeper nested routes that attempt to override the same meta tags. This is because the mergedMeta reducer will concatenate the meta from all the parent routes. So if you have any duplicates in the parent routes, or attempt to override the same tag in different routes, they will be duplicated in the merged meta. And if you try and override the duplicate meta, it will only override the first (closer to the root) instance of that tag.

This is a problem because the <Meta /> component uses a JSON string of the meta props. So when we have duplicate tags React complains about duplicate keys.

A better solution would be to only merge the meta from the direct parent route of the current route.
The output of matches should always be ordered such that root route -> parent route -> child route. So we can simply changing the following code:

  return arg => {
    // get meta from parent routes
-    let mergedMeta = arg.matches.reduce((acc, match) => {
-      return acc.concat(match.meta || []);
+    let mergedMeta = arg.matches.reduceRight((acc, match) => {
+      if (match.meta.length > 0 && acc.length == 0)
+        return acc.concat(match.meta || [])
+      return acc
    }, [] as V2_HtmlMetaDescriptor[]);

    // replace any parent meta with the same name or property with the override

This still has issues though, because you can still append meta using the appendFn and cause duplicates. To solve this you could check that you're not overriding any tags when appending, or just be careful when appending tags 🤷 This becomes tedious on larger projects.

An alternative solution is to provide only one function that takes precedence over the parent meta:

// NOTE: The type MetaFunction here is the v2 version
export const mergeMeta = <
  Loader extends LoaderFunction | unknown = unknown,
  ParentsLoaders extends Record<string, LoaderFunction | unknown> = Record<
    string,
    unknown
  >
>(
  leafMetaFn: MetaFunction<Loader, ParentsLoaders>
): MetaFunction<Loader, ParentsLoaders> => {
  return (arg) => {
    let leafMeta = leafMetaFn(arg)

    return arg.matches.reduceRight((acc, match) => {
      for (let parentMeta of match.meta) {
        let 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)
  }
}

This flips the original mergeMeta on it's head by assuming you always want the added leaf meta tags, and only including parent tags if they're not a duplicate - giving precendence to tags in routes furthest from the root.

This also duplicates the generic typing of MetaFunction so that you can pass your loader type through, so usage now looks like this:

export const meta: MetaFunction<typeof loader> = mergeMeta(({ data }) => [
  // data is now typed based on your loader
  { title: data.project.name },
  { name: "author", content: "Ryan Florence" }
])

And as before leafMetaFn takes the same arguments as MetaFunction.

@n3oney
Copy link

n3oney commented Dec 3, 2023

Thank you for your code @cairin, it works great, however it seems like you've forgotten about ('script:ld+json' in meta && 'script:ld+json' in parentMeta), since it's a possibility for merging too.

@cairin
Copy link

cairin commented Dec 5, 2023

Thank you for your code @cairin, it works great, however it seems like you've forgotten about ('script:ld+json' in meta && 'script:ld+json' in parentMeta), since it's a possibility for merging too.

You wouldn't want to deduplicate scripts like that, because that would deduplicate different scripts too. You would need to do a full object comparison. It's probably more useful for you to add that code yourself based on your specific requirements.

You also shouldn't need to install the same script in multiple routes anyway, as long as it's installed in a route somewhere above the current route it should still be usable on the leaf route.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment