@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.
Files changed (52) hide show
  1. package/README.md +56 -8
  2. package/dist/runtime/component.d.ts +9 -4
  3. package/dist/runtime/component.js +38 -9
  4. package/dist/runtime/component.js.map +1 -1
  5. package/dist/runtime/core/mix.d.ts +21 -0
  6. package/dist/runtime/core/mix.js +128 -0
  7. package/dist/runtime/core/mix.js.map +1 -0
  8. package/dist/runtime/diff-dom.js +7 -13
  9. package/dist/runtime/diff-dom.js.map +1 -1
  10. package/dist/runtime/document-reload.d.ts +2 -0
  11. package/dist/runtime/document-reload.js +14 -0
  12. package/dist/runtime/document-reload.js.map +1 -0
  13. package/dist/runtime/frame.d.ts +19 -4
  14. package/dist/runtime/frame.js +80 -11
  15. package/dist/runtime/frame.js.map +1 -1
  16. package/dist/runtime/import-map-manager.d.ts +8 -0
  17. package/dist/runtime/import-map-manager.js +295 -0
  18. package/dist/runtime/import-map-manager.js.map +1 -0
  19. package/dist/runtime/mixins/mixin.d.ts +0 -1
  20. package/dist/runtime/mixins/mixin.js +18 -132
  21. package/dist/runtime/mixins/mixin.js.map +1 -1
  22. package/dist/runtime/module-preloader.d.ts +2 -1
  23. package/dist/runtime/module-preloader.js +3 -2
  24. package/dist/runtime/module-preloader.js.map +1 -1
  25. package/dist/runtime/navigation.js +143 -48
  26. package/dist/runtime/navigation.js.map +1 -1
  27. package/dist/runtime/reconcile.js +9 -0
  28. package/dist/runtime/reconcile.js.map +1 -1
  29. package/dist/runtime/run.d.ts +3 -0
  30. package/dist/runtime/run.js +3 -1
  31. package/dist/runtime/run.js.map +1 -1
  32. package/dist/runtime/to-vnode.js +1 -1
  33. package/dist/runtime/to-vnode.js.map +1 -1
  34. package/dist/server/stream.d.ts +25 -2
  35. package/dist/server/stream.js +361 -166
  36. package/dist/server/stream.js.map +1 -1
  37. package/package.json +1 -1
  38. package/src/runtime/component.ts +52 -14
  39. package/src/runtime/core/mix.ts +157 -0
  40. package/src/runtime/diff-dom.ts +7 -12
  41. package/src/runtime/document-reload.ts +14 -0
  42. package/src/runtime/frame.ts +105 -16
  43. package/src/runtime/import-map-manager.ts +369 -0
  44. package/src/runtime/mixins/mixin.ts +18 -145
  45. package/src/runtime/module-preloader.ts +6 -3
  46. package/src/runtime/navigation.ts +178 -58
  47. package/src/runtime/reconcile.ts +9 -0
  48. package/src/runtime/run.ts +7 -1
  49. package/src/runtime/to-vnode.ts +1 -1
  50. package/src/server/README.md +25 -5
  51. package/src/server/stream.ts +478 -197
  52. 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 handle = state.handle as ScopedAnyMixinHandle | undefined
292
- if (!handle) {
293
- handle = createMixinHandle({
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 = 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
- for (let index = 0; index < descriptors.length && index < maxDescriptors; index++) {
311
- let descriptor = descriptors[index]
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
- if (!result) continue
339
- if (isMixinElement(result)) continue
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 = descriptors.length; index < state.runners.length; 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 > descriptors.length) {
383
- state.runners.length = descriptors.length
350
+ if (state.runners.length > runnerCount) {
351
+ state.runners.length = runnerCount
384
352
  }
385
353
 
386
- let nextMix = input.props.mix
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' && !isMixinElement(result)) {
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
- for (let href of hrefs) preload(href)
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(event: NavigateEvent, resetScroll: boolean): void {
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
- window.navigation.addEventListener(
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
- { once: true, signal: event.signal },
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 = window.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 = window.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
- resyncWebKitScrollAfterNavigation(event, event.info.resetScroll)
148
- event.intercept({
142
+ interceptNavigation(navigation, event, event.info.resetScroll, {
149
143
  async handler() {},
150
- scroll: event.info.resetScroll === false ? 'manual' : undefined,
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 { redirectedTo } = await options.reloadFrame(frame, {
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.intercept({
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.intercept(interceptOptions)
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.intercept(interceptOptions)
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 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
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 { 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;
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
- body {
312
- overflow-anchor: none !important;
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 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)
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.addEventListener('abort', cleanup, { once: true })
330
- navigation.addEventListener('navigatesuccess', cleanup, { once: true })
331
- navigation.addEventListener('navigateerror', cleanup, { once: true })
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(
@@ -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 {
@@ -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
- if (!response.ok) {
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()