@remix-run/ui 0.7.0 → 0.9.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.
Files changed (91) hide show
  1. package/README.md +90 -14
  2. package/dist/animation/demos/drag-release.js +5 -7
  3. package/dist/animation/demos/drag-release.js.map +1 -1
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.js +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/runtime/component.d.ts +9 -4
  8. package/dist/runtime/component.js +38 -9
  9. package/dist/runtime/component.js.map +1 -1
  10. package/dist/runtime/core/mix.d.ts +21 -0
  11. package/dist/runtime/core/mix.js +128 -0
  12. package/dist/runtime/core/mix.js.map +1 -0
  13. package/dist/runtime/diff-dom.js +15 -16
  14. package/dist/runtime/diff-dom.js.map +1 -1
  15. package/dist/runtime/document-reload.d.ts +2 -0
  16. package/dist/runtime/document-reload.js +14 -0
  17. package/dist/runtime/document-reload.js.map +1 -0
  18. package/dist/runtime/dom.d.ts +21 -11
  19. package/dist/runtime/event-types.d.ts +14 -0
  20. package/dist/runtime/event-types.js +2 -0
  21. package/dist/runtime/event-types.js.map +1 -0
  22. package/dist/runtime/frame-resolution.js +8 -0
  23. package/dist/runtime/frame-resolution.js.map +1 -1
  24. package/dist/runtime/frame.d.ts +24 -6
  25. package/dist/runtime/frame.js +187 -60
  26. package/dist/runtime/frame.js.map +1 -1
  27. package/dist/runtime/import-map-manager.d.ts +8 -0
  28. package/dist/runtime/import-map-manager.js +295 -0
  29. package/dist/runtime/import-map-manager.js.map +1 -0
  30. package/dist/runtime/mixins/link-mixin.js +4 -4
  31. package/dist/runtime/mixins/link-mixin.js.map +1 -1
  32. package/dist/runtime/mixins/mixin.d.ts +9 -4
  33. package/dist/runtime/mixins/mixin.js +18 -132
  34. package/dist/runtime/mixins/mixin.js.map +1 -1
  35. package/dist/runtime/mixins/on-mixin.d.ts +1 -1
  36. package/dist/runtime/module-preloader.d.ts +2 -1
  37. package/dist/runtime/module-preloader.js +6 -5
  38. package/dist/runtime/module-preloader.js.map +1 -1
  39. package/dist/runtime/navigation.js +211 -34
  40. package/dist/runtime/navigation.js.map +1 -1
  41. package/dist/runtime/reconcile.js +18 -3
  42. package/dist/runtime/reconcile.js.map +1 -1
  43. package/dist/runtime/run.d.ts +3 -0
  44. package/dist/runtime/run.js +8 -4
  45. package/dist/runtime/run.js.map +1 -1
  46. package/dist/runtime/scheduler.js +37 -11
  47. package/dist/runtime/scheduler.js.map +1 -1
  48. package/dist/runtime/spa-response.d.ts +30 -0
  49. package/dist/runtime/spa-response.js +47 -0
  50. package/dist/runtime/spa-response.js.map +1 -0
  51. package/dist/runtime/to-vnode.js +1 -1
  52. package/dist/runtime/to-vnode.js.map +1 -1
  53. package/dist/runtime/typed-event-target.d.ts +0 -4
  54. package/dist/runtime/typed-event-target.js.map +1 -1
  55. package/dist/server/stream.d.ts +25 -2
  56. package/dist/server/stream.js +382 -171
  57. package/dist/server/stream.js.map +1 -1
  58. package/dist/style/stylesheet.js +2 -2
  59. package/dist/style/stylesheet.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/animation/demos/drag-release.ts +5 -7
  62. package/src/index.ts +2 -2
  63. package/src/runtime/component.ts +52 -14
  64. package/src/runtime/core/mix.ts +157 -0
  65. package/src/runtime/demos/readme.demo.tsx +7 -17
  66. package/src/runtime/diff-dom.ts +13 -15
  67. package/src/runtime/document-reload.ts +14 -0
  68. package/src/runtime/dom.ts +21 -11
  69. package/src/runtime/event-types.ts +27 -0
  70. package/src/runtime/frame-resolution.ts +9 -0
  71. package/src/runtime/frame.ts +216 -65
  72. package/src/runtime/import-map-manager.ts +369 -0
  73. package/src/runtime/mixins/link-mixin.ts +4 -4
  74. package/src/runtime/mixins/mixin.ts +44 -149
  75. package/src/runtime/mixins/on-mixin.ts +1 -1
  76. package/src/runtime/module-preloader.ts +9 -6
  77. package/src/runtime/navigation.ts +257 -42
  78. package/src/runtime/reconcile.ts +20 -4
  79. package/src/runtime/run.ts +13 -4
  80. package/src/runtime/scheduler.ts +53 -13
  81. package/src/runtime/spa-response.ts +56 -0
  82. package/src/runtime/to-vnode.ts +1 -1
  83. package/src/runtime/typed-event-target.ts +1 -6
  84. package/src/server/README.md +25 -5
  85. package/src/server/stream.ts +506 -202
  86. package/src/style/stylesheet.ts +3 -3
  87. package/src/test/utils.ts +1 -6
  88. package/dist/runtime/event-listeners.d.ts +0 -50
  89. package/dist/runtime/event-listeners.js +0 -31
  90. package/dist/runtime/event-listeners.js.map +0 -1
  91. package/src/runtime/event-listeners.ts +0 -171
