@remix-run/ui 0.1.2 → 0.2.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 (109) hide show
  1. package/README.md +9 -7
  2. package/dist/animation/spring.d.ts +6 -35
  3. package/dist/animation/spring.js +9 -8
  4. package/dist/animation/spring.js.map +1 -1
  5. package/dist/components/accordion/accordion.d.ts +6 -6
  6. package/dist/components/accordion/accordion.js.map +1 -1
  7. package/dist/components/breadcrumbs/breadcrumbs.d.ts +1 -1
  8. package/dist/components/button/button.d.ts +4 -4
  9. package/dist/components/button/button.js +3 -1
  10. package/dist/components/button/button.js.map +1 -1
  11. package/dist/components/combobox/combobox.d.ts +10 -10
  12. package/dist/components/combobox/combobox.js.map +1 -1
  13. package/dist/components/listbox/listbox.d.ts +3 -3
  14. package/dist/components/listbox/listbox.js.map +1 -1
  15. package/dist/components/menu/menu.d.ts +12 -12
  16. package/dist/components/menu/menu.js.map +1 -1
  17. package/dist/components/popover/popover.d.ts +5 -5
  18. package/dist/components/popover/popover.js.map +1 -1
  19. package/dist/components/select/select.d.ts +10 -10
  20. package/dist/components/select/select.js.map +1 -1
  21. package/dist/components/tabs/tabs.d.ts +3 -3
  22. package/dist/index.d.ts +1 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/interactions/keydown/keydown.d.ts +1 -1
  25. package/dist/interactions/outside-click/outside-click-mixin.d.ts +2 -1
  26. package/dist/interactions/typeahead/typeahead-mixin.d.ts +3 -1
  27. package/dist/runtime/component.d.ts +18 -15
  28. package/dist/runtime/component.js +14 -5
  29. package/dist/runtime/component.js.map +1 -1
  30. package/dist/runtime/core/attributes.d.ts +3 -1
  31. package/dist/runtime/core/attributes.js +19 -1
  32. package/dist/runtime/core/attributes.js.map +1 -1
  33. package/dist/runtime/core/props.js +6 -5
  34. package/dist/runtime/core/props.js.map +1 -1
  35. package/dist/runtime/dom.d.ts +16 -9
  36. package/dist/runtime/jsx.d.ts +1 -1
  37. package/dist/runtime/mixins/link-mixin.d.ts +5 -1
  38. package/dist/runtime/mixins/link-mixin.js.map +1 -1
  39. package/dist/runtime/mixins/mixin.d.ts +2 -1
  40. package/dist/runtime/mixins/mixin.js.map +1 -1
  41. package/dist/runtime/mixins/ref-mixin.d.ts +2 -1
  42. package/dist/runtime/mixins/ref-mixin.js.map +1 -1
  43. package/dist/runtime/reconcile.d.ts +1 -1
  44. package/dist/runtime/reconcile.js +19 -5
  45. package/dist/runtime/reconcile.js.map +1 -1
  46. package/dist/runtime/scheduler.d.ts +10 -10
  47. package/dist/runtime/scheduler.js +1 -1
  48. package/dist/runtime/scheduler.js.map +1 -1
  49. package/dist/runtime/svg-attributes.js +1 -0
  50. package/dist/runtime/svg-attributes.js.map +1 -1
  51. package/dist/runtime/to-vnode.js +2 -2
  52. package/dist/runtime/to-vnode.js.map +1 -1
  53. package/dist/runtime/vnode.d.ts +6 -1
  54. package/dist/runtime/vnode.js +4 -0
  55. package/dist/runtime/vnode.js.map +1 -1
  56. package/dist/server/stream.d.ts +2 -0
  57. package/dist/server/stream.js +79 -9
  58. package/dist/server/stream.js.map +1 -1
  59. package/dist/style/css-mixin.d.ts +2 -1
  60. package/dist/style/css-mixin.js.map +1 -1
  61. package/dist/style/index.d.ts +1 -1
  62. package/dist/style/stylesheet.d.ts +13 -13
  63. package/dist/style/stylesheet.js.map +1 -1
  64. package/dist/theme/contract.d.ts +24 -222
  65. package/dist/theme/contract.js +2 -2
  66. package/dist/theme/contract.js.map +1 -1
  67. package/dist/theme/glyph-contract.d.ts +1 -3
  68. package/dist/theme/glyph-contract.js.map +1 -1
  69. package/dist/theme/presets/rmx-01/index.d.ts +4 -2
  70. package/dist/theme/presets/rmx-01/index.js.map +1 -1
  71. package/dist/utils/scroll-lock.d.ts +3 -3
  72. package/dist/utils/scroll-lock.js.map +1 -1
  73. package/package.json +3 -3
  74. package/src/animation/README.md +7 -5
  75. package/src/animation/spring.ts +21 -9
  76. package/src/components/accordion/accordion.tsx +7 -5
  77. package/src/components/anchor/anchor.ts +1 -1
  78. package/src/components/breadcrumbs/breadcrumbs.tsx +1 -1
  79. package/src/components/button/button.tsx +30 -18
  80. package/src/components/combobox/README.md +1 -1
  81. package/src/components/combobox/combobox.tsx +30 -12
  82. package/src/components/glyph/glyph.tsx +2 -2
  83. package/src/components/listbox/listbox.ts +88 -83
  84. package/src/components/menu/menu.tsx +27 -15
  85. package/src/components/popover/popover.ts +90 -79
  86. package/src/components/select/select.tsx +26 -9
  87. package/src/index.ts +1 -0
  88. package/src/runtime/component.ts +34 -12
  89. package/src/runtime/core/attributes.ts +21 -0
  90. package/src/runtime/core/props.ts +6 -4
  91. package/src/runtime/dom.ts +16 -9
  92. package/src/runtime/jsx.ts +1 -1
  93. package/src/runtime/mixins/link-mixin.ts +88 -86
  94. package/src/runtime/mixins/mixin.ts +9 -1
  95. package/src/runtime/mixins/ref-mixin.ts +4 -5
  96. package/src/runtime/reconcile.ts +23 -14
  97. package/src/runtime/scheduler.ts +20 -20
  98. package/src/runtime/svg-attributes.ts +1 -0
  99. package/src/runtime/to-vnode.ts +2 -2
  100. package/src/runtime/vnode.ts +10 -0
  101. package/src/server/README.md +4 -2
  102. package/src/server/stream.ts +77 -8
  103. package/src/style/css-mixin.ts +6 -1
  104. package/src/style/index.ts +1 -2
  105. package/src/style/stylesheet.ts +14 -2
  106. package/src/theme/contract.ts +37 -5
  107. package/src/theme/glyph-contract.ts +3 -1
  108. package/src/theme/presets/rmx-01/index.ts +4 -2
  109. package/src/utils/scroll-lock.ts +7 -3
