@remix-run/ui 0.1.1 → 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 +41 -47
- package/dist/runtime/frame.js.map +1 -1
- package/dist/runtime/jsx.js +2 -28
- 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.js +9 -1
- package/dist/runtime/navigation.js.map +1 -1
- package/dist/runtime/reconcile.js +433 -192
- 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/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 +5 -9
- 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 +76 -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 +3 -3
- 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 +71 -55
- package/src/runtime/jsx.ts +2 -29
- package/src/runtime/mixins/mixin.ts +9 -4
- package/src/runtime/mixins/on-mixin.ts +20 -12
- package/src/runtime/navigation.ts +10 -1
- package/src/runtime/reconcile.ts +540 -209
- package/src/runtime/run.ts +17 -1
- package/src/runtime/stream-protocol.ts +27 -0
- package/src/runtime/to-vnode.ts +5 -8
- package/src/runtime/vnode.ts +6 -9
- package/src/server/README.md +88 -0
- package/src/server/stream.ts +99 -116
- 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/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
|
|
|
@@ -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,5 +1,6 @@
|
|
|
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
|
|
|
@@ -13,21 +14,17 @@ function flatMapChildrenToVNodes(node: RemixElement): VNode[] {
|
|
|
13
14
|
}
|
|
14
15
|
|
|
15
16
|
function flattenChildrenToVNodes(nodes: RemixNode[], out: VNode[]): void {
|
|
16
|
-
for (let child of nodes) {
|
|
17
|
-
|
|
18
|
-
flattenChildrenToVNodes(child, out)
|
|
19
|
-
} else {
|
|
20
|
-
out.push(toVNode(child))
|
|
21
|
-
}
|
|
17
|
+
for (let child of normalizeChildren(nodes)) {
|
|
18
|
+
out.push(toVNode(child))
|
|
22
19
|
}
|
|
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
|
|
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,54 +122,32 @@ type Segment =
|
|
|
114
122
|
pending?: Promise<void>
|
|
115
123
|
}
|
|
116
124
|
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
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'
|
|
125
|
+
const TEXTAREA_VALUE_PROPS = new Set(['value', 'defaultValue'])
|
|
126
|
+
const INPUT_DEFAULT_PROPS = new Set(['defaultValue', 'defaultChecked'])
|
|
127
|
+
|
|
135
128
|
const DOCTYPE_PATTERN = /<!doctype(?:\s[^>]*)?>/gi
|
|
136
129
|
|
|
137
130
|
function stripDoctypeMarkup(html: string): string {
|
|
138
131
|
return html.replace(DOCTYPE_PATTERN, '')
|
|
139
132
|
}
|
|
140
133
|
|
|
141
|
-
function
|
|
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 {
|
|
142
147
|
return `${layer}.${selector}`
|
|
143
148
|
}
|
|
144
149
|
|
|
145
|
-
const
|
|
146
|
-
'z-index',
|
|
147
|
-
'opacity',
|
|
148
|
-
'flex-grow',
|
|
149
|
-
'flex-shrink',
|
|
150
|
-
'flex-order',
|
|
151
|
-
'grid-area',
|
|
152
|
-
'grid-row',
|
|
153
|
-
'grid-column',
|
|
154
|
-
'font-weight',
|
|
155
|
-
'line-height',
|
|
156
|
-
'order',
|
|
157
|
-
'orphans',
|
|
158
|
-
'widows',
|
|
159
|
-
'zoom',
|
|
160
|
-
'columns',
|
|
161
|
-
'column-count',
|
|
162
|
-
])
|
|
163
|
-
|
|
164
|
-
const FRAMEWORK_PROPS = new Set(['children', 'innerHTML', 'on', 'key', 'mix'])
|
|
150
|
+
const SSR_OMITTED_PROPS = RUNTIME_FRAMEWORK_PROPS
|
|
165
151
|
|
|
166
152
|
const ssrSignal = Object.freeze({
|
|
167
153
|
get aborted() {
|
|
@@ -218,6 +204,7 @@ export function renderToStream(
|
|
|
218
204
|
unresolvedHydrationData: new Map(),
|
|
219
205
|
frameData: new Map(),
|
|
220
206
|
blockingFrameTails: [],
|
|
207
|
+
flushKind: 'fragment',
|
|
221
208
|
serverIdScope: crypto.randomUUID().slice(0, 8),
|
|
222
209
|
serverIdCounter: 0,
|
|
223
210
|
}
|
|
@@ -231,7 +218,7 @@ export function renderToStream(
|
|
|
231
218
|
validateClientEntriesForHydration(context)
|
|
232
219
|
let html = serializeSegment(root)
|
|
233
220
|
let finalHtml = finalizeHtml(html, context)
|
|
234
|
-
let bytes = encoder.encode(finalHtml)
|
|
221
|
+
let bytes = encoder.encode(appendFlushMarker(finalHtml, context.flushKind))
|
|
235
222
|
controller.enqueue(bytes)
|
|
236
223
|
|
|
237
224
|
// If we have any tails from blocking frame streams, stream them now.
|
|
@@ -289,24 +276,28 @@ function createServerComponentId(context: RenderContext): string {
|
|
|
289
276
|
return `s${context.serverIdScope}-${context.serverIdCounter}`
|
|
290
277
|
}
|
|
291
278
|
|
|
292
|
-
async function splitFirstChunk(
|
|
293
|
-
stream: ReadableStream<Uint8Array>,
|
|
294
|
-
): Promise<{ first: Uint8Array; tail: ReadableStream<Uint8Array> }> {
|
|
279
|
+
async function splitFirstChunk(stream: ReadableStream<Uint8Array>): Promise<ResolvedFrameHtml> {
|
|
295
280
|
let reader = stream.getReader()
|
|
281
|
+
let decoder = new TextDecoder()
|
|
296
282
|
|
|
297
|
-
let
|
|
298
|
-
|
|
299
|
-
reader.
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
}),
|
|
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
|
|
307
292
|
}
|
|
308
293
|
}
|
|
309
294
|
|
|
295
|
+
if (!first) {
|
|
296
|
+
decoder.decode()
|
|
297
|
+
reader.releaseLock()
|
|
298
|
+
return { html: '', tail: emptyReadableStream() }
|
|
299
|
+
}
|
|
300
|
+
|
|
310
301
|
let released = false
|
|
311
302
|
function release() {
|
|
312
303
|
if (released) return
|
|
@@ -334,17 +325,15 @@ async function splitFirstChunk(
|
|
|
334
325
|
},
|
|
335
326
|
})
|
|
336
327
|
|
|
337
|
-
return {
|
|
328
|
+
return { html: stripFlushMarkers(stripDoctypeMarkup(decoder.decode(first))), tail }
|
|
338
329
|
}
|
|
339
330
|
|
|
340
331
|
async function resolveFrameHtml(
|
|
341
332
|
input: string | ReadableStream<Uint8Array>,
|
|
342
333
|
): Promise<ResolvedFrameHtml> {
|
|
343
|
-
if (typeof input === 'string') return { html: stripDoctypeMarkup(input) }
|
|
334
|
+
if (typeof input === 'string') return { html: stripFlushMarkers(stripDoctypeMarkup(input)) }
|
|
344
335
|
|
|
345
|
-
|
|
346
|
-
let { first, tail } = await splitFirstChunk(input)
|
|
347
|
-
return { html: stripDoctypeMarkup(decoder.decode(first)), tail }
|
|
336
|
+
return await splitFirstChunk(input)
|
|
348
337
|
}
|
|
349
338
|
|
|
350
339
|
function isRemixElement(node: unknown): node is RemixElement {
|
|
@@ -385,6 +374,7 @@ function buildSegment(node: RemixNode, context: RenderContext, frameState: SsrFr
|
|
|
385
374
|
let tag = type
|
|
386
375
|
|
|
387
376
|
if (tag === 'html') {
|
|
377
|
+
context.flushKind = 'document'
|
|
388
378
|
return buildElementSegment(tag, props, context, frameState)
|
|
389
379
|
}
|
|
390
380
|
|
|
@@ -464,7 +454,15 @@ function buildElementSegment(
|
|
|
464
454
|
let processedProps = processStyleProps(mixedProps)
|
|
465
455
|
// Determine namespace context for the current element and its children
|
|
466
456
|
let currentIsSvg = context.insideSvg || tag === 'svg'
|
|
467
|
-
|
|
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)
|
|
468
466
|
|
|
469
467
|
if (SELF_CLOSING_TAGS.has(tag)) {
|
|
470
468
|
return staticSeg(`<${tag}${attrs} />`)
|
|
@@ -485,6 +483,27 @@ function buildElementSegment(
|
|
|
485
483
|
return compositeSeg([open, children, close])
|
|
486
484
|
}
|
|
487
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
|
+
|
|
488
507
|
function buildHeadElementSegment(
|
|
489
508
|
tag: string,
|
|
490
509
|
props: any,
|
|
@@ -502,11 +521,12 @@ function buildHeadElementSegment(
|
|
|
502
521
|
return compositeSeg([open, children, close])
|
|
503
522
|
}
|
|
504
523
|
|
|
505
|
-
function renderAttributes(props: any, isSvg: boolean): string {
|
|
524
|
+
function renderAttributes(props: any, isSvg: boolean, excludedProps?: Set<string>): string {
|
|
506
525
|
let attrs = ''
|
|
507
526
|
|
|
508
527
|
for (let key in props) {
|
|
509
|
-
if (
|
|
528
|
+
if (SSR_OMITTED_PROPS.has(key)) continue
|
|
529
|
+
if (excludedProps?.has(key)) continue
|
|
510
530
|
|
|
511
531
|
let value = props[key]
|
|
512
532
|
if (value === undefined || value === null || value === false) continue
|
|
@@ -798,7 +818,11 @@ function buildComponentSegment(
|
|
|
798
818
|
let [renderedNode] = handle.render(props)
|
|
799
819
|
let childContext = { ...context, parentVNode: vnode }
|
|
800
820
|
|
|
801
|
-
|
|
821
|
+
let rendered = buildSegment(renderedNode, childContext, frameState)
|
|
822
|
+
if (childContext.flushKind === 'document') {
|
|
823
|
+
context.flushKind = 'document'
|
|
824
|
+
}
|
|
825
|
+
return rendered
|
|
802
826
|
}
|
|
803
827
|
|
|
804
828
|
function createHydrationPropsReplacer(context: RenderContext, frameState: SsrFrameState) {
|
|
@@ -1063,24 +1087,11 @@ function escapeTemplateContent(html: string): string {
|
|
|
1063
1087
|
}
|
|
1064
1088
|
|
|
1065
1089
|
function transformAttributeName(name: string, isSvg: boolean): string {
|
|
1066
|
-
|
|
1067
|
-
if (name.startsWith('aria-') || name.startsWith('data-')) return name
|
|
1068
|
-
|
|
1069
|
-
// HTML mappings
|
|
1070
|
-
if (name === 'className') return 'class'
|
|
1071
|
-
if (!isSvg) {
|
|
1072
|
-
if (name === 'htmlFor') return 'for'
|
|
1073
|
-
if (name === 'tabIndex') return 'tabindex'
|
|
1074
|
-
if (name === 'acceptCharset') return 'accept-charset'
|
|
1075
|
-
if (name === 'httpEquiv') return 'http-equiv'
|
|
1076
|
-
return name.toLowerCase()
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
return normalizeSvgAttribute(name).attr
|
|
1090
|
+
return normalizeAttributeName(name, isSvg).attr
|
|
1080
1091
|
}
|
|
1081
1092
|
|
|
1082
1093
|
function finalizeHtml(html: string, context: RenderContext): string {
|
|
1083
|
-
let hasHtmlRoot =
|
|
1094
|
+
let hasHtmlRoot = context.flushKind === 'document'
|
|
1084
1095
|
|
|
1085
1096
|
let styles = collectStyleTags(context)
|
|
1086
1097
|
if (styles) {
|
|
@@ -1164,7 +1175,7 @@ function collectStyleTags(context: RenderContext): string {
|
|
|
1164
1175
|
function wrapStyleForLayer(
|
|
1165
1176
|
selector: string,
|
|
1166
1177
|
css: string,
|
|
1167
|
-
layer: string =
|
|
1178
|
+
layer: string = REMIX_UI_STYLE_LAYER,
|
|
1168
1179
|
): string {
|
|
1169
1180
|
let trimmed = css.trim()
|
|
1170
1181
|
if (!trimmed) return ''
|
|
@@ -1174,7 +1185,7 @@ function wrapStyleForLayer(
|
|
|
1174
1185
|
function renderStyleTag(
|
|
1175
1186
|
selector: string,
|
|
1176
1187
|
css: string,
|
|
1177
|
-
layer: string =
|
|
1188
|
+
layer: string = REMIX_UI_STYLE_LAYER,
|
|
1178
1189
|
): string {
|
|
1179
1190
|
let wrappedCss = wrapStyleForLayer(selector, css, layer)
|
|
1180
1191
|
if (!wrappedCss) return ''
|
|
@@ -1225,36 +1236,6 @@ function escapeScriptJson(json: string): string {
|
|
|
1225
1236
|
return json.replace(/</g, '\\u003c')
|
|
1226
1237
|
}
|
|
1227
1238
|
|
|
1228
|
-
function serializeStyleObject(style: Record<string, any>): string {
|
|
1229
|
-
let parts: string[] = []
|
|
1230
|
-
|
|
1231
|
-
for (let [key, value] of Object.entries(style)) {
|
|
1232
|
-
if (value == null) continue
|
|
1233
|
-
if (typeof value === 'boolean') continue
|
|
1234
|
-
if (typeof value === 'number' && !Number.isFinite(value)) continue
|
|
1235
|
-
|
|
1236
|
-
// Convert camelCase to kebab-case
|
|
1237
|
-
let cssKey = key.replace(/[A-Z]/g, (m) => `-${m.toLowerCase()}`)
|
|
1238
|
-
|
|
1239
|
-
// Add px to numeric values where appropriate
|
|
1240
|
-
let shouldAppendPx =
|
|
1241
|
-
typeof value === 'number' &&
|
|
1242
|
-
value !== 0 &&
|
|
1243
|
-
!NUMERIC_CSS_PROPS.has(cssKey) &&
|
|
1244
|
-
!cssKey.startsWith('--')
|
|
1245
|
-
|
|
1246
|
-
let cssValue = shouldAppendPx
|
|
1247
|
-
? `${value}px`
|
|
1248
|
-
: Array.isArray(value)
|
|
1249
|
-
? value.join(', ')
|
|
1250
|
-
: String(value)
|
|
1251
|
-
|
|
1252
|
-
parts.push(`${cssKey}: ${cssValue};`)
|
|
1253
|
-
}
|
|
1254
|
-
|
|
1255
|
-
return parts.join(' ')
|
|
1256
|
-
}
|
|
1257
|
-
|
|
1258
1239
|
// Frame styles work end-to-end when frame handlers use their own `renderToStream`:
|
|
1259
1240
|
// the handler's `finalizeHtml` emits selector-addressed `<style>` tags in its HTML, and on the client,
|
|
1260
1241
|
// the `adoptServerStyleTag` MutationObserver (stylesheet.ts) picks it up anywhere in the
|
|
@@ -1337,11 +1318,13 @@ async function drain(stream: ReadableStream<Uint8Array>): Promise<string> {
|
|
|
1337
1318
|
* @returns Rendered HTML.
|
|
1338
1319
|
*/
|
|
1339
1320
|
export async function renderToString(node: RemixNode): Promise<string> {
|
|
1340
|
-
return
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1321
|
+
return stripFlushMarkers(
|
|
1322
|
+
await drain(
|
|
1323
|
+
renderToStream(node, {
|
|
1324
|
+
onError(error) {
|
|
1325
|
+
throw error
|
|
1326
|
+
},
|
|
1327
|
+
}),
|
|
1328
|
+
),
|
|
1346
1329
|
)
|
|
1347
1330
|
}
|