@@ -1,12 +1,14 @@
1
- const SERVER_MODULE_PRELOAD_SELECTOR = 'link[data-rmx][rel~="modulepreload" i][href]'
1
+ const SERVER_MODULE_PRELOAD_SELECTOR = 'link[data-rmx-module-preload][rel~="modulepreload" i][href]'
2
2
 
3
3
  interface ModulePreloader {
4
4
  adoptInitialPreloadLinks(source: ParentNode): void
5
- consumePreloadLinks(source: ParentNode): void
5
+ consumePreloadLinks(source: ParentNode, process?: ProcessClientEntryPreloads): Promise<void>
6
6
  hasActivePreloads(): boolean
7
7
  isActivePreload(node: Node): boolean
8
8
  }
9
9
 
10
+ export type ProcessClientEntryPreloads = (preloads: string[]) => string[] | Promise<string[]>
11
+
10
12
  const modulePreloaders = new WeakMap<Document, ModulePreloader>()
11
13
 
12
14
  export function getDocumentModulePreloader(doc: Document): ModulePreloader {
@@ -36,7 +38,7 @@ function createModulePreloader(doc: Document): ModulePreloader {
36
38
  let link = doc.createElement('link')
37
39
  link.rel = 'modulepreload'
38
40
  link.href = href
39
- link.setAttribute('data-rmx', '')
41
+ link.setAttribute('data-rmx-module-preload', '')
40
42
  let url = link.href
41
43
  if (requestedUrls.has(url)) return
42
44
  requestedUrls.add(url)
@@ -74,7 +76,7 @@ function createModulePreloader(doc: Document): ModulePreloader {
74
76
  let observerLink = doc.createElement('link')
75
77
  observerLink.rel = 'modulepreload'
76
78
  observerLink.href = initialLink.href
77
- observerLink.setAttribute('data-rmx', '')
79
+ observerLink.setAttribute('data-rmx-module-preload', '')
78
80
  let url = initialLink.href
79
81
  requestedUrls.add(url)
80
82
  activateLink(initialLink)
@@ -96,14 +98,15 @@ function createModulePreloader(doc: Document): ModulePreloader {
96
98
  doc.head.append(observerLink)
97
99
  }
98
100
  },
99
- consumePreloadLinks(source) {
101
+ async consumePreloadLinks(source, process) {
100
102
  let hrefs: string[] = []
101
103
  for (let link of source.querySelectorAll<HTMLLinkElement>(SERVER_MODULE_PRELOAD_SELECTOR)) {
102
104
  let href = link.getAttribute('href')
103
105
  link.remove()
104
106
  if (href) hrefs.push(href)
105
107
  }
106
- for (let href of hrefs) preload(href)
108
+ let processedHrefs = process ? await process(hrefs) : hrefs
109
+ for (let href of processedHrefs) preload(href)
107
110
  },
108
111
  hasActivePreloads() {
109
112
  return activeLinkCount > 0
@@ -1,15 +1,7 @@
1
1
  import { getTopFrame, getNamedFrame } from './run.ts'
2
2
  import { reloadFrameForNavigation } from './frame.ts'
3
3
  import { createFormNavigationResolver, type FormSubmission } from './form-navigation.ts'
4
-
5
- interface NavigationPrecommitControllerLike {
6
- redirect(url: string, options: { history: 'replace' }): void
7
- }
8
-
9
- interface NavigationInterceptOptionsWithPrecommit extends NavigationInterceptOptions {
10
- handler(): Promise<void>
11
- precommitHandler(controller: NavigationPrecommitControllerLike): void
12
- }
4
+ import { isDocumentReload } from './document-reload.ts'
13
5
 
14
6
  type NavigationState = {
15
7
  target: string | undefined
@@ -36,11 +28,38 @@ interface FormSubmissionNavigationInfo {
36
28
 
37
29
  interface FrameRedirectNavigationInfo {
38
30
  type: typeof frameRedirectNavigationInfoType
31
+ resetScroll: boolean
39
32
  }
40
33
 
41
34
  const formSubmissionNavigationInfoType = 'frame-form-submission'
42
35
  const frameRedirectNavigationInfoType = 'frame-redirect'
43
36
 
37
+ function resyncWebKitScrollAfterNavigation(
38
+ event: NavigateEvent,
39
+ resetScroll: boolean,
40
+ transition: NavigationTransition,
41
+ ): void {
42
+ let userAgent = navigator.userAgent
43
+ if (!userAgent.includes('AppleWebKit') || isChromiumUserAgent(userAgent)) return
44
+ if (!resetScroll || (event.navigationType !== 'push' && event.navigationType !== 'replace'))
45
+ return
46
+ if (new URL(event.destination.url).hash) return
47
+
48
+ void transition.finished.then(
49
+ () => {
50
+ // WebKit can reset its internal scroll position without synchronizing the visual viewport.
51
+ // https://bugs.webkit.org/show_bug.cgi?id=309542
52
+ if (event.signal.aborted || window.scrollX !== 0 || window.scrollY !== 0) return
53
+ window.scrollTo({ behavior: 'instant', left: 0, top: 1 })
54
+ requestAnimationFrame(() => {
55
+ if (event.signal.aborted || window.scrollX !== 0 || window.scrollY !== 1) return
56
+ window.scrollTo({ behavior: 'instant', left: 0, top: 0 })
57
+ })
58
+ },
59
+ () => {},
60
+ )
61
+ }
62
+
44
63
  /**
45
64
  * Options for client-side frame-aware navigation.
46
65
  */
@@ -64,7 +83,17 @@ export async function navigate(href: string, options?: NavigationOptions) {
64
83
  resetScroll: options?.resetScroll !== false,
65
84
  $rmx: true,
66
85
  } satisfies NavigationState
67
- let transition = window.navigation.navigate(href, { state, history: options?.history })
86
+ let navigation = getInterceptableNavigation()
87
+ if (!navigation) {
88
+ if (options?.history === 'replace') {
89
+ window.location.replace(href)
90
+ } else {
91
+ window.location.assign(href)
92
+ }
93
+ return
94
+ }
95
+
96
+ let transition = navigation.navigate(href, { state, history: options?.history })
68
97
  await transition.finished
69
98
  }
70
99
 
@@ -91,7 +120,8 @@ export function startNavigationListenerImpl(
91
120
  reloadFrame: typeof reloadFrameForNavigation
92
121
  },
93
122
  ) {
94
- let navigation = window.navigation
123
+ let navigation = getInterceptableNavigation()
124
+ if (!navigation) return
95
125
  let resolveFormNavigation = createFormNavigationResolver(signal)
96
126
 
97
127
  navigation.updateCurrentEntry({
@@ -101,6 +131,7 @@ export function startNavigationListenerImpl(
101
131
  navigation.addEventListener(
102
132
  'navigate',
103
133
  (event) => {
134
+ if (isDocumentReload(event.info)) return
104
135
  // Safari seems to incorrectly set canIntercept to true for sub-domain navigations, so
105
136
  // we do a host check ourselves/. The spec is clear that a different host should prevent
106
137
  // interception so this is likely a bug in Safari:
@@ -108,7 +139,10 @@ export function startNavigationListenerImpl(
108
139
  if (!event.canIntercept || isCrossOriginDestination(event)) return
109
140
 
110
141
  if (isFrameRedirectNavigationInfo(event.info)) {
111
- event.intercept({ async handler() {} })
142
+ interceptNavigation(navigation, event, event.info.resetScroll, {
143
+ async handler() {},
144
+ scroll: 'manual',
145
+ })
112
146
  return
113
147
  }
114
148
 
@@ -118,7 +152,7 @@ export function startNavigationListenerImpl(
118
152
  state: replayedSubmission.state,
119
153
  getSubmission: replayedSubmission.getSubmission,
120
154
  }
121
- : getRuntimeNavigation(event, resolveFormNavigation)
155
+ : getRuntimeNavigation(navigation, event, resolveFormNavigation)
122
156
  if (!runtimeNavigation) return
123
157
  let { state } = runtimeNavigation
124
158
 
@@ -127,6 +161,8 @@ export function startNavigationListenerImpl(
127
161
  let frame = namedFrame ?? topFrame
128
162
 
129
163
  let handler = async () => {
164
+ if (event.signal.aborted) return
165
+
130
166
  let submission = await runtimeNavigation.getSubmission?.()
131
167
  if (event.signal.aborted) return
132
168
 
@@ -136,10 +172,15 @@ export function startNavigationListenerImpl(
136
172
 
137
173
  topFrame.src = event.destination.url
138
174
  if (frame !== topFrame) frame.src = state.src
139
- let { redirectedTo } = await options.reloadFrame(frame, {
175
+ let reload = options.reloadFrame(frame, {
140
176
  ...submission,
141
177
  signal: event.signal,
142
178
  })
179
+ await reload.committed
180
+ if (event.signal.aborted || reload.signal.aborted) return
181
+ if (state.resetScroll) event.scroll()
182
+
183
+ let { redirectedTo } = await reload.finished
143
184
 
144
185
  if (redirectedTo && frame === topFrame) {
145
186
  frame.src = redirectedTo
@@ -150,16 +191,17 @@ export function startNavigationListenerImpl(
150
191
  state: { ...state, src: redirectedTo },
151
192
  info: {
152
193
  type: frameRedirectNavigationInfoType,
194
+ resetScroll: state.resetScroll,
153
195
  } satisfies FrameRedirectNavigationInfo,
154
196
  })
155
197
  }
156
-
157
- let isNewEntry = event.navigationType === 'push' || event.navigationType === 'replace'
158
- if (state.resetScroll && isNewEntry) {
159
- window.scrollTo(0, 0)
160
- }
161
198
  }
162
199
 
200
+ let interceptOptions = {
201
+ handler,
202
+ scroll: state.resetScroll === false ? 'manual' : undefined,
203
+ } satisfies NavigationInterceptOptions
204
+
163
205
  if (runtimeNavigation.getSubmission) {
164
206
  // <form method="post"> navigations
165
207
  if (runtimeNavigation.replaceHistory && replayedSubmission == null) {
@@ -168,20 +210,19 @@ export function startNavigationListenerImpl(
168
210
 
169
211
  // Modern browsers allow you to update the in-flight navigation entry before it's committed
170
212
  if (supportsPrecommit) {
171
- let interceptOptions: NavigationInterceptOptionsWithPrecommit = {
172
- handler,
213
+ interceptNavigation(navigation, event, state.resetScroll, {
214
+ ...interceptOptions,
173
215
  precommitHandler(controller) {
174
216
  controller.redirect(event.destination.url, { history: 'replace' })
175
217
  },
176
- }
177
- event.intercept(interceptOptions)
218
+ })
178
219
  return
179
220
  }
180
221
 
181
222
  // Safari doesn't support precommit as of Aug 2026, so we do a full replacement navigation
182
223
  if (event.cancelable) {
183
224
  event.preventDefault()
184
- window.navigation.navigate(event.destination.url, {
225
+ navigation.navigate(event.destination.url, {
185
226
  history: 'replace',
186
227
  state,
187
228
  info: {
@@ -194,14 +235,14 @@ export function startNavigationListenerImpl(
194
235
  }
195
236
  }
196
237
 
197
- event.intercept({ handler })
238
+ interceptNavigation(navigation, event, state.resetScroll, interceptOptions)
198
239
  } else {
199
240
  // <a>/<form method="get"> navigations
200
241
  if (runtimeNavigation.replaceHistory && event.cancelable) {
201
242
  event.preventDefault()
202
243
  navigation.navigate(event.destination.url, { history: 'replace', state })
203
244
  } else {
204
- event.intercept({ handler })
245
+ interceptNavigation(navigation, event, state.resetScroll, interceptOptions)
205
246
  }
206
247
  }
207
248
  },
@@ -209,6 +250,18 @@ export function startNavigationListenerImpl(
209
250
  )
210
251
  }
211
252
 
253
+ function getInterceptableNavigation(): Navigation | undefined {
254
+ let navigation = window.navigation
255
+ if (
256
+ !navigation ||
257
+ typeof NavigateEvent === 'undefined' ||
258
+ !('sourceElement' in NavigateEvent.prototype)
259
+ ) {
260
+ return
261
+ }
262
+ return navigation
263
+ }
264
+
212
265
  function isRuntimeNavigation(info: unknown): info is NavigationState {
213
266
  return typeof info === 'object' && info != null && '$rmx' in info
214
267
  }
@@ -231,7 +284,9 @@ function isFrameRedirectNavigationInfo(value: unknown): value is FrameRedirectNa
231
284
  typeof value === 'object' &&
232
285
  value != null &&
233
286
  'type' in value &&
234
- value.type === frameRedirectNavigationInfoType
287
+ value.type === frameRedirectNavigationInfoType &&
288
+ 'resetScroll' in value &&
289
+ typeof value.resetScroll === 'boolean'
235
290
  )
236
291
  }
237
292
 
@@ -240,23 +295,183 @@ function isCrossOriginDestination(event: NavigateEvent): boolean {
240
295
  return destination.origin !== window.location.origin
241
296
  }
242
297
 
298
+ function interceptNavigation(
299
+ navigation: Navigation,
300
+ event: NavigateEvent,
301
+ resetScroll: boolean,
302
+ options: NavigationInterceptOptions,
303
+ ): void {
304
+ let scrollStyleWorkarounds = installNavigationScrollStyleWorkarounds(event, resetScroll)
305
+
306
+ let transitionBound = false
307
+ function bindTransition() {
308
+ if (transitionBound) return
309
+ if (event.signal.aborted) return scrollStyleWorkarounds?.remove()
310
+
311
+ // The Navigation API creates the transition before running interception handlers.
312
+ let transition = navigation.transition
313
+ if (!transition) return scrollStyleWorkarounds?.remove()
314
+ transitionBound = true
315
+
316
+ resyncWebKitScrollAfterNavigation(event, resetScroll, transition)
317
+ if (scrollStyleWorkarounds) {
318
+ scheduleNavigationScrollCleanup(
319
+ transition,
320
+ scrollStyleWorkarounds.remove,
321
+ scrollStyleWorkarounds.cleanup,
322
+ )
323
+ }
324
+ }
325
+
326
+ let interceptOptions: NavigationInterceptOptions = {
327
+ ...options,
328
+ handler() {
329
+ bindTransition()
330
+ return options.handler?.()
331
+ },
332
+ }
333
+ let precommitHandler = options.precommitHandler
334
+ if (precommitHandler) {
335
+ interceptOptions.precommitHandler = function precommit(controller) {
336
+ bindTransition()
337
+ return precommitHandler(controller)
338
+ }
339
+ }
340
+
341
+ try {
342
+ event.intercept(interceptOptions)
343
+ } catch (error) {
344
+ scrollStyleWorkarounds?.remove()
345
+ throw error
346
+ }
347
+ }
348
+
349
+ type NavigationScrollStyles = {
350
+ cssText: string
351
+ cleanup: 'finished' | 'after-paint'
352
+ }
353
+
354
+ function getNavigationScrollStyles(
355
+ event: NavigateEvent,
356
+ resetScroll: boolean,
357
+ ): NavigationScrollStyles | undefined {
358
+ if (!resetScroll) return
359
+
360
+ if (event.navigationType === 'traverse') {
361
+ // Full-document reconciliation can temporarily shrink the page or trigger scroll anchoring
362
+ // before the Navigation API performs its deferred restoration. Preserve the starting scroll
363
+ // range and position until the navigation finishes so native restoration remains authoritative.
364
+ // Root scroll height includes page-level effects such as body padding.
365
+
366
+ // We think this is a bug in Chromium where they are incorrectly classifying a
367
+ // DOM-modification-driven scroll change as a user scroll action, causing it to skip restoration
368
+ // after the transition. The intended user-scroll behavior is tested here:
369
+ // https://github.com/web-platform-tests/wpt/blob/master/navigation-api/scroll-behavior/after-transition-skips-restore-when-scrolled.html
370
+ let { scrollHeight, clientHeight } = document.documentElement
371
+ return {
372
+ cssText: `
373
+ html {
374
+ min-height: ${scrollHeight + clientHeight}px !important;
375
+ overflow-anchor: none !important;
376
+ }
377
+
378
+ body {
379
+ overflow-anchor: none !important;
380
+ }
381
+ `,
382
+ cleanup: 'finished',
383
+ }
384
+ }
385
+
386
+ if (event.navigationType !== 'push' && event.navigationType !== 'replace') return
387
+ if (new URL(event.destination.url).hash) return
388
+ if (!isChromiumUserAgent(navigator.userAgent)) return
389
+
390
+ // Chromium can treat reconciliation-driven scroll anchoring as user scrolling and preserve an
391
+ // offset after NavigateEvent.scroll() resets the destination. Suppress anchoring before
392
+ // interception and keep it disabled through the first post-navigation paint.
393
+ return {
394
+ cssText: `
395
+ html,
396
+ body {
397
+ overflow-anchor: none !important;
398
+ }
399
+ `,
400
+ cleanup: 'after-paint',
401
+ }
402
+ }
403
+
404
+ function isChromiumUserAgent(userAgent: string): boolean {
405
+ return userAgent.includes('Chrome') || userAgent.includes('Chromium')
406
+ }
407
+
408
+ type NavigationScrollStylesheet = {
409
+ remove(): void
410
+ cleanup: NavigationScrollStyles['cleanup']
411
+ }
412
+
413
+ function installNavigationScrollStyleWorkarounds(
414
+ event: NavigateEvent,
415
+ resetScroll: boolean,
416
+ ): NavigationScrollStylesheet | undefined {
417
+ let scrollStyles = getNavigationScrollStyles(event, resetScroll)
418
+ if (!scrollStyles) return
419
+
420
+ let stylesheet = new CSSStyleSheet()
421
+ stylesheet.replaceSync(scrollStyles.cssText)
422
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]
423
+
424
+ let removed = false
425
+ function remove() {
426
+ event.signal.removeEventListener('abort', remove)
427
+ if (removed) return
428
+ removed = true
429
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
430
+ (current) => current !== stylesheet,
431
+ )
432
+ }
433
+
434
+ if (event.signal.aborted) remove()
435
+ else event.signal.addEventListener('abort', remove, { once: true })
436
+ return { remove, cleanup: scrollStyles.cleanup }
437
+ }
438
+
439
+ function scheduleNavigationScrollCleanup(
440
+ transition: NavigationTransition,
441
+ removeScrollStyles: () => void,
442
+ cleanup: NavigationScrollStylesheet['cleanup'],
443
+ ): void {
444
+ function removeAfterSuccess() {
445
+ if (cleanup === 'finished') return removeScrollStyles()
446
+ // The first callback runs before the first post-navigation paint. The second removes the
447
+ // stylesheet before the following frame.
448
+ requestAnimationFrame(() => requestAnimationFrame(removeScrollStyles))
449
+ }
450
+
451
+ void transition.finished.then(removeAfterSuccess, removeScrollStyles)
452
+ }
453
+
243
454
  function getRuntimeNavigation(
455
+ navigation: Navigation,
244
456
  event: NavigateEvent,
245
457
  resolveFormNavigation: ReturnType<typeof createFormNavigationResolver>,
246
458
  ): RuntimeNavigation | undefined {
247
459
  if (event.navigationType === 'traverse') {
248
- let state = getTraverseNavigationState(event)
460
+ let state = getTraverseNavigationState(navigation, event)
249
461
  return state ? { state } : undefined
250
462
  }
251
463
 
252
- let sourceNavigation = getSourceElementNavigation(event, resolveFormNavigation)
464
+ let sourceNavigation = getSourceElementNavigation(navigation, event, resolveFormNavigation)
253
465
  if (sourceNavigation) return sourceNavigation
254
466
 
255
467
  let destinationState = event.destination.getState()
256
468
  if (isRuntimeNavigation(destinationState)) return { state: destinationState }
257
469
  }
258
470
 
259
- function getTraverseNavigationState(event: NavigateEvent): NavigationState | undefined {
471
+ function getTraverseNavigationState(
472
+ navigation: Navigation,
473
+ event: NavigateEvent,
474
+ ): NavigationState | undefined {
260
475
  let destinationState = event.destination.getState()
261
476
  if (isRuntimeNavigation(destinationState)) {
262
477
  return destinationState
@@ -264,7 +479,6 @@ function getTraverseNavigationState(event: NavigateEvent): NavigationState | und
264
479
 
265
480
  // Safari returns `null` for destination.getState(), even though its in the
266
481
  // navigation.entries(), so we do its job for it and look it up.
267
- let navigation = window.navigation
268
482
  let matchingEntry = navigation.entries().find((entry) => entry.key === event.destination.key)
269
483
  if (matchingEntry) {
270
484
  let state = matchingEntry.getState()
@@ -277,6 +491,7 @@ function getTraverseNavigationState(event: NavigateEvent): NavigationState | und
277
491
  }
278
492
 
279
493
  function getSourceElementNavigation(
494
+ navigation: Navigation,
280
495
  event: NavigateEvent,
281
496
  resolveFormNavigation: ReturnType<typeof createFormNavigationResolver>,
282
497
  ): RuntimeNavigation | undefined {
@@ -286,36 +501,36 @@ function getSourceElementNavigation(
286
501
 
287
502
  let linkElement = sourceElement.closest('a, area')
288
503
  if (linkElement instanceof Element) {
289
- if (linkElement.hasAttribute('rmx-document')) return
504
+ if (linkElement.hasAttribute('data-rmx-document')) return
290
505
  if (linkElement.hasAttribute('download')) return
291
506
 
292
507
  return {
293
508
  state: {
294
- target: linkElement.getAttribute('rmx-target') ?? undefined,
295
- src: linkElement.getAttribute('rmx-src') ?? event.destination.url,
296
- resetScroll: linkElement.getAttribute('rmx-reset-scroll') !== 'false',
509
+ target: linkElement.getAttribute('data-rmx-target') ?? undefined,
510
+ src: linkElement.getAttribute('data-rmx-src') ?? event.destination.url,
511
+ resetScroll: linkElement.getAttribute('data-rmx-reset-scroll') !== 'false',
297
512
  $rmx: true,
298
513
  },
299
- replaceHistory: getReplaceHistory(linkElement.getAttribute('rmx-history'), false),
514
+ replaceHistory: getReplaceHistory(linkElement.getAttribute('data-rmx-history'), false),
300
515
  }
301
516
  }
302
517
 
303
518
  let formNavigation = resolveFormNavigation(event)
304
- if (!formNavigation || formNavigation.hasAttribute('rmx-document')) return
519
+ if (!formNavigation || formNavigation.hasAttribute('data-rmx-document')) return
305
520
 
306
521
  let replaceHistoryByDefault =
307
522
  formNavigation.getSubmission !== undefined &&
308
- event.destination.url === window.navigation.currentEntry?.url
523
+ event.destination.url === navigation.currentEntry?.url
309
524
 
310
525
  return {
311
526
  state: {
312
- target: formNavigation.getAttribute('rmx-target') ?? undefined,
313
- src: formNavigation.getAttribute('rmx-src') ?? event.destination.url,
314
- resetScroll: formNavigation.getAttribute('rmx-reset-scroll') !== 'false',
527
+ target: formNavigation.getAttribute('data-rmx-target') ?? undefined,
528
+ src: formNavigation.getAttribute('data-rmx-src') ?? event.destination.url,
529
+ resetScroll: formNavigation.getAttribute('data-rmx-reset-scroll') !== 'false',
315
530
  $rmx: true,
316
531
  },
317
532
  replaceHistory: getReplaceHistory(
318
- formNavigation.getAttribute('rmx-history'),
533
+ formNavigation.getAttribute('data-rmx-history'),
319
534
  replaceHistoryByDefault,
320
535
  ),
321
536
  getSubmission: formNavigation.getSubmission,
@@ -1097,6 +1097,7 @@ function insertFrame(
1097
1097
  moduleLoads: runtime.moduleLoads,
1098
1098
  frameInstances: runtime.frameInstances,
1099
1099
  namedFrames: runtime.namedFrames,
1100
+ processClientEntryPreloads: runtime.processClientEntryPreloads,
1100
1101
  })
1101
1102
  runtime.frameInstances.set(start, instance)
1102
1103
  }
@@ -1147,6 +1148,7 @@ function insertFrame(
1147
1148
  moduleLoads: runtime.moduleLoads,
1148
1149
  frameInstances: runtime.frameInstances,
1149
1150
  namedFrames: runtime.namedFrames,
1151
+ processClientEntryPreloads: runtime.processClientEntryPreloads,
1150
1152
  })
1151
1153
  runtime.frameInstances.set(start, instance)
1152
1154
 
@@ -1163,7 +1165,7 @@ function insertFrame(
1163
1165
  resolveController: undefined,
1164
1166
  },
1165
1167
  })
1166
- resolveClientFrame(committed, runtime)
1168
+ resolveClientFrame(committed, runtime, runtime.serverFrameReload)
1167
1169
 
1168
1170
  return committed
1169
1171
  }
@@ -1171,7 +1173,7 @@ function insertFrame(
1171
1173
  function resolveClientFrame(
1172
1174
  node: CommittedFrameNode,
1173
1175
  runtime: FrameRuntime,
1174
- serverFrameReload?: { signal: AbortSignal },
1176
+ serverFrameReload?: NonNullable<FrameRuntime['serverFrameReload']>,
1175
1177
  ): void {
1176
1178
  let frameSrc = getFrameSrc(node)
1177
1179
  let state = node._state
@@ -1188,8 +1190,9 @@ function resolveClientFrame(
1188
1190
  }
1189
1191
  let resolveController = reload?.controller ?? new AbortController()
1190
1192
  state.resolveController = resolveController
1193
+ let frameCommitted = Promise.withResolvers<void>()
1191
1194
 
1192
- Promise.resolve()
1195
+ let resolve = Promise.resolve()
1193
1196
  .then(() =>
1194
1197
  runtime.resolveFrame(frameSrc, {
1195
1198
  signal: resolveController.signal,
@@ -1203,7 +1206,12 @@ function resolveClientFrame(
1203
1206
  state.fallbackRoot?.dispose()
1204
1207
  state.fallbackRoot = undefined
1205
1208
  let nextContent = asAbortableFrameContent(content, resolveController.signal)
1206
- await instance.render(nextContent, { signal: resolveController.signal })
1209
+ await instance.render(nextContent, {
1210
+ signal: resolveController.signal,
1211
+ reconciliationTracker: serverFrameReload?.reconciliationTracker,
1212
+ blockingFrameTracker: serverFrameReload?.blockingFrameTracker,
1213
+ onCommit: frameCommitted.resolve,
1214
+ })
1207
1215
  if (state.resolveToken !== token || resolveController.signal.aborted) return
1208
1216
  state.resolved = true
1209
1217
  })
@@ -1213,11 +1221,19 @@ function resolveClientFrame(
1213
1221
  }
1214
1222
  })
1215
1223
  .finally(() => {
1224
+ frameCommitted.resolve()
1216
1225
  reload?.complete()
1217
1226
  if (state.resolveController === resolveController) {
1218
1227
  state.resolveController = undefined
1219
1228
  }
1220
1229
  })
1230
+
1231
+ if (serverFrameReload?.reconciliationTracker && !node.props.fallback) {
1232
+ serverFrameReload.reconciliationTracker.waitFor(resolve)
1233
+ }
1234
+ if (serverFrameReload?.blockingFrameTracker && !node.props.fallback) {
1235
+ serverFrameReload.blockingFrameTracker.waitFor(frameCommitted.promise)
1236
+ }
1221
1237
  }
1222
1238
 
1223
1239
  function disposeFrameResources(node: CommittedFrameNode): void {
@@ -7,6 +7,7 @@ import type { ComponentErrorEvent } from './error-event.ts'
7
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
+ import type { ProcessClientEntryPreloads } from './module-preloader.ts'
10
11
 
11
12
  /**
12
13
  * Options for starting the client runtime with {@link run}.
@@ -27,6 +28,9 @@ export interface RunInit {
27
28
  * method, encoding, and abort signal.
28
29
  */
29
30
  resolveFrame?: ResolveFrame
31
+
32
+ /** Processes module preloads discovered in late client entry responses before activation. */
33
+ processClientEntryPreloads?: ProcessClientEntryPreloads
30
34
  }
31
35
 
32
36
  /**
@@ -76,9 +80,12 @@ export function getNamedFrame(name: string): FrameHandle {
76
80
  // manual reloads use the requested form encoding instead of always sending multipart bodies.
77
81
  function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
78
82
  let formData = options?.formData
79
- if (!formData || options?.method?.toLowerCase() === 'get') return
83
+ let method = options?.method
84
+ if (!formData || !method || ['get', 'head'].includes(method.toLowerCase())) return
85
+
86
+ let encType = options?.encType
80
87
 
81
- if (options?.encType === 'text/plain') {
88
+ if (encType === 'text/plain') {
82
89
  let body = ''
83
90
  for (let [name, value] of formData) {
84
91
  name = normalizeLineBreaks(name)
@@ -88,7 +95,7 @@ function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
88
95
  return new Blob([body], { type: 'text/plain' })
89
96
  }
90
97
 
91
- if (options?.encType !== 'application/x-www-form-urlencoded') return formData
98
+ if (encType !== 'application/x-www-form-urlencoded') return formData
92
99
 
93
100
  let body = new URLSearchParams()
94
101
  for (let [name, value] of formData) {
@@ -109,7 +116,8 @@ async function defaultResolveFrame(src: string, options?: ResolveFrameOptions):
109
116
  signal: options?.signal,
110
117
  })
111
118
 
112
- if (!response.ok) {
119
+ let isHtml = response.headers.get('Content-Type')?.toLowerCase().includes('text/html')
120
+ if (response.status >= 500 || (response.status >= 300 && !isHtml)) {
113
121
  throw new Error(`Failed to resolve frame: ${response.status} ${response.statusText}`.trimEnd())
114
122
  }
115
123
 
@@ -142,6 +150,7 @@ export function run(init: RunInit): AppRuntime {
142
150
  moduleLoads: new Map(),
143
151
  frameInstances: new WeakMap(),
144
152
  namedFrames,
153
+ processClientEntryPreloads: init.processClientEntryPreloads,
145
154
  })
146
155
 
147
156
  let appController = new AbortController()