@remix-run/ui 0.8.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 +56 -8
- 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 +7 -13
- 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/frame.d.ts +19 -4
- package/dist/runtime/frame.js +80 -11
- 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/mixin.d.ts +0 -1
- package/dist/runtime/mixins/mixin.js +18 -132
- package/dist/runtime/mixins/mixin.js.map +1 -1
- package/dist/runtime/module-preloader.d.ts +2 -1
- package/dist/runtime/module-preloader.js +3 -2
- package/dist/runtime/module-preloader.js.map +1 -1
- package/dist/runtime/navigation.js +143 -48
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/reconcile.js +9 -0
- package/dist/runtime/reconcile.js.map +1 -1
- package/dist/runtime/run.d.ts +3 -0
- package/dist/runtime/run.js +3 -1
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/to-vnode.js +1 -1
- package/dist/runtime/to-vnode.js.map +1 -1
- package/dist/server/stream.d.ts +25 -2
- package/dist/server/stream.js +361 -166
- package/dist/server/stream.js.map +1 -1
- package/package.json +1 -1
- package/src/runtime/component.ts +52 -14
- package/src/runtime/core/mix.ts +157 -0
- package/src/runtime/diff-dom.ts +7 -12
- package/src/runtime/document-reload.ts +14 -0
- package/src/runtime/frame.ts +105 -16
- package/src/runtime/import-map-manager.ts +369 -0
- package/src/runtime/mixins/mixin.ts +18 -145
- package/src/runtime/module-preloader.ts +6 -3
- package/src/runtime/navigation.ts +178 -58
- package/src/runtime/reconcile.ts +9 -0
- package/src/runtime/run.ts +7 -1
- package/src/runtime/to-vnode.ts +1 -1
- package/src/server/README.md +25 -5
- package/src/server/stream.ts +478 -197
- package/src/test/utils.ts +1 -6
|
@@ -6,6 +6,7 @@ import type { SchedulerPhaseEvent } from '../scheduler.ts'
|
|
|
6
6
|
import { jsx } from '../jsx.ts'
|
|
7
7
|
import { TypedEventTarget } from '../typed-event-target.ts'
|
|
8
8
|
import { invariant } from '../invariant.ts'
|
|
9
|
+
import { composeMixedProps, isMixinElementFunction } from '../core/mix.ts'
|
|
9
10
|
|
|
10
11
|
type RebindNode<value, baseNode, boundNode> = value extends (
|
|
11
12
|
...args: infer fnArgs
|
|
@@ -288,9 +289,9 @@ export function createMixin<
|
|
|
288
289
|
|
|
289
290
|
export function resolveMixedProps(input: ResolveMixedPropsInput): ResolveMixedPropsOutput {
|
|
290
291
|
let state = input.state ?? createMixinRuntimeState()
|
|
291
|
-
let
|
|
292
|
-
if (!
|
|
293
|
-
|
|
292
|
+
let scopedHandle = state.handle as ScopedAnyMixinHandle | undefined
|
|
293
|
+
if (!scopedHandle) {
|
|
294
|
+
scopedHandle = createMixinHandle({
|
|
294
295
|
id: state.id,
|
|
295
296
|
hostType: input.hostType,
|
|
296
297
|
frame: input.frame,
|
|
@@ -299,16 +300,14 @@ export function resolveMixedProps(input: ResolveMixedPropsInput): ResolveMixedPr
|
|
|
299
300
|
getRuntimeSignal: () => getMixinRuntimeSignal(state),
|
|
300
301
|
getBinding: () => state.binding,
|
|
301
302
|
}) as ScopedAnyMixinHandle
|
|
302
|
-
state.handle =
|
|
303
|
+
state.handle = scopedHandle
|
|
303
304
|
}
|
|
305
|
+
let handle = scopedHandle
|
|
304
306
|
let hostType = input.hostType
|
|
305
|
-
let descriptors = resolveMixDescriptors(input.props)
|
|
306
|
-
let composedProps = withoutMix(input.props)
|
|
307
|
-
let mixinProps = withoutMixinTreeProps(composedProps)
|
|
308
|
-
let maxDescriptors = 1024
|
|
309
307
|
|
|
310
|
-
|
|
311
|
-
|
|
308
|
+
let runnerCount = 0
|
|
309
|
+
let props = composeMixedProps(hostType, input.props, (descriptor, index, mixinProps) => {
|
|
310
|
+
runnerCount = index + 1
|
|
312
311
|
let entry = state.runners[index]
|
|
313
312
|
if (!entry || entry.type !== descriptor.type) {
|
|
314
313
|
if (entry) {
|
|
@@ -332,46 +331,15 @@ export function resolveMixedProps(input: ResolveMixedPropsInput): ResolveMixedPr
|
|
|
332
331
|
}
|
|
333
332
|
}
|
|
334
333
|
|
|
334
|
+
// Unlike the server renderer, mixin errors are not isolated here: a
|
|
335
|
+
// throwing mixin aborts the client render.
|
|
335
336
|
handle.setActiveScope(entry.scope)
|
|
336
337
|
let result = entry.runner(...descriptor.args, mixinProps)
|
|
337
338
|
handle.setActiveScope(undefined)
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
let returnedDescriptors = resolveReturnedMixDescriptors(result)
|
|
342
|
-
if (returnedDescriptors) {
|
|
343
|
-
for (let returned of returnedDescriptors) descriptors.push(returned)
|
|
344
|
-
continue
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
if (!isRemixElement(result)) {
|
|
348
|
-
console.error(new Error('mixins must return a remix element'))
|
|
349
|
-
continue
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
let resultType =
|
|
353
|
-
typeof result.type === 'string'
|
|
354
|
-
? result.type
|
|
355
|
-
: isMixinElement(result.type)
|
|
356
|
-
? result.type.__rmxMixinElementType
|
|
357
|
-
: null
|
|
358
|
-
if (resultType !== hostType) {
|
|
359
|
-
console.error(new Error('mixins must return an element with the same host type'))
|
|
360
|
-
continue
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
if (result.type !== resultType) {
|
|
364
|
-
result = { ...result, type: resultType }
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
let nextProps = sanitizeReturnedMixinProps(result.props)
|
|
368
|
-
let nestedDescriptors = resolveMixDescriptors(nextProps)
|
|
369
|
-
for (let nested of nestedDescriptors) descriptors.push(nested)
|
|
370
|
-
composedProps = composeMixinProps(composedProps, withoutMix(nextProps))
|
|
371
|
-
mixinProps = withoutMixinTreeProps(composedProps)
|
|
372
|
-
}
|
|
339
|
+
return result
|
|
340
|
+
})
|
|
373
341
|
|
|
374
|
-
for (let index =
|
|
342
|
+
for (let index = runnerCount; index < state.runners.length; index++) {
|
|
375
343
|
let entry = state.runners[index]
|
|
376
344
|
if (entry) {
|
|
377
345
|
handle.dispatchScopedEvent(entry.scope, new Event('remove'))
|
|
@@ -379,18 +347,11 @@ export function resolveMixedProps(input: ResolveMixedPropsInput): ResolveMixedPr
|
|
|
379
347
|
}
|
|
380
348
|
}
|
|
381
349
|
|
|
382
|
-
if (state.runners.length >
|
|
383
|
-
state.runners.length =
|
|
350
|
+
if (state.runners.length > runnerCount) {
|
|
351
|
+
state.runners.length = runnerCount
|
|
384
352
|
}
|
|
385
353
|
|
|
386
|
-
|
|
387
|
-
return {
|
|
388
|
-
state,
|
|
389
|
-
props: {
|
|
390
|
-
...composedProps,
|
|
391
|
-
...(nextMix === undefined ? {} : { mix: nextMix }),
|
|
392
|
-
},
|
|
393
|
-
}
|
|
354
|
+
return { state, props }
|
|
394
355
|
}
|
|
395
356
|
|
|
396
357
|
export function teardownMixins(state?: MixinRuntimeState) {
|
|
@@ -847,96 +808,8 @@ function isBindingInUpdateScope(binding: MixinRuntimeBinding, parents: ParentNod
|
|
|
847
808
|
return false
|
|
848
809
|
}
|
|
849
810
|
|
|
850
|
-
function resolveMixDescriptors(props: ElementProps): AnyMixinDescriptor[] {
|
|
851
|
-
let mix = props.mix
|
|
852
|
-
if (!mix) return []
|
|
853
|
-
if (Array.isArray(mix)) {
|
|
854
|
-
if (mix.length === 0) return []
|
|
855
|
-
return mix.filter(Boolean) as AnyMixinDescriptor[]
|
|
856
|
-
}
|
|
857
|
-
return [mix] as AnyMixinDescriptor[]
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
function withoutMix(props: ElementProps): ElementProps {
|
|
861
|
-
if (!('mix' in props)) return props
|
|
862
|
-
let output = { ...props }
|
|
863
|
-
delete output.mix
|
|
864
|
-
return output
|
|
865
|
-
}
|
|
866
|
-
|
|
867
|
-
function withoutMixinTreeProps(props: ElementProps): ElementProps {
|
|
868
|
-
if (!('children' in props) && !('innerHTML' in props)) return props
|
|
869
|
-
let output = { ...props }
|
|
870
|
-
delete output.children
|
|
871
|
-
delete output.innerHTML
|
|
872
|
-
return output
|
|
873
|
-
}
|
|
874
|
-
|
|
875
|
-
function sanitizeReturnedMixinProps(props: ElementProps): ElementProps {
|
|
876
|
-
if (!('children' in props) && !('innerHTML' in props)) return props
|
|
877
|
-
console.error(new Error('mixins must not return children or innerHTML'))
|
|
878
|
-
return withoutMixinTreeProps(props)
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
function composeMixinProps(previous: ElementProps, next: ElementProps): ElementProps {
|
|
882
|
-
return { ...previous, ...next }
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
function resolveReturnedMixDescriptors(value: unknown): AnyMixinDescriptor[] | null {
|
|
886
|
-
let descriptors: AnyMixinDescriptor[] = []
|
|
887
|
-
if (!collectReturnedMixDescriptors(value, descriptors)) {
|
|
888
|
-
return null
|
|
889
|
-
}
|
|
890
|
-
|
|
891
|
-
return descriptors
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
function collectReturnedMixDescriptors(
|
|
895
|
-
value: unknown,
|
|
896
|
-
output: AnyMixinDescriptor[],
|
|
897
|
-
): value is MixInput<Element, ElementProps> {
|
|
898
|
-
if (!value) {
|
|
899
|
-
return true
|
|
900
|
-
}
|
|
901
|
-
|
|
902
|
-
if (Array.isArray(value)) {
|
|
903
|
-
for (let item of value) {
|
|
904
|
-
if (!collectReturnedMixDescriptors(item, output)) {
|
|
905
|
-
return false
|
|
906
|
-
}
|
|
907
|
-
}
|
|
908
|
-
return true
|
|
909
|
-
}
|
|
910
|
-
|
|
911
|
-
if (!isMixinDescriptor(value)) {
|
|
912
|
-
return false
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
output.push(value)
|
|
916
|
-
return true
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
function isRemixElement(value: unknown): value is RemixElement {
|
|
920
|
-
if (!value || typeof value !== 'object') return false
|
|
921
|
-
return (value as { $rmx?: unknown }).$rmx === true
|
|
922
|
-
}
|
|
923
|
-
|
|
924
|
-
export function isMixinDescriptor(value: unknown): value is AnyMixinDescriptor {
|
|
925
|
-
if (!value || typeof value !== 'object' || isRemixElement(value)) {
|
|
926
|
-
return false
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
let descriptor = value as { type?: unknown; args?: unknown }
|
|
930
|
-
return typeof descriptor.type === 'function' && Array.isArray(descriptor.args)
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function isMixinElement(value: unknown): value is MixinElement<Element, ElementProps> {
|
|
934
|
-
if (typeof value !== 'function') return false
|
|
935
|
-
return '__rmxMixinElementType' in value
|
|
936
|
-
}
|
|
937
|
-
|
|
938
811
|
function normalizeMixinRunner(result: AnyMixinSetupResult, handle: AnyMixinHandle): AnyMixinRunner {
|
|
939
|
-
if (typeof result === 'function' && !
|
|
812
|
+
if (typeof result === 'function' && !isMixinElementFunction(result)) {
|
|
940
813
|
return result as AnyMixinRunner
|
|
941
814
|
}
|
|
942
815
|
if (result === undefined) {
|
|
@@ -2,11 +2,13 @@ const SERVER_MODULE_PRELOAD_SELECTOR = 'link[data-rmx-module-preload][rel~="modu
|
|
|
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 {
|
|
@@ -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
|
|
@@ -42,16 +34,18 @@ interface FrameRedirectNavigationInfo {
|
|
|
42
34
|
const formSubmissionNavigationInfoType = 'frame-form-submission'
|
|
43
35
|
const frameRedirectNavigationInfoType = 'frame-redirect'
|
|
44
36
|
|
|
45
|
-
function resyncWebKitScrollAfterNavigation(
|
|
37
|
+
function resyncWebKitScrollAfterNavigation(
|
|
38
|
+
event: NavigateEvent,
|
|
39
|
+
resetScroll: boolean,
|
|
40
|
+
transition: NavigationTransition,
|
|
41
|
+
): void {
|
|
46
42
|
let userAgent = navigator.userAgent
|
|
47
|
-
if (!userAgent.includes('AppleWebKit')) return
|
|
48
|
-
if (userAgent.includes('Chrome') || userAgent.includes('Chromium')) return
|
|
43
|
+
if (!userAgent.includes('AppleWebKit') || isChromiumUserAgent(userAgent)) return
|
|
49
44
|
if (!resetScroll || (event.navigationType !== 'push' && event.navigationType !== 'replace'))
|
|
50
45
|
return
|
|
51
46
|
if (new URL(event.destination.url).hash) return
|
|
52
47
|
|
|
53
|
-
|
|
54
|
-
'navigatesuccess',
|
|
48
|
+
void transition.finished.then(
|
|
55
49
|
() => {
|
|
56
50
|
// WebKit can reset its internal scroll position without synchronizing the visual viewport.
|
|
57
51
|
// https://bugs.webkit.org/show_bug.cgi?id=309542
|
|
@@ -62,7 +56,7 @@ function resyncWebKitScrollAfterNavigation(event: NavigateEvent, resetScroll: bo
|
|
|
62
56
|
window.scrollTo({ behavior: 'instant', left: 0, top: 0 })
|
|
63
57
|
})
|
|
64
58
|
},
|
|
65
|
-
|
|
59
|
+
() => {},
|
|
66
60
|
)
|
|
67
61
|
}
|
|
68
62
|
|
|
@@ -89,7 +83,7 @@ export async function navigate(href: string, options?: NavigationOptions) {
|
|
|
89
83
|
resetScroll: options?.resetScroll !== false,
|
|
90
84
|
$rmx: true,
|
|
91
85
|
} satisfies NavigationState
|
|
92
|
-
let navigation =
|
|
86
|
+
let navigation = getInterceptableNavigation()
|
|
93
87
|
if (!navigation) {
|
|
94
88
|
if (options?.history === 'replace') {
|
|
95
89
|
window.location.replace(href)
|
|
@@ -126,7 +120,7 @@ export function startNavigationListenerImpl(
|
|
|
126
120
|
reloadFrame: typeof reloadFrameForNavigation
|
|
127
121
|
},
|
|
128
122
|
) {
|
|
129
|
-
let navigation =
|
|
123
|
+
let navigation = getInterceptableNavigation()
|
|
130
124
|
if (!navigation) return
|
|
131
125
|
let resolveFormNavigation = createFormNavigationResolver(signal)
|
|
132
126
|
|
|
@@ -137,6 +131,7 @@ export function startNavigationListenerImpl(
|
|
|
137
131
|
navigation.addEventListener(
|
|
138
132
|
'navigate',
|
|
139
133
|
(event) => {
|
|
134
|
+
if (isDocumentReload(event.info)) return
|
|
140
135
|
// Safari seems to incorrectly set canIntercept to true for sub-domain navigations, so
|
|
141
136
|
// we do a host check ourselves/. The spec is clear that a different host should prevent
|
|
142
137
|
// interception so this is likely a bug in Safari:
|
|
@@ -144,10 +139,9 @@ export function startNavigationListenerImpl(
|
|
|
144
139
|
if (!event.canIntercept || isCrossOriginDestination(event)) return
|
|
145
140
|
|
|
146
141
|
if (isFrameRedirectNavigationInfo(event.info)) {
|
|
147
|
-
|
|
148
|
-
event.intercept({
|
|
142
|
+
interceptNavigation(navigation, event, event.info.resetScroll, {
|
|
149
143
|
async handler() {},
|
|
150
|
-
scroll:
|
|
144
|
+
scroll: 'manual',
|
|
151
145
|
})
|
|
152
146
|
return
|
|
153
147
|
}
|
|
@@ -161,7 +155,6 @@ export function startNavigationListenerImpl(
|
|
|
161
155
|
: getRuntimeNavigation(navigation, event, resolveFormNavigation)
|
|
162
156
|
if (!runtimeNavigation) return
|
|
163
157
|
let { state } = runtimeNavigation
|
|
164
|
-
resyncWebKitScrollAfterNavigation(event, state.resetScroll)
|
|
165
158
|
|
|
166
159
|
let topFrame = options.getTopFrame()
|
|
167
160
|
let namedFrame = state.target ? options.getNamedFrame(state.target) : undefined
|
|
@@ -170,10 +163,6 @@ export function startNavigationListenerImpl(
|
|
|
170
163
|
let handler = async () => {
|
|
171
164
|
if (event.signal.aborted) return
|
|
172
165
|
|
|
173
|
-
if (event.navigationType === 'traverse' && state.resetScroll) {
|
|
174
|
-
preserveStartingDocumentScrollState(navigation, event)
|
|
175
|
-
}
|
|
176
|
-
|
|
177
166
|
let submission = await runtimeNavigation.getSubmission?.()
|
|
178
167
|
if (event.signal.aborted) return
|
|
179
168
|
|
|
@@ -183,10 +172,15 @@ export function startNavigationListenerImpl(
|
|
|
183
172
|
|
|
184
173
|
topFrame.src = event.destination.url
|
|
185
174
|
if (frame !== topFrame) frame.src = state.src
|
|
186
|
-
let
|
|
175
|
+
let reload = options.reloadFrame(frame, {
|
|
187
176
|
...submission,
|
|
188
177
|
signal: event.signal,
|
|
189
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
|
|
190
184
|
|
|
191
185
|
if (redirectedTo && frame === topFrame) {
|
|
192
186
|
frame.src = redirectedTo
|
|
@@ -216,7 +210,7 @@ export function startNavigationListenerImpl(
|
|
|
216
210
|
|
|
217
211
|
// Modern browsers allow you to update the in-flight navigation entry before it's committed
|
|
218
212
|
if (supportsPrecommit) {
|
|
219
|
-
event.
|
|
213
|
+
interceptNavigation(navigation, event, state.resetScroll, {
|
|
220
214
|
...interceptOptions,
|
|
221
215
|
precommitHandler(controller) {
|
|
222
216
|
controller.redirect(event.destination.url, { history: 'replace' })
|
|
@@ -241,14 +235,14 @@ export function startNavigationListenerImpl(
|
|
|
241
235
|
}
|
|
242
236
|
}
|
|
243
237
|
|
|
244
|
-
event.
|
|
238
|
+
interceptNavigation(navigation, event, state.resetScroll, interceptOptions)
|
|
245
239
|
} else {
|
|
246
240
|
// <a>/<form method="get"> navigations
|
|
247
241
|
if (runtimeNavigation.replaceHistory && event.cancelable) {
|
|
248
242
|
event.preventDefault()
|
|
249
243
|
navigation.navigate(event.destination.url, { history: 'replace', state })
|
|
250
244
|
} else {
|
|
251
|
-
event.
|
|
245
|
+
interceptNavigation(navigation, event, state.resetScroll, interceptOptions)
|
|
252
246
|
}
|
|
253
247
|
}
|
|
254
248
|
},
|
|
@@ -256,6 +250,18 @@ export function startNavigationListenerImpl(
|
|
|
256
250
|
)
|
|
257
251
|
}
|
|
258
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
|
+
|
|
259
265
|
function isRuntimeNavigation(info: unknown): info is NavigationState {
|
|
260
266
|
return typeof info === 'object' && info != null && '$rmx' in info
|
|
261
267
|
}
|
|
@@ -289,46 +295,160 @@ function isCrossOriginDestination(event: NavigateEvent): boolean {
|
|
|
289
295
|
return destination.origin !== window.location.origin
|
|
290
296
|
}
|
|
291
297
|
|
|
292
|
-
function
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
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
|
+
}
|
|
302
325
|
|
|
303
|
-
let
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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)
|
|
309
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
|
+
}
|
|
310
377
|
|
|
311
|
-
|
|
312
|
-
|
|
378
|
+
body {
|
|
379
|
+
overflow-anchor: none !important;
|
|
380
|
+
}
|
|
381
|
+
`,
|
|
382
|
+
cleanup: 'finished',
|
|
313
383
|
}
|
|
314
|
-
|
|
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)
|
|
315
422
|
document.adoptedStyleSheets = [...document.adoptedStyleSheets, stylesheet]
|
|
316
423
|
|
|
317
|
-
let
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
navigation.removeEventListener('navigatesuccess', cleanup)
|
|
323
|
-
navigation.removeEventListener('navigateerror', cleanup)
|
|
424
|
+
let removed = false
|
|
425
|
+
function remove() {
|
|
426
|
+
event.signal.removeEventListener('abort', remove)
|
|
427
|
+
if (removed) return
|
|
428
|
+
removed = true
|
|
324
429
|
document.adoptedStyleSheets = document.adoptedStyleSheets.filter(
|
|
325
430
|
(current) => current !== stylesheet,
|
|
326
431
|
)
|
|
327
432
|
}
|
|
328
433
|
|
|
329
|
-
event.signal.
|
|
330
|
-
|
|
331
|
-
|
|
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)
|
|
332
452
|
}
|
|
333
453
|
|
|
334
454
|
function getRuntimeNavigation(
|
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
|
|
|
@@ -1188,6 +1190,7 @@ 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
1195
|
let resolve = Promise.resolve()
|
|
1193
1196
|
.then(() =>
|
|
@@ -1206,6 +1209,8 @@ function resolveClientFrame(
|
|
|
1206
1209
|
await instance.render(nextContent, {
|
|
1207
1210
|
signal: resolveController.signal,
|
|
1208
1211
|
reconciliationTracker: serverFrameReload?.reconciliationTracker,
|
|
1212
|
+
blockingFrameTracker: serverFrameReload?.blockingFrameTracker,
|
|
1213
|
+
onCommit: frameCommitted.resolve,
|
|
1209
1214
|
})
|
|
1210
1215
|
if (state.resolveToken !== token || resolveController.signal.aborted) return
|
|
1211
1216
|
state.resolved = true
|
|
@@ -1216,6 +1221,7 @@ function resolveClientFrame(
|
|
|
1216
1221
|
}
|
|
1217
1222
|
})
|
|
1218
1223
|
.finally(() => {
|
|
1224
|
+
frameCommitted.resolve()
|
|
1219
1225
|
reload?.complete()
|
|
1220
1226
|
if (state.resolveController === resolveController) {
|
|
1221
1227
|
state.resolveController = undefined
|
|
@@ -1225,6 +1231,9 @@ function resolveClientFrame(
|
|
|
1225
1231
|
if (serverFrameReload?.reconciliationTracker && !node.props.fallback) {
|
|
1226
1232
|
serverFrameReload.reconciliationTracker.waitFor(resolve)
|
|
1227
1233
|
}
|
|
1234
|
+
if (serverFrameReload?.blockingFrameTracker && !node.props.fallback) {
|
|
1235
|
+
serverFrameReload.blockingFrameTracker.waitFor(frameCommitted.promise)
|
|
1236
|
+
}
|
|
1228
1237
|
}
|
|
1229
1238
|
|
|
1230
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
|
/**
|
|
@@ -112,7 +116,8 @@ async function defaultResolveFrame(src: string, options?: ResolveFrameOptions):
|
|
|
112
116
|
signal: options?.signal,
|
|
113
117
|
})
|
|
114
118
|
|
|
115
|
-
|
|
119
|
+
let isHtml = response.headers.get('Content-Type')?.toLowerCase().includes('text/html')
|
|
120
|
+
if (response.status >= 500 || (response.status >= 300 && !isHtml)) {
|
|
116
121
|
throw new Error(`Failed to resolve frame: ${response.status} ${response.statusText}`.trimEnd())
|
|
117
122
|
}
|
|
118
123
|
|
|
@@ -145,6 +150,7 @@ export function run(init: RunInit): AppRuntime {
|
|
|
145
150
|
moduleLoads: new Map(),
|
|
146
151
|
frameInstances: new WeakMap(),
|
|
147
152
|
namedFrames,
|
|
153
|
+
processClientEntryPreloads: init.processClientEntryPreloads,
|
|
148
154
|
})
|
|
149
155
|
|
|
150
156
|
let appController = new AbortController()
|