@remix-run/ui 0.6.0 → 0.8.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 (71) hide show
  1. package/README.md +86 -20
  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 +2 -1
  8. package/dist/runtime/component.js.map +1 -1
  9. package/dist/runtime/diff-dom.js +8 -3
  10. package/dist/runtime/diff-dom.js.map +1 -1
  11. package/dist/runtime/dom.d.ts +21 -11
  12. package/dist/runtime/event-types.d.ts +14 -0
  13. package/dist/runtime/event-types.js +2 -0
  14. package/dist/runtime/event-types.js.map +1 -0
  15. package/dist/runtime/frame-resolution.js +8 -0
  16. package/dist/runtime/frame-resolution.js.map +1 -1
  17. package/dist/runtime/frame.d.ts +5 -2
  18. package/dist/runtime/frame.js +107 -49
  19. package/dist/runtime/frame.js.map +1 -1
  20. package/dist/runtime/mixins/link-mixin.js +4 -4
  21. package/dist/runtime/mixins/link-mixin.js.map +1 -1
  22. package/dist/runtime/mixins/mixin.d.ts +9 -3
  23. package/dist/runtime/mixins/mixin.js.map +1 -1
  24. package/dist/runtime/mixins/on-mixin.d.ts +1 -1
  25. package/dist/runtime/module-preloader.js +3 -3
  26. package/dist/runtime/module-preloader.js.map +1 -1
  27. package/dist/runtime/navigation.d.ts +1 -2
  28. package/dist/runtime/navigation.js +115 -36
  29. package/dist/runtime/navigation.js.map +1 -1
  30. package/dist/runtime/reconcile.js +9 -3
  31. package/dist/runtime/reconcile.js.map +1 -1
  32. package/dist/runtime/run.d.ts +3 -3
  33. package/dist/runtime/run.js +43 -3
  34. package/dist/runtime/run.js.map +1 -1
  35. package/dist/runtime/scheduler.js +37 -11
  36. package/dist/runtime/scheduler.js.map +1 -1
  37. package/dist/runtime/spa-response.d.ts +30 -0
  38. package/dist/runtime/spa-response.js +47 -0
  39. package/dist/runtime/spa-response.js.map +1 -0
  40. package/dist/runtime/typed-event-target.d.ts +0 -4
  41. package/dist/runtime/typed-event-target.js.map +1 -1
  42. package/dist/server/stream.js +21 -5
  43. package/dist/server/stream.js.map +1 -1
  44. package/dist/style/stylesheet.js +2 -2
  45. package/dist/style/stylesheet.js.map +1 -1
  46. package/package.json +1 -1
  47. package/src/animation/demos/drag-release.ts +5 -7
  48. package/src/index.ts +2 -2
  49. package/src/runtime/component.ts +2 -1
  50. package/src/runtime/demos/readme.demo.tsx +7 -17
  51. package/src/runtime/diff-dom.ts +6 -3
  52. package/src/runtime/dom.ts +21 -11
  53. package/src/runtime/event-types.ts +27 -0
  54. package/src/runtime/frame-resolution.ts +9 -0
  55. package/src/runtime/frame.ts +112 -50
  56. package/src/runtime/mixins/link-mixin.ts +4 -4
  57. package/src/runtime/mixins/mixin.ts +26 -4
  58. package/src/runtime/mixins/on-mixin.ts +1 -1
  59. package/src/runtime/module-preloader.ts +3 -3
  60. package/src/runtime/navigation.ts +127 -34
  61. package/src/runtime/reconcile.ts +11 -4
  62. package/src/runtime/run.ts +53 -6
  63. package/src/runtime/scheduler.ts +53 -13
  64. package/src/runtime/spa-response.ts +56 -0
  65. package/src/runtime/typed-event-target.ts +1 -6
  66. package/src/server/stream.ts +28 -5
  67. package/src/style/stylesheet.ts +3 -3
  68. package/dist/runtime/event-listeners.d.ts +0 -50
  69. package/dist/runtime/event-listeners.js +0 -31
  70. package/dist/runtime/event-listeners.js.map +0 -1
  71. package/src/runtime/event-listeners.ts +0 -171
