@remix-run/ui 0.7.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 (66) hide show
  1. package/README.md +35 -7
  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/diff-dom.js +8 -3
  8. package/dist/runtime/diff-dom.js.map +1 -1
  9. package/dist/runtime/dom.d.ts +21 -11
  10. package/dist/runtime/event-types.d.ts +14 -0
  11. package/dist/runtime/event-types.js +2 -0
  12. package/dist/runtime/event-types.js.map +1 -0
  13. package/dist/runtime/frame-resolution.js +8 -0
  14. package/dist/runtime/frame-resolution.js.map +1 -1
  15. package/dist/runtime/frame.d.ts +5 -2
  16. package/dist/runtime/frame.js +107 -49
  17. package/dist/runtime/frame.js.map +1 -1
  18. package/dist/runtime/mixins/link-mixin.js +4 -4
  19. package/dist/runtime/mixins/link-mixin.js.map +1 -1
  20. package/dist/runtime/mixins/mixin.d.ts +9 -3
  21. package/dist/runtime/mixins/mixin.js.map +1 -1
  22. package/dist/runtime/mixins/on-mixin.d.ts +1 -1
  23. package/dist/runtime/module-preloader.js +3 -3
  24. package/dist/runtime/module-preloader.js.map +1 -1
  25. package/dist/runtime/navigation.js +114 -32
  26. package/dist/runtime/navigation.js.map +1 -1
  27. package/dist/runtime/reconcile.js +9 -3
  28. package/dist/runtime/reconcile.js.map +1 -1
  29. package/dist/runtime/run.js +5 -3
  30. package/dist/runtime/run.js.map +1 -1
  31. package/dist/runtime/scheduler.js +37 -11
  32. package/dist/runtime/scheduler.js.map +1 -1
  33. package/dist/runtime/spa-response.d.ts +30 -0
  34. package/dist/runtime/spa-response.js +47 -0
  35. package/dist/runtime/spa-response.js.map +1 -0
  36. package/dist/runtime/typed-event-target.d.ts +0 -4
  37. package/dist/runtime/typed-event-target.js.map +1 -1
  38. package/dist/server/stream.js +21 -5
  39. package/dist/server/stream.js.map +1 -1
  40. package/dist/style/stylesheet.js +2 -2
  41. package/dist/style/stylesheet.js.map +1 -1
  42. package/package.json +1 -1
  43. package/src/animation/demos/drag-release.ts +5 -7
  44. package/src/index.ts +2 -2
  45. package/src/runtime/demos/readme.demo.tsx +7 -17
  46. package/src/runtime/diff-dom.ts +6 -3
  47. package/src/runtime/dom.ts +21 -11
  48. package/src/runtime/event-types.ts +27 -0
  49. package/src/runtime/frame-resolution.ts +9 -0
  50. package/src/runtime/frame.ts +112 -50
  51. package/src/runtime/mixins/link-mixin.ts +4 -4
  52. package/src/runtime/mixins/mixin.ts +26 -4
  53. package/src/runtime/mixins/on-mixin.ts +1 -1
  54. package/src/runtime/module-preloader.ts +3 -3
  55. package/src/runtime/navigation.ts +126 -31
  56. package/src/runtime/reconcile.ts +11 -4
  57. package/src/runtime/run.ts +6 -3
  58. package/src/runtime/scheduler.ts +53 -13
  59. package/src/runtime/spa-response.ts +56 -0
  60. package/src/runtime/typed-event-target.ts +1 -6
  61. package/src/server/stream.ts +28 -5
  62. package/src/style/stylesheet.ts +3 -3
  63. package/dist/runtime/event-listeners.d.ts +0 -50
  64. package/dist/runtime/event-listeners.js +0 -31
  65. package/dist/runtime/event-listeners.js.map +0 -1
  66. 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
 
@@ -92,6 +127,7 @@ export function startNavigationListenerImpl(
92
127
  },
