diff --git a/.changeset/deduplicate-prefetch-link-tags.md b/.changeset/deduplicate-prefetch-link-tags.md
new file mode 100644
index 00000000000..b0b8ad82b6a
--- /dev/null
+++ b/.changeset/deduplicate-prefetch-link-tags.md
@@ -0,0 +1,5 @@
+---
+"@remix-run/react": patch
+---
+
+Deduplicate prefetch link tags
diff --git a/integration/prefetch-test.ts b/integration/prefetch-test.ts
index fe326c1e280..5c8b4fb8e50 100644
--- a/integration/prefetch-test.ts
+++ b/integration/prefetch-test.ts
@@ -1,6 +1,11 @@
import { test, expect } from "@playwright/test";
-import { createAppFixture, createFixture, js } from "./helpers/create-fixture";
+import {
+ createAppFixture,
+ createFixture,
+ js,
+ css,
+} from "./helpers/create-fixture";
import type {
Fixture,
FixtureInit,
@@ -424,4 +429,140 @@ test.describe("other scenarios", () => {
);
expect(stylesheets.length).toBe(1);
});
+
+ test("dedupes prefetch tags", async ({ page }) => {
+ fixture = await createFixture({
+ files: {
+ "app/root.tsx": js`
+ import {
+ Link,
+ Links,
+ Meta,
+ Outlet,
+ Scripts,
+ useLoaderData,
+ } from "@remix-run/react";
+
+ export default function Root() {
+ const styles =
+ 'a:hover { color: red; } a:hover:after { content: " (hovered)"; }' +
+ 'a:focus { color: green; } a:focus:after { content: " (focused)"; }';
+
+ return (
+
+
+
+
+
+
+
+ Root
+
+
+
+
+
+ );
+ }
+ `,
+
+ "app/global.css": css`
+ .global-class {
+ background-color: gray;
+ color: black;
+ }
+ `,
+
+ "app/local.css": css`
+ .local-class {
+ background-color: black;
+ color: white;
+ }
+ `,
+
+ "app/routes/_index.tsx": js`
+ export default function() {
+ return Index
;
+ }
+ `,
+
+ "app/routes/with-nested-links.tsx": js`
+ import { Outlet } from "@remix-run/react";
+ import globalCss from "../global.css";
+
+ export function links() {
+ return [
+ // Same links as child route but with different key order
+ {
+ rel: "stylesheet",
+ href: globalCss,
+ },
+ {
+ rel: "preload",
+ as: "image",
+ imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w",
+ imageSizes: "9999px",
+ },
+ ];
+ }
+ export default function() {
+ return ;
+ }
+ `,
+
+ "app/routes/with-nested-links.nested.tsx": js`
+ import globalCss from '../global.css';
+ import localCss from '../local.css';
+
+ export function links() {
+ return [
+ // Same links as parent route but with different key order
+ {
+ href: globalCss,
+ rel: "stylesheet",
+ },
+ {
+ imageSrcSet: "image-600.jpg 600w, image-1200.jpg 1200w",
+ imageSizes: "9999px",
+ rel: "preload",
+ as: "image",
+ },
+ // Unique links for child route
+ {
+ rel: "stylesheet",
+ href: localCss,
+ },
+ {
+ rel: "preload",
+ as: "image",
+ imageSrcSet: "image-700.jpg 700w, image-1400.jpg 1400w",
+ imageSizes: "9999px",
+ },
+ ];
+ }
+ export default function() {
+ return With Nested Links
;
+ }
+ `,
+ },
+ });
+ appFixture = await createAppFixture(fixture);
+
+ let app = new PlaywrightFixture(appFixture, page);
+ await app.goto("/");
+ await page.hover("a[href='/with-nested-links/nested']");
+ await page.waitForSelector("#nav link[rel='prefetch'][as='style']", {
+ state: "attached",
+ });
+ expect(
+ await page.locator("#nav link[rel='prefetch'][as='style']").count()
+ ).toBe(2);
+ expect(
+ await page.locator("#nav link[rel='prefetch'][as='image']").count()
+ ).toBe(2);
+ });
});
diff --git a/packages/remix-react/components.tsx b/packages/remix-react/components.tsx
index 4b79348dad3..97c1a010475 100644
--- a/packages/remix-react/components.tsx
+++ b/packages/remix-react/components.tsx
@@ -36,13 +36,13 @@ import { RemixRootDefaultErrorBoundary } from "./errorBoundaries";
import invariant from "./invariant";
import {
getDataLinkHrefs,
- getLinksForMatches,
+ getKeyedLinksForMatches,
+ getKeyedPrefetchLinks,
getModuleLinkHrefs,
getNewMatchesForLinks,
- getStylesheetPrefetchLinks,
isPageLinkDescriptor,
} from "./links";
-import type { HtmlLinkDescriptor, PrefetchPageDescriptor } from "./links";
+import type { KeyedHtmlLinkDescriptor, PrefetchPageDescriptor } from "./links";
import { createHtml, escapeHtml } from "./markup";
import type {
MetaFunction,
@@ -327,16 +327,16 @@ export function Links() {
)
: routerMatches;
- let links = React.useMemo(
- () => getLinksForMatches(matches, routeModules, manifest),
+ let keyedLinks = React.useMemo(
+ () => getKeyedLinksForMatches(matches, routeModules, manifest),
[matches, routeModules, manifest]
);
return (
<>
- {links.map((link) => {
+ {keyedLinks.map(({ key, link }) => {
if (isPageLinkDescriptor(link)) {
- return ;
+ return ;
}
let imageSrcSet: string | null = null;
@@ -360,7 +360,7 @@ export function Links() {
return (
([]);
+ let [keyedPrefetchLinks, setKeyedPrefetchLinks] = React.useState<
+ KeyedHtmlLinkDescriptor[]
+ >([]);
React.useEffect(() => {
let interrupted: boolean = false;
- getStylesheetPrefetchLinks(matches, manifest, routeModules).then(
- (links) => {
- if (!interrupted) setStyleLinks(links);
+ getKeyedPrefetchLinks(matches, manifest, routeModules).then((links) => {
+ if (!interrupted) {
+ setKeyedPrefetchLinks(links);
}
- );
+ });
return () => {
interrupted = true;
};
}, [matches, manifest, routeModules]);
- return styleLinks;
+ return keyedPrefetchLinks;
}
function PrefetchPageLinksImpl({
@@ -473,7 +475,7 @@ function PrefetchPageLinksImpl({
// needs to be a hook with async behavior because we need the modules, not
// just the manifest like the other links in here.
- let styleLinks = usePrefetchedStylesheets(newMatchesForAssets);
+ let keyedPrefetchLinks = useKeyedPrefetchLinks(newMatchesForAssets);
return (
<>
@@ -483,10 +485,10 @@ function PrefetchPageLinksImpl({
{moduleHrefs.map((href) => (
))}
- {styleLinks.map((link) => (
+ {keyedPrefetchLinks.map(({ key, link }) => (
// these don't spread `linkProps` because they are full link descriptors
// already with their own props
-
+
))}
>
);
diff --git a/packages/remix-react/links.ts b/packages/remix-react/links.ts
index 8ff5025c2fc..dd47c652f15 100644
--- a/packages/remix-react/links.ts
+++ b/packages/remix-react/links.ts
@@ -205,11 +205,11 @@ export type LinkDescriptor = HtmlLinkDescriptor | PrefetchPageDescriptor;
* Gets all the links for a set of matches. The modules are assumed to have been
* loaded already.
*/
-export function getLinksForMatches(
+export function getKeyedLinksForMatches(
matches: AgnosticDataRouteMatch[],
routeModules: RouteModules,
manifest: AssetsManifest
-): LinkDescriptor[] {
+): KeyedLinkDescriptor[] {
let descriptors = matches
.map((match): LinkDescriptor[] => {
let module = routeModules[match.route.id];
@@ -218,7 +218,7 @@ export function getLinksForMatches(
.flat(1);
let preloads = getCurrentPageModulePreloadHrefs(matches, manifest);
- return dedupe(descriptors, preloads);
+ return dedupeLinkDescriptors(descriptors, preloads);
}
let stylesheetPreloadTimeouts = 0;
@@ -339,11 +339,13 @@ function isHtmlLinkDescriptor(object: any): object is HtmlLinkDescriptor {
return typeof object.rel === "string" && typeof object.href === "string";
}
-export async function getStylesheetPrefetchLinks(
+export type KeyedHtmlLinkDescriptor = { key: string; link: HtmlLinkDescriptor };
+
+export async function getKeyedPrefetchLinks(
matches: AgnosticDataRouteMatch[],
manifest: AssetsManifest,
routeModules: RouteModules
-): Promise {
+): Promise {
let links = await Promise.all(
matches.map(async (match) => {
let mod = await loadRouteModule(
@@ -354,15 +356,17 @@ export async function getStylesheetPrefetchLinks(
})
);
- return links
- .flat(1)
- .filter(isHtmlLinkDescriptor)
- .filter((link) => link.rel === "stylesheet" || link.rel === "preload")
- .map((link) =>
- link.rel === "preload"
- ? ({ ...link, rel: "prefetch" } as HtmlLinkDescriptor)
- : ({ ...link, rel: "prefetch", as: "style" } as HtmlLinkDescriptor)
- );
+ return dedupeLinkDescriptors(
+ links
+ .flat(1)
+ .filter(isHtmlLinkDescriptor)
+ .filter((link) => link.rel === "stylesheet" || link.rel === "preload")
+ .map((link) =>
+ link.rel === "stylesheet"
+ ? ({ ...link, rel: "prefetch", as: "style" } as HtmlLinkDescriptor)
+ : ({ ...link, rel: "prefetch" } as HtmlLinkDescriptor)
+ )
+ );
}
// This is ridiculously identical to transition.ts `filterMatchesToLoad`
@@ -499,12 +503,32 @@ function dedupeHrefs(hrefs: string[]): string[] {
return [...new Set(hrefs)];
}
-export function dedupe(descriptors: LinkDescriptor[], preloads: string[]) {
+function sortKeys(obj: Obj): Obj {
+ let sorted = {} as Obj;
+ let keys = Object.keys(obj).sort();
+
+ for (let key of keys) {
+ sorted[key as keyof Obj] = obj[key as keyof Obj];
+ }
+
+ return sorted;
+}
+
+type KeyedLinkDescriptor = {
+ key: string;
+ link: Descriptor;
+};
+
+function dedupeLinkDescriptors(
+ descriptors: Descriptor[],
+ preloads?: string[]
+): KeyedLinkDescriptor[] {
let set = new Set();
let preloadsSet = new Set(preloads);
return descriptors.reduce((deduped, descriptor) => {
let alreadyModulePreload =
+ preloads &&
!isPageLinkDescriptor(descriptor) &&
descriptor.as === "script" &&
descriptor.href &&
@@ -514,14 +538,14 @@ export function dedupe(descriptors: LinkDescriptor[], preloads: string[]) {
return deduped;
}
- let str = JSON.stringify(descriptor);
- if (!set.has(str)) {
- set.add(str);
- deduped.push(descriptor);
+ let key = JSON.stringify(sortKeys(descriptor));
+ if (!set.has(key)) {
+ set.add(key);
+ deduped.push({ key, link: descriptor });
}
return deduped;
- }, [] as LinkDescriptor[]);
+ }, [] as KeyedLinkDescriptor[]);
}
// https://github.com/remix-run/history/issues/897