@remix-run/ui 0.1.0 → 0.1.2

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 (200) hide show
  1. package/README.md +24 -3
  2. package/dist/animation/animate-layout-mixin.js +66 -66
  3. package/dist/animation/animate-layout-mixin.js.map +1 -1
  4. package/dist/components/accordion/accordion.d.ts +3 -3
  5. package/dist/components/accordion/accordion.js +0 -2
  6. package/dist/components/accordion/accordion.js.map +1 -1
  7. package/dist/components/accordion/demos/card.demo.d.ts +6 -0
  8. package/dist/components/accordion/demos/card.demo.js +58 -0
  9. package/dist/components/accordion/demos/card.demo.js.map +1 -0
  10. package/dist/components/accordion/demos/multiple.demo.d.ts +6 -0
  11. package/dist/components/accordion/demos/multiple.demo.js +19 -0
  12. package/dist/components/accordion/demos/multiple.demo.js.map +1 -0
  13. package/dist/components/accordion/demos/overview.demo.d.ts +6 -0
  14. package/dist/components/accordion/demos/overview.demo.js +19 -0
  15. package/dist/components/accordion/demos/overview.demo.js.map +1 -0
  16. package/dist/components/breadcrumbs/breadcrumbs.js +0 -2
  17. package/dist/components/breadcrumbs/breadcrumbs.js.map +1 -1
  18. package/dist/components/breadcrumbs/demos/basic.demo.d.ts +5 -0
  19. package/dist/components/breadcrumbs/demos/basic.demo.js +14 -0
  20. package/dist/components/breadcrumbs/demos/basic.demo.js.map +1 -0
  21. package/dist/components/breadcrumbs/demos/separator.demo.d.ts +5 -0
  22. package/dist/components/breadcrumbs/demos/separator.demo.js +14 -0
  23. package/dist/components/breadcrumbs/demos/separator.demo.js.map +1 -0
  24. package/dist/components/button/button.d.ts +82 -0
  25. package/dist/components/button/button.js +60 -2
  26. package/dist/components/button/button.js.map +1 -1
  27. package/dist/components/button/demos/aliases.demo.d.ts +6 -0
  28. package/dist/components/button/demos/aliases.demo.js +18 -0
  29. package/dist/components/button/demos/aliases.demo.js.map +1 -0
  30. package/dist/components/button/demos/basic.demo.d.ts +6 -0
  31. package/dist/components/button/demos/basic.demo.js +19 -0
  32. package/dist/components/button/demos/basic.demo.js.map +1 -0
  33. package/dist/components/button/demos/states.demo.d.ts +6 -0
  34. package/dist/components/button/demos/states.demo.js +29 -0
  35. package/dist/components/button/demos/states.demo.js.map +1 -0
  36. package/dist/components/combobox/combobox.d.ts +3 -3
  37. package/dist/components/combobox/combobox.js +0 -2
  38. package/dist/components/combobox/combobox.js.map +1 -1
  39. package/dist/components/combobox/demos/overview.demo.d.ts +6 -0
  40. package/dist/components/combobox/demos/overview.demo.js +153 -0
  41. package/dist/components/combobox/demos/overview.demo.js.map +1 -0
  42. package/dist/components/listbox/demos/overview.demo.d.ts +6 -0
  43. package/dist/components/listbox/demos/overview.demo.js +64 -0
  44. package/dist/components/listbox/demos/overview.demo.js.map +1 -0
  45. package/dist/components/listbox/listbox.d.ts +1 -6
  46. package/dist/components/listbox/listbox.js +21 -33
  47. package/dist/components/listbox/listbox.js.map +1 -1
  48. package/dist/components/menu/demos/bubbling.demo.d.ts +5 -0
  49. package/dist/components/menu/demos/bubbling.demo.js +15 -0
  50. package/dist/components/menu/demos/bubbling.demo.js.map +1 -0
  51. package/dist/components/menu/demos/overview.demo.d.ts +6 -0
  52. package/dist/components/menu/demos/overview.demo.js +32 -0
  53. package/dist/components/menu/demos/overview.demo.js.map +1 -0
  54. package/dist/components/menu/menu.d.ts +5 -14
  55. package/dist/components/menu/menu.js +23 -38
  56. package/dist/components/menu/menu.js.map +1 -1
  57. package/dist/components/popover/demos/overview.demo.d.ts +6 -0
  58. package/dist/components/popover/demos/overview.demo.js +64 -0
  59. package/dist/components/popover/demos/overview.demo.js.map +1 -0
  60. package/dist/components/select/demos/deconstructed.demo.d.ts +6 -0
  61. package/dist/components/select/demos/deconstructed.demo.js +53 -0
  62. package/dist/components/select/demos/deconstructed.demo.js.map +1 -0
  63. package/dist/components/select/demos/overview.demo.d.ts +5 -0
  64. package/dist/components/select/demos/overview.demo.js +27 -0
  65. package/dist/components/select/demos/overview.demo.js.map +1 -0
  66. package/dist/components/select/select.d.ts +3 -3
  67. package/dist/components/select/select.js +17 -26
  68. package/dist/components/select/select.js.map +1 -1
  69. package/dist/components/tabs/tabs.d.ts +3 -3
  70. package/dist/components/tabs/tabs.js +0 -2
  71. package/dist/components/tabs/tabs.js.map +1 -1
  72. package/dist/index.d.ts +1 -1
  73. package/dist/index.js.map +1 -1
  74. package/dist/runtime/component.d.ts +8 -5
  75. package/dist/runtime/component.js +84 -80
  76. package/dist/runtime/component.js.map +1 -1
  77. package/dist/runtime/core/attributes.d.ts +12 -0
  78. package/dist/runtime/core/attributes.js +85 -0
  79. package/dist/runtime/core/attributes.js.map +1 -0
  80. package/dist/runtime/core/children.d.ts +7 -0
  81. package/dist/runtime/core/children.js +30 -0
  82. package/dist/runtime/core/children.js.map +1 -0
  83. package/dist/runtime/core/props.d.ts +9 -0
  84. package/dist/runtime/core/props.js +169 -0
  85. package/dist/runtime/core/props.js.map +1 -0
  86. package/dist/runtime/core/vnode.d.ts +3 -0
  87. package/dist/runtime/core/vnode.js +39 -0
  88. package/dist/runtime/core/vnode.js.map +1 -0
  89. package/dist/runtime/create-element.js +2 -1
  90. package/dist/runtime/create-element.js.map +1 -1
  91. package/dist/runtime/diff-dom.js +47 -14
  92. package/dist/runtime/diff-dom.js.map +1 -1
  93. package/dist/runtime/diff-props.d.ts +1 -2
  94. package/dist/runtime/diff-props.js +2 -181
  95. package/dist/runtime/diff-props.js.map +1 -1
  96. package/dist/runtime/dom.d.ts +4 -0
  97. package/dist/runtime/frame.d.ts +23 -2
  98. package/dist/runtime/frame.js +51 -52
  99. package/dist/runtime/frame.js.map +1 -1
  100. package/dist/runtime/jsx.js +2 -27
  101. package/dist/runtime/jsx.js.map +1 -1
  102. package/dist/runtime/mixins/mixin.js +9 -4
  103. package/dist/runtime/mixins/mixin.js.map +1 -1
  104. package/dist/runtime/mixins/on-mixin.d.ts +15 -4
  105. package/dist/runtime/mixins/on-mixin.js +9 -2
  106. package/dist/runtime/mixins/on-mixin.js.map +1 -1
  107. package/dist/runtime/navigation.d.ts +6 -0
  108. package/dist/runtime/navigation.js +16 -3
  109. package/dist/runtime/navigation.js.map +1 -1
  110. package/dist/runtime/reconcile.js +444 -181
  111. package/dist/runtime/reconcile.js.map +1 -1
  112. package/dist/runtime/run.d.ts +17 -2
  113. package/dist/runtime/run.js.map +1 -1
  114. package/dist/runtime/scheduler.js +10 -0
  115. package/dist/runtime/scheduler.js.map +1 -1
  116. package/dist/runtime/stream-protocol.d.ts +8 -0
  117. package/dist/runtime/stream-protocol.js +20 -0
  118. package/dist/runtime/stream-protocol.js.map +1 -0
  119. package/dist/runtime/to-vnode.js +17 -18
  120. package/dist/runtime/to-vnode.js.map +1 -1
  121. package/dist/runtime/vnode.d.ts +5 -4
  122. package/dist/runtime/vnode.js +2 -3
  123. package/dist/runtime/vnode.js.map +1 -1
  124. package/dist/server/stream.js +80 -106
  125. package/dist/server/stream.js.map +1 -1
  126. package/dist/style/layers.d.ts +2 -0
  127. package/dist/style/layers.js +3 -0
  128. package/dist/style/layers.js.map +1 -0
  129. package/dist/style/stylesheet.d.ts +3 -1
  130. package/dist/style/stylesheet.js +69 -8
  131. package/dist/style/stylesheet.js.map +1 -1
  132. package/dist/theme/glyph-contract.d.ts +1 -1
  133. package/dist/theme/runtime.js +4 -1
  134. package/dist/theme/runtime.js.map +1 -1
  135. package/package.json +6 -7
  136. package/src/animation/README.md +314 -0
  137. package/src/animation/animate-layout-mixin.ts +79 -71
  138. package/src/components/accordion/README.md +166 -0
  139. package/src/components/accordion/accordion.tsx +8 -10
  140. package/src/components/accordion/demos/card.demo.tsx +113 -0
  141. package/src/components/accordion/demos/multiple.demo.tsx +56 -0
  142. package/src/components/accordion/demos/overview.demo.tsx +56 -0
  143. package/src/components/anchor/README.md +153 -0
  144. package/src/components/breadcrumbs/README.md +55 -0
  145. package/src/components/breadcrumbs/breadcrumbs.tsx +0 -2
  146. package/src/components/breadcrumbs/demos/basic.demo.tsx +17 -0
  147. package/src/components/breadcrumbs/demos/separator.demo.tsx +18 -0
  148. package/src/components/button/README.md +4 -4
  149. package/src/components/button/button.tsx +88 -2
  150. package/src/components/button/demos/aliases.demo.tsx +27 -0
  151. package/src/components/button/demos/basic.demo.tsx +30 -0
  152. package/src/components/button/demos/states.demo.tsx +52 -0
  153. package/src/components/combobox/combobox.tsx +8 -10
  154. package/src/components/combobox/demos/overview.demo.tsx +199 -0
  155. package/src/components/glyph/README.md +72 -0
  156. package/src/components/listbox/README.md +115 -0
  157. package/src/components/listbox/demos/overview.demo.tsx +101 -0
  158. package/src/components/listbox/listbox.ts +23 -32
  159. package/src/components/menu/README.md +96 -0
  160. package/src/components/menu/demos/bubbling.demo.tsx +34 -0
  161. package/src/components/menu/demos/overview.demo.tsx +76 -0
  162. package/src/components/menu/menu.tsx +35 -41
  163. package/src/components/popover/README.md +3 -3
  164. package/src/components/popover/demos/overview.demo.tsx +143 -0
  165. package/src/components/select/README.md +107 -0
  166. package/src/components/select/demos/deconstructed.demo.tsx +95 -0
  167. package/src/components/select/demos/overview.demo.tsx +51 -0
  168. package/src/components/select/select.tsx +28 -28
  169. package/src/components/tabs/tabs.tsx +10 -5
  170. package/src/index.ts +1 -1
  171. package/src/runtime/component.ts +94 -82
  172. package/src/runtime/core/attributes.ts +97 -0
  173. package/src/runtime/core/children.ts +42 -0
  174. package/src/runtime/core/props.ts +185 -0
  175. package/src/runtime/core/vnode.ts +48 -0
  176. package/src/runtime/create-element.ts +2 -1
  177. package/src/runtime/diff-dom.ts +59 -12
  178. package/src/runtime/diff-props.ts +2 -187
  179. package/src/runtime/dom.ts +4 -0
  180. package/src/runtime/frame.ts +84 -61
  181. package/src/runtime/jsx.ts +2 -30
  182. package/src/runtime/mixins/mixin.ts +9 -4
  183. package/src/runtime/mixins/on-mixin.ts +20 -12
  184. package/src/runtime/navigation.ts +24 -3
  185. package/src/runtime/reconcile.ts +552 -186
  186. package/src/runtime/run.ts +17 -1
  187. package/src/runtime/scheduler.ts +7 -0
  188. package/src/runtime/stream-protocol.ts +27 -0
  189. package/src/runtime/to-vnode.ts +15 -17
  190. package/src/runtime/vnode.ts +6 -9
  191. package/src/server/README.md +88 -0
  192. package/src/server/stream.ts +105 -117
  193. package/src/style/layers.ts +2 -0
  194. package/src/style/stylesheet.ts +75 -10
  195. package/src/test/README.md +107 -0
  196. package/src/test/utils.ts +3 -1
  197. package/src/theme/README.md +103 -0
  198. package/src/theme/runtime.ts +4 -1
  199. package/src/utils/scroll-lock/README.md +33 -0
  200. package/src/test/setup.ts +0 -7
