@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.
- package/README.md +35 -7
- package/dist/animation/demos/drag-release.js +5 -7
- package/dist/animation/demos/drag-release.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/diff-dom.js +8 -3
- package/dist/runtime/diff-dom.js.map +1 -1
- package/dist/runtime/dom.d.ts +21 -11
- package/dist/runtime/event-types.d.ts +14 -0
- package/dist/runtime/event-types.js +2 -0
- package/dist/runtime/event-types.js.map +1 -0
- package/dist/runtime/frame-resolution.js +8 -0
- package/dist/runtime/frame-resolution.js.map +1 -1
- package/dist/runtime/frame.d.ts +5 -2
- package/dist/runtime/frame.js +107 -49
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/mixins/link-mixin.js +4 -4
- package/dist/runtime/mixins/link-mixin.js.map +1 -1
- package/dist/runtime/mixins/mixin.d.ts +9 -3
- package/dist/runtime/mixins/mixin.js.map +1 -1
- package/dist/runtime/mixins/on-mixin.d.ts +1 -1
- package/dist/runtime/module-preloader.js +3 -3
- package/dist/runtime/module-preloader.js.map +1 -1
- package/dist/runtime/navigation.js +114 -32
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/reconcile.js +9 -3
- package/dist/runtime/reconcile.js.map +1 -1
- package/dist/runtime/run.js +5 -3
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/scheduler.js +37 -11
- package/dist/runtime/scheduler.js.map +1 -1
- package/dist/runtime/spa-response.d.ts +30 -0
- package/dist/runtime/spa-response.js +47 -0
- package/dist/runtime/spa-response.js.map +1 -0
- package/dist/runtime/typed-event-target.d.ts +0 -4
- package/dist/runtime/typed-event-target.js.map +1 -1
- package/dist/server/stream.js +21 -5
- package/dist/server/stream.js.map +1 -1
- package/dist/style/stylesheet.js +2 -2
- package/dist/style/stylesheet.js.map +1 -1
- package/package.json +1 -1
- package/src/animation/demos/drag-release.ts +5 -7
- package/src/index.ts +2 -2
- package/src/runtime/demos/readme.demo.tsx +7 -17
- package/src/runtime/diff-dom.ts +6 -3
- package/src/runtime/dom.ts +21 -11
- package/src/runtime/event-types.ts +27 -0
- package/src/runtime/frame-resolution.ts +9 -0
- package/src/runtime/frame.ts +112 -50
- package/src/runtime/mixins/link-mixin.ts +4 -4
- package/src/runtime/mixins/mixin.ts +26 -4
- package/src/runtime/mixins/on-mixin.ts +1 -1
- package/src/runtime/module-preloader.ts +3 -3
- package/src/runtime/navigation.ts +126 -31
- package/src/runtime/reconcile.ts +11 -4
- package/src/runtime/run.ts +6 -3
- package/src/runtime/scheduler.ts +53 -13
- package/src/runtime/spa-response.ts +56 -0
- package/src/runtime/typed-event-target.ts +1 -6
- package/src/server/stream.ts +28 -5
- package/src/style/stylesheet.ts +3 -3
- package/dist/runtime/event-listeners.d.ts +0 -50
- package/dist/runtime/event-listeners.js +0 -31
- package/dist/runtime/event-listeners.js.map +0 -1
- 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
|
|
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.
|
|
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
|
-
|
|
172
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 ===
|
|
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,
|
package/src/runtime/reconcile.ts
CHANGED
|
@@ -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?:
|
|
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, {
|
|
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 {
|
package/src/runtime/run.ts
CHANGED
|
@@ -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
|
-
|
|
79
|
+
let method = options?.method
|
|
80
|
+
if (!formData || !method || ['get', 'head'].includes(method.toLowerCase())) return
|
|
80
81
|
|
|
81
|
-
|
|
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 (
|
|
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) {
|
package/src/runtime/scheduler.ts
CHANGED
|
@@ -35,13 +35,21 @@ export interface Scheduler {
|
|
|
35
35
|
dequeue(): void
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
const
|
|
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
|
|
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
|
-
|
|
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.
|
package/src/server/stream.ts
CHANGED
|
@@ -492,7 +492,7 @@ function buildFrameSegment(
|
|
|
492
492
|
framePromise.catch(() => {})
|
|
493
493
|
context.pendingFrames.push({ frameId, promise: framePromise })
|
|
494
494
|
} else {
|
|
495
|
-
|
|
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
|
-
//
|
|
1330
|
-
return css.replace(
|
|
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 {
|
package/src/style/stylesheet.ts
CHANGED
|
@@ -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
|
|