@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
package/src/runtime/frame.ts
CHANGED
|
@@ -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
|
-
| {
|
|
183
|
+
| {
|
|
184
|
+
signal: AbortSignal
|
|
185
|
+
reconciliationTracker?: ReconciliationTracker
|
|
186
|
+
blockingFrameTracker?: ReconciliationTracker
|
|
187
|
+
}
|
|
175
188
|
| undefined
|
|
176
|
-
reloadForNavigation?: (options?: FrameReloadOptions) =>
|
|
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
|
-
):
|
|
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
|
-
|
|
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
|
-
|
|
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({
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|