@remix-run/ui 0.7.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 (91) hide show
  1. package/README.md +90 -14
  2. package/dist/animation/demos/drag-release.js +5 -7
  3. package/dist/animation/demos/drag-release.js.map +1 -1
  4. package/dist/index.d.ts +2 -2
  5. package/dist/index.js +1 -1
  6. package/dist/index.js.map +1 -1
  7. package/dist/runtime/component.d.ts +9 -4
  8. package/dist/runtime/component.js +38 -9
  9. package/dist/runtime/component.js.map +1 -1
  10. package/dist/runtime/core/mix.d.ts +21 -0
  11. package/dist/runtime/core/mix.js +128 -0
  12. package/dist/runtime/core/mix.js.map +1 -0
  13. package/dist/runtime/diff-dom.js +15 -16
  14. package/dist/runtime/diff-dom.js.map +1 -1
  15. package/dist/runtime/document-reload.d.ts +2 -0
  16. package/dist/runtime/document-reload.js +14 -0
  17. package/dist/runtime/document-reload.js.map +1 -0
  18. package/dist/runtime/dom.d.ts +21 -11
  19. package/dist/runtime/event-types.d.ts +14 -0
  20. package/dist/runtime/event-types.js +2 -0
  21. package/dist/runtime/event-types.js.map +1 -0
  22. package/dist/runtime/frame-resolution.js +8 -0
  23. package/dist/runtime/frame-resolution.js.map +1 -1
  24. package/dist/runtime/frame.d.ts +24 -6
  25. package/dist/runtime/frame.js +187 -60
  26. package/dist/runtime/frame.js.map +1 -1
  27. package/dist/runtime/import-map-manager.d.ts +8 -0
  28. package/dist/runtime/import-map-manager.js +295 -0
  29. package/dist/runtime/import-map-manager.js.map +1 -0
  30. package/dist/runtime/mixins/link-mixin.js +4 -4
  31. package/dist/runtime/mixins/link-mixin.js.map +1 -1
  32. package/dist/runtime/mixins/mixin.d.ts +9 -4
  33. package/dist/runtime/mixins/mixin.js +18 -132
  34. package/dist/runtime/mixins/mixin.js.map +1 -1
  35. package/dist/runtime/mixins/on-mixin.d.ts +1 -1
  36. package/dist/runtime/module-preloader.d.ts +2 -1
  37. package/dist/runtime/module-preloader.js +6 -5
  38. package/dist/runtime/module-preloader.js.map +1 -1
  39. package/dist/runtime/navigation.js +211 -34
  40. package/dist/runtime/navigation.js.map +1 -1
  41. package/dist/runtime/reconcile.js +18 -3
  42. package/dist/runtime/reconcile.js.map +1 -1
  43. package/dist/runtime/run.d.ts +3 -0
  44. package/dist/runtime/run.js +8 -4
  45. package/dist/runtime/run.js.map +1 -1
  46. package/dist/runtime/scheduler.js +37 -11
  47. package/dist/runtime/scheduler.js.map +1 -1
  48. package/dist/runtime/spa-response.d.ts +30 -0
  49. package/dist/runtime/spa-response.js +47 -0
  50. package/dist/runtime/spa-response.js.map +1 -0
  51. package/dist/runtime/to-vnode.js +1 -1
  52. package/dist/runtime/to-vnode.js.map +1 -1
  53. package/dist/runtime/typed-event-target.d.ts +0 -4
  54. package/dist/runtime/typed-event-target.js.map +1 -1
  55. package/dist/server/stream.d.ts +25 -2
  56. package/dist/server/stream.js +382 -171
  57. package/dist/server/stream.js.map +1 -1
  58. package/dist/style/stylesheet.js +2 -2
  59. package/dist/style/stylesheet.js.map +1 -1
  60. package/package.json +1 -1
  61. package/src/animation/demos/drag-release.ts +5 -7
  62. package/src/index.ts +2 -2
  63. package/src/runtime/component.ts +52 -14
  64. package/src/runtime/core/mix.ts +157 -0
  65. package/src/runtime/demos/readme.demo.tsx +7 -17
  66. package/src/runtime/diff-dom.ts +13 -15
  67. package/src/runtime/document-reload.ts +14 -0
  68. package/src/runtime/dom.ts +21 -11
  69. package/src/runtime/event-types.ts +27 -0
  70. package/src/runtime/frame-resolution.ts +9 -0
  71. package/src/runtime/frame.ts +216 -65
  72. package/src/runtime/import-map-manager.ts +369 -0
  73. package/src/runtime/mixins/link-mixin.ts +4 -4
  74. package/src/runtime/mixins/mixin.ts +44 -149
  75. package/src/runtime/mixins/on-mixin.ts +1 -1
  76. package/src/runtime/module-preloader.ts +9 -6
  77. package/src/runtime/navigation.ts +257 -42
  78. package/src/runtime/reconcile.ts +20 -4
  79. package/src/runtime/run.ts +13 -4
  80. package/src/runtime/scheduler.ts +53 -13
  81. package/src/runtime/spa-response.ts +56 -0
  82. package/src/runtime/to-vnode.ts +1 -1
  83. package/src/runtime/typed-event-target.ts +1 -6
  84. package/src/server/README.md +25 -5
  85. package/src/server/stream.ts +506 -202
  86. package/src/style/stylesheet.ts +3 -3
  87. package/src/test/utils.ts +1 -6
  88. package/dist/runtime/event-listeners.d.ts +0 -50
  89. package/dist/runtime/event-listeners.js +0 -31
  90. package/dist/runtime/event-listeners.js.map +0 -1
  91. package/src/runtime/event-listeners.ts +0 -171