93
128
  ) {
94
129
  let navigation = window.navigation
130
+ if (!navigation) return
95
131
  let resolveFormNavigation = createFormNavigationResolver(signal)
96
132
 
97
133
  navigation.updateCurrentEntry({
@@ -108,7 +144,11 @@ export function startNavigationListenerImpl(
108
144
  if (!event.canIntercept || isCrossOriginDestination(event)) return
109
145
 
110
146
  if (isFrameRedirectNavigationInfo(event.info)) {
111
- 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
+ })
112
152
  return
113
153
  }
114
154
 
@@ -118,15 +158,22 @@ export function startNavigationListenerImpl(
118
158
  state: replayedSubmission.state,
119
159
  getSubmission: replayedSubmission.getSubmission,
120
160
  }
121
- : getRuntimeNavigation(event, resolveFormNavigation)
161
+ : getRuntimeNavigation(navigation, event, resolveFormNavigation)
122
162
  if (!runtimeNavigation) return
123
163
  let { state } = runtimeNavigation
164
+ resyncWebKitScrollAfterNavigation(event, state.resetScroll)
124
165
 
125
166
  let topFrame = options.getTopFrame()
126
167
  let namedFrame = state.target ? options.getNamedFrame(state.target) : undefined
127
168
  let frame = namedFrame ?? topFrame
128
169
 
129
170
  let handler = async () => {
171
+ if (event.signal.aborted) return
172
+
173
+ if (event.navigationType === 'traverse' && state.resetScroll) {
174
+ preserveStartingDocumentScrollState(navigation, event)
175
+ }
176
+
130
177
  let submission = await runtimeNavigation.getSubmission?.()
131
178
  if (event.signal.aborted) return
132
179
 
@@ -150,16 +197,17 @@ export function startNavigationListenerImpl(
150
197
  state: { ...state, src: redirectedTo },
151
198
  info: {
152
199
  type: frameRedirectNavigationInfoType,
200
+ resetScroll: state.resetScroll,
153
201
  } satisfies FrameRedirectNavigationInfo,
154
202
  })
155
203
  }
156
-
157
- let isNewEntry = event.navigationType === 'push' || event.navigationType === 'replace'
158
- if (state.resetScroll && isNewEntry) {
159
- window.scrollTo(0, 0)
160
- }
161
204
  }
162
205
 
