@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.
@@ -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, canResolveFrames = true) {
79
- if (!canResolveFrames) return
77
+ export function startNavigationListener(signal: AbortSignal) {
80
78
  return startNavigationListenerImpl(signal, {
81
79
  getTopFrame,
82
80
  getNamedFrame,
@@ -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
- * Omit this only when the runtime never needs to load or reload frames in the
27
- * browser.
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 hooks for loading modules and resolving frames.
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: ResolveFrame = init.resolveFrame ?? (() => '<p>resolve frame unimplemented</p>')
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, init.resolveFrame !== undefined)
154
+ startNavigationListener(appController.signal)
111
155
  let readyPromise = topFrame.ready().catch((error) => {
112
156
  errorTarget.dispatchEvent(createComponentErrorEvent(error))
113
157
  throw error
@@ -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.
@@ -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 : ''