-
Notifications
You must be signed in to change notification settings - Fork 2.6k
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
fix(react): dedupe prefetch links #7060
Changes from all commits
e73c2f9
6f966c1
86dc3a4
57d7bb9
e148a5c
741f0f9
115e25f
3ae2067
17ebefd
0136026
ee1a692
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,5 @@ | ||
--- | ||
"@remix-run/react": patch | ||
--- | ||
|
||
Deduplicate prefetch link tags |
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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 <PrefetchPageLinks key={link.page} {...link} />; | ||
return <PrefetchPageLinks key={key} {...link} />; | ||
} | ||
|
||
let imageSrcSet: string | null = null; | ||
|
@@ -360,7 +360,7 @@ export function Links() { | |
|
||
return ( | ||
<link | ||
key={link.rel + (link.href || "") + (imageSrcSet || "")} | ||
key={key} | ||
{...{ | ||
...link, | ||
[imageSizesKey]: imageSizes, | ||
|
@@ -402,26 +402,28 @@ export function PrefetchPageLinks({ | |
); | ||
} | ||
|
||
function usePrefetchedStylesheets(matches: AgnosticDataRouteMatch[]) { | ||
function useKeyedPrefetchLinks(matches: AgnosticDataRouteMatch[]) { | ||
let { manifest, routeModules } = useRemixContext(); | ||
|
||
let [styleLinks, setStyleLinks] = React.useState<HtmlLinkDescriptor[]>([]); | ||
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) => ( | ||
<link key={href} rel="modulepreload" href={href} {...linkProps} /> | ||
))} | ||
{styleLinks.map((link) => ( | ||
{keyedPrefetchLinks.map(({ key, link }) => ( | ||
// these don't spread `linkProps` because they are full link descriptors | ||
// already with their own props | ||
<link key={link.href} {...link} /> | ||
<link key={key} {...link} /> | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixes an unreported bug where responsive image prefetch links would all have a key of |
||
))} | ||
</> | ||
); | ||
|
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I renamed this from |
||
matches: AgnosticDataRouteMatch[], | ||
manifest: AssetsManifest, | ||
routeModules: RouteModules | ||
): Promise<HtmlLinkDescriptor[]> { | ||
): Promise<KeyedHtmlLinkDescriptor[]> { | ||
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( | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is the core fix for #5677. The prefetch links weren't deduped as they were in |
||
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) | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This was just a slight refactor for legibility since |
||
: ({ ...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 extends { [Key in keyof Obj]: Obj[Key] }>(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<Descriptor extends LinkDescriptor = LinkDescriptor> = { | ||
key: string; | ||
link: Descriptor; | ||
}; | ||
|
||
function dedupeLinkDescriptors<Descriptor extends LinkDescriptor>( | ||
descriptors: Descriptor[], | ||
preloads?: string[] | ||
): KeyedLinkDescriptor<Descriptor>[] { | ||
let set = new Set(); | ||
let preloadsSet = new Set(preloads); | ||
|
||
return descriptors.reduce((deduped, descriptor) => { | ||
let alreadyModulePreload = | ||
preloads && | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since the |
||
!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)); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This fixes an unreported bug where the deduping didn't work correctly if properties were specified in a different order. This behaviour is covered in the newly added test. |
||
if (!set.has(key)) { | ||
set.add(key); | ||
deduped.push({ key, link: descriptor }); | ||
} | ||
|
||
return deduped; | ||
}, [] as LinkDescriptor[]); | ||
}, [] as KeyedLinkDescriptor<Descriptor>[]); | ||
} | ||
|
||
// https://github.com/remix-run/history/issues/897 | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
They key is now the exact same string used for deduping. To enable this, the utilities that return arrays of link descriptors now provide
Keyed
objects that expose akey
property for each link.This ensures we never get any duplicate key errors, as opposed to the previous logic which could result in duplicate keys if
rel
,href
and/orimageSrcSet
properties were the same but other properties differed.