@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
@@ -1,5 +1,12 @@
1
- import type { ComponentHandle, FrameHandle, Key, RemixNode } from '../runtime/component.ts'
2
- import type { ElementType, ElementProps, RemixElement } from '../runtime/jsx.ts'
1
+ import type {
2
+ ComponentHandle,
3
+ FrameHandle,
4
+ Handle,
5
+ Key,
6
+ RemixNode,
7
+ RenderFn,
8
+ } from '../runtime/component.ts'
9
+ import type { ElementType, ElementProps, Props, RemixElement } from '../runtime/jsx.ts'
3
10
  import type { ElementFunction } from '../runtime/element-function.ts'
4
11
  import { Fragment, createComponent, createFrameHandle, Frame } from '../runtime/component.ts'
5
12
  import { isEntry, type EntryComponent } from '../runtime/client-entries.ts'
@@ -11,6 +18,7 @@ import {
11
18
  shouldStringifyBooleanAttribute,
12
19
  } from '../runtime/core/attributes.ts'
13
20
  import { appendFlushMarker, type FlushKind, stripFlushMarkers } from '../runtime/stream-protocol.ts'
21
+ import { composeMixedProps, resolveMixDescriptors } from '../runtime/core/mix.ts'
14
22
  import { REMIX_UI_STYLE_LAYER } from '../style/layers.ts'
15
23
 