@@ -1,4 +1,4 @@
1
- import { createMixin } from './mixin.ts'
1
+ import { createMixin, type MixinFactory } from './mixin.ts'
2
2
  import type { ElementProps } from '../jsx.ts'
3
3
 
4
4
  /**
@@ -9,8 +9,8 @@ export type RefCallback<node extends EventTarget> = (node: node, signal: AbortSi
9
9
  /**
10
10
  * Calls a callback when an element is inserted and aborts it when removed.
11
11
  */
12
- export const ref = createMixin<Element, [callback: RefCallback<Element>], ElementProps>(
13
- (handle) => {
12
+ export const ref: MixinFactory<Element, [callback: RefCallback<Element>], ElementProps> =
13
+ createMixin<Element, [callback: RefCallback<Element>], ElementProps>((handle) => {
14
14
  let controller: AbortController | undefined
15
15
 
16
16
  handle.addEventListener('insert', (event) => {
@@ -28,5 +28,4 @@ export const ref = createMixin<Element, [callback: RefCallback<Element>], Elemen
28
28
  callback = nextCallback
29
29
  return handle.element
30
30
  }
31
- },
32
- )
31
+ })
@@ -20,6 +20,7 @@ import {
20
20
  isCommittedTextNode,
21
21
  isFragmentNode,
22
22
  isHostNode,
23
+ isNonRenderNode,
23
24
  isTextNode,
24
25
  findContextFromAncestry,
25
26
  } from './vnode.ts'
@@ -421,6 +422,10 @@ export function diffVNodes(
421
422
  return rootCursor
422
423
  }
423
424
 
425
+ if (isNonRenderNode(curr) && isNonRenderNode(next)) {
426
+ return rootCursor
427
+ }
428
+
424
429
  if (isCommittedTextNode(curr) && isTextNode(next)) {
425
430
  diffText(curr, next, vParent)
426
431
  return rootCursor
@@ -444,7 +449,7 @@ export function diffVNodes(
444
449
  frame,
445
450
  scheduler,
446
451
  styles,
447
- vParent,
452
+ next,
448
453
  rootTarget,
449
454
  undefined,
450
455
  anchor,
@@ -484,7 +489,7 @@ function replace(
484
489
  return
485
490
  }
486
491
 
487
- let replacementAnchor = findNextSiblingDomAnchor(curr, vParent) ?? anchor
492
+ let replacementAnchor = findNextSiblingDomAnchor(curr) ?? anchor
488
493
  remove(curr, domParent, scheduler, styles)
489
494
  insert(next, domParent, frame, scheduler, styles, vParent, rootTarget, replacementAnchor)
490
495
  }
@@ -709,6 +714,10 @@ function insert(
709
714
  ? (dom: Node) => domParent.insertBefore(dom, anchor)
710
715
  : (dom: Node) => domParent.appendChild(dom)
711
716
 
717
+ if (isNonRenderNode(node)) {
718
+ return cursor
719
+ }
720
+
712
721
  if (isTextNode(node)) {
713
722
  if (cursor instanceof Text) {
714
723
  node._parent = vParent
@@ -878,17 +887,7 @@ function insert(
878
887
  if (isFragmentNode(node)) {
879
888
  // Insert fragment children in order before the same anchor
880
889
  for (let child of node._children) {
881
- cursor = insert(
882
- child,
883
- domParent,
884
- frame,
885
- scheduler,
886
- styles,
887
- vParent,
888
- rootTarget,
889
- anchor,
890
- cursor,
891
- )
890
+ cursor = insert(child, domParent, frame, scheduler, styles, node, rootTarget, anchor, cursor)
892
891
  }
893
892
  return cursor
894
893
  }
@@ -1275,6 +1274,8 @@ export function renderComponent(
1275
1274
  anchor?: Node,
1276
1275
  cursor?: Node | null,
1277
1276
  ): Node | null | undefined {
1277
+ if (handle.isRemoved()) return cursor
1278
+
1278
1279
  let [element, tasks] = handle.render(next.props)
1279
1280
  let content = toVNode(element)
1280
1281
  let newCursor = diffVNodes(
@@ -2020,15 +2021,23 @@ function shouldDispatchInlineMixinLifecycle(node: Node): boolean {
2020
2021
  return true
2021
2022
  }
2022
2023
 
2023
- export function findNextSiblingDomAnchor(curr: VNode, vParent?: VNode): Node | null {
2024
+ export function findNextSiblingDomAnchor(curr: VNode): Node | null {
2025
+ let vParent = curr._parent
2024
2026
  if (!vParent || !Array.isArray(vParent._children)) return null
2025
2027
  let children = vParent._children
2028
+ if (children.length === 0) return findNextSiblingDomAnchor(vParent)
2029
+
2026
2030
  let idx = children.indexOf(curr)
2027
2031
  if (idx === -1) return null
2028
2032
  for (let i = idx + 1; i < children.length; i++) {
2029
2033
  let dom = findFirstDomAnchor(children[i])
2030
2034
  if (dom) return dom
2031
2035
  }
2036
+
2037
+ if (isFragmentNode(vParent)) {
2038
+ return findNextSiblingDomAnchor(vParent)
2039
+ }
2040
+
2032
2041
  return null
2033
2042
  }
2034
2043
 
@@ -17,7 +17,23 @@ type SchedulerPhaseListener = EventListenerOrEventListenerObject | null
17
17
  /**
18
18
  * Scheduler API used by the reconciler and frame runtime.
19
19
  */
20
- export type Scheduler = ReturnType<typeof createScheduler>
20
+ export interface Scheduler {
21
+ enqueue(vnode: CommittedComponentNode, domParent: ParentNode): void
22
+ enqueueWork(newTasks: EmptyFn[]): void
23
+ enqueueCommitPhase(newTasks: EmptyFn[]): void
24
+ enqueueTasks(newTasks: EmptyFn[]): void
25
+ addEventListener(
26
+ type: SchedulerPhaseType,
27
+ listener: SchedulerPhaseListener,
28
+ options?: AddEventListenerOptions | boolean,
29
+ ): void
30
+ removeEventListener(
31
+ type: SchedulerPhaseType,
32
+ listener: SchedulerPhaseListener,
33
+ options?: EventListenerOptions | boolean,
34
+ ): void
35
+ dequeue(): void
36
+ }
21
37
 
22
38
  // Protect against infinite cascading updates (e.g. handle.update() during render)
23
39
  const MAX_CASCADING_UPDATES = 50
@@ -38,7 +54,7 @@ export function createScheduler(
38
54
  doc: Document,
39
55
  rootTarget: EventTarget,
40
56
  styles: StyleManager = defaultStyleManager,
41
- ) {
57
+ ): Scheduler {
42
58
  let documentState = createDocumentState(doc)
43
59
  let scheduled = new Map<CommittedComponentNode, ParentNode>()
44
60
  let workTasks: EmptyFn[] = []
@@ -53,23 +69,7 @@ export function createScheduler(
53
69
  beforeUpdate: 0,
54
70
  commit: 0,
55
71
  }
56
- let scheduler: {
57
- enqueue(vnode: CommittedComponentNode, domParent: ParentNode): void
58
- enqueueWork(newTasks: EmptyFn[]): void
59
- enqueueCommitPhase(newTasks: EmptyFn[]): void
60
- enqueueTasks(newTasks: EmptyFn[]): void
61
- addEventListener(
62
- type: SchedulerPhaseType,
63
- listener: SchedulerPhaseListener,
64
- options?: AddEventListenerOptions | boolean,
65
- ): void
66
- removeEventListener(
67
- type: SchedulerPhaseType,
68
- listener: SchedulerPhaseListener,
69
- options?: EventListenerOptions | boolean,
70
- ): void
71
- dequeue(): void
72
- }
72
+ let scheduler: Scheduler
73
73
 
74
74
  function dispatchError(error: unknown) {
75
75
  console.error(error)
@@ -137,7 +137,7 @@ export function createScheduler(
137
137
  // Needed for fragment self-updates that add children - without this, new children
138
138
  // would be appended after siblings. The keyed diff has placement logic, but unkeyed
139
139
  // diff relies on anchor for correct positioning.
140
- let anchor = findNextSiblingDomAnchor(vnode, vParent) || undefined
140
+ let anchor = findNextSiblingDomAnchor(vnode) || undefined
141
141
  try {
142
142
  let updateStyles = getFrameStyleManager(vnode)
143
143
  renderComponent(
@@ -5,6 +5,7 @@ const CANONICAL_CAMEL_SVG_ATTRS = new Set([
5
5
  'accentHeight',
6
6
  'attributeName',
7
7
  'attributeType',
8
+ 'autoReverse',
8
9
  'baseFrequency',
9
10
  'baseProfile',
10
11
  'calcMode',
@@ -2,7 +2,7 @@ import { Fragment } from './component.ts'
2
2
  import { invariant } from './invariant.ts'
3
3
  import { isEmptyChild, isPrimitiveChild, normalizeChildren } from './core/children.ts'
4
4
  import type { RemixElement, RemixNode } from './jsx.ts'
5
- import { isRemixElement, TEXT_NODE, type VNode } from './vnode.ts'
5
+ import { isRemixElement, NON_RENDER_NODE, TEXT_NODE, type VNode } from './vnode.ts'
6
6
 
7
7
  function flatMapChildrenToVNodes(node: RemixElement): VNode[] {
8
8
  if (!('children' in node.props)) return []
@@ -21,7 +21,7 @@ function flattenChildrenToVNodes(nodes: RemixNode[], out: VNode[]): void {
21
21
 
22
22
  export function toVNode(node: RemixNode): VNode {
23
23
  if (isEmptyChild(node)) {
24
- return { type: TEXT_NODE, _text: '' }
24
+ return { type: NON_RENDER_NODE }
25
25
  }
26
26
 
27
27
  if (isPrimitiveChild(node)) {
@@ -6,6 +6,7 @@ import type { ElementProps, RemixNode } from './jsx.ts'
6
6
  export { isRemixElement }
7
7
 
8
8
  export const TEXT_NODE = Symbol('TEXT_NODE')
9
+ export const NON_RENDER_NODE = Symbol('NON_RENDER_NODE')
9
10
  export const ROOT_VNODE = Symbol('ROOT_VNODE')
10
11
 
11
12
  export type VNodeType =
@@ -13,6 +14,7 @@ export type VNodeType =
13
14
  | string // host element
14
15
  | Function // component
15
16
  | typeof TEXT_NODE
17
+ | typeof NON_RENDER_NODE
16
18
  | typeof Fragment
17
19
  | typeof Frame
18
20
 
@@ -66,6 +68,10 @@ export type TextNode = VNode & {
66
68
  _text: string
67
69
  }
68
70
 
71
+ export type NonRenderNode = VNode & {
72
+ type: typeof NON_RENDER_NODE
73
+ }
74
+
69
75
  export type CommittedTextNode = TextNode & {
70
76
  _dom: Text
71
77
  }
@@ -102,6 +108,10 @@ export function isTextNode(node: VNode): node is TextNode {
102
108
  return node.type === TEXT_NODE
103
109
  }
104
110
 
111
+ export function isNonRenderNode(node: VNode): node is NonRenderNode {
112
+ return node.type === NON_RENDER_NODE
113
+ }
114
+
105
115
  export function isCommittedTextNode(node: VNode): node is CommittedTextNode {
106
116
  return isTextNode(node) && node._dom instanceof Text
107
117
  }
@@ -1,6 +1,6 @@
1
1
  # Server
2
2
 
3
- Remix Component can render to HTML on the server using two APIs:
3
+ Remix UI can render to HTML on the server using two APIs:
4
4
 
5
5
  - `renderToString` - Returns a complete HTML string. Simple, but buffers the entire response.
6
6
  - `renderToStream` - Returns a `ReadableStream<Uint8Array>`. Sends the initial HTML immediately and streams frame content as it resolves.
@@ -26,6 +26,7 @@ import { renderToStream } from 'remix/ui/server'
26
26
 
27
27
  let stream = renderToStream(<App />, {
28
28
  frameSrc: request.url,
29
+ signal: request.signal,
29
30
  resolveFrame(src, _target, context) {
30
31
  let frameUrl = new URL(src, context?.currentFrameSrc ?? request.url)
31
32
  return fetchHtml(frameUrl)
@@ -44,6 +45,7 @@ return new Response(stream, {
44
45
 
45
46
  - **`frameSrc`** - Seeds SSR frame state for the current render. When provided, server-rendered components can read `handle.frame.src` and `handle.frames.top.src` during SSR.
46
47
  - **`topFrameSrc`** - Overrides the root frame URL used for `handle.frames.top.src`. This is mainly useful when calling `renderToStream()` from inside `resolveFrame()` for a nested frame render.
48
+ - **`signal`** - Cancels pending server rendering work. Pass `request.signal` so client disconnects can stop unresolved frame work without invoking `onError` for the disconnect itself.
47
49
  - **`resolveFrame(src, target, context)`** - Called when a `<Frame>` needs its content. Return a string of HTML, a `ReadableStream<Uint8Array>`, or a promise of either. `context.currentFrameSrc` is the URL for the frame that contains the `<Frame>`, and `context.topFrameSrc` is the outer document URL. Required if your component tree contains `<Frame>` elements.
48
50
  - **`onError(error)`** - Called when a rendering error occurs. If not provided, the stream rejects with the error.
49
51
 
@@ -80,7 +82,7 @@ function ProductPage() {
80
82
 
81
83
  ## CSS
82
84
 
83
- Components using the `css` prop have their styles collected during rendering and emitted as a single `<style>` tag in the `<head>`. No client-side style injection is needed for server-rendered content.
85
+ Components using the `css(...)` mixin through `mix` have their styles collected during rendering and emitted as a single `<style>` tag in the `<head>`. No client-side style injection is needed for server-rendered content.
84
86
 
85
87
  ## See Also
86
88
 
@@ -7,6 +7,7 @@ import {
7
7
  SELF_CLOSING_TAGS,
8
8
  normalizeAttributeName,
9
9
  serializeStyleObject,
10
+ shouldStringifyBooleanAttribute,
10
11
  } from '../runtime/core/attributes.ts'
11
12
  import { appendFlushMarker, type FlushKind, stripFlushMarkers } from '../runtime/stream-protocol.ts'
12
13
  import { REMIX_UI_STYLE_LAYER } from '../style/layers.ts'
@@ -31,6 +32,8 @@ export interface RenderToStreamOptions {
31
32
  frameSrc?: string | URL
32
33
  /** Source URL for the top-level frame in nested frame renders. */
33
34
  topFrameSrc?: string | URL
35
+ /** Signal that cancels pending server rendering work. */
36
+ signal?: AbortSignal
34
37
  /** Error hook invoked when rendering work throws. */
35
38
  onError?: (error: unknown) => void
36
39
  /** Callback used to resolve nested frame content during streaming SSR. */
@@ -97,6 +100,7 @@ interface RenderContext {
97
100
  unresolvedHydrationData: Map<string, UnresolvedHydrationData>
98
101
  frameData: Map<string, FrameData>
99
102
  blockingFrameTails: ReadableStream<Uint8Array>[]
103
+ signal: AbortSignal
100
104
  flushKind: FlushKind
101
105
  serverIdScope: string
102
106
  serverIdCounter: number
@@ -192,6 +196,7 @@ export function renderToStream(
192
196
  let currentFrameSrc = normalizeFrameSrc(options?.frameSrc ?? options?.topFrameSrc)
193
197
  let topFrameSrc = normalizeFrameSrc(options?.topFrameSrc ?? currentFrameSrc)
194
198
  let rootFrameState = createSsrFrameState(currentFrameSrc, topFrameSrc)
199
+ let renderAbortController = new AbortController()
195
200
 
196
201
  let context: RenderContext = {
197
202
  insideSvg: false,
@@ -204,21 +209,38 @@ export function renderToStream(
204
209
  unresolvedHydrationData: new Map(),
205
210
  frameData: new Map(),
206
211
  blockingFrameTails: [],
212
+ signal: renderAbortController.signal,
207
213
  flushKind: 'fragment',
208
214
  serverIdScope: crypto.randomUUID().slice(0, 8),
209
215
  serverIdCounter: 0,
210
216
  }
211
217
 
218
+ function cancel(reason: unknown): void {
219
+ if (!renderAbortController.signal.aborted) {
220
+ renderAbortController.abort(reason)
221
+ }
222
+ }
223
+
224
+ let signal = options?.signal
225
+ if (signal?.aborted) {
226
+ cancel(signal.reason)
227
+ } else {
228
+ signal?.addEventListener('abort', () => cancel(signal.reason), { once: true })
229
+ }
230
+
212
231
  return new ReadableStream({
213
232
  async start(controller) {
214
233
  try {
215
234
  let root = buildSegment(node, context, rootFrameState)
216
235
  await resolveBlocking(root)
236
+ if (closeIfCancelled(controller, context)) return
217
237
  await resolveClientEntries(context, options?.resolveClientEntry)
238
+ if (closeIfCancelled(controller, context)) return
218
239
  validateClientEntriesForHydration(context)
219
240
  let html = serializeSegment(root)
220
241
  let finalHtml = finalizeHtml(html, context)
221
242
  let bytes = encoder.encode(appendFlushMarker(finalHtml, context.flushKind))
243
+ if (closeIfCancelled(controller, context)) return
222
244
  controller.enqueue(bytes)
223
245
 
224
246
  // If we have any tails from blocking frame streams, stream them now.
@@ -226,7 +248,7 @@ export function renderToStream(
226
248
  // that must come after the initial document chunk.
227
249
  let tailPromise =
228
250
  context.blockingFrameTails.length > 0
229
- ? streamByteStreams(context.blockingFrameTails, controller, context.onError)
251
+ ? streamByteStreams(context.blockingFrameTails, controller, context)
230
252
  : Promise.resolve()
231
253
 
232
254
  // If we have pending non-blocking frames, stream them as they resolve
@@ -237,15 +259,44 @@ export function renderToStream(
237
259
 
238
260
  await Promise.all([tailPromise, pendingPromise])
239
261
 
262
+ if (closeIfCancelled(controller, context)) return
240
263
  controller.close()
241
264
  } catch (error) {
265
+ if (isSignalAbortError(context.signal, error)) {
266
+ closeStream(controller)
267
+ return
268
+ }
242
269
  onError(error)
243
270
  controller.error(error)
244
271
  }
245
272
  },
273
+ cancel(reason) {
274
+ cancel(reason)
275
+ },
246
276
  })
247
277
  }
248
278
 
279
+ function isSignalAbortError(signal: AbortSignal, error: unknown): boolean {
280
+ return signal.aborted && error === signal.reason
281
+ }
282
+
283
+ function closeIfCancelled(
284
+ controller: ReadableStreamDefaultController,
285
+ context: RenderContext,
286
+ ): boolean {
287
+ if (!context.signal.aborted) return false
288
+ closeStream(controller)
289
+ return true
290
+ }
291
+
292
+ function closeStream(controller: ReadableStreamDefaultController): void {
293
+ try {
294
+ controller.close()
295
+ } catch {
296
+ // The consumer may already have cancelled the stream.
297
+ }
298
+ }
299
+
249
300
  function defaultResolveFrame(): never {
250
301
  throw new Error('No resolveFrame provided')
251
302
  }
@@ -428,6 +479,9 @@ function buildFrameSegment(props: any, context: RenderContext, frameState: SsrFr
428
479
  let framePromise = Promise.resolve(
429
480
  context.resolveFrame(props.src, props.name, resolveFrameContext),
430
481
  ).then(async (resolved) => resolveFrameHtml(resolved))
482
+ // The response stream can be cancelled before pending frames are drained.
483
+ // Keep the promise observed so request aborts don't become unhandled.
484
+ framePromise.catch(() => {})
431
485
  context.pendingFrames.push({ frameId, promise: framePromise })
432
486
  } else {
433
487
  seg.pending = Promise.resolve(
@@ -529,11 +583,15 @@ function renderAttributes(props: any, isSvg: boolean, excludedProps?: Set<string
529
583
  if (excludedProps?.has(key)) continue
530
584
 
531
585
  let value = props[key]
532
- if (value === undefined || value === null || value === false) continue
533
-
534
586
  let attrName = transformAttributeName(key, isSvg)
587
+ let shouldStringifyBoolean = shouldStringifyBooleanAttribute(attrName)
588
+ if (value === undefined || value === null || (value === false && !shouldStringifyBoolean)) {
589
+ continue
590
+ }
535
591
 
536
- if (value === true) {
592
+ if (typeof value === 'boolean' && shouldStringifyBoolean) {
593
+ attrs += ` ${attrName}="${escapeHtml(String(value))}"`
594
+ } else if (value === true) {
537
595
  attrs += ` ${attrName}`
538
596
  } else {
539
597
  attrs += ` ${attrName}="${escapeHtml(String(value))}"`
@@ -1248,26 +1306,33 @@ async function streamPendingFrames(
1248
1306
  let processedFrames = new Set<string>()
1249
1307
 
1250
1308
  while (true) {
1309
+ if (context.signal.aborted) break
1310
+
1251
1311
  let batch = context.pendingFrames.filter(({ frameId }) => !processedFrames.has(frameId))
1252
1312
  if (batch.length === 0) break
1253
1313
 
1254
1314
  await Promise.all(
1255
1315
  batch.map(async ({ frameId, promise }) => {
1316
+ if (context.signal.aborted) return
1256
1317
  processedFrames.add(frameId)
1257
1318
  try {
1258
1319
  let { html, tail } = await promise
1320
+ if (context.signal.aborted) return
1259
1321
  html = dedupeServerStyleTagsInHtml(html, context.emittedStyles)
1260
1322
 
1261
1323
  // Stream as a template element (first chunk only)
1262
1324
  let templateHtml = `<template id="${frameId}">${escapeTemplateContent(html)}</template>`
1325
+ if (context.signal.aborted) return
1263
1326
  controller.enqueue(encoder.encode(templateHtml))
1264
1327
 
1265
1328
  // Forward any additional chunks from a stream-valued resolveFrame result.
1266
1329
  if (tail) {
1267
- await streamByteStreams([tail], controller, context.onError)
1330
+ await streamByteStreams([tail], controller, context)
1268
1331
  }
1269
1332
  } catch (error) {
1270
- context.onError(error)
1333
+ if (!isSignalAbortError(context.signal, error)) {
1334
+ context.onError(error)
1335
+ }
1271
1336
  }
1272
1337
  }),
1273
1338
  )
@@ -1277,19 +1342,23 @@ async function streamPendingFrames(
1277
1342
  async function streamByteStreams(
1278
1343
  streams: ReadableStream<Uint8Array>[],
1279
1344
  controller: ReadableStreamDefaultController,
1280
- onError: (error: unknown) => void,
1345
+ context: RenderContext,
1281
1346
  ): Promise<void> {
1282
1347
  await Promise.all(
1283
1348
  streams.map(async (stream) => {
1284
1349
  let reader = stream.getReader()
1285
1350
  try {
1286
1351
  while (true) {
1352
+ if (context.signal.aborted) break
1287
1353
  let { done, value } = await reader.read()
1288
1354
  if (done) break
1355
+ if (context.signal.aborted) break
1289
1356
  controller.enqueue(value)
1290
1357
  }
1291
1358
  } catch (error) {
1292
- onError(error)
1359
+ if (!isSignalAbortError(context.signal, error)) {
1360
+ context.onError(error)
1361
+ }
1293
1362
  } finally {
1294
1363
  reader.releaseLock()
1295
1364
  }
@@ -1,4 +1,5 @@
1
1
  import { createMixin, renderMixinElement } from '../runtime/mixins/mixin.ts'
2
+ import type { MixinFactory } from '../runtime/mixins/mixin.ts'
2
3
  import type { MixinDescriptor } from '../runtime/mixins/mixin.ts'
3
4
  import type { ElementProps } from '../runtime/jsx.ts'
4
5
  import { invariant } from '../runtime/invariant.ts'
@@ -20,7 +21,11 @@ const clientStyleCache: StyleCache = new Map()
20
21
  /**
21
22
  * Applies generated class names for CSS object styles.
22
23
  */
23
- export const css = createMixin<Element, [styles: CSSProps], ElementProps>((handle) => {
24
+ export const css: MixinFactory<Element, [styles: CSSProps], ElementProps> = createMixin<
25
+ Element,
26
+ [styles: CSSProps],
27
+ ElementProps
28
+ >((handle) => {
24
29
  let activeSelector = ''
25
30
  let activeGeneration = -1
26
31
  let currentStyles: CSSProps = {}
@@ -6,5 +6,4 @@ export type {
6
6
  } from './style.ts'
7
7
  export { processStyleClass, normalizeCssValue } from './style.ts'
8
8
  export { createStyleManager }
9
-
10
- export type StyleManager = ReturnType<typeof createStyleManager>
9
+ export type { ServerStyleSource, StyleManager } from './stylesheet.ts'
@@ -1,7 +1,19 @@
1
1
  import { REMIX_UI_STYLE_LAYER } from './layers.ts'
2
2
 
3
3
  type RuleEntry = { count: number; index: number }
4
- type ServerStyleSource = ParentNode | Iterable<Node>
4
+ export type ServerStyleSource = ParentNode | Iterable<Node>
5
+
6
+ export interface StyleManager {
7
+ insert(className: string, rule: string): void
8
+ remove(className: string): void
9
+ has(className: string): boolean
10
+ getGeneration(): number
11
+ reset(): void
12
+ adoptServerStyles(source: ServerStyleSource): Set<string>
13
+ replaceServerStyles(source: ServerStyleSource): void
14
+ selectors(): IterableIterator<string>
15
+ dispose(): void
16
+ }
5
17
 
6
18
  const SERVER_STYLE_SELECTOR = 'style[data-rmx]'
7
19
 
@@ -68,7 +80,7 @@ function getStyleSelector(styleEl: HTMLStyleElement): string | null {
68
80
  return selector ? selector : null
69
81
  }
70
82
 
71
- export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER) {
83
+ export function createStyleManager(layer: string = REMIX_UI_STYLE_LAYER): StyleManager {
72
84
  let stylesheet: CSSStyleSheet | null = null
73
85
  let generation = 0
74
86
 
@@ -4,7 +4,38 @@ export interface ThemeVariableTree {
4
4
  [key: string]: string | ThemeVariableTree
5
5
  }
6
6
 
7
- const themeVariableNames = {
7
+ type ThemeVariableGroup<key extends string> = Readonly<Record<key, string>>
8
+ type ThemeActionVariableGroup = ThemeVariableGroup<
9
+ 'background' | 'backgroundHover' | 'backgroundActive' | 'foreground' | 'border'
10
+ >
11
+
12
+ type ThemeVariableNames = {
13
+ readonly space: ThemeVariableGroup<'none' | 'px' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'>
14
+ readonly radius: ThemeVariableGroup<'none' | 'sm' | 'md' | 'lg' | 'xl' | 'full'>
15
+ readonly fontFamily: ThemeVariableGroup<'sans' | 'mono'>
16
+ readonly fontSize: ThemeVariableGroup<'xxxs' | 'xxs' | 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl'>
17
+ readonly lineHeight: ThemeVariableGroup<'tight' | 'normal' | 'relaxed'>
18
+ readonly letterSpacing: ThemeVariableGroup<'tight' | 'normal' | 'meta' | 'wide'>
19
+ readonly fontWeight: ThemeVariableGroup<'normal' | 'medium' | 'semibold' | 'bold'>
20
+ readonly control: {
21
+ readonly height: ThemeVariableGroup<'sm' | 'md' | 'lg'>
22
+ }
23
+ readonly surface: ThemeVariableGroup<'lvl0' | 'lvl1' | 'lvl2' | 'lvl3' | 'lvl4'>
24
+ readonly shadow: ThemeVariableGroup<'xs' | 'sm' | 'md' | 'lg' | 'xl'>
25
+ readonly colors: {
26
+ readonly text: ThemeVariableGroup<'primary' | 'secondary' | 'muted' | 'link'>
27
+ readonly border: ThemeVariableGroup<'subtle' | 'default' | 'strong'>
28
+ readonly focus: ThemeVariableGroup<'ring'>
29
+ readonly overlay: ThemeVariableGroup<'scrim'>
30
+ readonly action: {
31
+ readonly primary: ThemeActionVariableGroup
32
+ readonly secondary: ThemeActionVariableGroup
33
+ readonly danger: ThemeActionVariableGroup
34
+ }
35
+ }
36
+ }
37
+
38
+ const themeVariableNamesSource: ThemeVariableNames = {
8
39
  space: {
9
40
  none: '--rmx-space-none',
10
41
  px: '--rmx-space-px',
@@ -117,7 +148,9 @@ const themeVariableNames = {
117
148
  },
118
149
  },
119
150
  },
120
- } as const satisfies ThemeVariableTree
151
+ } satisfies ThemeVariableTree
152
+
153
+ export const themeVariableNames: ThemeVariableNames = themeVariableNamesSource
121
154
 
122
155
  type MapLeaves<source, leaf> = source extends string
123
156
  ? leaf
@@ -126,6 +159,7 @@ type MapLeaves<source, leaf> = source extends string
126
159
  }
127
160
 
128
161
  export type ThemeValue = string | number
162
+ export type ThemeContract = MapLeaves<ThemeVariableNames, string>
129
163
  export type ThemeValues = MapLeaves<typeof themeVariableNames, ThemeValue>
130
164
  export type ThemeVars = Readonly<Record<string, string>>
131
165
  export type CreateThemeOptions = {
@@ -146,7 +180,7 @@ export type ThemeComponent = ThemeRenderer & {
146
180
  vars: ThemeVars
147
181
  }
148
182
 
149
- export const theme = createThemeContract(themeVariableNames)
183
+ export const theme: ThemeContract = createThemeContract(themeVariableNames)
150
184
 
151
185
  export type ThemeUtility = ReturnType<typeof css>
152
186
 
@@ -158,8 +192,6 @@ type NestedThemeMix<value, depth extends number = 4> = depth extends 0
158
192
 
159
193
  export type ThemeMix = NestedThemeMix<ThemeMixLeaf>
160
194
 
161
- export { themeVariableNames }
162
-
163
195
  function createThemeContract<tree extends ThemeVariableTree>(tree: tree): MapLeaves<tree, string> {
164
196
  return mapTreeLeaves(tree, (variableName) => `var(${variableName})`) as MapLeaves<tree, string>
165
197
  }
@@ -32,7 +32,9 @@ export type GlyphContract = Readonly<Record<GlyphName, { id: string }>>
32
32
 
33
33
  const DEFAULT_GLYPH_ID_PREFIX = 'rmx-glyph'
34
34
 
35
- export const glyphContract = Object.freeze(createGlyphContract(DEFAULT_GLYPH_ID_PREFIX))
35
+ export const glyphContract: GlyphContract = Object.freeze(
36
+ createGlyphContract(DEFAULT_GLYPH_ID_PREFIX),
37
+ )
36
38
 
37
39
  function createGlyphIds(idPrefix: string): Record<GlyphName, string> {
38
40
  return Object.fromEntries(glyphNames.map((name) => [name, `${idPrefix}-${name}`])) as Record<
@@ -1,9 +1,11 @@
1
1
  import { createGlyphSheet } from '../../../components/glyph/glyph.tsx'
2
2
  import { createTheme } from '../../runtime.ts'
3
+ import type { GlyphSheetComponent } from '../../../components/glyph/glyph.tsx'
4
+ import type { ThemeComponent } from '../../contract.ts'
3
5
 
4
6
  import { glyphValues } from './glyphs.tsx'
5
7
 
6
- export const RMX_01 = createTheme({
8
+ export const RMX_01: ThemeComponent = createTheme({
7
9
  space: {
8
10
  none: '0px',
9
11
  px: '1px',
@@ -118,4 +120,4 @@ export const RMX_01 = createTheme({
118
120
  },
119
121
  })
120
122
 
121
- export const RMX_01_GLYPHS = createGlyphSheet(glyphValues)
123
+ export const RMX_01_GLYPHS: GlyphSheetComponent = createGlyphSheet(glyphValues)