@@ -11,8 +11,21 @@ import { TypedEventTarget } from './typed-event-target.ts'
11
11
  /**
12
12
  * Options for starting the client runtime with {@link run}.
13
13
  */
14
- export type RunInit = {
14
+ export interface RunInit {
15
+ /**
16
+ * Loads the named browser module export for a hydrated `clientEntry()`.
17
+ *
18
+ * Implementations usually call dynamic `import(moduleUrl)` and return
19
+ * `mod[exportName]`.
20
+ */
15
21
  loadModule: LoadModule
22
+
23
+ /**
24
+ * Resolves browser-loaded `<Frame>` content.
25
+ *
26
+ * Omit this only when the runtime never needs to load or reload frames in the
27
+ * browser.
28
+ */
16
29
  resolveFrame?: ResolveFrame
17
30
  }
18
31
 
@@ -27,8 +40,11 @@ export type AppRuntimeEventMap = {
27
40
  * Client runtime returned by {@link run}.
28
41
  */
29
42
  export type AppRuntime = TypedEventTarget<AppRuntimeEventMap> & {
43
+ /** Resolves after the current document finishes hydrating. */
30
44
  ready(): Promise<void>
45
+ /** Flushes any queued component updates synchronously. */
31
46
  flush(): void
47
+ /** Stops runtime listeners and disposes the top-level frame. */
32
48
  dispose(): void
33
49
  }
34
50
 
@@ -49,6 +49,10 @@ export function createScheduler(
49
49
  let cascadingUpdateCount = 0
50
50
  let resetScheduled = false
51
51
  let phaseEvents = new EventTarget()
52
+ let phaseListenerCounts: Record<SchedulerPhaseType, number> = {
53
+ beforeUpdate: 0,
54
+ commit: 0,
55
+ }
52
56
  let scheduler: {
53
57
  enqueue(vnode: CommittedComponentNode, domParent: ParentNode): void
54
58
  enqueueWork(newTasks: EmptyFn[]): void
@@ -172,6 +176,7 @@ export function createScheduler(
172
176
  }
173
177
 
174
178
  function dispatchPhaseEvent(type: SchedulerPhaseType, parents: ParentNode[]) {
179
+ if (phaseListenerCounts[type] === 0) return
175
180
  let event = new Event(type) as SchedulerPhaseEvent
176
181
  event.parents = parents
177
182
  phaseEvents.dispatchEvent(event)
@@ -246,10 +251,12 @@ export function createScheduler(
246
251
  },
247
252
 
248
253
  addEventListener(type, listener, options) {
254
+ if (listener) phaseListenerCounts[type]++
249
255
  phaseEvents.addEventListener(type, listener, options)
250
256
  },
251
257
 
252
258
  removeEventListener(type, listener, options) {
259
+ if (listener) phaseListenerCounts[type] = Math.max(0, phaseListenerCounts[type] - 1)
253
260
  phaseEvents.removeEventListener(type, listener, options)
254
261
  },
255
262
 
@@ -0,0 +1,27 @@
1
+ export type FlushKind = 'document' | 'fragment'
2
+
3
+ const FLUSH_MARKER_PATTERN = /<!--\s*rmx:flush\s+(document|fragment)\s*-->/g
4
+
5
+ export function appendFlushMarker(html: string, kind: FlushKind): string {
6
+ return `${html}<!-- rmx:flush ${kind} -->`
7
+ }
8
+
9
+ export function stripFlushMarkers(html: string): string {
10
+ FLUSH_MARKER_PATTERN.lastIndex = 0
11
+ return html.replace(FLUSH_MARKER_PATTERN, '')
12
+ }
13
+
14
+ export function findFlushMarker(
15
+ html: string,
16
+ startIndex: number,
17
+ ): { index: number; endIndex: number; kind: FlushKind } | undefined {
18
+ FLUSH_MARKER_PATTERN.lastIndex = startIndex
19
+ let match = FLUSH_MARKER_PATTERN.exec(html)
20
+ if (!match) return undefined
21
+
22
+ return {
23
+ index: match.index,
24
+ endIndex: FLUSH_MARKER_PATTERN.lastIndex,
25
+ kind: match[1] as FlushKind,
26
+ }
27
+ }
@@ -1,39 +1,37 @@
1
1
  import { Fragment } from './component.ts'
2
2
  import { invariant } from './invariant.ts'
3
+ import { isEmptyChild, isPrimitiveChild, normalizeChildren } from './core/children.ts'
3
4
  import type { RemixElement, RemixNode } from './jsx.ts'
4
5
  import { isRemixElement, TEXT_NODE, type VNode } from './vnode.ts'
5
6
 
6
7
  function flatMapChildrenToVNodes(node: RemixElement): VNode[] {
7
- return 'children' in node.props
8
- ? Array.isArray(node.props.children)
9
- ? node.props.children.flat(Infinity).map(toVNode)
10
- : [toVNode(node.props.children)]
11
- : []
8
+ if (!('children' in node.props)) return []
9
+ let children = node.props.children
10
+ if (!Array.isArray(children)) return [toVNode(children)]
11
+ let vnodes: VNode[] = []
12
+ flattenChildrenToVNodes(children, vnodes)
13
+ return vnodes
12
14
  }
13
15
 
14
- function flattenRemixNodeArray(nodes: RemixNode[], out: RemixNode[] = []): RemixNode[] {
15
- for (let child of nodes) {
16
- if (Array.isArray(child)) {
17
- flattenRemixNodeArray(child, out)
18
- } else {
19
- out.push(child)
20
- }
16
+ function flattenChildrenToVNodes(nodes: RemixNode[], out: VNode[]): void {
17
+ for (let child of normalizeChildren(nodes)) {
18
+ out.push(toVNode(child))
21
19
  }
22
- return out
23
20
  }
24
21
 
25
22
  export function toVNode(node: RemixNode): VNode {
26
- if (node === null || node === undefined || typeof node === 'boolean') {
23
+ if (isEmptyChild(node)) {
27
24
  return { type: TEXT_NODE, _text: '' }
28
25
  }
29
26
 
30
- if (typeof node === 'string' || typeof node === 'number' || typeof node === 'bigint') {
27
+ if (isPrimitiveChild(node)) {
31
28
  return { type: TEXT_NODE, _text: String(node) }
32
29
  }
33
30
 
34
31
  if (Array.isArray(node)) {
35
- let flatChildren = flattenRemixNodeArray(node)
36
- return { type: Fragment, _children: flatChildren.map(toVNode) }
32
+ let children: VNode[] = []
33
+ flattenChildrenToVNodes(node, children)
34
+ return { type: Fragment, _children: children }
37
35
  }
38
36
 
39
37
  if (node.type === Fragment) {
@@ -1,6 +1,9 @@
1
1
  import type { ComponentHandle, Component } from './component.ts'
2
2
  import { Fragment, Frame } from './component.ts'
3
- import type { ElementProps, RemixElement, RemixNode } from './jsx.ts'
3
+ import { isRemixElement } from './core/vnode.ts'
4
+ import type { ElementProps, RemixNode } from './jsx.ts'
5
+
6
+ export { isRemixElement }
4
7
 
5
8
  export const TEXT_NODE = Symbol('TEXT_NODE')
6
9
  export const ROOT_VNODE = Symbol('ROOT_VNODE')
@@ -25,6 +28,8 @@ export type VNode<T extends VNodeType = VNodeType> = {
25
28
  _dom?: unknown
26
29
  _controller?: AbortController
27
30
  _mixState?: unknown
31
+ _directEventDescriptors?: unknown
32
+ _directEventState?: unknown
28
33
  _controlledState?: unknown
29
34
  _svg?: boolean
30
35
  // Range roots render between comment boundary markers
@@ -37,10 +42,6 @@ export type VNode<T extends VNodeType = VNodeType> = {
37
42
  _frameResolveController?: AbortController
38
43
  _frameResolved?: boolean
39
44
 
40
- // Internal diffing fields
41
- _index?: number
42
- _flags?: number
43
-
44
45
  // TEXT_NODE
45
46
  _text?: string
46
47
 
@@ -121,10 +122,6 @@ export function isCommittedComponentNode(node: VNode): node is CommittedComponen
121
122
  return isComponentNode(node) && node._content !== undefined
122
123
  }
123
124
 
124
- export function isRemixElement(node: RemixNode): node is RemixElement {
125
- return typeof node === 'object' && node !== null && '$rmx' in node
126
- }
127
-
128
125
  export function findContextFromAncestry(node: VNode, type: Component): unknown {
129
126
  let current: VNode | undefined = node
130
127
  while (current) {
@@ -0,0 +1,88 @@
1
+ # Server
2
+
3
+ Remix Component can render to HTML on the server using two APIs:
4
+
5
+ - `renderToString` - Returns a complete HTML string. Simple, but buffers the entire response.
6
+ - `renderToStream` - Returns a `ReadableStream<Uint8Array>`. Sends the initial HTML immediately and streams frame content as it resolves.
7
+
8
+ Both are exported from `remix/ui/server`.
9
+
10
+ ## renderToString
11
+
12
+ Renders a component tree to a complete HTML string. Use this when you need the full output before responding (e.g., generating static pages or embedding HTML in an email).
13
+
14
+ ```tsx
15
+ import { renderToString } from 'remix/ui/server'
16
+
17
+ let html = await renderToString(<App />)
18
+ ```
19
+
20
+ ## renderToStream
21
+
22
+ Renders a component tree to a streaming response. The initial HTML is sent immediately. Any `<Frame>` components with a `fallback` prop will render the fallback first, then stream the resolved content as it becomes available.
23
+
24
+ ```tsx
25
+ import { renderToStream } from 'remix/ui/server'
26
+
27
+ let stream = renderToStream(<App />, {
28
+ frameSrc: request.url,
29
+ resolveFrame(src, _target, context) {
30
+ let frameUrl = new URL(src, context?.currentFrameSrc ?? request.url)
31
+ return fetchHtml(frameUrl)
32
+ },
33
+ onError(error) {
34
+ console.error(error)
35
+ },
36
+ })
37
+
38
+ return new Response(stream, {
39
+ headers: { 'Content-Type': 'text/html; charset=utf-8' },
40
+ })
41
+ ```
42
+
43
+ ### Options
44
+
45
+ - **`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
+ - **`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.
47
+ - **`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
+ - **`onError(error)`** - Called when a rendering error occurs. If not provided, the stream rejects with the error.
49
+
50
+ When you render nested frame responses with `renderToStream()` inside `resolveFrame()`, pass `frameSrc` for the frame being rendered and carry `topFrameSrc` forward from the parent context. That preserves `handle.frames.top.src` across the whole SSR frame tree.
51
+
52
+ ### Streaming behavior
53
+
54
+ When the stream encounters a `<Frame>` component:
55
+
56
+ - **Without `fallback`** (blocking): The frame content is awaited before the initial HTML chunk is sent. The resolved content appears inline.
57
+ - **With `fallback`** (non-blocking): The fallback is rendered inline in the initial chunk. Once the frame resolves, a `<template>` element containing the real content is streamed at the end of the response. The client swaps it in automatically.
58
+
59
+ This means the first chunk always contains a complete, renderable page. Slow data sources don't block the initial paint.
60
+
61
+ ## Head content
62
+
63
+ To render content into the document head during SSR, use an explicit `<head>` element:
64
+
65
+ ```tsx
66
+ function ProductPage() {
67
+ return () => (
68
+ <html>
69
+ <head>
70
+ <title>Product Name</title>
71
+ <meta name="description" content="A great product" />
72
+ </head>
73
+ <body>
74
+ <h1>Product Name</h1>
75
+ </body>
76
+ </html>
77
+ )
78
+ }
79
+ ```
80
+
81
+ ## CSS
82
+
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.
84
+
85
+ ## See Also
86
+
87
+ - [Hydration](https://github.com/remix-run/remix/blob/main/packages/ui/docs/hydration.md) - Making server-rendered components interactive on the client
88
+ - [Frames](https://github.com/remix-run/remix/blob/main/packages/ui/docs/frames.md) - Streaming partial server UI with `<Frame>`
@@ -2,7 +2,14 @@ import type { ComponentHandle, FrameHandle, Key, RemixNode } from '../runtime/co
2
2
  import type { ElementType, ElementProps, RemixElement } from '../runtime/jsx.ts'
3
3
  import { Fragment, createComponent, createFrameHandle, Frame } from '../runtime/component.ts'
4
4
  import { isEntry, type EntryComponent } from '../runtime/client-entries.ts'
5
- import { normalizeSvgAttribute } from '../runtime/svg-attributes.ts'
5
+ import {
6
+ FRAMEWORK_PROPS as RUNTIME_FRAMEWORK_PROPS,
7
+ SELF_CLOSING_TAGS,
8
+ normalizeAttributeName,
9
+ serializeStyleObject,
10
+ } from '../runtime/core/attributes.ts'
11
+ import { appendFlushMarker, type FlushKind, stripFlushMarkers } from '../runtime/stream-protocol.ts'
12
+ import { REMIX_UI_STYLE_LAYER } from '../style/layers.ts'
6
13
 
7
14
  interface VNode {
8
15
  type: ElementType
@@ -90,6 +97,7 @@ interface RenderContext {
90
97
  unresolvedHydrationData: Map<string, UnresolvedHydrationData>
91
98
  frameData: Map<string, FrameData>
92
99
  blockingFrameTails: ReadableStream<Uint8Array>[]
100
+ flushKind: FlushKind
93
101
  serverIdScope: string
94
102
  serverIdCounter: number
95
103
  }
@@ -114,49 +122,32 @@ type Segment =
114
122
  pending?: Promise<void>
115
123
  }
116
124
 
117
- const SELF_CLOSING_TAGS = new Set([
118
- 'area',
119
- 'base',
120
- 'br',
121
- 'col',
122
- 'embed',
123
- 'hr',
124
- 'img',
125
- 'input',
126
- 'link',
127
- 'meta',
128
- 'param',
129
- 'source',
130
- 'track',
131
- 'wbr',
132
- ])
133
-
134
- const DEFAULT_STYLE_LAYER = 'rmx'
135
-
136
- function getStyleLayerName(selector: string, layer: string = DEFAULT_STYLE_LAYER): string {
125
+ const TEXTAREA_VALUE_PROPS = new Set(['value', 'defaultValue'])
126
+ const INPUT_DEFAULT_PROPS = new Set(['defaultValue', 'defaultChecked'])
127
+
128
+ const DOCTYPE_PATTERN = /<!doctype(?:\s[^>]*)?>/gi
129
+
130
+ function stripDoctypeMarkup(html: string): string {
131
+ return html.replace(DOCTYPE_PATTERN, '')
132
+ }
133
+
134
+ function hasRenderableHtml(html: string): boolean {
135
+ return stripDoctypeMarkup(html).trim() !== ''
136
+ }
137
+
138
+ function emptyReadableStream(): ReadableStream<Uint8Array> {
139
+ return new ReadableStream({
140
+ start(controller) {
141
+ controller.close()
142
+ },
143
+ })
144
+ }
145
+
146
+ function getStyleLayerName(selector: string, layer: string = REMIX_UI_STYLE_LAYER): string {
137
147
  return `${layer}.${selector}`
138
148
  }
139
149
 
140
- const NUMERIC_CSS_PROPS = new Set([
141
- 'z-index',
142
- 'opacity',
143
- 'flex-grow',
144
- 'flex-shrink',
145
- 'flex-order',
146
- 'grid-area',
147
- 'grid-row',
148
- 'grid-column',
149
- 'font-weight',
150
- 'line-height',
151
- 'order',
152
- 'orphans',
153
- 'widows',
154
- 'zoom',
155
- 'columns',
156
- 'column-count',
157
- ])
158
-
159
- const FRAMEWORK_PROPS = new Set(['children', 'innerHTML', 'on', 'key', 'mix'])
150
+ const SSR_OMITTED_PROPS = RUNTIME_FRAMEWORK_PROPS
160
151
 
161
152
  const ssrSignal = Object.freeze({
162
153
  get aborted() {
@@ -213,6 +204,7 @@ export function renderToStream(
213
204
  unresolvedHydrationData: new Map(),
214
205
  frameData: new Map(),
215
206
  blockingFrameTails: [],
207
+ flushKind: 'fragment',
216
208
  serverIdScope: crypto.randomUUID().slice(0, 8),
217
209
  serverIdCounter: 0,
218
210
  }
@@ -226,7 +218,7 @@ export function renderToStream(
226
218
  validateClientEntriesForHydration(context)
227
219
  let html = serializeSegment(root)
228
220
  let finalHtml = finalizeHtml(html, context)
229
- let bytes = encoder.encode(finalHtml)
221
+ let bytes = encoder.encode(appendFlushMarker(finalHtml, context.flushKind))
230
222
  controller.enqueue(bytes)
231
223
 
232
224
  // If we have any tails from blocking frame streams, stream them now.
@@ -284,24 +276,28 @@ function createServerComponentId(context: RenderContext): string {
284
276
  return `s${context.serverIdScope}-${context.serverIdCounter}`
285
277
  }
286
278
 
287
- async function splitFirstChunk(
288
- stream: ReadableStream<Uint8Array>,
289
- ): Promise<{ first: Uint8Array; tail: ReadableStream<Uint8Array> }> {
279
+ async function splitFirstChunk(stream: ReadableStream<Uint8Array>): Promise<ResolvedFrameHtml> {
290
280
  let reader = stream.getReader()
281
+ let decoder = new TextDecoder()
291
282
 
292
- let { value, done } = await reader.read()
293
- if (done || !value) {
294
- reader.releaseLock()
295
- return {
296
- first: new Uint8Array(),
297
- tail: new ReadableStream({
298
- start(controller) {
299
- controller.close()
300
- },
301
- }),
283
+ let first: Uint8Array | undefined
284
+ while (true) {
285
+ let { value, done } = await reader.read()
286
+ if (done || !value) break
287
+
288
+ let text = decoder.decode(value, { stream: true })
289
+ if (hasRenderableHtml(text)) {
290
+ first = value
291
+ break
302
292
  }
303
293
  }
304
294
 
295
+ if (!first) {
296
+ decoder.decode()
297
+ reader.releaseLock()
298
+ return { html: '', tail: emptyReadableStream() }
299
+ }
300
+
305
301
  let released = false
306
302
  function release() {
307
303
  if (released) return
@@ -329,17 +325,15 @@ async function splitFirstChunk(
329
325
  },
330
326
  })
331
327
 
332
- return { first: value, tail }
328
+ return { html: stripFlushMarkers(stripDoctypeMarkup(decoder.decode(first))), tail }
333
329
  }
334
330
 
335
331
  async function resolveFrameHtml(
336
332
  input: string | ReadableStream<Uint8Array>,
337
333
  ): Promise<ResolvedFrameHtml> {
338
- if (typeof input === 'string') return { html: input }
334
+ if (typeof input === 'string') return { html: stripFlushMarkers(stripDoctypeMarkup(input)) }
339
335
 
340
- let decoder = new TextDecoder()
341
- let { first, tail } = await splitFirstChunk(input)
342
- return { html: decoder.decode(first), tail }
336
+ return await splitFirstChunk(input)
343
337
  }
344
338
 
345
339
  function isRemixElement(node: unknown): node is RemixElement {
@@ -380,6 +374,7 @@ function buildSegment(node: RemixNode, context: RenderContext, frameState: SsrFr
380
374
  let tag = type
381
375
 
382
376
  if (tag === 'html') {
377
+ context.flushKind = 'document'
383
378
  return buildElementSegment(tag, props, context, frameState)
384
379
  }
385
380
 
@@ -459,7 +454,15 @@ function buildElementSegment(
459
454
  let processedProps = processStyleProps(mixedProps)
460
455
  // Determine namespace context for the current element and its children
461
456
  let currentIsSvg = context.insideSvg || tag === 'svg'
462
- let attrs = renderAttributes(processedProps, currentIsSvg)
457
+
458
+ if (!currentIsSvg && tag === 'textarea') {
459
+ return buildTextareaElementSegment(tag, processedProps)
460
+ }
461
+
462
+ let attrs =
463
+ !currentIsSvg && tag === 'input'
464
+ ? renderInputAttributes(processedProps)
465
+ : renderAttributes(processedProps, currentIsSvg)
463
466
 
464
467
  if (SELF_CLOSING_TAGS.has(tag)) {
465
468
  return staticSeg(`<${tag}${attrs} />`)
@@ -480,6 +483,27 @@ function buildElementSegment(
480
483
  return compositeSeg([open, children, close])
481
484
  }
482
485
 
486
+ function buildTextareaElementSegment(tag: string, props: any): Segment {
487
+ let attrs = renderAttributes(props, false, TEXTAREA_VALUE_PROPS)
488
+ let value = props.value ?? props.defaultValue ?? ''
489
+ return staticSeg(`<${tag}${attrs}>${escapeTextContent(String(value))}</${tag}>`)
490
+ }
491
+
492
+ function renderInputAttributes(props: any): string {
493
+ let value =
494
+ props.value === undefined && props.defaultValue !== undefined ? props.defaultValue : props.value
495
+ let checked =
496
+ props.checked === undefined && props.defaultChecked !== undefined
497
+ ? props.defaultChecked
498
+ : props.checked
499
+ let inputProps = {
500
+ ...props,
501
+ ...(value === undefined ? {} : { value }),
502
+ ...(checked === undefined ? {} : { checked }),
503
+ }
504
+ return renderAttributes(inputProps, false, INPUT_DEFAULT_PROPS)
505
+ }
506
+
483
507
  function buildHeadElementSegment(
484
508
  tag: string,
485
509
  props: any,
@@ -497,11 +521,12 @@ function buildHeadElementSegment(
497
521
  return compositeSeg([open, children, close])
498
522
  }
499
523
 
500
- function renderAttributes(props: any, isSvg: boolean): string {
524
+ function renderAttributes(props: any, isSvg: boolean, excludedProps?: Set<string>): string {
501
525
  let attrs = ''
502
526
 
503
527
  for (let key in props) {
504
- if (FRAMEWORK_PROPS.has(key)) continue
528
+ if (SSR_OMITTED_PROPS.has(key)) continue
529
+ if (excludedProps?.has(key)) continue
505
530
 
506
531
  let value = props[key]
507
532
  if (value === undefined || value === null || value === false) continue
@@ -793,7 +818,11 @@ function buildComponentSegment(
793
818
  let [renderedNode] = handle.render(props)
794
819
  let childContext = { ...context, parentVNode: vnode }
795
820
 
796
- return buildSegment(renderedNode, childContext, frameState)
821
+ let rendered = buildSegment(renderedNode, childContext, frameState)
822
+ if (childContext.flushKind === 'document') {
823
+ context.flushKind = 'document'
824
+ }
825
+ return rendered
797
826
  }
798
827
 
799
828
  function createHydrationPropsReplacer(context: RenderContext, frameState: SsrFrameState) {
@@ -1058,24 +1087,11 @@ function escapeTemplateContent(html: string): string {
1058
1087
  }
1059
1088
 
1060
1089
  function transformAttributeName(name: string, isSvg: boolean): string {
1061
- // aria-/data- pass through
1062
- if (name.startsWith('aria-') || name.startsWith('data-')) return name
1063
-
1064
- // HTML mappings
1065
- if (name === 'className') return 'class'
1066
- if (!isSvg) {
1067
- if (name === 'htmlFor') return 'for'
1068
- if (name === 'tabIndex') return 'tabindex'
1069
- if (name === 'acceptCharset') return 'accept-charset'
1070
- if (name === 'httpEquiv') return 'http-equiv'
1071
- return name.toLowerCase()
1072
- }
1073
-
1074
- return normalizeSvgAttribute(name).attr
1090
+ return normalizeAttributeName(name, isSvg).attr
1075
1091
  }
1076
1092
 
1077
1093
  function finalizeHtml(html: string, context: RenderContext): string {
1078
- let hasHtmlRoot = html.trimStart().toLowerCase().startsWith('<html')
1094
+ let hasHtmlRoot = context.flushKind === 'document'
1079
1095
 
1080
1096
  let styles = collectStyleTags(context)
1081
1097
  if (styles) {
@@ -1159,7 +1175,7 @@ function collectStyleTags(context: RenderContext): string {
1159
1175
  function wrapStyleForLayer(
1160
1176
  selector: string,
1161
1177
  css: string,
1162
- layer: string = DEFAULT_STYLE_LAYER,
1178
+ layer: string = REMIX_UI_STYLE_LAYER,
1163
1179
  ): string {
1164
1180
  let trimmed = css.trim()
1165
1181
  if (!trimmed) return ''
@@ -1169,7 +1185,7 @@ function wrapStyleForLayer(
1169
1185
  function renderStyleTag(
1170
1186
  selector: string,
1171
1187
  css: string,
1172
- layer: string = DEFAULT_STYLE_LAYER,
1188
+ layer: string = REMIX_UI_STYLE_LAYER,
1173
1189
  ): string {
1174
1190
  let wrappedCss = wrapStyleForLayer(selector, css, layer)
1175
1191
  if (!wrappedCss) return ''
@@ -1220,36 +1236,6 @@ function escapeScriptJson(json: string): string {
1220
1236
  return json.replace(/</g, '\\u003c')
1221
1237
  }
1222
1238
 
1223
- function serializeStyleObject(style: Record<string, any>): string {
1224
- let parts: string[] = []
1225
-
1226
- for (let [key, value] of Object.entries(style)) {
1227
- if (value == null) continue
1228
- if (typeof value === 'boolean') continue
1229
- if (typeof value === 'number' && !Number.isFinite(value)) continue
1230
-
1231
- // Convert camelCase to kebab-case
1232
- let cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
1233
-
1234
- // Add px to numeric values where appropriate
1235
- let shouldAppendPx =
1236
- typeof value === 'number' &&
1237
- value !== 0 &&
1238
- !NUMERIC_CSS_PROPS.has(cssKey) &&
1239
- !cssKey.startsWith('--')
1240
-
1241
- let cssValue = shouldAppendPx
1242
- ? `${value}px`
1243
- : Array.isArray(value)
1244
- ? value.join(', ')
1245
- : String(value)
1246
-
1247
- parts.push(`${cssKey}: ${cssValue};`)
1248
- }
1249
-
1250
- return parts.join(' ')
1251
- }
1252
-
1253
1239
  // Frame styles work end-to-end when frame handlers use their own `renderToStream`:
1254
1240
  // the handler's `finalizeHtml` emits selector-addressed `<style>` tags in its HTML, and on the client,
1255
1241
  // the `adoptServerStyleTag` MutationObserver (stylesheet.ts) picks it up anywhere in the
@@ -1332,11 +1318,13 @@ async function drain(stream: ReadableStream<Uint8Array>): Promise<string> {
1332
1318
  * @returns Rendered HTML.
1333
1319
  */
1334
1320
  export async function renderToString(node: RemixNode): Promise<string> {
1335
- return drain(
1336
- renderToStream(node, {
1337
- onError(error) {
1338
- throw error
1339
- },
1340
- }),
1321
+ return stripFlushMarkers(
1322
+ await drain(
1323
+ renderToStream(node, {
1324
+ onError(error) {
1325
+ throw error
1326
+ },
1327
+ }),
1328
+ ),
1341
1329
  )
1342
1330
  }
@@ -0,0 +1,2 @@
1
+ export const REMIX_UI_STYLE_LAYER = 'rmx'
2
+ export const REMIX_UI_RESET_LAYER = 'rmx-reset'