@@ -36,11 +36,36 @@ interface FormSubmissionNavigationInfo {
36
36
 
37
37
  interface FrameRedirectNavigationInfo {
38
38
  type: typeof frameRedirectNavigationInfoType
39
+ resetScroll: boolean
39
40
  }
40
41
 
41
42
  const formSubmissionNavigationInfoType = 'frame-form-submission'
42
43
  const frameRedirectNavigationInfoType = 'frame-redirect'
43
44
 
45
+ function resyncWebKitScrollAfterNavigation(event: NavigateEvent, resetScroll: boolean): void {
46
+ let userAgent = navigator.userAgent
47
+ if (!userAgent.includes('AppleWebKit')) return
48
+ if (userAgent.includes('Chrome') || userAgent.includes('Chromium')) return
49
+ if (!resetScroll || (event.navigationType !== 'push' && event.navigationType !== 'replace'))
50
+ return
51
+ if (new URL(event.destination.url).hash) return
52
+
53
+ window.navigation.addEventListener(
54
+ 'navigatesuccess',
55
+ () => {
56
+ // WebKit can reset its internal scroll position without synchronizing the visual viewport.
57
+ // https://bugs.webkit.org/show_bug.cgi?id=309542
58
+ if (event.signal.aborted || window.scrollX !== 0 || window.scrollY !== 0) return
59
+ window.scrollTo({ behavior: 'instant', left: 0, top: 1 })
60
+ requestAnimationFrame(() => {
61
+ if (event.signal.aborted || window.scrollX !== 0 || window.scrollY !== 1) return
62
+ window.scrollTo({ behavior: 'instant', left: 0, top: 0 })
63
+ })
64
+ },
65
+ { once: true, signal: event.signal },
66
+ )
67
+ }
68
+
44
69
  /**
45
70
  * Options for client-side frame-aware navigation.
46
71
  */
@@ -64,7 +89,17 @@ export async function navigate(href: string, options?: NavigationOptions) {
64
89
  resetScroll: options?.resetScroll !== false,
65
90
  $rmx: true,
66
91
  } satisfies NavigationState
67
- let transition = window.navigation.navigate(href, { state, history: options?.history })
92
+ let navigation = window.navigation
93
+ if (!navigation) {
94
+ if (options?.history === 'replace') {
95
+ window.location.replace(href)
96
+ } else {
97
+ window.location.assign(href)
98
+ }
99
+ return
100
+ }
101
+
102
+ let transition = navigation.navigate(href, { state, history: options?.history })
68
103
  await transition.finished
69
104
  }
70
105
 