@@ -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
  }
@@ -492,16 +562,19 @@ function buildFrameSegment(
492
562
  framePromise.catch(() => {})
493
563
  context.pendingFrames.push({ frameId, promise: framePromise })
494
564
  } else {
495
- seg.pending = Promise.resolve(
565
+ let framePromise = Promise.resolve(
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)
503
573
  }
504
574
  })
575
+ // An earlier blocking frame may reject before this promise is awaited.
576
+ framePromise.catch(() => {})
577
+ seg.pending = framePromise
505
578
  }
506
579
 
507
580
  return seg
@@ -535,6 +608,19 @@ function buildElementSegment(
535
608
  return staticSeg(`<${tag}${attrs}>${props.innerHTML}</${tag}>`)
536
609
  }
537
610
 
611
+ if (tag === 'script') {
612
+ if (typeof props.children === 'string') {
613
+ if (context.insideHead) {
614
+ collectAuthoredImportMap(context, tag, processedProps, props.children)
615
+ }
616
+ return staticSeg(`<${tag}${attrs}>${escapeScriptTextContent(props.children)}</${tag}>`)
617
+ }
618
+ if (props.children != null) {
619
+ console.error(new Error('script elements with children must have a single string child'))
620
+ }
621
+ return staticSeg(`<${tag}${attrs}></${tag}>`)
622
+ }
623
+
538
624
  let open = staticSeg(`<${tag}${attrs}>`)
539
625
  // Adjust svg context for children: foreignObject switches back to HTML
540
626
  let previousInsideSvg = context.insideSvg
@@ -552,6 +638,75 @@ function buildTextareaElementSegment(tag: string, props: any): Segment {
552
638
  return staticSeg(`<${tag}${attrs}>${escapeTextContent(String(value))}</${tag}>`)
553
639
  }
