@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
@@ -10,7 +10,7 @@ import { createRangeRoot, createRoot } from './vdom.ts'
10
10
  import { diffNodes } from './diff-dom.ts'
11
11
  import { createStyleManager, type StyleManager } from '../style/index.ts'
12
12
  import { findFlushMarker, type FlushKind } from './stream-protocol.ts'
13
- import { getDocumentModulePreloader } from './module-preloader.ts'
13
+ import { getDocumentModulePreloader, type ProcessClientEntryPreloads } from './module-preloader.ts'
14
14
  import { unwrapFrameResolution } from './frame-resolution.ts'
15
15
  import {
16
16
  disposeClientEntryBoundary,
@@ -18,6 +18,8 @@ import {
18
18
  setClientEntryBoundaryOwner,
19
19
  type ClientEntryIdentity,
20
20
  } from './client-entry-boundary.ts'
21
+ import { getDocumentImportMapManager } from './import-map-manager.ts'
22
+ import { reloadDocument } from './document-reload.ts'
21
23
 
22
24
  type FrameRoot = [Comment, Comment] | Element | Document | DocumentFragment
23
25
 
@@ -104,6 +106,12 @@ type FrameReloadResult = {
104
106
  redirectedTo?: string
105
107
  }
106
108
 
109
+ type FrameReloadTransition = {
110
+ signal: AbortSignal
111
+ committed: Promise<void>
112
+ finished: Promise<FrameReloadResult>
113
+ }
114
+
107
115
  type FrameTemplateListener = (fragment: DocumentFragment) => void
108
116
 
109
117
  const bufferedFrameTemplates = new Map<string, DocumentFragment[]>()
@@ -170,10 +178,15 @@ export type FrameRuntime = {
170
178
  moduleLoads: Map<string, Promise<ElementFunction | undefined>>
171
179
  frameInstances: WeakMap<Comment, Frame>
172
180
  namedFrames: Map<string, FrameHandle>
181
+ processClientEntryPreloads?: ProcessClientEntryPreloads
173
182
  serverFrameReload:
174
- | { signal: AbortSignal; reconciliationTracker?: ReconciliationTracker }
183
+ | {
184
+ signal: AbortSignal
185
+ reconciliationTracker?: ReconciliationTracker
186
+ blockingFrameTracker?: ReconciliationTracker
187
+ }
175
188
  | undefined
176
- reloadForNavigation?: (options?: FrameReloadOptions) => Promise<FrameReloadResult>
189
+ reloadForNavigation?: (options?: FrameReloadOptions) => FrameReloadTransition
177
190
  }
178
191
 
179
192
  export function isFrameRuntime(value: unknown): value is FrameRuntime {
@@ -190,7 +203,7 @@ export function isFrameRuntime(value: unknown): value is FrameRuntime {
190
203
  export function reloadFrameForNavigation(
191
204
  frame: FrameHandle,
192
205
  options?: FrameReloadOptions,
193
- ): Promise<FrameReloadResult> {
206
+ ): FrameReloadTransition {
194
207
  let runtime = frame.$runtime
195
208
  invariant(isFrameRuntime(runtime), 'Expected a frame runtime')
196
209
  let reload = runtime.reloadForNavigation
@@ -212,12 +225,14 @@ export type FrameContext = {
212
225
  moduleLoads: Map<string, Promise<ElementFunction | undefined>>
213
226
  frameInstances: WeakMap<Comment, Frame>
214
227
  namedFrames: Map<string, FrameHandle>
228
+ processClientEntryPreloads?: ProcessClientEntryPreloads
215
229
  lifecycleSignal: AbortSignal
216
230
  regionTailRef?: ChildNode | null
217
231
  regionParent?: ParentNode | null
218
232
  signal?: AbortSignal
219
- isActiveModulePreload?: (node: Node) => boolean
233
+ shouldPreserveHeadNode?: (node: Node) => boolean
220
234
  reconciliationTracker?: ReconciliationTracker
235
+ blockingFrameTracker?: ReconciliationTracker
221
236
  }
222
237
 
223
238
  type FrameInit = {
@@ -236,6 +251,7 @@ type FrameInit = {
236
251
  moduleLoads: Map<string, Promise<ElementFunction | undefined>>
237
252
  frameInstances: WeakMap<Comment, Frame>
238
253
  namedFrames: Map<string, FrameHandle>
254
+ processClientEntryPreloads?: ProcessClientEntryPreloads
239
255
  }
240
256
 
241
257
  export type Frame = {
@@ -262,8 +278,11 @@ export type Frame = {
262
278
  }
263
279
 
264
280
  type RenderOptions = {
281
+ documentHref?: string
265
282
  flushKind?: FlushKind
266
283
  reconciliationTracker?: ReconciliationTracker
284
+ blockingFrameTracker?: ReconciliationTracker
285
+ onCommit?: () => void
267
286
  signal?: AbortSignal
268
287
  contentStatus?: 'pending' | 'resolved'
269
288
  data?: RmxData
@@ -284,6 +303,7 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
284
303
  let reloadKind: 'direct' | 'ancestor' | undefined
285
304
  let styleManager = init.styleManager ?? createStyleManager()
286
305
  let modulePreloader = getDocumentModulePreloader(container.doc)
306
+ let importMapManager = getDocumentImportMapManager(container.doc)
287
307
  let currentMarker = init.marker
288
308
  let displayedContentStatus: 'pending' | 'resolved' = init.marker?.status ?? 'resolved'
289
309
  let pendingTemplateMarkerId: string | undefined
@@ -294,16 +314,42 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
294
314
  let disposed = false
295
315
  let lifecycleController = new AbortController()
296
316
 
317
+ async function consumeClientEntryResources(
318
+ source: ParentNode,
319
+ documentHref?: string,
320
+ ): Promise<boolean> {
321
+ let importMapStatus = importMapManager.consumeImportMaps(source)
322
+ if (importMapStatus !== 'ready') {
323
+ lifecycleController.abort()
324
+ if (importMapStatus === 'conflict') reloadDocument(container.doc, documentHref)
325
+ return false
326
+ }
327
+ await modulePreloader.consumePreloadLinks(source, init.processClientEntryPreloads)
328
+ return true
329
+ }
330
+
331
+ let initialClientEntryResources: Promise<boolean> | undefined
332
+ function shouldPreserveManagedHeadNode(node: Node): boolean {
333
+ return (
334
+ importMapManager.shouldPreserveHeadNode(node) ||
335
+ (modulePreloader.hasActivePreloads() && modulePreloader.isActivePreload(node))
336
+ )
337
+ }
338
+
297
339
  if (isDocumentNode(container.root)) {
298
340
  modulePreloader.adoptInitialPreloadLinks(container.root)
299
341
  } else {
300
- modulePreloader.consumePreloadLinks(container.root)
342
+ initialClientEntryResources = consumeClientEntryResources(container.root)
301
343
  }
302
344
 
303
345
  // Merge any rmx-data found in the current document once at startup.
304
346
  mergeRmxDataFromDocument(init.data, container.doc)
305
347
 
306
- let runtime = createFrameRuntime({ ...init, styleManager, reloadForNavigation: reload })
348
+ let runtime = createFrameRuntime({
349
+ ...init,
350
+ styleManager,
351
+ reloadForNavigation: startReloadTransition,
352
+ })
307
353
 
308
354
  let frame = createFrameHandle({
309
355
  src: init.src,
@@ -334,6 +380,7 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
334
380
  moduleLoads: init.moduleLoads,
335
381
  frameInstances: init.frameInstances,
336
382
  namedFrames: init.namedFrames,
383
+ processClientEntryPreloads: init.processClientEntryPreloads,
337
384
  lifecycleSignal: lifecycleController.signal,
338
385
  regionTailRef: container.regionTailRef,
339
386
  regionParent: container.regionParent,
@@ -393,12 +440,14 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
393
440
  runtime.serverFrameReload = {
394
441
  signal: options.signal,
395
442
  reconciliationTracker: options.reconciliationTracker,
443
+ blockingFrameTracker: options.blockingFrameTracker,
396
444
  }
397
445
  }
398
446
 
399
447
  try {
400
448
  contentRoot.render(content)
401
449
  await new Promise<void>((resolve) => context.scheduler.enqueueCommitPhase([resolve]))
450
+ options.onCommit?.()
402
451
  } finally {
403
452
  runtime.serverFrameReload = previousServerFrameReload
404
453
  }
@@ -434,13 +483,15 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
434
483
 
435
484
  if (isFullDocumentReload && htmlContent !== undefined) {
436
485
  let parsed = new DOMParser().parseFromString(htmlContent, 'text/html')
437
- modulePreloader.consumePreloadLinks(parsed)
486
+ if (!(await consumeClientEntryResources(parsed, options.documentHref))) return
487
+ if (isRenderAborted(options.signal)) return
438
488
  let responseData = options.data
439
489
  mergeRmxDataFromDocument(responseData, parsed)
440
490
  let responseContext = {
441
491
  ...context,
442
492
  data: responseData,
443
493
  reconciliationTracker: options.reconciliationTracker,
494
+ blockingFrameTracker: options.blockingFrameTracker,
444
495
  }
445
496
  context.styleManager.adoptServerStyles(
446
497
  collectFrameServerStyleTags(createElementContainer(parsed)),
@@ -453,9 +504,7 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
453
504
  regionParent: container.doc.documentElement,
454
505
  regionTailRef: null,
455
506
  signal: options.signal,
456
- isActiveModulePreload: modulePreloader.hasActivePreloads()
457
- ? modulePreloader.isActivePreload
458
- : undefined,
507
+ shouldPreserveHeadNode: shouldPreserveManagedHeadNode,
459
508
  })
460
509
  diffNodes([container.doc.body], [parsed.body], {
461
510
  ...responseContext,
@@ -472,7 +521,9 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
472
521
  options.reconciliationTracker,
473
522
  options.signal,
474
523
  )
475
- await createSubFrames(bodyContainer.childNodes, responseContext, options)
524
+ let subFramesReady = createSubFrames(bodyContainer.childNodes, responseContext, options)
525
+ options.onCommit?.()
526
+ await subFramesReady
476
527
  if (isRenderAborted(options.signal)) return
477
528
  displayedContentStatus = options.contentStatus ?? 'resolved'
478
529
  return
@@ -480,7 +531,8 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
480
531
 
481
532
  let fragment =
482
533
  htmlContent !== undefined ? createFragmentFromString(container.doc, htmlContent) : content
483
- modulePreloader.consumePreloadLinks(fragment)
534
+ if (!(await consumeClientEntryResources(fragment, options.documentHref))) return
535
+ if (isRenderAborted(options.signal)) return
484
536
  context.styleManager.adoptServerStyles(
485
537
  collectFrameServerStyleTags(createElementContainer(fragment)),
486
538
  )
@@ -491,6 +543,7 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
491
543
  ...context,
492
544
  data: responseData,
493
545
  reconciliationTracker: options.reconciliationTracker,
546
+ blockingFrameTracker: options.blockingFrameTracker,
494
547
  }
495
548
 
496
549
  let nextContainer = createContainer(fragment)
@@ -510,7 +563,9 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
510
563
  options.reconciliationTracker,
511
564
  options.signal,
512
565
  )
513
- await createSubFrames(container.childNodes, responseContext, options)
566
+ let subFramesReady = createSubFrames(container.childNodes, responseContext, options)
567
+ options.onCommit?.()
568
+ await subFramesReady
514
569
  if (isRenderAborted(options.signal)) return
515
570
  displayedContentStatus = options.contentStatus ?? 'resolved'
516
571
  }
@@ -559,6 +614,8 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
559
614
  async function hydrateInitial(): Promise<void> {
560
615
  let reconciliationTracker = createReconciliationTracker()
561
616
 
617
+ if ((await initialClientEntryResources) === false) return
618
+ if (disposed || context.lifecycleSignal.aborted) return
562
619
  context.styleManager.adoptServerStyles(collectFrameServerStyleTags(container))
563
620
  let subFramesReady = createSubFrames(container.childNodes, context)
564
621
  scheduleHydrationInContainer(container, context, reconciliationTracker)
@@ -684,8 +741,25 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
684
741
  }
685
742
 
686
743
  async function reload(options?: FrameReloadOptions): Promise<FrameReloadResult> {
744
+ let transition = startReloadTransition(options)
745
+ void transition.committed.catch(() => {})
746
+ return await transition.finished
747
+ }
748
+
749
+ function startReloadTransition(options?: FrameReloadOptions): FrameReloadTransition {
687
750
  let controller = startReload(options?.signal)
688
- return await resolveAndRenderReload(controller, options)
751
+ let committed = Promise.withResolvers<void>()
752
+ let commitStarted = false
753
+ let finished = resolveAndRenderReload(controller, options, (ready) => {
754
+ if (commitStarted) return
755
+ commitStarted = true
756
+ void ready.then(committed.resolve, committed.reject)
757
+ })
758
+
759
+ // Settle committed when a reload is aborted or fails before rendering any content.
760
+ void finished.then(() => committed.resolve(), committed.reject)
761
+
762
+ return { signal: controller.signal, committed: committed.promise, finished }
689
763
  }
690
764
 
691
765
  function startReload(signal?: AbortSignal): AbortController {
@@ -747,6 +821,7 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
747
821
  async function resolveAndRenderReload(
748
822
  controller: AbortController,
749
823
  options?: FrameReloadOptions,
824
+ resolveCommit?: (ready: Promise<void>) => void,
750
825
  ): Promise<FrameReloadResult> {
751
826
  try {
752
827
  let resolution = await init.resolveFrame(frame.src, {
@@ -762,9 +837,19 @@ export function createFrame(root: FrameRoot, init: FrameInit): Frame {
762
837
  return { signal: controller.signal }
763
838
  }
764
839
  let reconciliationTracker = createReconciliationTracker()
840
+ let blockingFrameTracker = createReconciliationTracker()
841
+ let commitStarted = false
765
842
  await render(content, {
843
+ documentHref: isDocumentNode(container.root) ? (redirectedTo ?? frame.src) : undefined,
766
844
  signal: controller.signal,
767
845
  reconciliationTracker,
846
+ blockingFrameTracker,
847
+ onCommit() {
848
+ if (commitStarted) return
849
+ commitStarted = true
850
+ blockingFrameTracker.finalize()
851
+ resolveCommit?.(blockingFrameTracker.ready())
852
+ },
768
853
  })
769
854
  reconciliationTracker.finalize()
770
855
  await reconciliationTracker.ready()
@@ -921,7 +1006,8 @@ export function createFrameRuntime(init: {
921
1006
  moduleLoads: Map<string, Promise<ElementFunction | undefined>>
922
1007
  frameInstances: WeakMap<Comment, Frame>
923
1008
  namedFrames: Map<string, FrameHandle>
924
- reloadForNavigation?: (options?: FrameReloadOptions) => Promise<FrameReloadResult>
1009
+ processClientEntryPreloads?: ProcessClientEntryPreloads
1010
+ reloadForNavigation?: (options?: FrameReloadOptions) => FrameReloadTransition
925
1011
  }): FrameRuntime {
926
1012
  return {
927
1013
  [FRAME_RUNTIME]: true,
@@ -936,6 +1022,7 @@ export function createFrameRuntime(init: {
936
1022
  moduleLoads: init.moduleLoads,
937
1023
  frameInstances: init.frameInstances,
938
1024
  namedFrames: init.namedFrames,
1025
+ processClientEntryPreloads: init.processClientEntryPreloads,
939
1026
  serverFrameReload: undefined,
940
1027
  reloadForNavigation: init.reloadForNavigation,
941
1028
  }
@@ -1285,6 +1372,7 @@ function hydrateRegion(
1285
1372
  frameRuntime.serverFrameReload = {
1286
1373
  signal,
1287
1374
  reconciliationTracker: context.reconciliationTracker,
1375
+ blockingFrameTracker: context.blockingFrameTracker,
1288
1376
  }
1289
1377
  try {
1290
1378
  root.render(vElement)
@@ -1359,6 +1447,7 @@ async function createSubFrames(
1359
1447
  moduleLoads: context.moduleLoads,
1360
1448
  frameInstances: context.frameInstances,
1361
1449
  namedFrames: context.namedFrames,
1450
+ processClientEntryPreloads: context.processClientEntryPreloads,
1362
1451
  })
1363
1452
  context.frameInstances.set(node, subFrame)
1364
1453
  if (frameMarker.status === 'resolved') {
@@ -0,0 +1,369 @@
1
+ type ImportMap = {
2
+ imports?: ImportMapImports
3
+ scopes?: Record<string, ImportMapImports>
4
+ integrity?: Record<string, string>
5
+ }
6
+
7
+ type ImportMapAddress = string | null
8
+ type ImportMapImports = Record<string, ImportMapAddress>
9
+ type InstalledImportMapEntry = { href: ImportMapAddress; normalizedHref: ImportMapAddress }
10
+
11
+ type InstalledImportMap = {
12
+ imports: Map<string, InstalledImportMapEntry>
13
+ scopes: Map<string, Map<string, InstalledImportMapEntry>>
14
+ integrity: Map<string, string>
15
+ }
16
+
17
+ interface ImportMapManager {
18
+ consumeImportMaps(source: ParentNode): 'ready' | 'conflict' | 'blocked'
19
+ disconnect(): void
20
+ shouldPreserveHeadNode(node: Node): boolean
21
+ }
22
+
23
+ const MANAGED_IMPORT_MAP_SELECTOR = 'script[data-rmx-import-map][type="importmap"]'
24
+ const IMPORT_MAP_SELECTOR = 'script[type="importmap"]'
25
+ const importMapManagers = new WeakMap<Document, ImportMapManager>()
26
+
27
+ class ImportMapConflictError extends Error {}
28
+
29
+ export function getDocumentImportMapManager(doc: Document): ImportMapManager {
30
+ let manager = importMapManagers.get(doc)
31
+ if (!manager) {
32
+ manager = createImportMapManager(doc)
33
+ importMapManagers.set(doc, manager)
34
+ }
35
+ return manager
36
+ }
37
+
38
+ export function resetDocumentImportMapManager(doc: Document): void {
39
+ importMapManagers.get(doc)?.disconnect()
40
+ importMapManagers.delete(doc)
41
+ }
42
+
43
+ function createImportMapManager(doc: Document): ImportMapManager {
44
+ let nonce = doc.head.querySelector<HTMLScriptElement>(MANAGED_IMPORT_MAP_SELECTOR)?.nonce
45
+ let installedImportMap = createInstalledImportMap()
46
+ let processedScripts = new WeakSet<HTMLScriptElement>()
47
+ let conflicted = false
48
+
49
+ function processImportMap(script: HTMLScriptElement): void {
50
+ if (processedScripts.has(script)) return
51
+ processedScripts.add(script)
52
+
53
+ let importMap = parseImportMap(script.textContent ?? '')
54
+ if (importMap) mergeInstalledImportMap(installedImportMap, importMap, script.baseURI)
55
+ }
56
+
57
+ function processImportMaps(): void {
58
+ for (let script of doc.querySelectorAll<HTMLScriptElement>(IMPORT_MAP_SELECTOR)) {
59
+ processImportMap(script)
60
+ }
61
+ }
62
+
63
+ function processMutations(mutations: MutationRecord[]): void {
64
+ for (let mutation of mutations) {
65
+ if (mutation.type !== 'childList') continue
66
+ for (let node of mutation.addedNodes) {
67
+ if (node instanceof HTMLScriptElement && node.matches(IMPORT_MAP_SELECTOR)) {
68
+ processImportMap(node)
69
+ }
70
+ }
71
+ }
72
+ }
73
+
74
+ let observer = new MutationObserver(processMutations)
75
+ observer.observe(doc, { childList: true })
76
+ observer.observe(doc.head, { childList: true })
77
+ processImportMaps()
78
+
79
+ return {
80
+ consumeImportMaps(source) {
81
+ if (conflicted) return 'blocked'
82
+ processMutations(observer.takeRecords())
83
+ processImportMaps()
84
+ let scripts = Array.from(
85
+ source.querySelectorAll<HTMLScriptElement>(MANAGED_IMPORT_MAP_SELECTOR),
86
+ )
87
+ if (scripts.length === 0) return 'ready'
88
+
89
+ let pendingImportMap: InstalledImportMap = {
90
+ imports: new Map(installedImportMap.imports),
91
+ scopes: new Map(
92
+ Array.from(installedImportMap.scopes, ([scope, imports]) => [scope, new Map(imports)]),
93
+ ),
94
+ integrity: new Map(installedImportMap.integrity),
95
+ }
96
+ let deltas: ImportMap[] = []
97
+ let baseUrl = doc.baseURI
98
+ try {
99
+ for (let script of scripts) {
100
+ let importMap = parseImportMap(script.textContent ?? '')
101
+ if (!importMap) continue
102
+ let delta = getImportMapDelta(pendingImportMap, importMap, baseUrl)
103
+ if (delta) {
104
+ deltas.push(delta)
105
+ mergeInstalledImportMap(pendingImportMap, delta, baseUrl)
106
+ }
107
+ }
108
+ } catch (error) {
109
+ if (!(error instanceof ImportMapConflictError)) throw error
110
+ console.warn(error.message)
111
+ conflicted = true
112
+ return 'conflict'
113
+ }
114
+
115
+ for (let delta of deltas) {
116
+ let installedScript = appendImportMapScript(doc, delta, nonce)
117
+ processedScripts.add(installedScript)
118
+ }
119
+ installedImportMap = pendingImportMap
120
+ for (let script of scripts) script.remove()
121
+ return 'ready'
122
+ },
123
+ disconnect() {
124
+ observer.disconnect()
125
+ },
126
+ shouldPreserveHeadNode(node) {
127
+ return (
128
+ node.isConnected &&
129
+ node instanceof HTMLScriptElement &&
130
+ node.matches(MANAGED_IMPORT_MAP_SELECTOR)
131
+ )
132
+ },
133
+ }
134
+ }
135
+
136
+ function createInstalledImportMap(): InstalledImportMap {
137
+ return {
138
+ imports: new Map(),
139
+ scopes: new Map(),
140
+ integrity: new Map(),
141
+ }
142
+ }
143
+
144
+ function getImportMapDelta(
145
+ installedImportMap: InstalledImportMap,
146
+ importMap: ImportMap,
147
+ baseUrl: string,
148
+ ): ImportMap | undefined {
149
+ let imports = getImportMapImportsDelta(installedImportMap.imports, importMap.imports, baseUrl)
150
+ let scopes = getImportMapScopesDelta(installedImportMap, importMap.scopes, baseUrl)
151
+ let integrity = getImportMapIntegrityDelta(
152
+ installedImportMap.integrity,
153
+ importMap.integrity,
154
+ baseUrl,
155
+ )
156
+ if (!imports && !scopes && !integrity) return undefined
157
+ return {
158
+ ...(imports ? { imports } : null),
159
+ ...(scopes ? { scopes } : null),
160
+ ...(integrity ? { integrity } : null),
161
+ }
162
+ }
163
+
164
+ function getImportMapImportsDelta(
165
+ installedImports: Map<string, InstalledImportMapEntry>,
166
+ imports: ImportMapImports | undefined,
167
+ baseUrl: string,
168
+ scope?: string,
169
+ ): ImportMapImports | undefined {
170
+ if (!imports) return undefined
171
+
172
+ let delta: ImportMapImports = {}
173
+ for (let [specifier, href] of Object.entries(imports)) {
174
+ let normalizedSpecifier = normalizeImportMapSpecifier(specifier, baseUrl)
175
+ if (normalizedSpecifier === null) continue
176
+ let normalizedHref = normalizeImportMapAddress(href, baseUrl)
177
+ let installedEntry = installedImports.get(normalizedSpecifier)
178
+ if (installedEntry?.normalizedHref === normalizedHref) continue
179
+ if (installedEntry) {
180
+ let scopeDescription = scope ? ` in scope "${scope}"` : ''
181
+ throw new ImportMapConflictError(
182
+ `[remix] Reloading page after import map conflict for "${specifier}"${scopeDescription}: ` +
183
+ `${formatImportMapAddress(installedEntry.href)} is already installed, but the new map points to ${formatImportMapAddress(href)}`,
184
+ )
185
+ }
186
+ delta[specifier] = href
187
+ }
188
+
189
+ return Object.keys(delta).length > 0 ? delta : undefined
190
+ }
191
+
192
+ function getImportMapScopesDelta(
193
+ installedImportMap: InstalledImportMap,
194
+ scopes: Record<string, ImportMapImports> | undefined,
195
+ baseUrl: string,
196
+ ): Record<string, ImportMapImports> | undefined {
197
+ if (!scopes) return undefined
198
+
199
+ let delta: Record<string, ImportMapImports> = {}
200
+ for (let [scope, imports] of Object.entries(scopes)) {
201
+ let normalizedScope = normalizeImportMapUrl(scope, baseUrl)
202
+ if (normalizedScope === null) continue
203
+ let installedScopeImports = installedImportMap.scopes.get(normalizedScope) ?? new Map()
204
+ let importsDelta = getImportMapImportsDelta(installedScopeImports, imports, baseUrl, scope)
205
+ if (importsDelta) delta[scope] = importsDelta
206
+ }
207
+
208
+ return Object.keys(delta).length > 0 ? delta : undefined
209
+ }
210
+
211
+ function getImportMapIntegrityDelta(
212
+ installedIntegrity: Map<string, string>,
213
+ integrity: Record<string, string> | undefined,
214
+ baseUrl: string,
215
+ ): Record<string, string> | undefined {
216
+ if (!integrity) return undefined
217
+
218
+ let delta: Record<string, string> = {}
219
+ for (let [url, metadata] of Object.entries(integrity)) {
220
+ let normalizedUrl = normalizeImportMapUrl(url, baseUrl)
221
+ if (normalizedUrl === null) continue
222
+ let installedMetadata = installedIntegrity.get(normalizedUrl)
223
+ if (installedMetadata === metadata) continue
224
+ if (installedMetadata !== undefined) {
225
+ throw new ImportMapConflictError(
226
+ `[remix] Reloading page after import map integrity conflict for "${url}": ` +
227
+ `"${installedMetadata}" is already installed, but the new map points to "${metadata}"`,
228
+ )
229
+ }
230
+ delta[url] = metadata
231
+ }
232
+
233
+ return Object.keys(delta).length > 0 ? delta : undefined
234
+ }
235
+
236
+ function mergeInstalledImportMap(
237
+ installedImportMap: InstalledImportMap,
238
+ importMap: ImportMap,
239
+ baseUrl: string,
240
+ ): void {
241
+ mergeInstalledImports(installedImportMap.imports, importMap.imports, baseUrl)
242
+
243
+ if (importMap.scopes) {
244
+ for (let [scope, imports] of Object.entries(importMap.scopes)) {
245
+ let normalizedScope = normalizeImportMapUrl(scope, baseUrl)
246
+ if (normalizedScope === null) continue
247
+ let installedScopeImports = installedImportMap.scopes.get(normalizedScope)
248
+ if (!installedScopeImports) {
249
+ installedScopeImports = new Map()
250
+ installedImportMap.scopes.set(normalizedScope, installedScopeImports)
251
+ }
252
+
253
+ mergeInstalledImports(installedScopeImports, imports, baseUrl)
254
+ }
255
+ }
256
+
257
+ if (importMap.integrity) {
258
+ for (let [url, metadata] of Object.entries(importMap.integrity)) {
259
+ let normalizedUrl = normalizeImportMapUrl(url, baseUrl)
260
+ if (normalizedUrl === null || installedImportMap.integrity.has(normalizedUrl)) continue
261
+ installedImportMap.integrity.set(normalizedUrl, metadata)
262
+ }
263
+ }
264
+ }
265
+
266
+ function mergeInstalledImports(
267
+ installed: Map<string, InstalledImportMapEntry>,
268
+ imports: ImportMapImports | undefined,
269
+ baseUrl: string,
270
+ ): void {
271
+ for (let [specifier, href] of Object.entries(imports ?? {})) {
272
+ let normalizedSpecifier = normalizeImportMapSpecifier(specifier, baseUrl)
273
+ if (normalizedSpecifier === null || installed.has(normalizedSpecifier)) continue
274
+ installed.set(normalizedSpecifier, {
275
+ href,
276
+ normalizedHref: normalizeImportMapAddress(href, baseUrl),
277
+ })
278
+ }
279
+ }
280
+
281
+ function appendImportMapScript(
282
+ doc: Document,
283
+ importMap: ImportMap,
284
+ nonce: string | undefined,
285
+ ): HTMLScriptElement {
286
+ let script = doc.createElement('script')
287
+ script.setAttribute('data-rmx-import-map', '')
288
+ script.type = 'importmap'
289
+ if (nonce) script.nonce = nonce
290
+ script.textContent = JSON.stringify(importMap)
291
+ doc.head.appendChild(script)
292
+ return script
293
+ }
294
+
295
+ function parseImportMap(json: string): ImportMap | null {
296
+ let parsed: unknown
297
+ try {
298
+ parsed = JSON.parse(json)
299
+ } catch {
300
+ return null
301
+ }
302
+
303
+ if (!parsed || typeof parsed !== 'object') return null
304
+ let importMap: ImportMap = {}
305
+
306
+ if ('imports' in parsed) {
307
+ let imports = parsed.imports
308
+ if (!isImportMapImports(imports)) return null
309
+ importMap.imports = imports
310
+ }
311
+
312
+ if ('scopes' in parsed) {
313
+ let scopes = parsed.scopes
314
+ if (!isScopedImportMapRecord(scopes)) return null
315
+ importMap.scopes = scopes
316
+ }
317
+
318
+ if ('integrity' in parsed) {
319
+ let integrity = parsed.integrity
320
+ if (!isImportMapIntegrity(integrity)) return null
321
+ importMap.integrity = integrity
322
+ }
323
+
324
+ return importMap
325
+ }
326
+
327
+ function isImportMapImports(value: unknown): value is ImportMapImports {
328
+ if (!value || typeof value !== 'object') return false
329
+ return Object.values(value).every((entry) => entry === null || typeof entry === 'string')
330
+ }
331
+
332
+ function isScopedImportMapRecord(value: unknown): value is Record<string, ImportMapImports> {
333
+ if (!value || typeof value !== 'object') return false
334
+ return Object.values(value).every(isImportMapImports)
335
+ }
336
+
337
+ function isImportMapIntegrity(value: unknown): value is Record<string, string> {
338
+ if (!value || typeof value !== 'object') return false
339
+ return Object.values(value).every((entry) => typeof entry === 'string')
340
+ }
341
+
342
+ function normalizeImportMapSpecifier(specifier: string, baseUrl: string): string | null {
343
+ if (
344
+ specifier.startsWith('/') ||
345
+ specifier.startsWith('./') ||
346
+ specifier.startsWith('../') ||
347
+ URL.canParse(specifier)
348
+ ) {
349
+ return normalizeImportMapUrl(specifier, baseUrl)
350
+ }
351
+ return specifier
352
+ }
353
+
354
+ function normalizeImportMapAddress(address: ImportMapAddress, baseUrl: string): ImportMapAddress {
355
+ if (address === null) return null
356
+ return normalizeImportMapUrl(address, baseUrl)
357
+ }
358
+
359
+ function normalizeImportMapUrl(value: string, baseUrl: string): string | null {
360
+ try {
361
+ return new URL(value, baseUrl).href
362
+ } catch {
363
+ return null
364
+ }
365
+ }
366
+
367
+ function formatImportMapAddress(address: ImportMapAddress | undefined): string {
368
+ return address === null ? 'null' : `"${address}"`
369
+ }