Skip to content

Commit 9b44e82

Browse files
authored
fix(runtime): dedupe string-mode SSR stylesheets via shared hasStylesheetLink (v2) (#8689)
1 parent 192de27 commit 9b44e82

5 files changed

Lines changed: 56 additions & 50 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
'@modern-js/runtime': patch
3+
---
4+
5+
fix(runtime): string-mode SSR no longer drops a route's stylesheet when the same CSS is referenced by a non-stylesheet `<link>` (e.g. `<link rel="prefetch">`)
6+
7+
`LoadableCollector.emitStyleAssets` (string SSR) deduped injected route stylesheets against every `<link href>` in the template, so a `<link rel="prefetch">` for the same css URL (e.g. from `performance.prefetch`) made the real `<link rel="stylesheet">` be skipped and the route rendered unstyled. It now reuses the shared `hasStylesheetLink` helper (also used by streaming SSR), which only matches existing `<link rel="stylesheet">` tags.

packages/runtime/plugin-runtime/src/core/server/stream/beforeTemplate.ts

Lines changed: 1 addition & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import { CHUNK_CSS_PLACEHOLDER } from '../constants';
66
import { createReplaceHelemt } from '../helmet';
77
import type { HandleRequestConfig } from '../requestHandler';
88
import { type BuildHtmlCb, buildHtml } from '../shared';
9-
import { checkIsNode, safeReplace } from '../utils';
9+
import { checkIsNode, hasStylesheetLink, safeReplace } from '../utils';
1010

1111
const readAsset = async (chunk: string) => {
1212
// working node env
@@ -36,42 +36,6 @@ const checkIsInline = (
3636
}
3737
};
3838

39-
export const hasStylesheetLink = (template: string, href: string) => {
40-
const linkTags = template.match(/<link\b[^>]*>/gi) ?? [];
41-
return linkTags.some(linkTag => {
42-
const attributes = getLinkAttributes(linkTag);
43-
const linkHref = attributes.get('href');
44-
const rel = attributes.get('rel');
45-
46-
return (
47-
linkHref === href &&
48-
rel
49-
?.split(/\s+/)
50-
.some(relToken => relToken.toLowerCase() === 'stylesheet')
51-
);
52-
});
53-
};
54-
55-
const getLinkAttributes = (linkTag: string) => {
56-
const attributes = new Map<string, string>();
57-
const attributeRegExp =
58-
/([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
59-
let match: RegExpExecArray | null;
60-
61-
while ((match = attributeRegExp.exec(linkTag))) {
62-
const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match;
63-
if (name.toLowerCase() === 'link') {
64-
continue;
65-
}
66-
attributes.set(
67-
name.toLowerCase(),
68-
doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? '',
69-
);
70-
}
71-
72-
return attributes;
73-
};
74-
7539
export interface BuildShellBeforeTemplateOptions {
7640
runtimeContext: RuntimeContext;
7741
entryName: string;

packages/runtime/plugin-runtime/src/core/server/string/loadable.ts

Lines changed: 5 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { type Chunk, ChunkExtractor } from '@loadable/server';
22
import type { ReactElement } from 'react';
3-
import { attributesToString, checkIsNode } from '../utils';
3+
import { attributesToString, checkIsNode, hasStylesheetLink } from '../utils';
44
import type { ChunkSet, Collector } from './types';
55

66
declare module '@loadable/server' {
@@ -209,21 +209,14 @@ export class LoadableCollector implements Collector {
209209

210210
const atrributes = attributesToString(this.generateAttributes());
211211

212-
const linkRegExp = /<link .*?href="([^"]+)".*?>/g;
213-
214-
const matchs = template.matchAll(linkRegExp);
215-
216-
const existedLinks: string[] = [];
217-
218-
for (const match of matchs) {
219-
existedLinks.push(match[1]);
220-
}
221-
222212
const css = await Promise.all(
223213
chunks
224214
.filter(chunk => {
215+
// Only an existing `<link rel="stylesheet">` should dedupe the route
216+
// stylesheet we are about to inject. A `<link rel="prefetch">` for the
217+
// same css URL (e.g. from `performance.prefetch`) must not block it.
225218
return (
226-
!existedLinks.includes(chunk.url) &&
219+
!hasStylesheetLink(template, chunk.url) &&
227220
!this.existsAssets?.includes(chunk.path)
228221
);
229222
})

packages/runtime/plugin-runtime/src/core/server/utils.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,3 +79,45 @@ export function getSSRMode(ssrConfig?: SSRConfig): 'string' | 'stream' | false {
7979

8080
return ssrConfig?.mode === 'stream' ? 'stream' : 'string';
8181
}
82+
83+
const getLinkAttributes = (linkTag: string) => {
84+
const attributes = new Map<string, string>();
85+
const attributeRegExp =
86+
/([^\s"'<>/=]+)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+)))?/g;
87+
let match: RegExpExecArray | null;
88+
89+
while ((match = attributeRegExp.exec(linkTag))) {
90+
const [, name, doubleQuotedValue, singleQuotedValue, unquotedValue] = match;
91+
if (name.toLowerCase() === 'link') {
92+
continue;
93+
}
94+
attributes.set(
95+
name.toLowerCase(),
96+
doubleQuotedValue ?? singleQuotedValue ?? unquotedValue ?? '',
97+
);
98+
}
99+
100+
return attributes;
101+
};
102+
103+
/**
104+
* Whether the template already contains a `<link rel="stylesheet">` for `href`.
105+
* Other link rels (e.g. `<link rel="prefetch">` emitted by `performance.prefetch`,
106+
* or `<link rel="preload" as="style">`) may reference the same css URL but do not
107+
* apply styles, so they must not block stylesheet injection during SSR.
108+
*/
109+
export const hasStylesheetLink = (template: string, href: string) => {
110+
const linkTags = template.match(/<link\b[^>]*>/gi) ?? [];
111+
return linkTags.some(linkTag => {
112+
const attributes = getLinkAttributes(linkTag);
113+
const linkHref = attributes.get('href');
114+
const rel = attributes.get('rel');
115+
116+
return (
117+
linkHref === href &&
118+
rel
119+
?.split(/\s+/)
120+
.some(relToken => relToken.toLowerCase() === 'stylesheet')
121+
);
122+
});
123+
};

packages/runtime/plugin-runtime/tests/ssr/beforeTemplate.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { hasStylesheetLink } from '../../src/core/server/stream/beforeTemplate';
1+
import { hasStylesheetLink } from '../../src/core/server/utils';
22

33
describe('hasStylesheetLink', () => {
44
const href = '/static/css/async/about/page.css';

0 commit comments

Comments
 (0)