554
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
+
555
710
  function renderInputAttributes(props: any): string {
556
711
  let value =
557
712
  props.value === undefined && props.defaultValue !== undefined ? props.defaultValue : props.value
@@ -577,8 +732,11 @@ function buildHeadElementSegment(
577
732
  let attrs = renderAttributes(processedProps, false)
578
733
 
579
734
  let open = staticSeg(`<${tag}${attrs}>`)
735
+ let previousInsideHead = context.insideHead
736
+ context.insideHead = true
580
737
  let children =
581
738
  props.children != null ? buildSegment(props.children, context, frameState) : staticSeg('')
739
+ context.insideHead = previousInsideHead
582
740
  let close = staticSeg(`</${tag}>`)
583
741
 
584
742
  return compositeSeg([open, children, close])
@@ -616,74 +774,25 @@ function resolveSsrMixedProps(
616
774
  context: RenderContext,
617
775
  frameState: SsrFrameState,
618
776
  ): ElementProps {
619
- let descriptors = resolveSsrMixDescriptors(initialProps)
620
- if (descriptors.length === 0) return initialProps
621
-
622
- let composedProps = withoutSsrMix(initialProps)
623
- let mixinProps = withoutSsrMixinTreeProps(composedProps)
624
- let maxDescriptors = 1024
777
+ if (resolveMixDescriptors(initialProps).length === 0) return initialProps
625
778
 
626
- for (let index = 0; index < descriptors.length && index < maxDescriptors; index++) {
627
- let descriptor = descriptors[index]
779
+ return composeMixedProps(hostType, initialProps, (descriptor, _index, mixinProps) => {
628
780
  let runner = resolveSsrMixinRunner(hostType, descriptor, context, frameState)
629
- if (!runner) continue
630
-
631
- 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.
632
784
  try {
633
- result = runner(...descriptor.args, mixinProps)
785
+ return runner(...descriptor.args, mixinProps)
634
786
  } catch (error) {
635
787
  console.error(error)
636
- continue
637
- }
638
-
639
- if (!result) continue
640
- if (isSsrMixinElement(result)) continue
641
-
642
- let returnedDescriptors = resolveReturnedSsrMixDescriptors(result)
643
- if (returnedDescriptors) {
644
- for (let returned of returnedDescriptors) descriptors.push(returned)
645
- continue
646
- }
647
-
648
- if (!isRemixElement(result)) {
649
- console.error(new Error('mixins must return a remix element'))
650
- continue
651
- }
652
- let remixResult = result
653
-
654
- let resultType =
655
- typeof remixResult.type === 'string'
656
- ? remixResult.type
657
- : isSsrMixinElement(remixResult.type)
658
- ? remixResult.type.__rmxMixinElementType
659
- : null
660
-
661
- if (resultType !== hostType) {
662
- console.error(new Error('mixins must return an element with the same host type'))
663
- continue
664
- }
665
-
666
- if (remixResult.type !== resultType) {
667
- remixResult = { ...remixResult, type: resultType }
788
+ return undefined
668
789
  }
669
-
670
- let nextProps = sanitizeReturnedSsrMixinProps(remixResult.props as ElementProps)
671
- let nestedDescriptors = resolveSsrMixDescriptors(nextProps)
672
- for (let nested of nestedDescriptors) descriptors.push(nested)
673
- composedProps = { ...composedProps, ...withoutSsrMix(nextProps) }
674
- mixinProps = withoutSsrMixinTreeProps(composedProps)
675
- }
676
-
677
- let nextMix = initialProps.mix
678
- return {
679
- ...composedProps,
680
- ...(nextMix === undefined ? {} : { mix: nextMix }),
681
- }
790
+ })
682
791
  }
683
792
 
684
793
  function resolveSsrMixinRunner(
685
794
  hostType: string,
686
- descriptor: { type?: unknown; args?: unknown[] },
795
+ descriptor: { type?: unknown; args?: readonly unknown[] },
687
796
  context: RenderContext,
688
797
  frameState: SsrFrameState,
689
798
  ): ((...args: unknown[]) => unknown) | null {
@@ -759,93 +868,10 @@ function createSsrMixinHandle(
759
868
  }
760
869
  }
761
870
 
762
- function resolveSsrMixDescriptors(props: ElementProps): Array<{ type: any; args: unknown[] }> {
763
- let mix = props.mix
764
- if (!mix) return []
765
- if (Array.isArray(mix)) {
766
- if (mix.length === 0) return []
767
- return mix.filter(Boolean) as Array<{ type: any; args: unknown[] }>
768
- }
769
- return [mix] as Array<{ type: any; args: unknown[] }>
770
- }
771
-
772
- function withoutSsrMix(props: ElementProps): ElementProps {
773
- if (!('mix' in props)) return props
774
- let output = { ...props }
775
- delete output.mix
776
- return output
777
- }
778
-
779
- function withoutSsrMixinTreeProps(props: ElementProps): ElementProps {
780
- if (!('children' in props) && !('innerHTML' in props)) return props
781
- let output = { ...props }
782
- delete output.children
783
- delete output.innerHTML
784
- return output
785
- }
786
-
787
- function sanitizeReturnedSsrMixinProps(props: ElementProps): ElementProps {
788
- if (!('children' in props) && !('innerHTML' in props)) return props
789
- console.error(new Error('mixins must not return children or innerHTML'))
790
- return withoutSsrMixinTreeProps(props)
791
- }
792
-
793
- function resolveReturnedSsrMixDescriptors(
794
- value: unknown,
795
- ): Array<{ type: ElementFunction; args: unknown[] }> | null {
796
- let descriptors: Array<{ type: ElementFunction; args: unknown[] }> = []
797
- if (!collectReturnedSsrMixDescriptors(value, descriptors)) {
798
- return null
799
- }
800
-
801
- return descriptors
802
- }
803
-
804
- function collectReturnedSsrMixDescriptors(
805
- value: unknown,
806
- output: Array<{ type: ElementFunction; args: unknown[] }>,
807
- ): boolean {
808
- if (!value) {
809
- return true
810
- }
811
-
812
- if (Array.isArray(value)) {
813
- for (let item of value) {
814
- if (!collectReturnedSsrMixDescriptors(item, output)) {
815
- return false
816
- }
817
- }
818
- return true
819
- }
820
-
821
- if (!isSsrMixinDescriptor(value)) {
822
- return false
823
- }
824
-
825
- output.push(value)
826
- return true
827
- }
828
-
829
- function isSsrMixinElement(
830
- value: unknown,
831
- ): value is ((...args: unknown[]) => unknown) & { __rmxMixinElementType: string } {
832
- if (typeof value !== 'function') return false
833
- return '__rmxMixinElementType' in value
834
- }
835
-
836
871
  function isElementFunction(value: unknown): value is ElementFunction {
837
872
  return typeof value === 'function'
838
873
  }
839
874
 
840
- function isSsrMixinDescriptor(value: unknown): value is { type: ElementFunction; args: unknown[] } {
841
- if (!value || typeof value !== 'object' || isRemixElement(value)) {
842
- return false
843
- }
844
-
845
- let descriptor = value as { type?: unknown; args?: unknown }
846
- return typeof descriptor.type === 'function' && Array.isArray(descriptor.args)
847
- }
848
-
849
875
  function buildComponentSegment(
850
876
  type: ElementFunction,
851
877
  props: any,
@@ -1071,6 +1097,7 @@ async function resolveClientEntries(
1071
1097
  : resolveDefaultClientEntry(entryId, component)
1072
1098
  validateResolvedClientEntry(entryId, resolvedEntry)
1073
1099
  resolvedEntries.set(component, resolvedEntry)
1100
+ collectResolvedClientEntryResources(context.clientEntryHeadResources, resolvedEntry)
1074
1101
  }
1075
1102
 
1076
1103
  context.hydrationData.set(hydrationId, {
@@ -1078,15 +1105,23 @@ async function resolveClientEntries(
1078
1105
  moduleUrl: resolvedEntry.href,
1079
1106
  props,
1080
1107
  })
1081
-
1082
- for (let preload of resolvedEntry.preloads ?? []) {
1083
- context.modulePreloadTags.add(createModulePreloadTag(preload))
1084
- }
1085
1108
  }
1086
1109
 
1087
1110
  context.unresolvedHydrationData.clear()
1088
1111
  }
1089
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
+
1090
1125
  function validateResolvedClientEntry(
1091
1126
  entryId: string,
1092
1127
  resolvedEntry: ResolvedClientEntry,
@@ -1117,6 +1152,12 @@ function validateResolvedClientEntry(
1117
1152
  }
1118
1153
  }
1119
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
+ }
1120
1161
  }
