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