@@ -72,11 +107,9 @@ export async function navigate(href: string, options?: NavigationOptions) {
72
107
  * Starts listening for Navigation API transitions and routes them through frame reloads.
73
108
  *
74
109
  * @param signal Abort signal used to remove the listener.
75
- * @param canResolveFrames Whether the runtime has a resolver that can handle intercepted navigations.
76
110
  * @returns void
77
111
  */
78
- export function startNavigationListener(signal: AbortSignal, canResolveFrames = true) {
79
- if (!canResolveFrames) return
112
+ export function startNavigationListener(signal: AbortSignal) {
80
113
  return startNavigationListenerImpl(signal, {
81
114
  getTopFrame,
82
115
  getNamedFrame,
@@ -94,6 +127,7 @@ export function startNavigationListenerImpl(
94
127
  },
95
128
  ) {
96
129
  let navigation = window.navigation
130
+ if (!navigation) return
97
131
  let resolveFormNavigation = createFormNavigationResolver(signal)
98
132
 
99
133
  navigation.updateCurrentEntry({
@@ -110,7 +144,11 @@ export function startNavigationListenerImpl(
110
144
  if (!event.canIntercept || isCrossOriginDestination(event)) return
111
145
 
112
146
  if (isFrameRedirectNavigationInfo(event.info)) {
113
- event.intercept({ async handler() {} })
147
+ resyncWebKitScrollAfterNavigation(event, event.info.resetScroll)
148
+ event.intercept({
149
+ async handler() {},
150
+ scroll: event.info.resetScroll === false ? 'manual' : undefined,
151
+ })
114
152
  return
115
153
  }
116
154
 
@@ -120,15 +158,22 @@ export function startNavigationListenerImpl(
120
158
  state: replayedSubmission.state,
121
159
  getSubmission: replayedSubmission.getSubmission,
122
160
  }
123
- : getRuntimeNavigation(event, resolveFormNavigation)
161
+ : getRuntimeNavigation(navigation, event, resolveFormNavigation)
124
162
  if (!runtimeNavigation) return
125
163
  let { state } = runtimeNavigation
164
+ resyncWebKitScrollAfterNavigation(event, state.resetScroll)
126
165
 
127
166
  let topFrame = options.getTopFrame()
128
167
  let namedFrame = state.target ? options.getNamedFrame(state.target) : undefined
129
168
  let frame = namedFrame ?? topFrame
130
169
 
131
170
  let handler = async () => {
171
+ if (event.signal.aborted) return
172
+
173
+ if (event.navigationType === 'traverse' && state.resetScroll) {
174
+ preserveStartingDocumentScrollState(navigation, event)
175
+ }
176
+
132
177
  let submission = await runtimeNavigation.getSubmission?.()
133
178
  if (event.signal.aborted) return
134
179
 
@@ -152,16 +197,17 @@ export function startNavigationListenerImpl(
152
197
  state: { ...state, src: redirectedTo },
153
198
  info: {
154
199
  type: frameRedirectNavigationInfoType,
200
+ resetScroll: state.resetScroll,
155
201
  } satisfies FrameRedirectNavigationInfo,
156
202
  })
157
203
  }
158
-
159
- let isNewEntry = event.navigationType === 'push' || event.navigationType === 'replace'
160
- if (state.resetScroll && isNewEntry) {
161
- window.scrollTo(0, 0)
162
- }
163
204
  }
164
205
 
206
+ let interceptOptions = {
207
+ handler,
208
+ scroll: state.resetScroll === false ? 'manual' : undefined,
209
+ } satisfies NavigationInterceptOptions
210
+
165
211
  if (runtimeNavigation.getSubmission) {
166
212
  // <form method="post"> navigations
167
213
  if (runtimeNavigation.replaceHistory && replayedSubmission == null) {
@@ -170,20 +216,19 @@ export function startNavigationListenerImpl(
170
216
 
171
217
  // Modern browsers allow you to update the in-flight navigation entry before it's committed
172
218
  if (supportsPrecommit) {
173
- let interceptOptions: NavigationInterceptOptionsWithPrecommit = {
174
- handler,
219
+ event.intercept({
220
+ ...interceptOptions,
175
221
  precommitHandler(controller) {
176
222
  controller.redirect(event.destination.url, { history: 'replace' })
177
223
  },
178
- }
179
- event.intercept(interceptOptions)
224
+ })
180
225
  return
181
226
  }
182
227
 
183
228
  // Safari doesn't support precommit as of Aug 2026, so we do a full replacement navigation
184
229
  if (event.cancelable) {
185
230
  event.preventDefault()
186
- window.navigation.navigate(event.destination.url, {
231
+ navigation.navigate(event.destination.url, {
187
232
  history: 'replace',
188
233
  state,
189
234
  info: {
@@ -196,14 +241,14 @@ export function startNavigationListenerImpl(
196
241
  }
197
242
  }
198
243
 
199
- event.intercept({ handler })
244
+ event.intercept(interceptOptions)
200
245
  } else {
201
246
  // <a>/<form method="get"> navigations
202
247
  if (runtimeNavigation.replaceHistory && event.cancelable) {
203
248
  event.preventDefault()
204
249
  navigation.navigate(event.destination.url, { history: 'replace', state })
205
250
  } else {
206
- event.intercept({ handler })
251
+ event.intercept(interceptOptions)
207
252
  }
208
253
  }
209
254
  },
@@ -233,7 +278,9 @@ function isFrameRedirectNavigationInfo(value: unknown): value is FrameRedirectNa
233
278
  typeof value === 'object' &&
234
279
  value != null &&
235
280
  'type' in value &&
236
- value.type === frameRedirectNavigationInfoType
281
+ value.type === frameRedirectNavigationInfoType &&
282
+ 'resetScroll' in value &&
283
+ typeof value.resetScroll === 'boolean'
237
284
  )
238
285
  }
239
286
 
@@ -242,23 +289,69 @@ function isCrossOriginDestination(event: NavigateEvent): boolean {
242
289
  return destination.origin !== window.location.origin
243
290
  }
244
291
 
292
+ function preserveStartingDocumentScrollState(navigation: Navigation, event: NavigateEvent): void {
293
+ // Full-document reconciliation can temporarily shrink the page or trigger scroll anchoring
294
+ // before the Navigation API performs its deferred restoration. Preserve the starting scroll
295
+ // range and position until the navigation finishes so native restoration remains authoritative.
296
+ // Root scroll height includes page-level effects such as body padding.
297
+
298
+ // We think this is a bug in Chromium where they are incorrectly classifying a
299
+ // DOM-modification-driven scroll change as a user scroll action, causing it to skip restoration
300
+ // after the transition. The intended user-scroll behavior is tested here:
301
+ // https://github.com/web-platform-tests/wpt/blob/master/navigation-api/scroll-behavior/after-transition-skips-restore-when-scrolled.html
302
+
303
+ let { scrollHeight, clientHeight } = document.documentElement
304
+ let stylesheet = new CSSStyleSheet()
305
+ stylesheet.replaceSync(`
306
+ html {
307
+ min-height: ${scrollHeight + clientHeight}px !important;
308
+ overflow-anchor: none !important;
309
+ }
310
+
311
+ body {
312
+ overflow-anchor: none !important;
313
+ }
314
+ `)
315
+ document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]
316
+
317
+ let cleanedUp = false
318
+ let cleanup = () => {
319
+ if (cleanedUp) return
320
+ cleanedUp = true
321
+ event.signal.removeEventListener('abort', cleanup)
322
+ navigation.removeEventListener('navigatesuccess', cleanup)
323
+ navigation.removeEventListener('navigateerror', cleanup)
324
+ document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
325
+ (current) => current !== stylesheet,
326
+ )
327
+ }
328
+
329
+ event.signal.addEventListener('abort', cleanup, { once: true })
330
+ navigation.addEventListener('navigatesuccess', cleanup, { once: true })
331
+ navigation.addEventListener('navigateerror', cleanup, { once: true })
332
+ }
333
+
245
334
  function getRuntimeNavigation(
335
+ navigation: Navigation,
246
336
  event: NavigateEvent,
247
337
  resolveFormNavigation: ReturnType<typeof createFormNavigationResolver>,
248
338
  ): RuntimeNavigation | undefined {
249
339
  if (event.navigationType === 'traverse') {
250
- let state = getTraverseNavigationState(event)
340
+ let state = getTraverseNavigationState(navigation, event)
251
341
  return state ? { state } : undefined
252
342
  }
253
343
 
254
- let sourceNavigation = getSourceElementNavigation(event, resolveFormNavigation)
344
+ let sourceNavigation = getSourceElementNavigation(navigation, event, resolveFormNavigation)
255
345
  if (sourceNavigation) return sourceNavigation
256
346
 
257
347
  let destinationState = event.destination.getState()
258
348
  if (isRuntimeNavigation(destinationState)) return { state: destinationState }
259
349
  }
260
350
 
261
- function getTraverseNavigationState(event: NavigateEvent): NavigationState | undefined {
351
+ function getTraverseNavigationState(
352
+ navigation: Navigation,
353
+ event: NavigateEvent,
354
+ ): NavigationState | undefined {
262
355
  let destinationState = event.destination.getState()
263
356
  if (isRuntimeNavigation(destinationState)) {
264
357
  return destinationState
@@ -266,7 +359,6 @@ function getTraverseNavigationState(event: NavigateEvent): NavigationState | und
266
359
 
267
360
  // Safari returns `null` for destination.getState(), even though its in the
268
361
  // navigation.entries(), so we do its job for it and look it up.
269
- let navigation = window.navigation
270
362
  let matchingEntry = navigation.entries().find((entry) => entry.key === event.destination.key)
271
363
  if (matchingEntry) {
272
364
  let state = matchingEntry.getState()
@@ -279,6 +371,7 @@ function getTraverseNavigationState(event: NavigateEvent): NavigationState | und
279
371
  }
280
372
 
281
373
  function getSourceElementNavigation(
374
+ navigation: Navigation,
282
375
  event: NavigateEvent,
283
376
  resolveFormNavigation: ReturnType<typeof createFormNavigationResolver>,
284
377
  ): RuntimeNavigation | undefined {
@@ -288,36 +381,36 @@ function getSourceElementNavigation(
288
381
 
289
382
  let linkElement = sourceElement.closest('a, area')
290
383
  if (linkElement instanceof Element) {
291
- if (linkElement.hasAttribute('rmx-document')) return
384
+ if (linkElement.hasAttribute('data-rmx-document')) return
292
385
  if (linkElement.hasAttribute('download')) return
293
386
 
294
387
  return {
295
388
  state: {
296
- target: linkElement.getAttribute('rmx-target') ?? undefined,
297
- src: linkElement.getAttribute('rmx-src') ?? event.destination.url,
298
- resetScroll: linkElement.getAttribute('rmx-reset-scroll') !== 'false',
389
+ target: linkElement.getAttribute('data-rmx-target') ?? undefined,
390
+ src: linkElement.getAttribute('data-rmx-src') ?? event.destination.url,
391
+ resetScroll: linkElement.getAttribute('data-rmx-reset-scroll') !== 'false',
299
392
  $rmx: true,
300
393
  },
301
- replaceHistory: getReplaceHistory(linkElement.getAttribute('rmx-history'), false),
394
+ replaceHistory: getReplaceHistory(linkElement.getAttribute('data-rmx-history'), false),
302
395
  }
303
396
  }
304
397
 
305
398
  let formNavigation = resolveFormNavigation(event)
306
- if (!formNavigation || formNavigation.hasAttribute('rmx-document')) return
399
+ if (!formNavigation || formNavigation.hasAttribute('data-rmx-document')) return
307
400
 
308
401
  let replaceHistoryByDefault =
309
402
  formNavigation.getSubmission !== undefined &&
310
- event.destination.url === window.navigation.currentEntry?.url
403
+ event.destination.url === navigation.currentEntry?.url
311
404
 
312
405
  return {
313
406
  state: {
314
- target: formNavigation.getAttribute('rmx-target') ?? undefined,
315
- src: formNavigation.getAttribute('rmx-src') ?? event.destination.url,
316
- resetScroll: formNavigation.getAttribute('rmx-reset-scroll') !== 'false',
407
+ target: formNavigation.getAttribute('data-rmx-target') ?? undefined,
408
+ src: formNavigation.getAttribute('data-rmx-src') ?? event.destination.url,
409
+ resetScroll: formNavigation.getAttribute('data-rmx-reset-scroll') !== 'false',
317
410
  $rmx: true,
318
411
  },
319
412
  replaceHistory: getReplaceHistory(
320
- formNavigation.getAttribute('rmx-history'),
413
+ formNavigation.getAttribute('data-rmx-history'),
321
414
  replaceHistoryByDefault,
322
415
  ),
323
416
  getSubmission: formNavigation.getSubmission,
@@ -1163,7 +1163,7 @@ function insertFrame(
1163
1163
  resolveController: undefined,
1164
1164
  },
1165
1165
  })
1166
- resolveClientFrame(committed, runtime)
1166
+ resolveClientFrame(committed, runtime, runtime.serverFrameReload)
1167
1167
 
1168
1168
  return committed
1169
1169
  }
@@ -1171,7 +1171,7 @@ function insertFrame(
1171
1171
  function resolveClientFrame(
1172
1172
  node: CommittedFrameNode,
1173
1173
  runtime: FrameRuntime,
1174
- serverFrameReload?: { signal: AbortSignal },
1174
+ serverFrameReload?: NonNullable<FrameRuntime['serverFrameReload']>,
1175
1175
  ): void {
1176
1176
  let frameSrc = getFrameSrc(node)
1177
1177
  let state = node._state
@@ -1189,7 +1189,7 @@ function resolveClientFrame(
1189
1189
  let resolveController = reload?.controller ?? new AbortController()
1190
1190
  state.resolveController = resolveController
1191
1191
 
1192
- Promise.resolve()
1192
+ let resolve = Promise.resolve()
1193
1193
  .then(() =>
1194
1194
  runtime.resolveFrame(frameSrc, {
1195
1195
  signal: resolveController.signal,
@@ -1203,7 +1203,10 @@ function resolveClientFrame(
1203
1203
  state.fallbackRoot?.dispose()
1204
1204
  state.fallbackRoot = undefined
1205
1205
  let nextContent = asAbortableFrameContent(content, resolveController.signal)
1206
- await instance.render(nextContent, { signal: resolveController.signal })
1206
+ await instance.render(nextContent, {
1207
+ signal: resolveController.signal,
1208
+ reconciliationTracker: serverFrameReload?.reconciliationTracker,
1209
+ })
1207
1210
  if (state.resolveToken !== token || resolveController.signal.aborted) return
1208
1211
  state.resolved = true
1209
1212
  })
@@ -1218,6 +1221,10 @@ function resolveClientFrame(
1218
1221
  state.resolveController = undefined
1219
1222
  }
1220
1223
  })
1224
+
1225
+ if (serverFrameReload?.reconciliationTracker && !node.props.fallback) {
1226
+ serverFrameReload.reconciliationTracker.waitFor(resolve)
1227
+ }
1221
1228
  }
1222
1229
 
1223
1230
  function disposeFrameResources(node: CommittedFrameNode): void {
@@ -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,57 @@ 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
+ let method = options?.method
80
+ if (!formData || !method || ['get', 'head'].includes(method.toLowerCase())) return
81
+
82
+ let encType = options?.encType
83
+
84
+ if (encType === 'text/plain') {
85
+ let body = ''
86
+ for (let [name, value] of formData) {
87
+ name = normalizeLineBreaks(name)
88
+ value = normalizeLineBreaks(typeof value === 'string' ? value : value.name)
89
+ body += `${name}=${value}\r\n`
90
+ }
91
+ return new Blob([body], { type: 'text/plain' })
92
+ }
93
+
94
+ if (encType !== 'application/x-www-form-urlencoded') return formData
95
+
96
+ let body = new URLSearchParams()
97
+ for (let [name, value] of formData) {
98
+ body.append(name, typeof value === 'string' ? value : value.name)
99
+ }
100
+ return body
101
+ }
102
+
103
+ function normalizeLineBreaks(value: string): string {
104
+ return value.replace(/\r\n|\r|\n/g, '\r\n')
105
+ }
106
+
107
+ async function defaultResolveFrame(src: string, options?: ResolveFrameOptions): Promise<Response> {
108
+ let response = await fetch(src, {
109
+ body: getRequestBody(options),
110
+ headers: { Accept: 'text/html' },
111
+ method: options?.method,
112
+ signal: options?.signal,
113
+ })
114
+
115
+ if (!response.ok) {
116
+ throw new Error(`Failed to resolve frame: ${response.status} ${response.statusText}`.trimEnd())
117
+ }
118
+
119
+ return response
120
+ }
121
+
75
122
  /**
76
123
  * Starts the client-side Remix component runtime for the current document.
77
124
  *
78
- * @param init Runtime hooks for loading modules and resolving frames.
125
+ * @param init Runtime options for loading modules and customizing frame resolution.
79
126
  * @returns The running application runtime.
80
127
  */
81
128
  export function run(init: RunInit): AppRuntime {
@@ -83,7 +130,7 @@ export function run(init: RunInit): AppRuntime {
83
130
  let errorTarget = new TypedEventTarget<AppRuntimeEventMap>()
84
131
  let scheduler = createScheduler(document, errorTarget, styleManager)
85
132
 
86
- let resolveFrame: ResolveFrame = init.resolveFrame ?? (() => '<p>resolve frame unimplemented</p>')
133
+ let resolveFrame = init.resolveFrame ?? defaultResolveFrame
87
134
 
88
135
  topFrame = createFrame(document, {
89
136
  src: document.location.href,
@@ -107,7 +154,7 @@ export function run(init: RunInit): AppRuntime {
107
154
  return namedFrames.get(name)
108
155
  },
109
156
  }
110
- startNavigationListener(appController.signal, init.resolveFrame !== undefined)
157
+ startNavigationListener(appController.signal)
111
158
  let readyPromise = topFrame.ready().catch((error) => {
112
159
  errorTarget.dispatchEvent(createComponentErrorEvent(error))
113
160
  throw error
@@ -35,13 +35,21 @@ export interface Scheduler {
35
35
  dequeue(): void
36
36
  }
37
37
 
38
- // Protect against infinite cascading updates (e.g. handle.update() during render)
39
- const MAX_CASCADING_UPDATES = 50
38
+ const CASCADING_UPDATE_WARN_THRESHOLD = 50
39
+ const MAX_CASCADING_COMPONENT_UPDATES = 50
40
40
 
41
41
  export type SchedulerPhaseEvent = Event & {
42
42
  parents: ParentNode[]
43
43
  }
44
44
 
45
+ function getComponentName(vnode: CommittedComponentNode): string {
46
+ return vnode.type.name || 'Anonymous'
47
+ }
48
+
49
+ function formatComponentCounts(counts: Map<string, number>): string {
50
+ return Array.from(counts, ([name, count]) => `${name} x${count}`).join(', ')
51
+ }
52
+
45
53
  /**
46
54
  * Creates the DOM update scheduler used by the component runtime.
47
55
  *
@@ -62,7 +70,10 @@ export function createScheduler(
62
70
  let postCommitTasks: EmptyFn[] = []
63
71
  let flushScheduled = false
64
72
  let flushing = false
65
- let cascadingUpdateCount = 0
73
+ let cascadingComponentUpdateCount = 0
74
+ let cascadingComponentUpdateCounts = new WeakMap<CommittedComponentNode, number>()
75
+ let cascadingComponentNameCounts = new Map<string, number>()
76
+ let warnedAboutCascadingUpdates = false
66
77
  let resetScheduled = false
67
78
  let phaseEvents = new EventTarget()
68
79
  let phaseListenerCounts: Record<SchedulerPhaseType, number> = {
@@ -82,11 +93,48 @@ export function createScheduler(
82
93
  // Reset when control returns to the event loop while still allowing
83
94
  // microtask-driven flushes in the same turn to count as cascading.
84
95
  setTimeout(() => {
85
- cascadingUpdateCount = 0
96
+ cascadingComponentUpdateCount = 0
97
+ cascadingComponentUpdateCounts = new WeakMap()
98
+ cascadingComponentNameCounts = new Map()
99
+ warnedAboutCascadingUpdates = false
86
100
  resetScheduled = false
87
101
  }, 0)
88
102
  }
89
103
 
104
+ function trackCascadingUpdate(vnode: CommittedComponentNode): boolean {
105
+ cascadingComponentUpdateCount++
106
+
107
+ let componentName = getComponentName(vnode)
108
+ cascadingComponentNameCounts.set(
109
+ componentName,
110
+ (cascadingComponentNameCounts.get(componentName) ?? 0) + 1,
111
+ )
112
+
113
+ let componentUpdateCount = (cascadingComponentUpdateCounts.get(vnode) ?? 0) + 1
114
+ cascadingComponentUpdateCounts.set(vnode, componentUpdateCount)
115
+ scheduleCounterReset()
116
+
117
+ if (
118
+ !warnedAboutCascadingUpdates &&
119
+ cascadingComponentUpdateCount >= CASCADING_UPDATE_WARN_THRESHOLD
120
+ ) {
121
+ warnedAboutCascadingUpdates = true
122
+ console.warn(
123
+ `${cascadingComponentUpdateCount} cascading component updates detected in one event loop turn. Consider reducing hydration regions. Components: ${formatComponentCounts(cascadingComponentNameCounts)}`,
124
+ )
125
+ }
126
+
127
+ if (componentUpdateCount > MAX_CASCADING_COMPONENT_UPDATES) {
128
+ let error = new Error(
129
+ `handle.update() infinite loop detected in ${componentName} after ${componentUpdateCount} cascading updates. Components: ${formatComponentCounts(cascadingComponentNameCounts)}`,
130
+ )
131
+ dispatchError(error)
132
+ return false
133
+ }
134
+
135
+ return true
136
+ }
137
+
90
138
  function flush() {
91
139
  if (flushing) return
92
140
  flushing = true
@@ -104,15 +152,6 @@ export function createScheduler(
104
152
  postCommitTasks.length > 0
105
153
  if (!hasWork) return
106
154
 
107
- cascadingUpdateCount++
108
- scheduleCounterReset()
109
-
110
- if (cascadingUpdateCount > MAX_CASCADING_UPDATES) {
111
- let error = new Error('handle.update() infinite loop detected')
112
- dispatchError(error)
113
- return
114
- }
115
-
116
155
  documentState.capture()
117
156
 
118
157
  let updateParents = batch.size > 0 ? Array.from(new Set(batch.values())) : []
@@ -125,6 +164,7 @@ export function createScheduler(
125
164
 
126
165
  for (let [vnode, domParent] of vnodes) {
127
166
  if (ancestorIsScheduled(vnode, batch, noScheduledAncestor)) continue
167
+ if (!trackCascadingUpdate(vnode)) return
128
168
  let curr = vnode._content
129
169
  // Calculate anchor at render time from current vdom position (never stale).
130
170
  // Needed for fragment self-updates that add children - without this, new children
@@ -0,0 +1,56 @@
1
+ import type { RemixNode } from './jsx.ts'
2
+
3
+ type SPAResponseData = {
4
+ node: RemixNode
5
+ redirectedTo?: string
6
+ }
7
+
8
+ let spaResponses: WeakMap<Response, SPAResponseData> | undefined
9
+
10
+ /**
11
+ * Creates and finalizes responses carrying renderable Remix nodes for the SPA runtime.
12
+ */
13
+ export const spaResponse = {
14
+ /**
15
+ * Creates a bodyless response associated with a renderable Remix node.
16
+ *
17
+ * @param node Node to render when the response resolves a frame.
18
+ * @param init Standard response status, status text, and headers.
19
+ * @returns A response understood by the SPA runtime.
20
+ * @throws {TypeError} When called outside a browser environment.
21
+ */
22
+ create(node: RemixNode, init?: ResponseInit): Response {
23
+ if (typeof document === 'undefined') {
24
+ throw new TypeError('spaResponse.create() can only be used in a browser')
25
+ }
26
+
27
+ let response = new Response(null, init)
28
+ let responses = (spaResponses ??= new WeakMap())
29
+ responses.set(response, { node })
30
+ return response
31
+ },
32
+
33
+ /**
34
+ * Prepares the final route response for frame resolution.
35
+ *
36
+ * @param response Final response returned by the SPA router.
37
+ * @param redirectedTo Final redirect URL, when the route followed redirects.
38
+ * @returns The same response after validating it and recording its redirect URL.
39
+ * @throws {TypeError} When the response was not created by `spaResponse.create()`.
40
+ */
41
+ finalize(response: Response, redirectedTo?: string): Response {
42
+ let data = getSpaResponseData(response)
43
+ if (!data) throw new TypeError('Expected a Remix SPA response')
44
+
45
+ if (redirectedTo === undefined) {
46
+ delete data.redirectedTo
47
+ } else {
48
+ data.redirectedTo = redirectedTo
49
+ }
50
+ return response
51
+ },
52
+ }
53
+
54
+ export function getSpaResponseData(response: Response): SPAResponseData | undefined {
55
+ return spaResponses?.get(response)
56
+ }
@@ -1,12 +1,7 @@
1
1
  /**
2
2
  * An `EventTarget` subclass with typed event maps.
3
3
  */
4
- export class TypedEventTarget<eventMap> extends EventTarget {
5
- /**
6
- * Phantom property that carries the event map type on instances.
7
- */
8
- declare readonly __eventMap?: eventMap
9
- }
4
+ export class TypedEventTarget<eventMap> extends EventTarget {}
10
5
 
11
6
  /**
12
7
  * Interface surface for {@link TypedEventTarget} with typed listener overloads.