1121
1162
 
1122
1163
  function validateClientEntriesForHydration(context: RenderContext): void {
@@ -1174,6 +1215,16 @@ function escapeTemplateContent(html: string): string {
1174
1215
  return html.replace(/<\/template/gi, '<\\/template')
1175
1216
  }
1176
1217
 
1218
+ const SCRIPT_TAG_PATTERN = /(<\/|<)(s)(cript)/gi
1219
+
1220
+ function escapeScriptTextContent(value: string): string {
1221
+ return value.replace(
1222
+ SCRIPT_TAG_PATTERN,
1223
+ (_match, prefix: string, firstLetter: string, suffix: string) =>
1224
+ `${prefix}${firstLetter === 's' ? '\\u0073' : '\\u0053'}${suffix}`,
1225
+ )
1226
+ }
1227
+
1177
1228
  function transformAttributeName(name: string, isSvg: boolean): string {
1178
1229
  return normalizeAttributeName(name, isSvg).attr
1179
1230
  }
@@ -1181,31 +1232,29 @@ function transformAttributeName(name: string, isSvg: boolean): string {
1181
1232
  function finalizeHtml(html: string, context: RenderContext): string {
1182
1233
  let hasHtmlRoot = context.flushKind === 'document'
1183
1234
 
1184
- let preloads = collectModulePreloadTags(context)
1235
+ let preloads = collectModulePreloadTags(context.clientEntryHeadResources)
1185
1236
  let styles = collectStyleTags(context)
1186
- if (preloads || styles) {
1187
- let headContent = preloads + styles
1188
- if (hasHtmlRoot) {
1189
- // For HTML root, inject into existing head or create one
1190
- let headCloseIndex = html.indexOf('</head>')
1191
- if (headCloseIndex !== -1) {
1192
- // Inject before existing </head>
1193
- 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)
1194
1248
  } else {
1195
- // No existing head, inject after <html>
1196
- let htmlOpenMatch = html.match(/<html[^>]*>/)
1197
- if (htmlOpenMatch) {
1198
- let insertIndex = htmlOpenMatch.index! + htmlOpenMatch[0].length
1199
- html =
1200
- html.slice(0, insertIndex) + `<head>${headContent}</head>` + html.slice(insertIndex)
1201
- }
1249
+ html = headContent + html
1202
1250
  }
1203
- } else {
1204
- // No HTML root, prepend head
1205
- html = `<head>${headContent}</head>${html}`
1206
1251
  }
1207
1252
  }
1208
1253
 
1254
+ if (!hasHtmlRoot && headContent) {
1255
+ html = `<head>${headContent}</head>${html}`
1256
+ }
1257
+
1209
1258
  // Append aggregated hydration/frame data script at the end
1210
1259
  let rmxData = buildRmxDataScript(context)
1211
1260
  if (rmxData) {
@@ -1232,48 +1281,61 @@ function finalizeHtml(html: string, context: RenderContext): string {
1232
1281
 
1233
1282
  const FRAME_HEAD_OPEN_TAG = '<head>'
1234
1283
  const FRAME_HEAD_CLOSE_TAG = '</head>'
1235
- const MARKED_MODULE_PRELOAD_START = '<link data-rmx rel="modulepreload" href="'
1284
+ const MARKED_MODULE_PRELOAD_START = '<link data-rmx-module-preload rel="modulepreload" href="'
1236
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>'
1237
1288
 
1238
1289
  function createModulePreloadTag(href: string): string {
1239
1290
  return `${MARKED_MODULE_PRELOAD_START}${escapeHtml(href)}${MODULE_PRELOAD_END}`
1240
1291
  }
1241
1292
 
1242
- function collectModulePreloadTags(context: RenderContext): string {
1243
- return Array.from(context.modulePreloadTags).join('')
1293
+ function collectModulePreloadTags(resources: ClientEntryHeadResources): string {
1294
+ return Array.from(resources.modulePreloadTags).join('')
1244
1295
  }
1245
1296
 
1246
- function hoistModulePreloadsFromFrameHead(html: string, context: RenderContext): string {
1297
+ function hoistClientEntryResourcesFromFrameHead(
1298
+ html: string,
1299
+ resources: ClientEntryHeadResources,
1300
+ ): string {
1247
1301
  if (!html.startsWith(FRAME_HEAD_OPEN_TAG)) return html
1248
1302
 
1249
- let tags: string[] = []
1250
- let remainingHeadStart = FRAME_HEAD_OPEN_TAG.length
1251
- while (html.startsWith(MARKED_MODULE_PRELOAD_START, remainingHeadStart)) {
1252
- let tagEnd = html.indexOf(
1253
- MODULE_PRELOAD_END,
1254
- remainingHeadStart + MARKED_MODULE_PRELOAD_START.length,
1255
- )
1256
- 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
1257
1305
 
1258
- tagEnd += MODULE_PRELOAD_END.length
1259
- tags.push(html.slice(remainingHeadStart, tagEnd))
1260
- 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
1261
1315
  }
1262
1316
 
1263
- 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
+ }
1264
1324
 
1265
- let headClose = html.indexOf(FRAME_HEAD_CLOSE_TAG, remainingHeadStart)
1266
- if (headClose === -1) return html
1325
+ if (preloadTags.length === 0 && importMaps.length === 0) return html
1267
1326
 
1268
- for (let tag of tags) {
1269
- context.modulePreloadTags.add(tag)
1327
+ for (let tag of preloadTags) {
1328
+ resources.modulePreloadTags.add(tag)
1270
1329
  }
1271
-
1272
- if (remainingHeadStart === headClose) {
1273
- return html.slice(headClose + FRAME_HEAD_CLOSE_TAG.length)
1330
+ for (let importMap of importMaps) {
1331
+ mergeImportMap(resources, importMap)
1274
1332
  }
1275
1333
 
1276
- 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}`
1277
1339
  }
1278
1340
 
1279
1341
  function processStyleProps(props: any): any {
@@ -1322,12 +1384,12 @@ function renderStyleTag(
1322
1384
  ): string {
1323
1385
  let wrappedCss = wrapStyleForLayer(selector, css, layer)
1324
1386
  if (!wrappedCss) return ''
1325
- return `<style data-rmx="${escapeHtml(selector)}">${escapeStyleText(wrappedCss)}</style>`
1387
+ return `<style data-rmx-style="${escapeHtml(selector)}">${escapeStyleText(wrappedCss)}</style>`
1326
1388
  }
1327
1389
 
1328
1390
  function escapeStyleText(css: string): string {
1329
- // A literal "</style" closes an HTML style element even when it appears inside a CSS string.
1330
- return css.replace(/</g, '\\3C ')
1391
+ // Only neutralize literal style end tags. Escaping every '<' breaks valid range media queries.
1392
+ return css.replace(/<\/style/gi, '\\3C/style')
1331
1393
  }
1332
1394
 
1333
1395
  function buildRmxDataScript(context: RenderContext): string {
@@ -1352,11 +1414,253 @@ function buildRmxDataScript(context: RenderContext): string {
1352
1414
  return `<script type="application/json" id="rmx-data">${serializedData}</script>`
1353
1415
  }
1354
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
+
1355
1655
  function escapeScriptJson(json: string): string {
1356
1656
  // Avoid prematurely closing the script tag when serialized data contains "</script>".
1357
1657
  return json.replace(/</g, '\\u003c')
1358
1658
  }
1359
1659
 
1660
+ function formatImportMapAddress(address: ImportMapAddress | undefined): string {
1661
+ return address === null ? 'null' : `"${address}"`
1662
+ }
1663
+
1360
1664
  // Frame styles work end-to-end when frame handlers use their own `renderToStream`:
1361
1665
  // the handler's `finalizeHtml` emits selector-addressed `<style>` tags in its HTML, and on the client,
1362
1666
  // the `adoptServerStyleTag` MutationObserver (stylesheet.ts) picks it up anywhere in the