@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.
- package/README.md +24 -3
- package/dist/animation/animate-layout-mixin.js +66 -66
- package/dist/animation/animate-layout-mixin.js.map +1 -1
- package/dist/components/accordion/accordion.d.ts +3 -3
- package/dist/components/accordion/accordion.js +0 -2
- package/dist/components/accordion/accordion.js.map +1 -1
- package/dist/components/accordion/demos/card.demo.d.ts +6 -0
- package/dist/components/accordion/demos/card.demo.js +58 -0
- package/dist/components/accordion/demos/card.demo.js.map +1 -0
- package/dist/components/accordion/demos/multiple.demo.d.ts +6 -0
- package/dist/components/accordion/demos/multiple.demo.js +19 -0
- package/dist/components/accordion/demos/multiple.demo.js.map +1 -0
- package/dist/components/accordion/demos/overview.demo.d.ts +6 -0
- package/dist/components/accordion/demos/overview.demo.js +19 -0
- package/dist/components/accordion/demos/overview.demo.js.map +1 -0
- package/dist/components/breadcrumbs/breadcrumbs.js +0 -2
- package/dist/components/breadcrumbs/breadcrumbs.js.map +1 -1
- package/dist/components/breadcrumbs/demos/basic.demo.d.ts +5 -0
- package/dist/components/breadcrumbs/demos/basic.demo.js +14 -0
- package/dist/components/breadcrumbs/demos/basic.demo.js.map +1 -0
- package/dist/components/breadcrumbs/demos/separator.demo.d.ts +5 -0
- package/dist/components/breadcrumbs/demos/separator.demo.js +14 -0
- package/dist/components/breadcrumbs/demos/separator.demo.js.map +1 -0
- package/dist/components/button/button.d.ts +82 -0
- package/dist/components/button/button.js +60 -2
- package/dist/components/button/button.js.map +1 -1
- package/dist/components/button/demos/aliases.demo.d.ts +6 -0
- package/dist/components/button/demos/aliases.demo.js +18 -0
- package/dist/components/button/demos/aliases.demo.js.map +1 -0
- package/dist/components/button/demos/basic.demo.d.ts +6 -0
- package/dist/components/button/demos/basic.demo.js +19 -0
- package/dist/components/button/demos/basic.demo.js.map +1 -0
- package/dist/components/button/demos/states.demo.d.ts +6 -0
- package/dist/components/button/demos/states.demo.js +29 -0
- package/dist/components/button/demos/states.demo.js.map +1 -0
- package/dist/components/combobox/combobox.d.ts +3 -3
- package/dist/components/combobox/combobox.js +0 -2
- package/dist/components/combobox/combobox.js.map +1 -1
- package/dist/components/combobox/demos/overview.demo.d.ts +6 -0
- package/dist/components/combobox/demos/overview.demo.js +153 -0
- package/dist/components/combobox/demos/overview.demo.js.map +1 -0
- package/dist/components/listbox/demos/overview.demo.d.ts +6 -0
- package/dist/components/listbox/demos/overview.demo.js +64 -0
- package/dist/components/listbox/demos/overview.demo.js.map +1 -0
- package/dist/components/listbox/listbox.d.ts +1 -6
- package/dist/components/listbox/listbox.js +21 -33
- package/dist/components/listbox/listbox.js.map +1 -1
- package/dist/components/menu/demos/bubbling.demo.d.ts +5 -0
- package/dist/components/menu/demos/bubbling.demo.js +15 -0
- package/dist/components/menu/demos/bubbling.demo.js.map +1 -0
- package/dist/components/menu/demos/overview.demo.d.ts +6 -0
- package/dist/components/menu/demos/overview.demo.js +32 -0
- package/dist/components/menu/demos/overview.demo.js.map +1 -0
- package/dist/components/menu/menu.d.ts +5 -14
- package/dist/components/menu/menu.js +23 -38
- package/dist/components/menu/menu.js.map +1 -1
- package/dist/components/popover/demos/overview.demo.d.ts +6 -0
- package/dist/components/popover/demos/overview.demo.js +64 -0
- package/dist/components/popover/demos/overview.demo.js.map +1 -0
- package/dist/components/select/demos/deconstructed.demo.d.ts +6 -0
- package/dist/components/select/demos/deconstructed.demo.js +53 -0
- package/dist/components/select/demos/deconstructed.demo.js.map +1 -0
- package/dist/components/select/demos/overview.demo.d.ts +5 -0
- package/dist/components/select/demos/overview.demo.js +27 -0
- package/dist/components/select/demos/overview.demo.js.map +1 -0
- package/dist/components/select/select.d.ts +3 -3
- package/dist/components/select/select.js +17 -26
- package/dist/components/select/select.js.map +1 -1
- package/dist/components/tabs/tabs.d.ts +3 -3
- package/dist/components/tabs/tabs.js +0 -2
- package/dist/components/tabs/tabs.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js.map +1 -1
- package/dist/runtime/component.d.ts +8 -5
- package/dist/runtime/component.js +84 -80
- package/dist/runtime/component.js.map +1 -1
- package/dist/runtime/core/attributes.d.ts +12 -0
- package/dist/runtime/core/attributes.js +85 -0
- package/dist/runtime/core/attributes.js.map +1 -0
- package/dist/runtime/core/children.d.ts +7 -0
- package/dist/runtime/core/children.js +30 -0
- package/dist/runtime/core/children.js.map +1 -0
- package/dist/runtime/core/props.d.ts +9 -0
- package/dist/runtime/core/props.js +169 -0
- package/dist/runtime/core/props.js.map +1 -0
- package/dist/runtime/core/vnode.d.ts +3 -0
- package/dist/runtime/core/vnode.js +39 -0
- package/dist/runtime/core/vnode.js.map +1 -0
- package/dist/runtime/create-element.js +2 -1
- package/dist/runtime/create-element.js.map +1 -1
- package/dist/runtime/diff-dom.js +47 -14
- package/dist/runtime/diff-dom.js.map +1 -1
- package/dist/runtime/diff-props.d.ts +1 -2
- package/dist/runtime/diff-props.js +2 -181
- package/dist/runtime/diff-props.js.map +1 -1
- package/dist/runtime/dom.d.ts +4 -0
- package/dist/runtime/frame.d.ts +23 -2
- package/dist/runtime/frame.js +51 -52
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/jsx.js +2 -27
- package/dist/runtime/jsx.js.map +1 -1
- package/dist/runtime/mixins/mixin.js +9 -4
- package/dist/runtime/mixins/mixin.js.map +1 -1
- package/dist/runtime/mixins/on-mixin.d.ts +15 -4
- package/dist/runtime/mixins/on-mixin.js +9 -2
- package/dist/runtime/mixins/on-mixin.js.map +1 -1
- package/dist/runtime/navigation.d.ts +6 -0
- package/dist/runtime/navigation.js +16 -3
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/reconcile.js +444 -181
- package/dist/runtime/reconcile.js.map +1 -1
- package/dist/runtime/run.d.ts +17 -2
- package/dist/runtime/run.js.map +1 -1
- package/dist/runtime/scheduler.js +10 -0
- package/dist/runtime/scheduler.js.map +1 -1
- package/dist/runtime/stream-protocol.d.ts +8 -0
- package/dist/runtime/stream-protocol.js +20 -0
- package/dist/runtime/stream-protocol.js.map +1 -0
- package/dist/runtime/to-vnode.js +17 -18
- package/dist/runtime/to-vnode.js.map +1 -1
- package/dist/runtime/vnode.d.ts +5 -4
- package/dist/runtime/vnode.js +2 -3
- package/dist/runtime/vnode.js.map +1 -1
- package/dist/server/stream.js +80 -106
- package/dist/server/stream.js.map +1 -1
- package/dist/style/layers.d.ts +2 -0
- package/dist/style/layers.js +3 -0
- package/dist/style/layers.js.map +1 -0
- package/dist/style/stylesheet.d.ts +3 -1
- package/dist/style/stylesheet.js +69 -8
- package/dist/style/stylesheet.js.map +1 -1
- package/dist/theme/glyph-contract.d.ts +1 -1
- package/dist/theme/runtime.js +4 -1
- package/dist/theme/runtime.js.map +1 -1
- package/package.json +6 -7
- package/src/animation/README.md +314 -0
- package/src/animation/animate-layout-mixin.ts +79 -71
- package/src/components/accordion/README.md +166 -0
- package/src/components/accordion/accordion.tsx +8 -10
- package/src/components/accordion/demos/card.demo.tsx +113 -0
- package/src/components/accordion/demos/multiple.demo.tsx +56 -0
- package/src/components/accordion/demos/overview.demo.tsx +56 -0
- package/src/components/anchor/README.md +153 -0
- package/src/components/breadcrumbs/README.md +55 -0
- package/src/components/breadcrumbs/breadcrumbs.tsx +0 -2
- package/src/components/breadcrumbs/demos/basic.demo.tsx +17 -0
- package/src/components/breadcrumbs/demos/separator.demo.tsx +18 -0
- package/src/components/button/README.md +4 -4
- package/src/components/button/button.tsx +88 -2
- package/src/components/button/demos/aliases.demo.tsx +27 -0
- package/src/components/button/demos/basic.demo.tsx +30 -0
- package/src/components/button/demos/states.demo.tsx +52 -0
- package/src/components/combobox/combobox.tsx +8 -10
- package/src/components/combobox/demos/overview.demo.tsx +199 -0
- package/src/components/glyph/README.md +72 -0
- package/src/components/listbox/README.md +115 -0
- package/src/components/listbox/demos/overview.demo.tsx +101 -0
- package/src/components/listbox/listbox.ts +23 -32
- package/src/components/menu/README.md +96 -0
- package/src/components/menu/demos/bubbling.demo.tsx +34 -0
- package/src/components/menu/demos/overview.demo.tsx +76 -0
- package/src/components/menu/menu.tsx +35 -41
- package/src/components/popover/README.md +3 -3
- package/src/components/popover/demos/overview.demo.tsx +143 -0
- package/src/components/select/README.md +107 -0
- package/src/components/select/demos/deconstructed.demo.tsx +95 -0
- package/src/components/select/demos/overview.demo.tsx +51 -0
- package/src/components/select/select.tsx +28 -28
- package/src/components/tabs/tabs.tsx +10 -5
- package/src/index.ts +1 -1
- package/src/runtime/component.ts +94 -82
- package/src/runtime/core/attributes.ts +97 -0
- package/src/runtime/core/children.ts +42 -0
- package/src/runtime/core/props.ts +185 -0
- package/src/runtime/core/vnode.ts +48 -0
- package/src/runtime/create-element.ts +2 -1
- package/src/runtime/diff-dom.ts +59 -12
- package/src/runtime/diff-props.ts +2 -187
- package/src/runtime/dom.ts +4 -0
- package/src/runtime/frame.ts +84 -61
- package/src/runtime/jsx.ts +2 -30
- package/src/runtime/mixins/mixin.ts +9 -4
- package/src/runtime/mixins/on-mixin.ts +20 -12
- package/src/runtime/navigation.ts +24 -3
- package/src/runtime/reconcile.ts +552 -186
- package/src/runtime/run.ts +17 -1
- package/src/runtime/scheduler.ts +7 -0
- package/src/runtime/stream-protocol.ts +27 -0
- package/src/runtime/to-vnode.ts +15 -17
- package/src/runtime/vnode.ts +6 -9
- package/src/server/README.md +88 -0
- package/src/server/stream.ts +105 -117
- package/src/style/layers.ts +2 -0
- package/src/style/stylesheet.ts +75 -10
- package/src/test/README.md +107 -0
- package/src/test/utils.ts +3 -1
- package/src/theme/README.md +103 -0
- package/src/theme/runtime.ts +4 -1
- package/src/utils/scroll-lock/README.md +33 -0
- package/src/test/setup.ts +0 -7
package/src/runtime/run.ts
CHANGED
|
@@ -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
|
|
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
|
|
package/src/runtime/scheduler.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/runtime/to-vnode.ts
CHANGED
|
@@ -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
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
15
|
-
for (let child of nodes) {
|
|
16
|
-
|
|
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
|
|
23
|
+
if (isEmptyChild(node)) {
|
|
27
24
|
return { type: TEXT_NODE, _text: '' }
|
|
28
25
|
}
|
|
29
26
|
|
|
30
|
-
if (
|
|
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
|
|
36
|
-
|
|
32
|
+
let children: VNode[] = []
|
|
33
|
+
flattenChildrenToVNodes(node, children)
|
|
34
|
+
return { type: Fragment, _children: children }
|
|
37
35
|
}
|
|
38
36
|
|
|
39
37
|
if (node.type === Fragment) {
|
package/src/runtime/vnode.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import type { ComponentHandle, Component } from './component.ts'
|
|
2
2
|
import { Fragment, Frame } from './component.ts'
|
|
3
|
-
import
|
|
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>`
|
package/src/server/stream.ts
CHANGED
|
@@ -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 {
|
|
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
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
'
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
'
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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
|
|
293
|
-
|
|
294
|
-
reader.
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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 =
|
|
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
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1321
|
+
return stripFlushMarkers(
|
|
1322
|
+
await drain(
|
|
1323
|
+
renderToStream(node, {
|
|
1324
|
+
onError(error) {
|
|
1325
|
+
throw error
|
|
1326
|
+
},
|
|
1327
|
+
}),
|
|
1328
|
+
),
|
|
1341
1329
|
)
|
|
1342
1330
|
}
|