@bleedingdev/modern-js-runtime-renderer-extensions 0.0.0 → 3.9.0-ultramodern.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/dist/cjs/assetPolicy.js +89 -0
- package/dist/cjs/federatedCss.js +57 -0
- package/dist/cjs/node.js +53 -0
- package/dist/cjs/runtimePlugin.js +143 -0
- package/dist/cjs/templatePolicy.js +76 -0
- package/dist/esm/assetPolicy.mjs +48 -0
- package/dist/esm/federatedCss.mjs +19 -0
- package/dist/esm/runtimePlugin.mjs +87 -0
- package/dist/esm/templatePolicy.mjs +38 -0
- package/dist/esm-node/assetPolicy.mjs +49 -0
- package/dist/esm-node/federatedCss.mjs +20 -0
- package/dist/esm-node/node.mjs +11 -0
- package/dist/esm-node/runtimePlugin.mjs +88 -0
- package/dist/esm-node/templatePolicy.mjs +39 -0
- package/dist/types/assetPolicy.d.ts +19 -0
- package/dist/types/federatedCss.d.ts +6 -0
- package/dist/types/node.d.ts +3 -0
- package/dist/types/runtimePlugin.d.ts +13 -0
- package/dist/types/templatePolicy.d.ts +2 -0
- package/package.json +60 -7
- package/rslib.config.mts +19 -0
- package/rstest.config.mts +29 -0
- package/src/assetPolicy.ts +86 -0
- package/src/federatedCss.ts +43 -0
- package/src/node.ts +16 -0
- package/src/runtimePlugin.ts +141 -0
- package/src/templatePolicy.ts +69 -0
- package/tests/federatedCss.test.ts +49 -0
- package/tests/headRuntime.test.tsx +163 -0
- package/tests/nativeAssetPolicy.test.tsx +377 -0
- package/tests/nativeStreamHead.test.tsx +77 -0
- package/tests/nativeStringHead.test.tsx +128 -0
- package/tests/nativeTemplatePolicy.test.tsx +366 -0
- package/tests/rootWrapOrder.test.tsx +70 -0
- package/tests/scriptOrder.security.test.ts +42 -0
- package/tsconfig.json +10 -0
- package/README.md +0 -3
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
import { getGlobalInternalRuntimeContext } from '../../plugin-runtime/src/core/context';
|
|
2
|
+
import { registerPlugin } from '../../plugin-runtime/src/core/plugin';
|
|
3
|
+
import {
|
|
4
|
+
createSSRRenderLifecycle,
|
|
5
|
+
replaceSSRTemplateChunk,
|
|
6
|
+
} from '../../plugin-runtime/src/core/server/shared';
|
|
7
|
+
import { rendererHeadPlugin } from '../src/node';
|
|
8
|
+
|
|
9
|
+
const lifecycleFor = (options: any, mode: 'string' | 'stream' = 'stream') => {
|
|
10
|
+
registerPlugin([rendererHeadPlugin()]);
|
|
11
|
+
const hooks = getGlobalInternalRuntimeContext().hooks;
|
|
12
|
+
const render = {
|
|
13
|
+
runtimeContext: options.runtimeContext,
|
|
14
|
+
request: options.request ?? new Request('http://localhost/'),
|
|
15
|
+
resource: options.resource ?? {
|
|
16
|
+
entryName: options.entryName,
|
|
17
|
+
routeManifest: options.routeManifest,
|
|
18
|
+
moduleFederationCssAssets: options.moduleFederationCssAssets,
|
|
19
|
+
},
|
|
20
|
+
config: options.config ?? {},
|
|
21
|
+
platform: 'node' as const,
|
|
22
|
+
mode,
|
|
23
|
+
isRsc: false,
|
|
24
|
+
};
|
|
25
|
+
return createSSRRenderLifecycle(
|
|
26
|
+
mode === 'string'
|
|
27
|
+
? (hooks.extendStringSSRCollectors
|
|
28
|
+
.call({ chunkSet: options.chunkSet, render })
|
|
29
|
+
.filter(Boolean) as any)
|
|
30
|
+
: (hooks.extendStreamSSR
|
|
31
|
+
.call({ ...render, terminalMarker: '<!--end-->' })
|
|
32
|
+
.filter(Boolean) as any),
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const buildShellBeforeTemplate = (template: string, options: any) =>
|
|
37
|
+
nativeBuildShellBeforeTemplate(template, {
|
|
38
|
+
...options,
|
|
39
|
+
lifecycle: lifecycleFor(options),
|
|
40
|
+
});
|
|
41
|
+
const buildWorkerShellBeforeTemplate = (template: string, options: any) =>
|
|
42
|
+
nativeBuildWorkerShellBeforeTemplate(template, {
|
|
43
|
+
...options,
|
|
44
|
+
lifecycle: lifecycleFor(options),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
import { applyRouterRuntimeState } from '@modern-js/runtime-extensions/router-state';
|
|
48
|
+
import { CHUNK_CSS_PLACEHOLDER } from '../../plugin-runtime/src/core/server/constants';
|
|
49
|
+
import { buildShellBeforeTemplate as nativeBuildShellBeforeTemplate } from '../../plugin-runtime/src/core/server/stream/beforeTemplate';
|
|
50
|
+
import { buildShellBeforeTemplate as nativeBuildWorkerShellBeforeTemplate } from '../../plugin-runtime/src/core/server/stream/beforeTemplate.worker';
|
|
51
|
+
|
|
52
|
+
const withRouterSnapshot = (
|
|
53
|
+
runtimeContext: Record<string, unknown>,
|
|
54
|
+
serverSnapshot: Record<string, unknown>,
|
|
55
|
+
) => {
|
|
56
|
+
applyRouterRuntimeState(runtimeContext as any, {
|
|
57
|
+
framework: 'react-router',
|
|
58
|
+
serverSnapshot,
|
|
59
|
+
});
|
|
60
|
+
return runtimeContext;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
describe('buildShellBeforeTemplate', () => {
|
|
64
|
+
it.each([
|
|
65
|
+
['node', buildShellBeforeTemplate],
|
|
66
|
+
['worker', buildWorkerShellBeforeTemplate],
|
|
67
|
+
])('preserves the complete %s CSS priority order without duplicate assets', async (_runtime, buildTemplate) => {
|
|
68
|
+
const runtimeContext = withRouterSnapshot(
|
|
69
|
+
{
|
|
70
|
+
routeManifest: {
|
|
71
|
+
routeAssets: {
|
|
72
|
+
'route-a': {
|
|
73
|
+
referenceCssAssets: ['/assets/route-a.css', '/assets/shared.css'],
|
|
74
|
+
},
|
|
75
|
+
'route-b': {
|
|
76
|
+
referenceCssAssets: ['/assets/route-b.css'],
|
|
77
|
+
},
|
|
78
|
+
'async-main': {
|
|
79
|
+
referenceCssAssets: ['/assets/async-main.css'],
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
matchedRouteIds: ['route-a', 'route-b'],
|
|
86
|
+
},
|
|
87
|
+
);
|
|
88
|
+
const helmetStylesheet =
|
|
89
|
+
'<link href="/assets/helmet.css" rel="stylesheet" data-rh="true">';
|
|
90
|
+
const helmetData = {
|
|
91
|
+
bodyAttributes: '',
|
|
92
|
+
htmlAttributes: '',
|
|
93
|
+
base: '',
|
|
94
|
+
priority: '',
|
|
95
|
+
link: helmetStylesheet,
|
|
96
|
+
meta: '',
|
|
97
|
+
noscript: '',
|
|
98
|
+
script: '',
|
|
99
|
+
style: '',
|
|
100
|
+
title: '',
|
|
101
|
+
} as any;
|
|
102
|
+
|
|
103
|
+
const styledComponentsStyleTags =
|
|
104
|
+
'<style data-styled="true">.styled{color:red}</style>';
|
|
105
|
+
const orderedFragments = [
|
|
106
|
+
'<link href="/assets/route-a.css" rel="stylesheet" />',
|
|
107
|
+
'<link href="/assets/shared.css" rel="stylesheet" />',
|
|
108
|
+
'<link href="/assets/route-b.css" rel="stylesheet" />',
|
|
109
|
+
'<link href="/assets/async-main.css" rel="stylesheet" />',
|
|
110
|
+
styledComponentsStyleTags,
|
|
111
|
+
'<link href="/assets/federated.css" rel="stylesheet" />',
|
|
112
|
+
helmetStylesheet,
|
|
113
|
+
];
|
|
114
|
+
const expectedHtml = `<html><head>${orderedFragments
|
|
115
|
+
.slice(0, -1)
|
|
116
|
+
.join('')} ${helmetStylesheet}\n</head><body></body></html>`;
|
|
117
|
+
const html = await buildTemplate(
|
|
118
|
+
`<html><head>${CHUNK_CSS_PLACEHOLDER}</head><body></body></html>`,
|
|
119
|
+
{
|
|
120
|
+
entryName: 'main',
|
|
121
|
+
runtimeContext: runtimeContext as any,
|
|
122
|
+
config: {} as any,
|
|
123
|
+
styledComponentsStyleTags,
|
|
124
|
+
helmetData,
|
|
125
|
+
moduleFederationCssAssets: [
|
|
126
|
+
'/assets/shared.css',
|
|
127
|
+
'/assets/federated.css',
|
|
128
|
+
],
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
expect(html).toBe(expectedHtml);
|
|
133
|
+
});
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const createRouteHydrationScriptTags = (
|
|
137
|
+
runtimeContext: any,
|
|
138
|
+
entryName: string,
|
|
139
|
+
options: any = {},
|
|
140
|
+
) => {
|
|
141
|
+
const lifecycle = lifecycleFor({
|
|
142
|
+
runtimeContext,
|
|
143
|
+
entryName,
|
|
144
|
+
config: options,
|
|
145
|
+
});
|
|
146
|
+
return replaceSSRTemplateChunk(
|
|
147
|
+
{
|
|
148
|
+
name: 'scripts',
|
|
149
|
+
template: `${options.template ?? ''}<!--assets-->`,
|
|
150
|
+
placeholder: '<!--assets-->',
|
|
151
|
+
content: '',
|
|
152
|
+
},
|
|
153
|
+
lifecycle,
|
|
154
|
+
).replace(options.template ?? '', '');
|
|
155
|
+
};
|
|
156
|
+
const createLoadableCollector = (options: any) => {
|
|
157
|
+
const lifecycle = lifecycleFor(options, 'string');
|
|
158
|
+
const collector = new NativeLoadableCollector({ ...options, lifecycle });
|
|
159
|
+
const effect = collector.effect.bind(collector);
|
|
160
|
+
collector.effect = async () => {
|
|
161
|
+
await effect();
|
|
162
|
+
options.chunkSet.cssChunk = replaceSSRTemplateChunk(
|
|
163
|
+
{
|
|
164
|
+
name: 'styles',
|
|
165
|
+
template: `${options.template}<!--assets-->`,
|
|
166
|
+
placeholder: '<!--assets-->',
|
|
167
|
+
content: options.chunkSet.cssChunk,
|
|
168
|
+
...collector.stylesheetInfo,
|
|
169
|
+
},
|
|
170
|
+
lifecycle,
|
|
171
|
+
).slice(options.template.length);
|
|
172
|
+
};
|
|
173
|
+
return collector;
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
import { orderHydrationScriptChunks } from '@modern-js/runtime-extensions';
|
|
177
|
+
import React from 'react';
|
|
178
|
+
import { RenderLevel } from '../../plugin-runtime/src/core/constants';
|
|
179
|
+
import { LoadableCollector as NativeLoadableCollector } from '../../plugin-runtime/src/core/server/string/loadable';
|
|
180
|
+
|
|
181
|
+
const createRuntimeContextWithMatchedRoutes = (matchedRouteIds: string[]) => {
|
|
182
|
+
const runtimeContext = {} as any;
|
|
183
|
+
applyRouterRuntimeState(runtimeContext, {
|
|
184
|
+
framework: 'react-router',
|
|
185
|
+
serverSnapshot: { matchedRouteIds },
|
|
186
|
+
});
|
|
187
|
+
return runtimeContext;
|
|
188
|
+
};
|
|
189
|
+
|
|
190
|
+
const chunk = (url: string, filename = url) => ({
|
|
191
|
+
url,
|
|
192
|
+
filename,
|
|
193
|
+
path: url,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe('createRouteHydrationScriptTags', () => {
|
|
197
|
+
it('does not treat substring matches in the template as existing scripts', () => {
|
|
198
|
+
const runtimeContext = createRuntimeContextWithMatchedRoutes(['route-a']);
|
|
199
|
+
runtimeContext.routeManifest = {
|
|
200
|
+
routeAssets: {
|
|
201
|
+
'route-a': {
|
|
202
|
+
assets: ['/static/js/route-a.js'],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
const scripts = createRouteHydrationScriptTags(runtimeContext, 'index', {
|
|
208
|
+
template:
|
|
209
|
+
'<html><head><link href="/static/js/route-a.js.map" rel="prefetch" /></head></html>',
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
expect(scripts).toBe('<script src="/static/js/route-a.js"></script>');
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
describe('LoadableCollector federated css', () => {
|
|
217
|
+
it('appends deduped module federation css after local route css', async () => {
|
|
218
|
+
const chunkSet = {
|
|
219
|
+
renderLevel: RenderLevel.CLIENT_RENDER,
|
|
220
|
+
ssrScripts: '',
|
|
221
|
+
jsChunk: '',
|
|
222
|
+
cssChunk: '',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
const collector = createLoadableCollector({
|
|
226
|
+
runtimeContext: createRuntimeContextWithMatchedRoutes(['route-a']),
|
|
227
|
+
template:
|
|
228
|
+
'<html><head><link href="https://remote.example.com/already.css" rel="stylesheet" /></head></html>',
|
|
229
|
+
entryName: 'main',
|
|
230
|
+
chunkSet,
|
|
231
|
+
config: {},
|
|
232
|
+
routeManifest: {
|
|
233
|
+
routeAssets: {
|
|
234
|
+
'route-a': {
|
|
235
|
+
assets: ['/static/css/route-a.css', '/static/css/shared.css'],
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
},
|
|
239
|
+
moduleFederationCssAssets: [
|
|
240
|
+
'/static/css/shared.css',
|
|
241
|
+
'https://remote.example.com/expose.css',
|
|
242
|
+
'https://remote.example.com/expose.css',
|
|
243
|
+
'https://remote.example.com/already.css',
|
|
244
|
+
],
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
(collector as any).extractor = {
|
|
248
|
+
chunks: ['local'],
|
|
249
|
+
getChunkAssets: () => [
|
|
250
|
+
chunk('/static/css/local.css'),
|
|
251
|
+
chunk('/static/css/shared.css'),
|
|
252
|
+
],
|
|
253
|
+
getScriptTags: () => '',
|
|
254
|
+
};
|
|
255
|
+
await collector.effect();
|
|
256
|
+
|
|
257
|
+
expect(chunkSet.cssChunk).toBe(
|
|
258
|
+
'<link href="/static/css/local.css" rel="stylesheet" /><link href="/static/css/shared.css" rel="stylesheet" /><link href="/static/css/route-a.css" rel="stylesheet" /><link href="https://remote.example.com/expose.css" rel="stylesheet" />',
|
|
259
|
+
);
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('does not fall back to runtime route manifest when options omit routeManifest', async () => {
|
|
263
|
+
const chunkSet = {
|
|
264
|
+
renderLevel: RenderLevel.CLIENT_RENDER,
|
|
265
|
+
ssrScripts: '',
|
|
266
|
+
jsChunk: '',
|
|
267
|
+
cssChunk: '',
|
|
268
|
+
};
|
|
269
|
+
const runtimeContext = createRuntimeContextWithMatchedRoutes(['route-a']);
|
|
270
|
+
runtimeContext.routeManifest = {
|
|
271
|
+
routeAssets: {
|
|
272
|
+
'route-a': {
|
|
273
|
+
assets: ['/static/css/route-a.css'],
|
|
274
|
+
},
|
|
275
|
+
},
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
const collector = createLoadableCollector({
|
|
279
|
+
runtimeContext,
|
|
280
|
+
template: '<html><head></head></html>',
|
|
281
|
+
entryName: 'main',
|
|
282
|
+
chunkSet,
|
|
283
|
+
config: {},
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
collector.collect(React.createElement('div'));
|
|
287
|
+
await collector.effect();
|
|
288
|
+
|
|
289
|
+
expect(chunkSet.cssChunk).toBe('');
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
test('registered asset policy orders native loadable output before the async entry', async () => {
|
|
294
|
+
const chunkSet = {
|
|
295
|
+
renderLevel: RenderLevel.CLIENT_RENDER,
|
|
296
|
+
ssrScripts: '',
|
|
297
|
+
jsChunk: '',
|
|
298
|
+
cssChunk: '',
|
|
299
|
+
};
|
|
300
|
+
const collector = createLoadableCollector({
|
|
301
|
+
runtimeContext: createRuntimeContextWithMatchedRoutes(['route-a']),
|
|
302
|
+
template: '',
|
|
303
|
+
entryName: 'index',
|
|
304
|
+
chunkSet,
|
|
305
|
+
config: { enableAsyncEntry: true },
|
|
306
|
+
routeManifest: { routeAssets: { 'route-a': { assets: ['/route-a.js'] } } },
|
|
307
|
+
});
|
|
308
|
+
(collector as any).extractor = {
|
|
309
|
+
chunks: ['child'],
|
|
310
|
+
getScriptTags: () => '',
|
|
311
|
+
getChunkAssets: (ids: string[]) =>
|
|
312
|
+
ids.includes('async-index')
|
|
313
|
+
? [chunk('/vendor.js'), chunk('/async-index.js')]
|
|
314
|
+
: [chunk('/child.js')],
|
|
315
|
+
};
|
|
316
|
+
await collector.effect();
|
|
317
|
+
expect(
|
|
318
|
+
Array.from(chunkSet.jsChunk.matchAll(/src="([^"]+)"/g), match => match[1]),
|
|
319
|
+
).toEqual(['/vendor.js', '/child.js', '/route-a.js', '/async-index.js']);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
describe('SSR hydration helper matrix', () => {
|
|
323
|
+
it('dedupes hydration script chunks and generated tags by the exact script src', () => {
|
|
324
|
+
expect(
|
|
325
|
+
orderHydrationScriptChunks({
|
|
326
|
+
entryName: 'main',
|
|
327
|
+
asyncEntryChunks: [
|
|
328
|
+
{
|
|
329
|
+
filename: 'async-main.123.js',
|
|
330
|
+
url: '/assets/async-main.123.js',
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
filename: 'vendor.js',
|
|
334
|
+
url: '/assets/vendor.js',
|
|
335
|
+
},
|
|
336
|
+
{
|
|
337
|
+
filename: 'vendor-copy.js',
|
|
338
|
+
url: '/assets/vendor.js',
|
|
339
|
+
},
|
|
340
|
+
],
|
|
341
|
+
collectedChunks: [
|
|
342
|
+
{
|
|
343
|
+
filename: 'route.js',
|
|
344
|
+
url: '/assets/route.js',
|
|
345
|
+
},
|
|
346
|
+
],
|
|
347
|
+
matchedRouteChunks: [
|
|
348
|
+
{
|
|
349
|
+
filename: 'route-copy.js',
|
|
350
|
+
url: '/assets/route.js',
|
|
351
|
+
},
|
|
352
|
+
{
|
|
353
|
+
filename: 'vendor-query.js',
|
|
354
|
+
url: '/assets/vendor.js?cache=1',
|
|
355
|
+
},
|
|
356
|
+
],
|
|
357
|
+
}),
|
|
358
|
+
).toEqual([
|
|
359
|
+
{
|
|
360
|
+
filename: 'vendor.js',
|
|
361
|
+
url: '/assets/vendor.js',
|
|
362
|
+
},
|
|
363
|
+
{
|
|
364
|
+
filename: 'route.js',
|
|
365
|
+
url: '/assets/route.js',
|
|
366
|
+
},
|
|
367
|
+
{
|
|
368
|
+
filename: 'vendor-query.js',
|
|
369
|
+
url: '/assets/vendor.js?cache=1',
|
|
370
|
+
},
|
|
371
|
+
{
|
|
372
|
+
filename: 'async-main.123.js',
|
|
373
|
+
url: '/assets/async-main.123.js',
|
|
374
|
+
},
|
|
375
|
+
]);
|
|
376
|
+
});
|
|
377
|
+
});
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { registerPlugin } from '../../plugin-runtime/src/core/plugin';
|
|
3
|
+
import { renderStreaming } from '../../plugin-runtime/src/core/server/stream';
|
|
4
|
+
import { Helmet } from '../../plugin-runtime/src/exports/head';
|
|
5
|
+
import { rendererHeadPlugin } from '../src/node';
|
|
6
|
+
|
|
7
|
+
const createRuntimeContext = () => ({
|
|
8
|
+
isBrowser: false,
|
|
9
|
+
requestContext: {},
|
|
10
|
+
context: {},
|
|
11
|
+
initialData: {},
|
|
12
|
+
__i18nData__: {},
|
|
13
|
+
routeManifest: {},
|
|
14
|
+
ssrContext: {
|
|
15
|
+
request: {
|
|
16
|
+
params: {},
|
|
17
|
+
query: {},
|
|
18
|
+
pathname: '/',
|
|
19
|
+
host: 'localhost',
|
|
20
|
+
url: 'http://localhost/',
|
|
21
|
+
headers: {},
|
|
22
|
+
},
|
|
23
|
+
reporter: { sessionId: 'session-1' },
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
describe('streaming Helmet collection', () => {
|
|
28
|
+
it('publishes only Helmet markers present in the completed shell', async () => {
|
|
29
|
+
registerPlugin([rendererHeadPlugin()]);
|
|
30
|
+
|
|
31
|
+
const AbandonPrimary = (): null => {
|
|
32
|
+
throw new Error('abandon primary');
|
|
33
|
+
};
|
|
34
|
+
const runtimeContext = createRuntimeContext();
|
|
35
|
+
const stream = await renderStreaming(
|
|
36
|
+
new Request('http://localhost/'),
|
|
37
|
+
<>
|
|
38
|
+
<Helmet>
|
|
39
|
+
<meta name="outside" content="committed" />
|
|
40
|
+
</Helmet>
|
|
41
|
+
<React.Suspense
|
|
42
|
+
fallback={
|
|
43
|
+
<>
|
|
44
|
+
<Helmet>
|
|
45
|
+
<meta name="fallback" content="committed" />
|
|
46
|
+
</Helmet>
|
|
47
|
+
fallback rendered
|
|
48
|
+
</>
|
|
49
|
+
}
|
|
50
|
+
>
|
|
51
|
+
<Helmet>
|
|
52
|
+
<meta name="abandoned-unique" content="ghost" />
|
|
53
|
+
</Helmet>
|
|
54
|
+
<AbandonPrimary />
|
|
55
|
+
</React.Suspense>
|
|
56
|
+
</>,
|
|
57
|
+
{
|
|
58
|
+
resource: {
|
|
59
|
+
entryName: 'index',
|
|
60
|
+
htmlTemplate:
|
|
61
|
+
'<html><head></head><body><!--<?- html ?>--></body></html>',
|
|
62
|
+
routeManifest: {},
|
|
63
|
+
},
|
|
64
|
+
runtimeContext,
|
|
65
|
+
config: {},
|
|
66
|
+
onError: () => {},
|
|
67
|
+
onTiming: () => {},
|
|
68
|
+
} as any,
|
|
69
|
+
);
|
|
70
|
+
|
|
71
|
+
const html = await new Response(stream).text();
|
|
72
|
+
expect(html).toContain('name="outside" content="committed"');
|
|
73
|
+
expect(html).toContain('name="fallback" content="committed"');
|
|
74
|
+
expect(html).not.toContain('abandoned-unique');
|
|
75
|
+
expect(html).not.toContain('data-modern-helmet');
|
|
76
|
+
});
|
|
77
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { RenderLevel } from '../../plugin-runtime/src/core/constants';
|
|
3
|
+
import { registerPlugin } from '../../plugin-runtime/src/core/plugin';
|
|
4
|
+
import { renderString } from '../../plugin-runtime/src/core/server/string';
|
|
5
|
+
import { Helmet } from '../../plugin-runtime/src/exports/head';
|
|
6
|
+
import { rendererHeadPlugin } from '../src/node';
|
|
7
|
+
|
|
8
|
+
describe('renderString', () => {
|
|
9
|
+
it('collects helmet data without falling back to client render', async () => {
|
|
10
|
+
const onErrorCalls: unknown[][] = [];
|
|
11
|
+
const runtimeContext = {
|
|
12
|
+
isBrowser: false,
|
|
13
|
+
requestContext: {},
|
|
14
|
+
context: {},
|
|
15
|
+
initialData: {},
|
|
16
|
+
__i18nData__: {},
|
|
17
|
+
ssrContext: {
|
|
18
|
+
request: {
|
|
19
|
+
params: {},
|
|
20
|
+
query: {},
|
|
21
|
+
pathname: '/',
|
|
22
|
+
host: 'localhost',
|
|
23
|
+
url: 'http://localhost/',
|
|
24
|
+
headers: {},
|
|
25
|
+
},
|
|
26
|
+
reporter: { sessionId: 'session-1' },
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
registerPlugin([rendererHeadPlugin()]);
|
|
31
|
+
|
|
32
|
+
const html = await renderString(
|
|
33
|
+
new Request('http://localhost/'),
|
|
34
|
+
React.createElement(
|
|
35
|
+
React.Fragment,
|
|
36
|
+
null,
|
|
37
|
+
React.createElement(
|
|
38
|
+
Helmet,
|
|
39
|
+
null,
|
|
40
|
+
React.createElement('title', null, 'Server Title'),
|
|
41
|
+
),
|
|
42
|
+
React.createElement('main', null, 'SSR body'),
|
|
43
|
+
),
|
|
44
|
+
{
|
|
45
|
+
resource: {
|
|
46
|
+
entryName: 'main',
|
|
47
|
+
htmlTemplate:
|
|
48
|
+
'<html><head><title>Default</title><!--<?- chunksMap.css ?>--></head><body><div id="root"><!--<?- html ?>--></div><!--<?- SSRDataScript ?>--><!--<?- chunksMap.js ?>--></body></html>',
|
|
49
|
+
routeManifest: {},
|
|
50
|
+
},
|
|
51
|
+
runtimeContext,
|
|
52
|
+
config: {},
|
|
53
|
+
onError: (...args: unknown[]) => {
|
|
54
|
+
onErrorCalls.push(args);
|
|
55
|
+
},
|
|
56
|
+
onTiming: () => {},
|
|
57
|
+
} as any,
|
|
58
|
+
);
|
|
59
|
+
|
|
60
|
+
expect(onErrorCalls).toHaveLength(0);
|
|
61
|
+
expect(html).toContain('<title data-rh="true">Server Title</title>');
|
|
62
|
+
expect(html).toContain('SSR body');
|
|
63
|
+
expect(html).toContain(`"renderLevel":${RenderLevel.SERVER_RENDER}`);
|
|
64
|
+
});
|
|
65
|
+
it('publishes only Helmet markers present in completed Suspense output', async () => {
|
|
66
|
+
registerPlugin([rendererHeadPlugin()]);
|
|
67
|
+
const never = new Promise<never>(() => {});
|
|
68
|
+
const SuspendForever = (): null => {
|
|
69
|
+
throw never;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const html = await renderString(
|
|
73
|
+
new Request('http://localhost/'),
|
|
74
|
+
<>
|
|
75
|
+
<Helmet>
|
|
76
|
+
<meta name="outside" content="committed" />
|
|
77
|
+
</Helmet>
|
|
78
|
+
<React.Suspense
|
|
79
|
+
fallback={
|
|
80
|
+
<>
|
|
81
|
+
<Helmet>
|
|
82
|
+
<meta name="fallback" content="committed" />
|
|
83
|
+
</Helmet>
|
|
84
|
+
fallback rendered
|
|
85
|
+
</>
|
|
86
|
+
}
|
|
87
|
+
>
|
|
88
|
+
<Helmet>
|
|
89
|
+
<meta name="abandoned-unique" content="ghost" />
|
|
90
|
+
</Helmet>
|
|
91
|
+
<SuspendForever />
|
|
92
|
+
</React.Suspense>
|
|
93
|
+
</>,
|
|
94
|
+
{
|
|
95
|
+
resource: {
|
|
96
|
+
entryName: 'index',
|
|
97
|
+
htmlTemplate:
|
|
98
|
+
'<html><head></head><body><!--<?- html ?>--></body></html>',
|
|
99
|
+
routeManifest: {},
|
|
100
|
+
},
|
|
101
|
+
runtimeContext: {
|
|
102
|
+
isBrowser: false,
|
|
103
|
+
initialData: {},
|
|
104
|
+
__i18nData__: {},
|
|
105
|
+
ssrContext: {
|
|
106
|
+
request: {
|
|
107
|
+
params: {},
|
|
108
|
+
query: {},
|
|
109
|
+
pathname: '/',
|
|
110
|
+
host: 'localhost',
|
|
111
|
+
url: 'http://localhost/',
|
|
112
|
+
headers: {},
|
|
113
|
+
},
|
|
114
|
+
reporter: { sessionId: 'session-1' },
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
config: {},
|
|
118
|
+
onError: () => {},
|
|
119
|
+
onTiming: () => {},
|
|
120
|
+
} as any,
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
expect(html).toContain('name="outside" content="committed"');
|
|
124
|
+
expect(html).toContain('name="fallback" content="committed"');
|
|
125
|
+
expect(html).not.toContain('abandoned-unique');
|
|
126
|
+
expect(html).not.toContain('data-modern-helmet');
|
|
127
|
+
});
|
|
128
|
+
});
|