@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.
- package/README.md +86 -20
- 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/component.d.ts +2 -1
- package/dist/runtime/component.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.d.ts +1 -2
- package/dist/runtime/navigation.js +115 -36
- 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.d.ts +3 -3
- package/dist/runtime/run.js +43 -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/component.ts +2 -1
- 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 +127 -34
- package/src/runtime/reconcile.ts +11 -4
- package/src/runtime/run.ts +53 -6
- 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
|
|
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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 ===
|
|
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,
|
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
|
@@ -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
|
-
*
|
|
27
|
-
*
|
|
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
|
|
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
|
|
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
|
|
157
|
+
startNavigationListener(appController.signal)
|
|
111
158
|
let readyPromise = topFrame.ready().catch((error) => {
|
|
112
159
|
errorTarget.dispatchEvent(createComponentErrorEvent(error))
|
|
113
160
|
throw error
|
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.
|