206
+ let interceptOptions = {
207
+ handler,
208
+ scroll: state.resetScroll === false ? 'manual' : undefined,
209
+ } satisfies NavigationInterceptOptions
210
+
163
211
  if (runtimeNavigation.getSubmission) {
164
212
  // <form method="post"> navigations
165
213
  if (runtimeNavigation.replaceHistory && replayedSubmission == null) {
@@ -168,20 +216,19 @@ export function startNavigationListenerImpl(
168
216
 
169
217
  // Modern browsers allow you to update the in-flight navigation entry before it's committed
170
218
  if (supportsPrecommit) {
171
- let interceptOptions: NavigationInterceptOptionsWithPrecommit = {
172
- handler,
219
+ event.intercept({
220
+ ...interceptOptions,
173
221
  precommitHandler(controller) {
174
222
  controller.redirect(event.destination.url, { history: 'replace' })
175
223
  },
176
- }
177
- event.intercept(interceptOptions)
224
+ })
178
225
  return
179
226
  }
180
227
 
181
228
  // Safari doesn't support precommit as of Aug 2026, so we do a full replacement navigation
182
229
  if (event.cancelable) {
183
230
  event.preventDefault()
184
- window.navigation.navigate(event.destination.url, {
231
+ navigation.navigate(event.destination.url, {
185
232
  history: 'replace',
186
233
  state,
187
234
  info: {
@@ -194,14 +241,14 @@ export function startNavigationListenerImpl(
194
241
  }
195
242
  }
196
243
 
197
- event.intercept({ handler })
244
+ event.intercept(interceptOptions)
198
245
  } else {
199
246
  // <a>/<form method="get"> navigations
200
247
  if (runtimeNavigation.replaceHistory && event.cancelable) {
201
248
  event.preventDefault()
202
249
  navigation.navigate(event.destination.url, { history: 'replace', state })
203
250
  } else {
204
- event.intercept({ handler })
251
+ event.intercept(interceptOptions)
205
252
  }
206
253
  }
207
254
  },
@@ -231,7 +278,9 @@ function isFrameRedirectNavigationInfo(value: unknown): value is FrameRedirectNa
231
278
  typeof value === 'object' &&
232
279
  value != null &&
233
280
  'type' in value &&
234
- value.type === frameRedirectNavigationInfoType
281
+ value.type === frameRedirectNavigationInfoType &&
282
+ 'resetScroll' in value &&
283
+ typeof value.resetScroll === 'boolean'
235
284
  )
236
285
  }
237
286
 
@@ -240,23 +289,69 @@ function isCrossOriginDestination(event: NavigateEvent): boolean {
240
289
  return destination.origin !== window.location.origin
241
290
  }
242
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
+
243
334
  function getRuntimeNavigation(
335
+ navigation: Navigation,
244
336
  event: NavigateEvent,
245
337
  resolveFormNavigation: ReturnType<typeof createFormNavigationResolver>,
246
338
  ): RuntimeNavigation | undefined {
247
339
  if (event.navigationType === 'traverse') {
248
- let state = getTraverseNavigationState(event)
340
+ let state = getTraverseNavigationState(navigation, event)
249
341
  return state ? { state } : undefined
250
342
  }
251
343
 
252
- let sourceNavigation = getSourceElementNavigation(event, resolveFormNavigation)
344
+ let sourceNavigation = getSourceElementNavigation(navigation, event, resolveFormNavigation)
253
345
  if (sourceNavigation) return sourceNavigation
254
346
 
255
347
  let destinationState = event.destination.getState()
256
348
  if (isRuntimeNavigation(destinationState)) return { state: destinationState }
257
349
  }
258
350
 
259
- function getTraverseNavigationState(event: NavigateEvent): NavigationState | undefined {
351
+ function getTraverseNavigationState(
352
+ navigation: Navigation,
353
+ event: NavigateEvent,
354
+ ): NavigationState | undefined {
260
355
  let destinationState = event.destination.getState()
261
356
  if (isRuntimeNavigation(destinationState)) {
262
357
  return destinationState
@@ -264,7 +359,6 @@ function getTraverseNavigationState(event: NavigateEvent): NavigationState | und
264
359
 
265
360
  // Safari returns `null` for destination.getState(), even though its in the
266
361
  // navigation.entries(), so we do its job for it and look it up.
267
- let navigation = window.navigation
268
362
  let matchingEntry = navigation.entries().find((entry) => entry.key === event.destination.key)
269
363
  if (matchingEntry) {
270
364
  let state = matchingEntry.getState()
@@ -277,6 +371,7 @@ function getTraverseNavigationState(event: NavigateEvent): NavigationState | und
277
371
  }
278
372
 
279
373
  function getSourceElementNavigation(
374
+ navigation: Navigation,
280
375
  event: NavigateEvent,
281
376
  resolveFormNavigation: ReturnType<typeof createFormNavigationResolver>,
282
377
  ): RuntimeNavigation | undefined {
@@ -286,36 +381,36 @@ function getSourceElementNavigation(
286
381
 
287
382
  let linkElement = sourceElement.closest('a, area')
288
383
  if (linkElement instanceof Element) {
289
- if (linkElement.hasAttribute('rmx-document')) return
384
+ if (linkElement.hasAttribute('data-rmx-document')) return
290
385
  if (linkElement.hasAttribute('download')) return
291
386
 
292
387
  return {
293
388
  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',
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',
297
392
  $rmx: true,
298
393
  },
299
- replaceHistory: getReplaceHistory(linkElement.getAttribute('rmx-history'), false),
394
+ replaceHistory: getReplaceHistory(linkElement.getAttribute('data-rmx-history'), false),
300
395
  }
301
396
  }
302
397
 
303
398
  let formNavigation = resolveFormNavigation(event)
304
- if (!formNavigation || formNavigation.hasAttribute('rmx-document')) return
399
+ if (!formNavigation || formNavigation.hasAttribute('data-rmx-document')) return
305
400
 
306
401
  let replaceHistoryByDefault =
307
402
  formNavigation.getSubmission !== undefined &&
308
- event.destination.url === window.navigation.currentEntry?.url
403
+ event.destination.url === navigation.currentEntry?.url
309
404
 
310
405
  return {
311
406
  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',
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',
315
410
  $rmx: true,
316
411
  },
317
412
  replaceHistory: getReplaceHistory(
318
- formNavigation.getAttribute('rmx-history'),
413
+ formNavigation.getAttribute('data-rmx-history'),
319
414
  replaceHistoryByDefault,
320
415
  ),
321
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 {
@@ -76,9 +76,12 @@ export function getNamedFrame(name: string): FrameHandle {
76
76
  // manual reloads use the requested form encoding instead of always sending multipart bodies.
77
77
  function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
78
78
  let formData = options?.formData
79
- if (!formData || options?.method?.toLowerCase() === 'get') return
79
+ let method = options?.method
80
+ if (!formData || !method || ['get', 'head'].includes(method.toLowerCase())) return
80
81
 
81
- if (options?.encType === 'text/plain') {
82
+ let encType = options?.encType
83
+
84
+ if (encType === 'text/plain') {
82
85
  let body = ''
83
86
  for (let [name, value] of formData) {
84
87
  name = normalizeLineBreaks(name)
@@ -88,7 +91,7 @@ function getRequestBody(options?: ResolveFrameOptions): BodyInit | undefined {
88
91
  return new Blob([body], { type: 'text/plain' })
89
92
  }
90
93
 
91
- if (options?.encType !== 'application/x-www-form-urlencoded') return formData
94
+ if (encType !== 'application/x-www-form-urlencoded') return formData
92
95
 
93
96
  let body = new URLSearchParams()
94
97
  for (let [name, value] of formData) {
@@ -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.
@@ -492,7 +492,7 @@ function buildFrameSegment(
492
492
  framePromise.catch(() => {})
493
493
  context.pendingFrames.push({ frameId, promise: framePromise })
494
494
  } else {
495
- seg.pending = Promise.resolve(
495
+ let framePromise = Promise.resolve(
496
496
  context.resolveFrame(props.src, props.name, resolveFrameContext),
497
497
  ).then(async (resolved) => {
498
498
  let { html, tail } = await resolveFrameHtml(resolved)
@@ -502,6 +502,9 @@ function buildFrameSegment(
502
502
  context.blockingFrameTails.push(tail)
503
503
  }
504
504
  })
505
+ // An earlier blocking frame may reject before this promise is awaited.
506
+ framePromise.catch(() => {})
507
+ seg.pending = framePromise
505
508
  }
506
509
 
507
510
  return seg
@@ -535,6 +538,16 @@ function buildElementSegment(
535
538
  return staticSeg(`<${tag}${attrs}>${props.innerHTML}</${tag}>`)
536
539
  }
537
540
 
541
+ if (tag === 'script') {
542
+ if (typeof props.children === 'string') {
543
+ return staticSeg(`<${tag}${attrs}>${escapeScriptTextContent(props.children)}</${tag}>`)
544
+ }
545
+ if (props.children != null) {
546
+ console.error(new Error('script elements with children must have a single string child'))
547
+ }
548
+ return staticSeg(`<${tag}${attrs}></${tag}>`)
549
+ }
550
+
538
551
  let open = staticSeg(`<${tag}${attrs}>`)
539
552
  // Adjust svg context for children: foreignObject switches back to HTML
540
553
  let previousInsideSvg = context.insideSvg
@@ -1174,6 +1187,16 @@ function escapeTemplateContent(html: string): string {
1174
1187
  return html.replace(/<\/template/gi, '<\\/template')
1175
1188
  }
1176
1189
 
1190
+ const SCRIPT_TAG_PATTERN = /(<\/|<)(s)(cript)/gi
1191
+
1192
+ function escapeScriptTextContent(value: string): string {
1193
+ return value.replace(
1194
+ SCRIPT_TAG_PATTERN,
1195
+ (_match, prefix: string, firstLetter: string, suffix: string) =>
1196
+ `${prefix}${firstLetter === 's' ? '\\u0073' : '\\u0053'}${suffix}`,
1197
+ )
1198
+ }
1199
+
1177
1200
  function transformAttributeName(name: string, isSvg: boolean): string {
1178
1201
  return normalizeAttributeName(name, isSvg).attr
1179
1202
  }
@@ -1232,7 +1255,7 @@ function finalizeHtml(html: string, context: RenderContext): string {
1232
1255
 
1233
1256
  const FRAME_HEAD_OPEN_TAG = '<head>'
1234
1257
  const FRAME_HEAD_CLOSE_TAG = '</head>'
1235
- const MARKED_MODULE_PRELOAD_START = '<link data-rmx rel="modulepreload" href="'
1258
+ const MARKED_MODULE_PRELOAD_START = '<link data-rmx-module-preload rel="modulepreload" href="'
1236
1259
  const MODULE_PRELOAD_END = '" />'
1237
1260
 
1238
1261
  function createModulePreloadTag(href: string): string {
@@ -1322,12 +1345,12 @@ function renderStyleTag(
1322
1345
  ): string {
1323
1346
  let wrappedCss = wrapStyleForLayer(selector, css, layer)
1324
1347
  if (!wrappedCss) return ''
1325
- return `<style data-rmx="${escapeHtml(selector)}">${escapeStyleText(wrappedCss)}</style>`
1348
+ return `<style data-rmx-style="${escapeHtml(selector)}">${escapeStyleText(wrappedCss)}</style>`
1326
1349
  }
1327
1350
 
1328
1351
  function escapeStyleText(css: string): string {
1329
- // A literal "</style" closes an HTML style element even when it appears inside a CSS string.
1330
- return css.replace(/</g, '\\3C ')
1352
+ // Only neutralize literal style end tags. Escaping every '<' breaks valid range media queries.
1353
+ return css.replace(/<\/style/gi, '\\3C/style')
1331
1354
  }
1332
1355
 
1333
1356
  function buildRmxDataScript(context: RenderContext): string {
@@ -7,7 +7,7 @@ import { REMIX_UI_STYLE_LAYER } from './layers.ts'
7
7
  // never become *wrong* — only unused. That observation drives a two-tier
8
8
  // lifetime model on a single document-level registry:
9
9
  //
10
- // - **Server-adopted rules are pinned.** Once a `<style data-rmx>` tag is
10
+ // - **Server-adopted rules are pinned.** Once a `<style data-rmx-style>` tag is
11
11
  // adopted, its rule stays for the life of the manager. Frame reloads,
12
12
  // island hydration, and streamed templates never need to agree on which
13
13
  // scope "owns" a shared rule — adoption is additive and idempotent, and the
@@ -34,7 +34,7 @@ export interface StyleManager {
34
34
  dispose(): void
35
35
  }
36
36
 
37
- const SERVER_STYLE_SELECTOR = 'style[data-rmx]'
37
+ const SERVER_STYLE_SELECTOR = 'style[data-rmx-style]'
38
38
 
39
39
  function getStyleLayerName(className: string, layer: string = REMIX_UI_STYLE_LAYER): string {
40
40
  return `${layer}.${className}`
@@ -95,7 +95,7 @@ function isHtmlStyleElement(node: unknown): node is HTMLStyleElement {
95
95
  }
96
96
 
97
97
  function getStyleSelector(styleEl: HTMLStyleElement): string | null {
98
- let selector = styleEl.getAttribute('data-rmx')?.trim()
98
+ let selector = styleEl.getAttribute('data-rmx-style')?.trim()
99
99
  return selector ? selector : null
100
100
  }
101
101