@remix-run/ui 0.5.0 → 0.7.0
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/README.md +52 -14
- package/dist/runtime/component.d.ts +2 -1
- package/dist/runtime/component.js.map +1 -1
- package/dist/runtime/diff-dom.js +13 -1
- package/dist/runtime/diff-dom.js.map +1 -1
- package/dist/runtime/frame.d.ts +1 -0
- package/dist/runtime/frame.js +16 -0
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/module-preloader.d.ts +8 -0
- package/dist/runtime/module-preloader.js +95 -0
- package/dist/runtime/module-preloader.js.map +1 -0
- package/dist/runtime/navigation.d.ts +1 -2
- package/dist/runtime/navigation.js +1 -4
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/run.d.ts +3 -3
- package/dist/runtime/run.js +41 -3
- package/dist/runtime/run.js.map +1 -1
- package/dist/server/stream.d.ts +2 -0
- package/dist/server/stream.js +54 -2
- package/dist/server/stream.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/component.ts +2 -1
- package/src/runtime/diff-dom.ts +12 -1
- package/src/runtime/frame.ts +18 -0
- package/src/runtime/module-preloader.ts +115 -0
- package/src/runtime/navigation.ts +1 -3
- package/src/runtime/run.ts +50 -6
- package/src/server/README.md +13 -0
- package/src/server/stream.ts +71 -2
package/src/runtime/frame.ts
CHANGED
|
@@ -10,6 +10,7 @@ import { createRangeRoot, createRoot } from './vdom.ts'
|
|
|
10
10
|
import { diffNodes } from './diff-dom.ts'
|
|
11
11
|
import { createStyleManager, type StyleManager } from '../style/index.ts'
|
|
12
12
|
import { findFlushMarker, type FlushKind } from './stream-protocol.ts'
|
|
13
|
+
import { getDocumentModulePreloader } from './module-preloader.ts'
|
|
13
14
|
import { unwrapFrameResolution } from './frame-resolution.ts'
|
|
14
15
|
import {
|
|
15
16
|
disposeClientEntryBoundary,
|
|
@@ -213,6 +214,7 @@ export type FrameContext = {
|
|
|
213
214
|
regionTailRef?: ChildNode | null
|
|
214
215
|
regionParent?: ParentNode | null
|
|
215
216
|
signal?: AbortSignal
|
|
217
|
+
isActiveModulePreload?: (node: Node) => boolean
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
type FrameInit = {
|
|
@@ -278,6 +280,7 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
|
|
|
278
280
|
let reloadAbortUnsubscribe: (() => void) | undefined
|
|
279
281
|
let reloadKind: 'direct' | 'ancestor' | undefined
|
|
280
282
|
let styleManager = init.styleManager ?? createStyleManager()
|
|
283
|
+
let modulePreloader = getDocumentModulePreloader(container.doc)
|
|
281
284
|
let currentMarker = init.marker
|
|
282
285
|
let displayedContentStatus: 'pending' | 'resolved' = init.marker?.status ?? 'resolved'
|
|
283
286
|
let pendingTemplateMarkerId: string | undefined
|
|
@@ -288,6 +291,12 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
|
|
|
288
291
|
let disposed = false
|
|
289
292
|
let lifecycleController = new AbortController()
|
|
290
293
|
|
|
294
|
+
if (isDocumentNode(container.root)) {
|
|
295
|
+
modulePreloader.adoptInitialPreloadLinks(container.root)
|
|
296
|
+
} else {
|
|
297
|
+
modulePreloader.consumePreloadLinks(container.root)
|
|
298
|
+
}
|
|
299
|
+
|
|
291
300
|
// Merge any rmx-data found in the current document once at startup.
|
|
292
301
|
mergeRmxDataFromDocument(init.data, container.doc)
|
|
293
302
|
|
|
@@ -407,6 +416,7 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
|
|
|
407
416
|
|
|
408
417
|
if (isFullDocumentReload && htmlContent !== undefined) {
|
|
409
418
|
let parsed = new DOMParser().parseFromString(htmlContent, 'text/html')
|
|
419
|
+
modulePreloader.consumePreloadLinks(parsed)
|
|
410
420
|
let responseData = options.data
|
|
411
421
|
mergeRmxDataFromDocument(responseData, parsed)
|
|
412
422
|
let responseContext = { ...context, data: responseData }
|
|
@@ -421,6 +431,9 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
|
|
|
421
431
|
regionParent: container.doc.documentElement,
|
|
422
432
|
regionTailRef: null,
|
|
423
433
|
signal: options.signal,
|
|
434
|
+
isActiveModulePreload: modulePreloader.hasActivePreloads()
|
|
435
|
+
? modulePreloader.isActivePreload
|
|
436
|
+
: undefined,
|
|
424
437
|
})
|
|
425
438
|
diffNodes([container.doc.body], [parsed.body], {
|
|
426
439
|
...responseContext,
|
|
@@ -445,6 +458,7 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
|
|
|
445
458
|
|
|
446
459
|
let fragment =
|
|
447
460
|
htmlContent !== undefined ? createFragmentFromString(container.doc, htmlContent) : content
|
|
461
|
+
modulePreloader.consumePreloadLinks(fragment)
|
|
448
462
|
context.styleManager.adoptServerStyles(
|
|
449
463
|
collectFrameServerStyleTags(createElementContainer(fragment)),
|
|
450
464
|
)
|
|
@@ -1760,6 +1774,10 @@ function isCommentNode(node: Node | null | undefined): node is Comment {
|
|
|
1760
1774
|
return node?.nodeType === Node.COMMENT_NODE
|
|
1761
1775
|
}
|
|
1762
1776
|
|
|
1777
|
+
function isDocumentNode(node: Node): node is Document {
|
|
1778
|
+
return node.nodeType === Node.DOCUMENT_NODE
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1763
1781
|
function isDocumentFragmentNode(value: unknown): value is DocumentFragment {
|
|
1764
1782
|
return (
|
|
1765
1783
|
typeof value === 'object' &&
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const SERVER_MODULE_PRELOAD_SELECTOR = 'link[data-rmx][rel~="modulepreload" i][href]'
|
|
2
|
+
|
|
3
|
+
interface ModulePreloader {
|
|
4
|
+
adoptInitialPreloadLinks(source: ParentNode): void
|
|
5
|
+
consumePreloadLinks(source: ParentNode): void
|
|
6
|
+
hasActivePreloads(): boolean
|
|
7
|
+
isActivePreload(node: Node): boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const modulePreloaders = new WeakMap<Document, ModulePreloader>()
|
|
11
|
+
|
|
12
|
+
export function getDocumentModulePreloader(doc: Document): ModulePreloader {
|
|
13
|
+
let preloader = modulePreloaders.get(doc)
|
|
14
|
+
if (!preloader) {
|
|
15
|
+
preloader = createModulePreloader(doc)
|
|
16
|
+
modulePreloaders.set(doc, preloader)
|
|
17
|
+
}
|
|
18
|
+
return preloader
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function createModulePreloader(doc: Document): ModulePreloader {
|
|
22
|
+
let requestedUrls = new Set<string>()
|
|
23
|
+
let activeLinks = new WeakSet<HTMLLinkElement>()
|
|
24
|
+
let activeLinkCount = 0
|
|
25
|
+
|
|
26
|
+
function activateLink(link: HTMLLinkElement): void {
|
|
27
|
+
activeLinks.add(link)
|
|
28
|
+
activeLinkCount++
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function deactivateLink(link: HTMLLinkElement): void {
|
|
32
|
+
if (activeLinks.delete(link)) activeLinkCount--
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function preload(href: string): void {
|
|
36
|
+
let link = doc.createElement('link')
|
|
37
|
+
link.rel = 'modulepreload'
|
|
38
|
+
link.href = href
|
|
39
|
+
link.setAttribute('data-rmx', '')
|
|
40
|
+
let url = link.href
|
|
41
|
+
if (requestedUrls.has(url)) return
|
|
42
|
+
requestedUrls.add(url)
|
|
43
|
+
|
|
44
|
+
activateLink(link)
|
|
45
|
+
link.addEventListener(
|
|
46
|
+
'load',
|
|
47
|
+
() => {
|
|
48
|
+
deactivateLink(link)
|
|
49
|
+
link.remove()
|
|
50
|
+
},
|
|
51
|
+
{ once: true },
|
|
52
|
+
)
|
|
53
|
+
link.addEventListener(
|
|
54
|
+
'error',
|
|
55
|
+
() => {
|
|
56
|
+
deactivateLink(link)
|
|
57
|
+
link.remove()
|
|
58
|
+
requestedUrls.delete(url)
|
|
59
|
+
},
|
|
60
|
+
{ once: true },
|
|
61
|
+
)
|
|
62
|
+
doc.head.append(link)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
adoptInitialPreloadLinks(source) {
|
|
67
|
+
for (let initialLink of source.querySelectorAll<HTMLLinkElement>(
|
|
68
|
+
SERVER_MODULE_PRELOAD_SELECTOR,
|
|
69
|
+
)) {
|
|
70
|
+
if (activeLinks.has(initialLink)) continue
|
|
71
|
+
|
|
72
|
+
// An identical link joins the document's existing module-map request and receives its own
|
|
73
|
+
// terminal event, even when the parser-created link settled before runtime startup.
|
|
74
|
+
let observerLink = doc.createElement('link')
|
|
75
|
+
observerLink.rel = 'modulepreload'
|
|
76
|
+
observerLink.href = initialLink.href
|
|
77
|
+
observerLink.setAttribute('data-rmx', '')
|
|
78
|
+
let url = initialLink.href
|
|
79
|
+
requestedUrls.add(url)
|
|
80
|
+
activateLink(initialLink)
|
|
81
|
+
activateLink(observerLink)
|
|
82
|
+
|
|
83
|
+
let settled = false
|
|
84
|
+
function settle(succeeded: boolean): void {
|
|
85
|
+
if (settled) return
|
|
86
|
+
settled = true
|
|
87
|
+
deactivateLink(initialLink)
|
|
88
|
+
deactivateLink(observerLink)
|
|
89
|
+
initialLink.remove()
|
|
90
|
+
observerLink.remove()
|
|
91
|
+
if (!succeeded) requestedUrls.delete(url)
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
observerLink.addEventListener('load', () => settle(true), { once: true })
|
|
95
|
+
observerLink.addEventListener('error', () => settle(false), { once: true })
|
|
96
|
+
doc.head.append(observerLink)
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
consumePreloadLinks(source) {
|
|
100
|
+
let hrefs: string[] = []
|
|
101
|
+
for (let link of source.querySelectorAll<HTMLLinkElement>(SERVER_MODULE_PRELOAD_SELECTOR)) {
|
|
102
|
+
let href = link.getAttribute('href')
|
|
103
|
+
link.remove()
|
|
104
|
+
if (href) hrefs.push(href)
|
|
105
|
+
}
|
|
106
|
+
for (let href of hrefs) preload(href)
|
|
107
|
+
},
|
|
108
|
+
hasActivePreloads() {
|
|
109
|
+
return activeLinkCount > 0
|
|
110
|
+
},
|
|
111
|
+
isActivePreload(node) {
|
|
112
|
+
return node instanceof HTMLLinkElement && activeLinks.has(node)
|
|
113
|
+
},
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -72,11 +72,9 @@ export async function navigate(href: string, options?: NavigationOptions) {
|
|
|
72
72
|
* Starts listening for Navigation API transitions and routes them through frame reloads.
|
|
73
73
|
*
|
|
74
74
|
* @param signal Abort signal used to remove the listener.
|
|
75
|
-
* @param canResolveFrames Whether the runtime has a resolver that can handle intercepted navigations.
|
|
76
75
|
* @returns void
|
|
77
76
|
*/
|
|
78
|
-
export function startNavigationListener(signal: AbortSignal
|
|
79
|
-
if (!canResolveFrames) return
|
|
77
|
+
export function startNavigationListener(signal: AbortSignal) {
|
|
80
78
|
return startNavigationListenerImpl(signal, {
|
|
81
79
|
getTopFrame,
|
|
82
80
|
getNamedFrame,
|
package/src/runtime/run.ts
CHANGED
|
@@ -4,7 +4,7 @@ import { createStyleManager } from '../style/index.ts'
|
|
|
4
4
|
import type { FrameHandle, Handle } from './component.ts'
|
|
5
5
|
import { createComponentErrorEvent } from './error-event.ts'
|
|
6
6
|
import type { ComponentErrorEvent } from './error-event.ts'
|
|
7
|
-
import type { LoadModule, ResolveFrame } from './frame.ts'
|
|
7
|
+
import type { LoadModule, ResolveFrame, ResolveFrameOptions } from './frame.ts'
|
|
8
8
|
import { startNavigationListener } from './navigation.ts'
|
|
9
9
|
import { TypedEventTarget } from './typed-event-target.ts'
|
|
10
10
|
|
|
@@ -23,8 +23,8 @@ export interface RunInit {
|
|
|
23
23
|
/**
|
|
24
24
|
* Resolves browser-loaded `<Frame>` content.
|
|
25
25
|
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
26
|
+
* Defaults to fetching the frame source as HTML with the submitted form data,
|
|
27
|
+
* method, encoding, and abort signal.
|
|
28
28
|
*/
|
|
29
29
|
resolveFrame?: ResolveFrame
|
|
30
30
|
}
|
|
@@ -72,10 +72,54 @@ export function getNamedFrame(name: string): FrameHandle {
|
|
|
72
72
|
return namedFrames.get(name) ?? getTopFrame()
|
|
73
73
|
}
|
|
74
74
|
|
|
75
|
+
// Frame reloads can receive raw FormData without going through form navigation. Encode it here so
|
|
76
|
+
// manual reloads use the requested form encoding instead of always sending multipart bodies.
|
|
77
|
+
function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
|
|
78
|
+
let formData = options?.formData
|
|
79
|
+
if (!formData || options?.method?.toLowerCase() === 'get') return
|
|
80
|
+
|
|
81
|
+
if (options?.encType === 'text/plain') {
|
|
82
|
+
let body = ''
|
|
83
|
+
for (let [name, value] of formData) {
|
|
84
|
+
name = normalizeLineBreaks(name)
|
|
85
|
+
value = normalizeLineBreaks(typeof value === 'string' ? value : value.name)
|
|
86
|
+
body += `${name}=${value}\r\n`
|
|
87
|
+
}
|
|
88
|
+
return new Blob([body], { type: 'text/plain' })
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (options?.encType !== 'application/x-www-form-urlencoded') return formData
|
|
92
|
+
|
|
93
|
+
let body = new URLSearchParams()
|
|
94
|
+
for (let [name, value] of formData) {
|
|
95
|
+
body.append(name, typeof value === 'string' ? value : value.name)
|
|
96
|
+
}
|
|
97
|
+
return body
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function normalizeLineBreaks(value: string): string {
|
|
101
|
+
return value.replace(/\r\n|\r|\n/g, '\r\n')
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function defaultResolveFrame(src: string, options?: ResolveFrameOptions): Promise<Response> {
|
|
105
|
+
let response = await fetch(src, {
|
|
106
|
+
body: getRequestBody(options),
|
|
107
|
+
headers: { Accept: 'text/html' },
|
|
108
|
+
method: options?.method,
|
|
109
|
+
signal: options?.signal,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
throw new Error(`Failed to resolve frame: ${response.status} ${response.statusText}`.trimEnd())
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return response
|
|
117
|
+
}
|
|
118
|
+
|
|
75
119
|
/**
|
|
76
120
|
* Starts the client-side Remix component runtime for the current document.
|
|
77
121
|
*
|
|
78
|
-
* @param init Runtime
|
|
122
|
+
* @param init Runtime options for loading modules and customizing frame resolution.
|
|
79
123
|
* @returns The running application runtime.
|
|
80
124
|
*/
|
|
81
125
|
export function run(init: RunInit): AppRuntime {
|
|
@@ -83,7 +127,7 @@ export function run(init: RunInit): AppRuntime {
|
|
|
83
127
|
let errorTarget = new TypedEventTarget<AppRuntimeEventMap>()
|
|
84
128
|
let scheduler = createScheduler(document, errorTarget, styleManager)
|
|
85
129
|
|
|
86
|
-
let resolveFrame
|
|
130
|
+
let resolveFrame = init.resolveFrame ?? defaultResolveFrame
|
|
87
131
|
|
|
88
132
|
topFrame = createFrame(document, {
|
|
89
133
|
src: document.location.href,
|
|
@@ -107,7 +151,7 @@ export function run(init: RunInit): AppRuntime {
|
|
|
107
151
|
return namedFrames.get(name)
|
|
108
152
|
},
|
|
109
153
|
}
|
|
110
|
-
startNavigationListener(appController.signal
|
|
154
|
+
startNavigationListener(appController.signal)
|
|
111
155
|
let readyPromise = topFrame.ready().catch((error) => {
|
|
112
156
|
errorTarget.dispatchEvent(createComponentErrorEvent(error))
|
|
113
157
|
throw error
|
package/src/server/README.md
CHANGED
|
@@ -23,6 +23,7 @@ Renders a component tree to a streaming response. The initial HTML is sent immed
|
|
|
23
23
|
|
|
24
24
|
```tsx
|
|
25
25
|
import { renderToStream } from 'remix/ui/server'
|
|
26
|
+
import { assetServer } from './assets.ts'
|
|
26
27
|
|
|
27
28
|
let stream = renderToStream(<App />, {
|
|
28
29
|
frameSrc: request.url,
|
|
@@ -31,6 +32,17 @@ let stream = renderToStream(<App />, {
|
|
|
31
32
|
let frameUrl = new URL(src, context?.currentFrameSrc ?? request.url)
|
|
32
33
|
return fetchHtml(frameUrl)
|
|
33
34
|
},
|
|
35
|
+
async resolveClientEntry(entryId, component) {
|
|
36
|
+
let [href, preloads] = await Promise.all([
|
|
37
|
+
assetServer.getHref(entryId),
|
|
38
|
+
assetServer.getPreloads(entryId),
|
|
39
|
+
])
|
|
40
|
+
return {
|
|
41
|
+
href,
|
|
42
|
+
exportName: entryId.split('#')[1] || component.name,
|
|
43
|
+
preloads,
|
|
44
|
+
}
|
|
45
|
+
},
|
|
34
46
|
onError(error) {
|
|
35
47
|
console.error(error)
|
|
36
48
|
},
|
|
@@ -47,6 +59,7 @@ return new Response(stream, {
|
|
|
47
59
|
- **`topFrameSrc`** - Overrides the root frame URL used for `handle.frames.top.src`. This is mainly useful when calling `renderToStream()` from inside `resolveFrame()` for a nested frame render.
|
|
48
60
|
- **`signal`** - Cancels pending server rendering work. Pass `request.signal` so client disconnects can stop unresolved frame work without invoking `onError` for the disconnect itself.
|
|
49
61
|
- **`resolveFrame(src, target, context)`** - Called when a `<Frame>` needs its content. Return a string of HTML, a `ReadableStream<Uint8Array>`, or a promise of either. `context.currentFrameSrc` is the URL for the frame that contains the `<Frame>`, and `context.topFrameSrc` is the outer document URL. Required if your component tree contains `<Frame>` elements.
|
|
62
|
+
- **`resolveClientEntry(entryId, component)`** - Resolves the public module URL, export name, and optional module preload hrefs for a hydrated client entry.
|
|
50
63
|
- **`onError(error)`** - Called when a rendering error occurs. If not provided, the stream rejects with the error.
|
|
51
64
|
|
|
52
65
|
When you render nested frame responses with `renderToStream()` inside `resolveFrame()`, pass `frameSrc` for the frame being rendered and carry `topFrameSrc` forward from the parent context. That preserves `handle.frames.top.src` across the whole SSR frame tree.
|
package/src/server/stream.ts
CHANGED
|
@@ -77,6 +77,8 @@ interface UnresolvedHydrationData {
|
|
|
77
77
|
interface ResolvedClientEntry {
|
|
78
78
|
href: string
|
|
79
79
|
exportName: string
|
|
80
|
+
/** Browser module hrefs to begin preloading before hydrating this entry. */
|
|
81
|
+
preloads?: readonly string[]
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
interface FrameData {
|
|
@@ -99,6 +101,7 @@ interface RenderContext {
|
|
|
99
101
|
hydrationData: Map<string, HydrationData>
|
|
100
102
|
unresolvedHydrationData: Map<string, UnresolvedHydrationData>
|
|
101
103
|
frameData: Map<string, FrameData>
|
|
104
|
+
modulePreloadTags: Set<string>
|
|
102
105
|
blockingFrameTails: ReadableStream<Uint8Array>[]
|
|
103
106
|
signal: AbortSignal
|
|
104
107
|
flushKind: FlushKind
|
|
@@ -207,6 +210,7 @@ export function renderToStream(
|
|
|
207
210
|
hydrationData: new Map(),
|
|
208
211
|
unresolvedHydrationData: new Map(),
|
|
209
212
|
frameData: new Map(),
|
|
213
|
+
modulePreloadTags: new Set(),
|
|
210
214
|
blockingFrameTails: [],
|
|
211
215
|
signal: renderAbortController.signal,
|
|
212
216
|
flushKind: 'fragment',
|
|
@@ -492,6 +496,7 @@ function buildFrameSegment(
|
|
|
492
496
|
context.resolveFrame(props.src, props.name, resolveFrameContext),
|
|
493
497
|
).then(async (resolved) => {
|
|
494
498
|
let { html, tail } = await resolveFrameHtml(resolved)
|
|
499
|
+
html = hoistModulePreloadsFromFrameHead(html, context)
|
|
495
500
|
seg.content = staticSeg(html)
|
|
496
501
|
if (tail) {
|
|
497
502
|
context.blockingFrameTails.push(tail)
|
|
@@ -1073,6 +1078,10 @@ async function resolveClientEntries(
|
|
|
1073
1078
|
moduleUrl: resolvedEntry.href,
|
|
1074
1079
|
props,
|
|
1075
1080
|
})
|
|
1081
|
+
|
|
1082
|
+
for (let preload of resolvedEntry.preloads ?? []) {
|
|
1083
|
+
context.modulePreloadTags.add(createModulePreloadTag(preload))
|
|
1084
|
+
}
|
|
1076
1085
|
}
|
|
1077
1086
|
|
|
1078
1087
|
context.unresolvedHydrationData.clear()
|
|
@@ -1095,6 +1104,19 @@ function validateResolvedClientEntry(
|
|
|
1095
1104
|
if (!resolvedEntry.exportName) {
|
|
1096
1105
|
throw new Error(`resolveClientEntry must return a non-empty exportName. Received "${entryId}".`)
|
|
1097
1106
|
}
|
|
1107
|
+
|
|
1108
|
+
if (resolvedEntry.preloads !== undefined) {
|
|
1109
|
+
if (!Array.isArray(resolvedEntry.preloads)) {
|
|
1110
|
+
throw new Error(`resolveClientEntry preloads must be an array. Received "${entryId}".`)
|
|
1111
|
+
}
|
|
1112
|
+
for (let preload of resolvedEntry.preloads) {
|
|
1113
|
+
if (typeof preload !== 'string' || preload.length === 0) {
|
|
1114
|
+
throw new Error(
|
|
1115
|
+
`resolveClientEntry preloads must contain non-empty strings. Received "${entryId}".`,
|
|
1116
|
+
)
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
}
|
|
1098
1120
|
}
|
|
1099
1121
|
|
|
1100
1122
|
function validateClientEntriesForHydration(context: RenderContext): void {
|
|
@@ -1159,9 +1181,10 @@ function transformAttributeName(name: string, isSvg: boolean): string {
|
|
|
1159
1181
|
function finalizeHtml(html: string, context: RenderContext): string {
|
|
1160
1182
|
let hasHtmlRoot = context.flushKind === 'document'
|
|
1161
1183
|
|
|
1184
|
+
let preloads = collectModulePreloadTags(context)
|
|
1162
1185
|
let styles = collectStyleTags(context)
|
|
1163
|
-
if (styles) {
|
|
1164
|
-
let headContent = styles
|
|
1186
|
+
if (preloads || styles) {
|
|
1187
|
+
let headContent = preloads + styles
|
|
1165
1188
|
if (hasHtmlRoot) {
|
|
1166
1189
|
// For HTML root, inject into existing head or create one
|
|
1167
1190
|
let headCloseIndex = html.indexOf('</head>')
|
|
@@ -1207,6 +1230,52 @@ function finalizeHtml(html: string, context: RenderContext): string {
|
|
|
1207
1230
|
return html
|
|
1208
1231
|
}
|
|
1209
1232
|
|
|
1233
|
+
const FRAME_HEAD_OPEN_TAG = '<head>'
|
|
1234
|
+
const FRAME_HEAD_CLOSE_TAG = '</head>'
|
|
1235
|
+
const MARKED_MODULE_PRELOAD_START = '<link data-rmx rel="modulepreload" href="'
|
|
1236
|
+
const MODULE_PRELOAD_END = '" />'
|
|
1237
|
+
|
|
1238
|
+
function createModulePreloadTag(href: string): string {
|
|
1239
|
+
return `${MARKED_MODULE_PRELOAD_START}${escapeHtml(href)}${MODULE_PRELOAD_END}`
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function collectModulePreloadTags(context: RenderContext): string {
|
|
1243
|
+
return Array.from(context.modulePreloadTags).join('')
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
function hoistModulePreloadsFromFrameHead(html: string, context: RenderContext): string {
|
|
1247
|
+
if (!html.startsWith(FRAME_HEAD_OPEN_TAG)) return html
|
|
1248
|
+
|
|
1249
|
+
let tags: string[] = []
|
|
1250
|
+
let remainingHeadStart = FRAME_HEAD_OPEN_TAG.length
|
|
1251
|
+
while (html.startsWith(MARKED_MODULE_PRELOAD_START, remainingHeadStart)) {
|
|
1252
|
+
let tagEnd = html.indexOf(
|
|
1253
|
+
MODULE_PRELOAD_END,
|
|
1254
|
+
remainingHeadStart + MARKED_MODULE_PRELOAD_START.length,
|
|
1255
|
+
)
|
|
1256
|
+
if (tagEnd === -1) return html
|
|
1257
|
+
|
|
1258
|
+
tagEnd += MODULE_PRELOAD_END.length
|
|
1259
|
+
tags.push(html.slice(remainingHeadStart, tagEnd))
|
|
1260
|
+
remainingHeadStart = tagEnd
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
if (tags.length === 0) return html
|
|
1264
|
+
|
|
1265
|
+
let headClose = html.indexOf(FRAME_HEAD_CLOSE_TAG, remainingHeadStart)
|
|
1266
|
+
if (headClose === -1) return html
|
|
1267
|
+
|
|
1268
|
+
for (let tag of tags) {
|
|
1269
|
+
context.modulePreloadTags.add(tag)
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
if (remainingHeadStart === headClose) {
|
|
1273
|
+
return html.slice(headClose + FRAME_HEAD_CLOSE_TAG.length)
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
return FRAME_HEAD_OPEN_TAG + html.slice(remainingHeadStart)
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1210
1279
|
function processStyleProps(props: any): any {
|
|
1211
1280
|
let processedProps = { ...props }
|
|
1212
1281
|
let classAttr = typeof props.class === 'string' ? props.class : ''
|