16
24
  interface VNode {
@@ -79,6 +87,52 @@ interface ResolvedClientEntry {
79
87
  exportName: string
80
88
  /** Browser module hrefs to begin preloading before hydrating this entry. */
81
89
  preloads?: readonly string[]
90
+ importMap?: ImportMapData
91
+ }
92
+
93
+ /** Import map data accepted by the server renderer. */
94
+ export interface ImportMapData {
95
+ /** Top-level module specifier mappings. */
96
+ imports?: ImportMapImports
97
+ /** Module specifier mappings scoped by URL. */
98
+ scopes?: Record<string, ImportMapImports>
99
+ /** Subresource integrity metadata keyed by module URL. */
100
+ integrity?: Record<string, string>
101
+ }
102
+
103
+ type ImportMapAddress = string | null
104
+ type ImportMapImports = Record<string, ImportMapAddress>
105
+ type AuthoredImportMapEntries = Map<string, ImportMapAddress>
106
+ type AuthoredImportMapScope = { imports: AuthoredImportMapEntries }
107
+ type StaticSegment = { kind: 'static'; html: string }
108
+ type ManagedImportMap = {
109
+ attrs: string
110
+ segment: StaticSegment
111
+ value: ImportMapData
112
+ }
113
+
114
+ export type ImportMapProps = Omit<
115
+ Props<'script'>,
116
+ 'children' | 'innerHTML' | 'integrity' | 'src' | 'type'
117
+ > & {
118
+ /** Initial import map entries to render and merge with resolved client entries. */
119
+ value: ImportMapData
120
+ }
121
+
122
+ /**
123
+ * Renders the document import map and merges maps from server-resolved client entries.
124
+ *
125
+ * @param handle Server component handle containing the initial import map and script attributes.
126
+ * @returns This component is handled directly by the server renderer.
127
+ */
128
+ export function ImportMap(handle: Handle<ImportMapProps>): RenderFn {
129
+ void handle
130
+ return () => null
131
+ }
132
+
133
+ interface ClientEntryHeadResources {
134
+ modulePreloadTags: Set<string>
135
+ importMap?: ImportMapData
82
136
  }
83
137
 
84
138
  interface FrameData {
@@ -89,6 +143,7 @@ interface FrameData {
89
143
 
90
144
  interface RenderContext {
91
145
  insideSvg: boolean
146
+ insideHead: boolean
92
147
  onError: (error: unknown) => void
93
148
  parentVNode?: VNode
94
149
  styleCache: Map<string, { selector: string; css: string }>
@@ -100,8 +155,12 @@ interface RenderContext {
100
155
  pendingFrames: Array<{ frameId: string; promise: Promise<ResolvedFrameHtml> }>
101
156
  hydrationData: Map<string, HydrationData>
102
157
  unresolvedHydrationData: Map<string, UnresolvedHydrationData>
158
+ authoredImportMapImports: AuthoredImportMapEntries
159
+ authoredImportMapScopes: Map<string, AuthoredImportMapScope>
160
+ authoredImportMapIntegrity: Map<string, string>
161
+ managedImportMaps: ManagedImportMap[]
103
162
  frameData: Map<string, FrameData>
104
- modulePreloadTags: Set<string>
163
+ clientEntryHeadResources: ClientEntryHeadResources
105
164
  blockingFrameTails: ReadableStream<Uint8Array>[]
106
165
  signal: AbortSignal
107
166
  flushKind: FlushKind
@@ -120,7 +179,7 @@ interface SsrFrameState {
120
179
  }
121
180
 
122
181
  type Segment =
123
- | { kind: 'static'; html: string }
182
+ | StaticSegment
124
183
  | { kind: 'composite'; parts: Segment[] }
125
184
  | {
126
185
  kind: 'frame'
@@ -133,7 +192,6 @@ const TEXTAREA_VALUE_PROPS = new Set(['value', 'defaultValue'])
133
192
  const INPUT_DEFAULT_PROPS = new Set(['defaultValue', 'defaultChecked'])
134
193
 
135
194
  const DOCTYPE_PATTERN = /<!doctype(?:\s[^>]*)?>/gi
136
-
137
195
  function stripDoctypeMarkup(html: string): string {
138
196
  return html.replace(DOCTYPE_PATTERN, '')
139
197
  }
@@ -203,14 +261,19 @@ export function renderToStream(
203
261
 
204
262
  let context: RenderContext = {
205
263
  insideSvg: false,
264
+ insideHead: false,
206
265
  onError,
207
266
  resolveFrame: options?.resolveFrame ?? defaultResolveFrame,
208
267
  styleCache: new Map(),
209
268
  pendingFrames: [],
210
269
  hydrationData: new Map(),
211
270
  unresolvedHydrationData: new Map(),
271
+ authoredImportMapImports: new Map(),
272
+ authoredImportMapScopes: new Map(),
273
+ authoredImportMapIntegrity: new Map(),
274
+ managedImportMaps: [],
212
275
  frameData: new Map(),
213
- modulePreloadTags: new Set(),
276
+ clientEntryHeadResources: { modulePreloadTags: new Set() },
214
277
  blockingFrameTails: [],
215
278
  signal: renderAbortController.signal,
216
279
  flushKind: 'fragment',
@@ -240,6 +303,7 @@ export function renderToStream(
240
303
  await resolveClientEntries(context, options?.resolveClientEntry)
241
304
  if (closeIfCancelled(controller, context)) return
242
305
  validateClientEntriesForHydration(context)
306
+ finalizeManagedImportMap(context)
243
307
  let html = serializeSegment(root)
244
308
  let finalHtml = finalizeHtml(html, context)
245
309
  let bytes = encoder.encode(appendFlushMarker(finalHtml, context.flushKind))
@@ -385,16 +449,19 @@ async function splitFirstChunk(stream: ReadableStream<Uint8Array>): Promise<Reso
385
449
  async function resolveFrameHtml(
386
450
  input: string | ReadableStream<Uint8Array>,
387
451
  ): Promise<ResolvedFrameHtml> {
388
- if (typeof input === 'string') return { html: stripFlushMarkers(stripDoctypeMarkup(input)) }
452
+ if (typeof input === 'string') {
453
+ let html = stripFlushMarkers(stripDoctypeMarkup(input))
454
+ return { html }
455
+ }
389
456
 
390
- return await splitFirstChunk(input)
457
+ return splitFirstChunk(input)
391
458
  }
392
459
 
393
460
  function isRemixElement(node: unknown): node is RemixElement {
394
461
  return typeof node === 'object' && node !== null && '$rmx' in node
395
462
  }
396
463
 
397
- function staticSeg(html: string): Segment {
464
+ function staticSeg(html: string): StaticSegment {
398
465
  return { kind: 'static', html }
399
466
  }
400
467
 
@@ -440,6 +507,9 @@ function buildSegment(node: RemixNode, context: RenderContext, frameState: SsrFr
440
507
  }
441
508
 
442
509
  if (isElementFunction(type)) {
510
+ if (type === ImportMap) {
511
+ return buildImportMapSegment(props, context)
512
+ }
443
513
  if (type === Frame) {
444
514
  return buildFrameSegment(node, context, frameState)
445
515
  }
@@ -496,7 +566,7 @@ function buildFrameSegment(
496
566
  context.resolveFrame(props.src, props.name, resolveFrameContext),
497
567
  ).then(async (resolved) => {
498
568
  let { html, tail } = await resolveFrameHtml(resolved)
499
- html = hoistModulePreloadsFromFrameHead(html, context)
569
+ html = hoistClientEntryResourcesFromFrameHead(html, context.clientEntryHeadResources)
500
570
  seg.content = staticSeg(html)
501
571
  if (tail) {
502
572
  context.blockingFrameTails.push(tail)
@@ -540,6 +610,9 @@ function buildElementSegment(
540
610
 
541
611
  if (tag === 'script') {
542
612
  if (typeof props.children === 'string') {
613
+ if (context.insideHead) {
614
+ collectAuthoredImportMap(context, tag, processedProps, props.children)
615
+ }
543
616
  return staticSeg(`<${tag}${attrs}>${escapeScriptTextContent(props.children)}</${tag}>`)
544
617
  }
545
618
  if (props.children != null) {
@@ -565,6 +638,75 @@ function buildTextareaElementSegment(tag: string, props: any): Segment {
565
638
  return staticSeg(`<${tag}${attrs}>${escapeTextContent(String(value))}</${tag}>`)
566
639
  }
567
640
 
641
+ function collectAuthoredImportMap(
642
+ context: RenderContext,
643
+ tag: string,
644
+ props: Record<string, unknown>,
645
+ children: unknown,
646
+ ): void {
647
+ if (
648
+ tag !== 'script' ||
649
+ typeof props.type !== 'string' ||
650
+ props.type.toLowerCase() !== 'importmap' ||
651
+ (props.src !== undefined && props.src !== null && props.src !== false) ||
652
+ typeof children !== 'string'
653
+ ) {
654
+ return
655
+ }
656
+
657
+ let importMap = parseAuthoredImportMap(children)
658
+ if (!importMap) return
659
+
660
+ if (importMap.imports) {
661
+ collectAuthoredImportMapEntries(context.authoredImportMapImports, importMap.imports)
662
+ }
663
+ if (importMap.scopes) {
664
+ for (let [scope, imports] of Object.entries(importMap.scopes)) {
665
+ let authoredScope = context.authoredImportMapScopes.get(scope)
666
+ if (!authoredScope) {
667
+ authoredScope = { imports: new Map() }
668
+ context.authoredImportMapScopes.set(scope, authoredScope)
669
+ }
670
+ collectAuthoredImportMapEntries(authoredScope.imports, imports)
671
+ }
672
+ }
673
+ if (importMap.integrity) {
674
+ for (let [url, integrity] of Object.entries(importMap.integrity)) {
675
+ if (!context.authoredImportMapIntegrity.has(url)) {
676
+ context.authoredImportMapIntegrity.set(url, integrity)
677
+ }
678
+ }
679
+ }
680
+ }
681
+
682
+ function collectAuthoredImportMapEntries(
683
+ target: AuthoredImportMapEntries,
684
+ source: ImportMapImports,
685
+ ): void {
686
+ for (let [specifier, address] of Object.entries(source)) {
687
+ if (!target.has(specifier)) target.set(specifier, address)
688
+ }
689
+ }
690
+
691
+ function buildImportMapSegment(props: ElementProps, context: RenderContext): Segment {
692
+ if (context.flushKind !== 'document' || !context.insideHead) {
693
+ throw new Error('ImportMap must be rendered inside a document head')
694
+ }
695
+ if (context.managedImportMaps.length > 0) {
696
+ throw new Error('Only one ImportMap can be rendered per document')
697
+ }
698
+ let value = props.value
699
+ if (!isImportMap(value)) {
700
+ throw new TypeError('ImportMap value must be a valid import map')
701
+ }
702
+
703
+ let { value: _value, ...scriptProps } = props
704
+ let attrs = renderAttributes(scriptProps, false)
705
+ let segment = staticSeg('')
706
+ context.managedImportMaps.push({ attrs, segment, value })
707
+ return segment
708
+ }
709
+
568
710
  function renderInputAttributes(props: any): string {
569
711
  let value =
570
712
  props.value === undefined && props.defaultValue !== undefined ? props.defaultValue : props.value
@@ -590,8 +732,11 @@ function buildHeadElementSegment(
590
732
  let attrs = renderAttributes(processedProps, false)
591
733
 
592
734
  let open = staticSeg(`<${tag}${attrs}>`)
735
+ let previousInsideHead = context.insideHead
736
+ context.insideHead = true
593
737
  let children =
594
738
  props.children != null ? buildSegment(props.children, context, frameState) : staticSeg('')
739
+ context.insideHead = previousInsideHead
595
740
  let close = staticSeg(`</${tag}>`)
596
741
 
597
742
  return compositeSeg([open, children, close])
@@ -629,74 +774,25 @@ function resolveSsrMixedProps(
629
774
  context: RenderContext,
630
775
  frameState: SsrFrameState,
631
776
  ): ElementProps {
632
- let descriptors = resolveSsrMixDescriptors(initialProps)
633
- if (descriptors.length === 0) return initialProps
634
-
635
- let composedProps = withoutSsrMix(initialProps)
636
- let mixinProps = withoutSsrMixinTreeProps(composedProps)
637
- let maxDescriptors = 1024
777
+ if (resolveMixDescriptors(initialProps).length === 0) return initialProps
638
778
 
639
- for (let index = 0; index < descriptors.length && index < maxDescriptors; index++) {
640
- let descriptor = descriptors[index]
779
+ return composeMixedProps(hostType, initialProps, (descriptor, _index, mixinProps) => {
641
780
  let runner = resolveSsrMixinRunner(hostType, descriptor, context, frameState)
642
- if (!runner) continue
643
-
644
- let result: unknown
781
+ if (!runner) return undefined
782
+ // Unlike the client runtime, a throwing mixin is isolated here so a
783
+ // single bad mixin cannot take down the whole stream.
645
784
  try {
646
- result = runner(...descriptor.args, mixinProps)
785
+ return runner(...descriptor.args, mixinProps)
647
786
  } catch (error) {
648
787
  console.error(error)
649
- continue
650
- }
651
-
652
- if (!result) continue
653
- if (isSsrMixinElement(result)) continue
654
-
655
- let returnedDescriptors = resolveReturnedSsrMixDescriptors(result)
656
- if (returnedDescriptors) {
657
- for (let returned of returnedDescriptors) descriptors.push(returned)
658
- continue
659
- }
660
-
661
- if (!isRemixElement(result)) {
662
- console.error(new Error('mixins must return a remix element'))
663
- continue
664
- }
665
- let remixResult = result
666
-
667
- let resultType =
668
- typeof remixResult.type === 'string'
669
- ? remixResult.type
670
- : isSsrMixinElement(remixResult.type)
671
- ? remixResult.type.__rmxMixinElementType
672
- : null
673
-
674
- if (resultType !== hostType) {
675
- console.error(new Error('mixins must return an element with the same host type'))
676
- continue
677
- }
678
-
679
- if (remixResult.type !== resultType) {
680
- remixResult = { ...remixResult, type: resultType }
788
+ return undefined
681
789
  }
682
-
683
- let nextProps = sanitizeReturnedSsrMixinProps(remixResult.props as ElementProps)
684
- let nestedDescriptors = resolveSsrMixDescriptors(nextProps)
685
- for (let nested of nestedDescriptors) descriptors.push(nested)
686
- composedProps = { ...composedProps, ...withoutSsrMix(nextProps) }
687
- mixinProps = withoutSsrMixinTreeProps(composedProps)
688
- }
689
-
690
- let nextMix = initialProps.mix
691
- return {
692
- ...composedProps,
693
- ...(nextMix === undefined ? {} : { mix: nextMix }),
694
- }
790
+ })
695
791
  }
696
792
 
697
793
  function resolveSsrMixinRunner(
698
794
  hostType: string,
699
- descriptor: { type?: unknown; args?: unknown[] },
795
+ descriptor: { type?: unknown; args?: readonly unknown[] },
700
796
  context: RenderContext,
701
797
  frameState: SsrFrameState,
702
798
  ): ((...args: unknown[]) => unknown) | null {
@@ -772,93 +868,10 @@ function createSsrMixinHandle(
772
868
  }
773
869
  }
774
870
 
775
- function resolveSsrMixDescriptors(props: ElementProps): Array<{ type: any; args: unknown[] }> {
776
- let mix = props.mix
777
- if (!mix) return []
778
- if (Array.isArray(mix)) {
779
- if (mix.length === 0) return []
780
- return mix.filter(Boolean) as Array<{ type: any; args: unknown[] }>
781
- }
782
- return [mix] as Array<{ type: any; args: unknown[] }>
783
- }
784
-
785
- function withoutSsrMix(props: ElementProps): ElementProps {
786
- if (!('mix' in props)) return props
787
- let output = { ...props }
788
- delete output.mix
789
- return output
790
- }
791
-
792
- function withoutSsrMixinTreeProps(props: ElementProps): ElementProps {
793
- if (!('children' in props) && !('innerHTML' in props)) return props
794
- let output = { ...props }
795
- delete output.children
796
- delete output.innerHTML
797
- return output
798
- }
799
-
800
- function sanitizeReturnedSsrMixinProps(props: ElementProps): ElementProps {
801
- if (!('children' in props) && !('innerHTML' in props)) return props
802
- console.error(new Error('mixins must not return children or innerHTML'))
803
- return withoutSsrMixinTreeProps(props)
804
- }
805
-
806
- function resolveReturnedSsrMixDescriptors(
807
- value: unknown,
808
- ): Array<{ type: ElementFunction; args: unknown[] }> | null {
809
- let descriptors: Array<{ type: ElementFunction; args: unknown[] }> = []
810
- if (!collectReturnedSsrMixDescriptors(value, descriptors)) {
811
- return null
812
- }
813
-
814
- return descriptors
815
- }
816
-
817
- function collectReturnedSsrMixDescriptors(
818
- value: unknown,
819
- output: Array<{ type: ElementFunction; args: unknown[] }>,
820
- ): boolean {
821
- if (!value) {
822
- return true
823
- }
824
-
825
- if (Array.isArray(value)) {
826
- for (let item of value) {
827
- if (!collectReturnedSsrMixDescriptors(item, output)) {
828
- return false
829
- }
830
- }
831
- return true
832
- }
833
-
834
- if (!isSsrMixinDescriptor(value)) {
835
- return false
836
- }
837
-
838
- output.push(value)
839
- return true
840
- }
841
-
842
- function isSsrMixinElement(
843
- value: unknown,
844
- ): value is ((...args: unknown[]) => unknown) & { __rmxMixinElementType: string } {
845
- if (typeof value !== 'function') return false
846
- return '__rmxMixinElementType' in value
847
- }
848
-
849
871
  function isElementFunction(value: unknown): value is ElementFunction {
850
872
  return typeof value === 'function'
851
873
  }
852
874
 
853
- function isSsrMixinDescriptor(value: unknown): value is { type: ElementFunction; args: unknown[] } {
854
- if (!value || typeof value !== 'object' || isRemixElement(value)) {
855
- return false
856
- }
857
-
858
- let descriptor = value as { type?: unknown; args?: unknown }
859
- return typeof descriptor.type === 'function' && Array.isArray(descriptor.args)
860
- }
861
-
862
875
  function buildComponentSegment(
863
876
  type: ElementFunction,
864
877
  props: any,
@@ -1084,6 +1097,7 @@ async function resolveClientEntries(
1084
1097
  : resolveDefaultClientEntry(entryId, component)
1085
1098
  validateResolvedClientEntry(entryId, resolvedEntry)
1086
1099
  resolvedEntries.set(component, resolvedEntry)
1100
+ collectResolvedClientEntryResources(context.clientEntryHeadResources, resolvedEntry)
1087
1101
  }
1088
1102
 
1089
1103
  context.hydrationData.set(hydrationId, {
@@ -1091,15 +1105,23 @@ async function resolveClientEntries(
1091
1105
  moduleUrl: resolvedEntry.href,
1092
1106
  props,
1093
1107
  })
1094
-
1095
- for (let preload of resolvedEntry.preloads ?? []) {
1096
- context.modulePreloadTags.add(createModulePreloadTag(preload))
1097
- }
1098
1108
  }
1099
1109
 
1100
1110
  context.unresolvedHydrationData.clear()
1101
1111
  }
1102
1112
 
1113
+ function collectResolvedClientEntryResources(
1114
+ resources: ClientEntryHeadResources,
1115
+ resolvedEntry: ResolvedClientEntry,
1116
+ ): void {
1117
+ for (let preload of resolvedEntry.preloads ?? []) {
1118
+ resources.modulePreloadTags.add(createModulePreloadTag(preload))
1119
+ }
1120
+ if (resolvedEntry.importMap) {
1121
+ mergeImportMap(resources, resolvedEntry.importMap)
1122
+ }
1123
+ }
1124
+
1103
1125
  function validateResolvedClientEntry(
1104
1126
  entryId: string,
1105
1127
  resolvedEntry: ResolvedClientEntry,
@@ -1130,6 +1152,12 @@ function validateResolvedClientEntry(
1130
1152
  }
1131
1153
  }
1132
1154
  }
1155
+
1156
+ if (resolvedEntry.importMap !== undefined && !isImportMap(resolvedEntry.importMap)) {
1157
+ throw new Error(
1158
+ `resolveClientEntry importMap must be a valid import map. Received "${entryId}".`,
1159
+ )
1160
+ }
1133
1161
  }
1134
1162
 
1135
1163
  function validateClientEntriesForHydration(context: RenderContext): void {
@@ -1204,31 +1232,29 @@ function transformAttributeName(name: string, isSvg: boolean): string {
1204
1232
  function finalizeHtml(html: string, context: RenderContext): string {
1205
1233
  let hasHtmlRoot = context.flushKind === 'document'
1206
1234
 
1207
- let preloads = collectModulePreloadTags(context)
1235
+ let preloads = collectModulePreloadTags(context.clientEntryHeadResources)
1208
1236
  let styles = collectStyleTags(context)
1209
- if (preloads || styles) {
1210
- let headContent = preloads + styles
1211
- if (hasHtmlRoot) {
1212
- // For HTML root, inject into existing head or create one
1213
- let headCloseIndex = html.indexOf('</head>')
1214
- if (headCloseIndex !== -1) {
1215
- // Inject before existing </head>
1216
- html = html.slice(0, headCloseIndex) + headContent + html.slice(headCloseIndex)
1237
+ let importMapScript = collectImportMapScript(context, context.clientEntryHeadResources)
1238
+ let headContent = importMapScript + preloads + styles
1239
+ if (hasHtmlRoot && headContent) {
1240
+ let headCloseIndex = html.indexOf('</head>')
1241
+ if (headCloseIndex !== -1) {
1242
+ html = html.slice(0, headCloseIndex) + headContent + html.slice(headCloseIndex)
1243
+ } else {
1244
+ let htmlOpenMatch = html.match(/<html[^>]*>/)
1245
+ if (htmlOpenMatch) {
1246
+ let insertIndex = htmlOpenMatch.index! + htmlOpenMatch[0].length
1247
+ html = html.slice(0, insertIndex) + `<head>${headContent}</head>` + html.slice(insertIndex)
1217
1248
  } else {
1218
- // No existing head, inject after <html>
1219
- let htmlOpenMatch = html.match(/<html[^>]*>/)
1220
- if (htmlOpenMatch) {
1221
- let insertIndex = htmlOpenMatch.index! + htmlOpenMatch[0].length
1222
- html =
1223
- html.slice(0, insertIndex) + `<head>${headContent}</head>` + html.slice(insertIndex)
1224
- }
1249
+ html = headContent + html
1225
1250
  }
1226
- } else {
1227
- // No HTML root, prepend head
1228
- html = `<head>${headContent}</head>${html}`
1229
1251
  }
1230
1252
  }
1231
1253
 
1254
+ if (!hasHtmlRoot && headContent) {
1255
+ html = `<head>${headContent}</head>${html}`
1256
+ }
1257
+
1232
1258
  // Append aggregated hydration/frame data script at the end
1233
1259
  let rmxData = buildRmxDataScript(context)
1234
1260
  if (rmxData) {
@@ -1257,46 +1283,59 @@ const FRAME_HEAD_OPEN_TAG = '<head>'
1257
1283
  const FRAME_HEAD_CLOSE_TAG = '</head>'
1258
1284
  const MARKED_MODULE_PRELOAD_START = '<link data-rmx-module-preload rel="modulepreload" href="'
1259
1285
  const MODULE_PRELOAD_END = '" />'
1286
+ const MANAGED_IMPORT_MAP_START = '<script data-rmx-import-map type="importmap">'
1287
+ const IMPORT_MAP_SCRIPT_END = '</script>'
1260
1288
 
1261
1289
  function createModulePreloadTag(href: string): string {
1262
1290
  return `${MARKED_MODULE_PRELOAD_START}${escapeHtml(href)}${MODULE_PRELOAD_END}`
1263
1291
  }
1264
1292
 
1265
- function collectModulePreloadTags(context: RenderContext): string {
1266
- return Array.from(context.modulePreloadTags).join('')
1293
+ function collectModulePreloadTags(resources: ClientEntryHeadResources): string {
1294
+ return Array.from(resources.modulePreloadTags).join('')
1267
1295
  }
1268
1296
 
1269
- function hoistModulePreloadsFromFrameHead(html: string, context: RenderContext): string {
1297
+ function hoistClientEntryResourcesFromFrameHead(
1298
+ html: string,
1299
+ resources: ClientEntryHeadResources,
1300
+ ): string {
1270
1301
  if (!html.startsWith(FRAME_HEAD_OPEN_TAG)) return html
1271
1302
 
1272
- let tags: string[] = []
1273
- let remainingHeadStart = FRAME_HEAD_OPEN_TAG.length
1274
- while (html.startsWith(MARKED_MODULE_PRELOAD_START, remainingHeadStart)) {
1275
- let tagEnd = html.indexOf(
1276
- MODULE_PRELOAD_END,
1277
- remainingHeadStart + MARKED_MODULE_PRELOAD_START.length,
1278
- )
1279
- if (tagEnd === -1) return html
1303
+ let headClose = html.indexOf(FRAME_HEAD_CLOSE_TAG, FRAME_HEAD_OPEN_TAG.length)
1304
+ if (headClose === -1) return html
1280
1305
 
1281
- tagEnd += MODULE_PRELOAD_END.length
1282
- tags.push(html.slice(remainingHeadStart, tagEnd))
1283
- remainingHeadStart = tagEnd
1306
+ let preloadTags: string[] = []
1307
+ let importMaps: ImportMapData[] = []
1308
+ let cursor = FRAME_HEAD_OPEN_TAG.length
1309
+ if (html.startsWith(MANAGED_IMPORT_MAP_START, cursor)) {
1310
+ let contentStart = cursor + MANAGED_IMPORT_MAP_START.length
1311
+ let scriptEnd = html.indexOf(IMPORT_MAP_SCRIPT_END, contentStart)
1312
+ if (scriptEnd === -1 || scriptEnd >= headClose) return html
1313
+ importMaps.push(parseFrameworkImportMap(html.slice(contentStart, scriptEnd)))
1314
+ cursor = scriptEnd + IMPORT_MAP_SCRIPT_END.length
1284
1315
  }
1285
1316
 
1286
- if (tags.length === 0) return html
1317
+ while (html.startsWith(MARKED_MODULE_PRELOAD_START, cursor)) {
1318
+ let tagEnd = html.indexOf(MODULE_PRELOAD_END, cursor + MARKED_MODULE_PRELOAD_START.length)
1319
+ if (tagEnd === -1 || tagEnd >= headClose) return html
1320
+ tagEnd += MODULE_PRELOAD_END.length
1321
+ preloadTags.push(html.slice(cursor, tagEnd))
1322
+ cursor = tagEnd
1323
+ }
1287
1324
 
1288
- let headClose = html.indexOf(FRAME_HEAD_CLOSE_TAG, remainingHeadStart)
1289
- if (headClose === -1) return html
1325
+ if (preloadTags.length === 0 && importMaps.length === 0) return html
1290
1326
 
1291
- for (let tag of tags) {
1292
- context.modulePreloadTags.add(tag)
1327
+ for (let tag of preloadTags) {
1328
+ resources.modulePreloadTags.add(tag)
1293
1329
  }
1294
-
1295
- if (remainingHeadStart === headClose) {
1296
- return html.slice(headClose + FRAME_HEAD_CLOSE_TAG.length)
1330
+ for (let importMap of importMaps) {
1331
+ mergeImportMap(resources, importMap)
1297
1332
  }
1298
1333
 
1299
- return FRAME_HEAD_OPEN_TAG + html.slice(remainingHeadStart)
1334
+ let remainingHeadHtml = html.slice(cursor, headClose)
1335
+ let contentAfterHead = html.slice(headClose + FRAME_HEAD_CLOSE_TAG.length)
1336
+ if (!remainingHeadHtml) return contentAfterHead
1337
+
1338
+ return `${FRAME_HEAD_OPEN_TAG}${remainingHeadHtml}${FRAME_HEAD_CLOSE_TAG}${contentAfterHead}`
1300
1339
  }
1301
1340
 
1302
1341
  function processStyleProps(props: any): any {
@@ -1375,11 +1414,253 @@ function buildRmxDataScript(context: RenderContext): string {
1375
1414
  return `<script type="application/json" id="rmx-data">${serializedData}</script>`
1376
1415
  }
1377
1416
 
1417
+ function buildImportMapScript(importMap: ImportMapData, attrs: string = ''): string {
1418
+ let serializedData = escapeScriptJson(JSON.stringify(importMap))
1419
+ return `<script data-rmx-import-map type="importmap"${attrs}>${serializedData}</script>`
1420
+ }
1421
+
1422
+ function collectImportMapScript(
1423
+ context: RenderContext,
1424
+ resources: ClientEntryHeadResources,
1425
+ ): string {
1426
+ let importMap = getImportMapDelta(context, resources.importMap)
1427
+ return importMap ? buildImportMapScript(importMap) : ''
1428
+ }
1429
+
1430
+ function finalizeManagedImportMap(context: RenderContext): void {
1431
+ let managed = context.managedImportMaps[0]
1432
+ if (!managed) return
1433
+
1434
+ let resources: ClientEntryHeadResources = { modulePreloadTags: new Set() }
1435
+ mergeImportMap(resources, managed.value)
1436
+ if (context.clientEntryHeadResources.importMap) {
1437
+ mergeImportMap(resources, context.clientEntryHeadResources.importMap)
1438
+ }
1439
+ managed.segment.html = buildImportMapScript(resources.importMap ?? {}, managed.attrs)
1440
+ context.clientEntryHeadResources.importMap = undefined
1441
+ }
1442
+
1443
+ function getImportMapDelta(
1444
+ context: RenderContext,
1445
+ importMap: ImportMapData | undefined,
1446
+ ): ImportMapData | null {
1447
+ if (!importMap) return null
1448
+
1449
+ let imports = importMap.imports
1450
+ ? getImportMapImportsDelta(context.authoredImportMapImports, importMap.imports)
1451
+ : undefined
1452
+ let scopes: Record<string, ImportMapImports> = {}
1453
+ for (let [scope, scopedImports] of Object.entries(importMap.scopes ?? {})) {
1454
+ let authoredImports =
1455
+ context.authoredImportMapScopes.get(scope)?.imports ?? new Map<string, ImportMapAddress>()
1456
+ let importsDelta = getImportMapImportsDelta(authoredImports, scopedImports, scope)
1457
+ if (importsDelta) scopes[scope] = importsDelta
1458
+ }
1459
+ let integrity = importMap.integrity
1460
+ ? getImportMapIntegrityDelta(context.authoredImportMapIntegrity, importMap.integrity)
1461
+ : undefined
1462
+
1463
+ if (!imports && Object.keys(scopes).length === 0 && !integrity) return null
1464
+ return {
1465
+ ...(imports ? { imports } : null),
1466
+ ...(Object.keys(scopes).length > 0 ? { scopes } : null),
1467
+ ...(integrity ? { integrity } : null),
1468
+ }
1469
+ }
1470
+
1471
+ function getImportMapImportsDelta(
1472
+ authoredImports: AuthoredImportMapEntries,
1473
+ discoveredImports: ImportMapImports,
1474
+ scope?: string,
1475
+ ): ImportMapImports | undefined {
1476
+ let delta: ImportMapImports = {}
1477
+ for (let [specifier, address] of Object.entries(discoveredImports)) {
1478
+ if (!authoredImports.has(specifier)) {
1479
+ delta[specifier] = address
1480
+ continue
1481
+ }
1482
+
1483
+ let authoredAddress = authoredImports.get(specifier)
1484
+ if (authoredAddress === address) continue
1485
+
1486
+ let scopeDescription = scope ? ` in scope "${scope}"` : ''
1487
+ console.warn(
1488
+ `[remix] Ignoring conflicting import map entry for "${specifier}"${scopeDescription}: ` +
1489
+ `${formatImportMapAddress(authoredAddress)} is already authored, but the discovered map points to ${formatImportMapAddress(address)}`,
1490
+ )
1491
+ }
1492
+
1493
+ return Object.keys(delta).length > 0 ? delta : undefined
1494
+ }
1495
+
1496
+ function getImportMapIntegrityDelta(
1497
+ authoredIntegrity: Map<string, string>,
1498
+ discoveredIntegrity: Record<string, string>,
1499
+ ): Record<string, string> | undefined {
1500
+ let delta: Record<string, string> = {}
1501
+ for (let [url, integrity] of Object.entries(discoveredIntegrity)) {
1502
+ if (!authoredIntegrity.has(url)) {
1503
+ delta[url] = integrity
1504
+ continue
1505
+ }
1506
+
1507
+ let authoredIntegrityValue = authoredIntegrity.get(url)
1508
+ if (authoredIntegrityValue === integrity) continue
1509
+
1510
+ console.warn(
1511
+ `[remix] Ignoring conflicting import map integrity entry for "${url}": ` +
1512
+ `"${authoredIntegrityValue}" is already authored, but the discovered map points to "${integrity}"`,
1513
+ )
1514
+ }
1515
+
1516
+ return Object.keys(delta).length > 0 ? delta : undefined
1517
+ }
1518
+
1519
+ function parseAuthoredImportMap(json: string): ImportMapData | null {
1520
+ let value: unknown
1521
+ try {
1522
+ value = JSON.parse(json)
1523
+ } catch {
1524
+ return null
1525
+ }
1526
+ if (!isObjectRecord(value)) return null
1527
+
1528
+ let importMap: ImportMapData = {}
1529
+ if (value.imports !== undefined) {
1530
+ if (!isObjectRecord(value.imports)) return null
1531
+ importMap.imports = parseAuthoredImportMapImports(value.imports)
1532
+ }
1533
+ if (value.scopes !== undefined) {
1534
+ if (!isObjectRecord(value.scopes)) return null
1535
+ let scopes: Array<[string, ImportMapImports]> = []
1536
+ for (let [scope, imports] of Object.entries(value.scopes)) {
1537
+ if (!isObjectRecord(imports)) continue
1538
+ scopes.push([scope, parseAuthoredImportMapImports(imports)])
1539
+ }
1540
+ importMap.scopes = Object.fromEntries(scopes)
1541
+ }
1542
+ if (value.integrity !== undefined) {
1543
+ if (!isObjectRecord(value.integrity)) return null
1544
+ importMap.integrity = Object.fromEntries(
1545
+ Object.entries(value.integrity).filter(
1546
+ (entry): entry is [string, string] => typeof entry[1] === 'string',
1547
+ ),
1548
+ )
1549
+ }
1550
+ return importMap
1551
+ }
1552
+
1553
+ function parseAuthoredImportMapImports(value: Record<string, unknown>): ImportMapImports {
1554
+ return Object.fromEntries(
1555
+ Object.entries(value).map(([specifier, address]) => [
1556
+ specifier,
1557
+ address === null || typeof address === 'string' ? address : null,
1558
+ ]),
1559
+ )
1560
+ }
1561
+
1562
+ function parseFrameworkImportMap(json: string): ImportMapData {
1563
+ let value: unknown
1564
+ try {
1565
+ value = JSON.parse(json)
1566
+ } catch {
1567
+ throw new Error('Invalid framework-owned import map in frame head')
1568
+ }
1569
+ if (!isImportMap(value)) {
1570
+ throw new Error('Invalid framework-owned import map in frame head')
1571
+ }
1572
+ return value
1573
+ }
1574
+
1575
+ function isImportMap(value: unknown): value is ImportMapData {
1576
+ if (!isObjectRecord(value)) return false
1577
+ if (value.imports !== undefined && !isImportMapImports(value.imports)) return false
1578
+ if (value.scopes !== undefined) {
1579
+ if (!isObjectRecord(value.scopes)) return false
1580
+ for (let imports of Object.values(value.scopes)) {
1581
+ if (!isImportMapImports(imports)) return false
1582
+ }
1583
+ }
1584
+ if (value.integrity !== undefined && !isImportMapIntegrity(value.integrity)) return false
1585
+ return true
1586
+ }
1587
+
1588
+ function isImportMapImports(value: unknown): value is ImportMapImports {
1589
+ if (!isObjectRecord(value)) return false
1590
+ return Object.values(value).every((address) => address === null || typeof address === 'string')
1591
+ }
1592
+
1593
+ function isImportMapIntegrity(value: unknown): value is Record<string, string> {
1594
+ if (!isObjectRecord(value)) return false
1595
+ return Object.values(value).every((integrity) => typeof integrity === 'string')
1596
+ }
1597
+
1598
+ function isObjectRecord(value: unknown): value is Record<string, unknown> {
1599
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
1600
+ }
1601
+
1602
+ function mergeImportMap(resources: ClientEntryHeadResources, source: ImportMapData): void {
1603
+ let target = (resources.importMap ??= {})
1604
+ if (source.imports) {
1605
+ target.imports ??= {}
1606
+ mergeImportMapImports(target.imports, source.imports)
1607
+ }
1608
+ if (source.scopes) {
1609
+ target.scopes ??= {}
1610
+ for (let [scope, imports] of Object.entries(source.scopes)) {
1611
+ let targetImports = (target.scopes[scope] ??= {})
1612
+ mergeImportMapImports(targetImports, imports, scope)
1613
+ }
1614
+ }
1615
+ if (source.integrity) {
1616
+ target.integrity ??= {}
1617
+ mergeImportMapIntegrity(target.integrity, source.integrity)
1618
+ }
1619
+ }
1620
+
1621
+ function mergeImportMapImports(
1622
+ target: ImportMapImports,
1623
+ source: ImportMapImports,
1624
+ scope?: string,
1625
+ ): void {
1626
+ for (let [specifier, address] of Object.entries(source)) {
1627
+ if (!Object.hasOwn(target, specifier)) {
1628
+ target[specifier] = address
1629
+ continue
1630
+ }
1631
+ if (target[specifier] !== address) {
1632
+ let scopeDescription = scope ? ` in scope "${scope}"` : ''
1633
+ throw new Error(
1634
+ `Conflicting framework import map entry for "${specifier}"${scopeDescription}`,
1635
+ )
1636
+ }
1637
+ }
1638
+ }
1639
+
1640
+ function mergeImportMapIntegrity(
1641
+ target: Record<string, string>,
1642
+ source: Record<string, string>,
1643
+ ): void {
1644
+ for (let [url, integrity] of Object.entries(source)) {
1645
+ if (!Object.hasOwn(target, url)) {
1646
+ target[url] = integrity
1647
+ continue
1648
+ }
1649
+ if (target[url] !== integrity) {
1650
+ throw new Error(`Conflicting framework import map integrity entry for "${url}"`)
1651
+ }
1652
+ }
1653
+ }
1654
+
1378
1655
  function escapeScriptJson(json: string): string {
1379
1656
  // Avoid prematurely closing the script tag when serialized data contains "</script>".
1380
1657
  return json.replace(/</g, '\\u003c')
1381
1658
  }
1382
1659
 
1660
+ function formatImportMapAddress(address: ImportMapAddress | undefined): string {
1661
+ return address === null ? 'null' : `"${address}"`
1662
+ }
1663
+
1383
1664
  // Frame styles work end-to-end when frame handlers use their own `renderToStream`:
1384
1665
  // the handler's `finalizeHtml` emits selector-addressed `<style>` tags in its HTML, and on the client,
1385
1666
  // the `adoptServerStyleTag` MutationObserver (stylesheet.ts) picks it up anywhere in the