Skip to content

Commit 1a6834c

Browse files
antfubotantfu
andauthored
feat(json-render): client-only json-render docks via an inline view spec (#131)
Co-authored-by: Anthony Fu <github@antfu.me>
1 parent e00e389 commit 1a6834c

22 files changed

Lines changed: 297 additions & 102 deletions

File tree

‎docs/examples/minimal-next-devframe-hub.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)**
1616
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed.
1717
- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the Next route handler at `/__hub/__connection.json`, which starts the singleton host on demand.
1818
- The [JSON-render](/guide/json-render) hub integration with **registry replacement**: the host authors a view and projects it onto a `json-render` dock, and the React client renders it with a small in-example React registry (rather than the Vue `@devframes/json-render-ui`) — the path a non-Vue host uses.
19+
- [Client-only docks](/guide/client-context#client-only-docks) the page registers itself with `context.docks.register()`: an iframe dock rendered from a Blob URL, and an interactive `json-render` dock whose spec is authored in the browser and carried inline in the dock entry (`view: { spec }`) — its inputs, toggles, and `pushState`/`setState` buttons drive the view's own state (no shared state, nothing synced to the hub), rendered by the same React registry as the server-authored view.
1920

2021
## Run it
2122

‎docs/examples/minimal-vite-devframe-hub.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ Package: `minimal-vite-devframe-hub` · framework: **Vanilla TypeScript (Vite)**
1616
- The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed.
1717
- The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the kit's `__connection.json` middleware.
1818
- The opt-in [JSON-render](/guide/json-render) hub integration end to end: the host authors a view on its hub context and projects it onto a `json-render` dock, and the client host renders it via `@devframes/json-render-ui` (registered through `createDevframeClientHost({ renderers })`).
19+
- [Client-only docks](/guide/client-context#client-only-docks) the page registers itself with `context.docks.register()`: an iframe dock rendered from a Blob URL, and an interactive `json-render` dock whose spec is authored in the browser and carried inline in the dock entry (`view: { spec }`) — its inputs, toggles, and `pushState`/`setState` buttons drive the view's own state (no shared state, nothing synced to the hub), rendered by the same renderer as the server-authored view.
1920

2021
## Run it
2122

‎docs/guide/client-context.md‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,22 @@ handle.dispose() // remove it
9090

9191
Client-only docks merge into the same `docks.entries` list, group, select, and load their client scripts exactly like server docks — they just never sync to the hub or other viewers. A client dock sharing an id with a server dock overrides it locally. `ctx.docks.update(entry)` replaces a previously registered client dock wholesale. Registering an id that a client dock already owns throws unless you pass `register(entry, true)`.
9292

93+
A client-only dock can render a [JSON-render](./json-render) view the page authors itself. Carry the spec **inline** in the dock's `view` — no shared state, no server round-trip — and register a `json-render` dock. With a `json-render` renderer registered at boot, it renders through the same path as a server-authored view:
94+
95+
```ts
96+
const spec = { /* a DevframeJsonRenderSpec built in the browser */ }
97+
98+
ctx.docks.register({
99+
id: 'client-playground',
100+
title: 'Client Playground',
101+
icon: 'ph:sliders-horizontal-duotone',
102+
type: 'json-render',
103+
view: { spec },
104+
})
105+
```
106+
107+
The `view` field accepts either `{ spec }` (the spec rendered inline) or `{ stateKey }` (subscribed to a live shared state, the shape `createJsonRenderView` produces server-side). An inline view still runs its own state: `{ $bindState }` inputs and `{ $state }` reads work against the spec's `state`, and the built-in `setState` / `pushState` / `removeState` actions mutate it — so a client-authored view is interactive with no server and no shared state. What `{ spec }` lacks versus `{ stateKey }` is a server-driven update stream.
108+
93109
## Dock client scripts
94110

95111
A dock entry declares its client script as a `ClientScriptEntry``{ importFrom, importName? }`, where `importName` defaults to `'default'`. The field depends on the entry kind:

‎docs/guide/json-render.md‎

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,6 @@ its SPA. Connect, read the view's shared state, and render it with
129129
`JsonRenderView`:
130130

131131
```ts
132-
import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
133132
import { JsonRenderView } from '@devframes/json-render-ui'
134133
import { connectDevframe } from 'devframe/client'
135134
import { createApp, h, shallowRef } from 'vue'
@@ -145,7 +144,6 @@ createApp({
145144
render: () => h(JsonRenderView, {
146145
spec: spec.value,
147146
rpc,
148-
upstreamVersion: JSON_RENDER_UPSTREAM_VERSION,
149147
interactive: rpc.connectionMeta.backend !== 'static',
150148
}),
151149
}).mount('#app')
@@ -205,10 +203,13 @@ const host = await createDevframeClientHost({
205203
const dispose = await host.context.renderers.mount(entry, container)
206204
```
207205

208-
The dock carries only a serializable `JsonRenderViewRef` (`{ stateKey,
209-
upstreamVersion }`) — no functions cross the wire. The client host disposes the
210-
renderer when the dock deactivates. A renderer/upstream-version mismatch logs a
211-
warning rather than blocking.
206+
The dock carries only a serializable `JsonRenderViewRef` — no functions cross
207+
the wire. It comes in two shapes: `{ stateKey }` points the client at a live
208+
shared state to subscribe to (what `createJsonRenderView` produces), while
209+
`{ spec }` embeds the whole spec inline, so a client can synthesize a view in
210+
the browser and render it with no shared state at all (see [client-only
211+
docks](./client-context#client-only-docks)). The client host disposes the
212+
renderer when the dock deactivates.
212213

213214
Both hub example shells dogfood this end to end: the [Vite hub](/examples/minimal-vite-devframe-hub)
214215
registers `@devframes/json-render-ui` (Vue), and the [Next hub](/examples/minimal-next-devframe-hub)

‎examples/minimal-next-devframe-hub/src/client/app/page.tsx‎

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
DevframeTerminalSession,
99
DevframeViewIframe,
1010
} from '@devframes/hub/types'
11+
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
12+
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
1113
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
1214
import { useEffect, useMemo, useRef, useState } from 'react'
1315
import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer'
@@ -56,6 +58,79 @@ function createClientNotesUrl(): string {
5658
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
5759
}
5860

61+
// An *interactive* json-render spec synthesized entirely in the browser — the
62+
// client-only counterpart to a server-authored view. Interactivity needs no
63+
// server and no shared state: `{ $bindState }` inputs write straight into the
64+
// view's own `state`, `{ $state }` reads mirror it live, and the buttons use the
65+
// framework's built-in state actions (`pushState` / `setState`) to mutate that
66+
// state — every change re-renders through the mini React registry.
67+
function createClientPlaygroundSpec(clientType: string): DevframeJsonRenderSpec {
68+
return {
69+
root: 'root',
70+
elements: {
71+
root: { type: 'Stack', props: { gap: 14 }, children: ['head', 'hello', 'notes', 'env'] },
72+
73+
head: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['icon', 'title', 'badge'] },
74+
icon: { type: 'Icon', props: { name: 'ph:sliders-horizontal-duotone', size: 22 }, children: [] },
75+
title: { type: 'Text', props: { text: 'Client Playground', variant: 'heading' }, children: [] },
76+
badge: { type: 'Badge', props: { text: 'client-only', variant: 'info' }, children: [] },
77+
78+
// ── Two-way binding: type a name, see it echoed live; toggle a switch ──
79+
hello: { type: 'Card', props: { title: 'Say hello' }, children: ['helloBody'] },
80+
helloBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greetRow', 'compact'] },
81+
nameInput: { type: 'TextInput', props: { label: 'Your name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] },
82+
greetRow: { type: 'Stack', props: { direction: 'row', gap: 6, align: 'center' }, children: ['greetLabel', 'greetName'] },
83+
greetLabel: { type: 'Text', props: { text: 'Hello,', variant: 'body', color: 'muted' }, children: [] },
84+
greetName: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'body', color: 'primary' }, children: [] },
85+
compact: { type: 'Switch', props: { label: 'Compact mode', value: { $bindState: '/prefs/compact' } }, children: [] },
86+
87+
// ── Actions mutate state → the DataTable re-renders ──
88+
notes: { type: 'Card', props: { title: 'Notes' }, children: ['notesBody'] },
89+
notesBody: { type: 'Stack', props: { gap: 10 }, children: ['draftRow', 'notesTable', 'clearBtn'] },
90+
draftRow: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'end' }, children: ['draftInput', 'addBtn'] },
91+
draftInput: { type: 'TextInput', props: { label: 'New note', placeholder: 'Write something…', value: { $bindState: '/draft' } }, children: [] },
92+
addBtn: {
93+
type: 'Button',
94+
props: { label: 'Add', variant: 'primary', icon: 'ph:plus' },
95+
// Built-in `pushState`: append the typed draft to /notes, then clear the input.
96+
on: { press: { action: 'pushState', params: { statePath: '/notes', value: { text: { $state: '/draft' } }, clearStatePath: '/draft' } } },
97+
children: [],
98+
},
99+
notesTable: {
100+
type: 'DataTable',
101+
props: { columns: [{ key: 'text', label: 'Note' }], rows: { $state: '/notes' }, height: 160 },
102+
children: [],
103+
},
104+
clearBtn: {
105+
type: 'Button',
106+
props: { label: 'Clear all', variant: 'ghost', icon: 'ph:trash' },
107+
// Built-in `setState`: replace /notes with an empty array.
108+
on: { press: { action: 'setState', params: { statePath: '/notes', value: [] } } },
109+
children: [],
110+
},
111+
112+
env: { type: 'Card', props: { title: 'Environment', collapsible: true, defaultCollapsed: true }, children: ['envTable'] },
113+
envTable: {
114+
type: 'KeyValueTable',
115+
props: {
116+
data: {
117+
clientType,
118+
language: navigator.language,
119+
viewport: `${window.innerWidth}×${window.innerHeight}`,
120+
},
121+
},
122+
children: [],
123+
},
124+
},
125+
state: {
126+
form: { name: '' },
127+
prefs: { compact: false },
128+
draft: '',
129+
notes: [{ text: 'Authored entirely in the browser' }],
130+
},
131+
}
132+
}
133+
59134
/** Render a dock icon, falling back to the title's initial when unmapped. */
60135
function DockIcon({ entry }: { entry: DevframeDockEntry }) {
61136
const cls = iconClass(entry.icon)
@@ -120,6 +195,23 @@ export default function Page() {
120195
// Patch it in place with the returned handle (the id is immutable).
121196
clientDock.update({ badge: clientHost.context.clientType })
122197

198+
// Register a second client-only dock — this one a *json-render* view the
199+
// page authors itself, the richer sibling of the iframe dock above. Its
200+
// spec is carried **inline** in the dock entry (`view.spec`), so it needs
201+
// no shared state at all: it lives only in this page yet renders — and
202+
// stays fully interactive (inputs, toggles, and buttons that mutate its
203+
// state) — through the same `json-render` dock renderer (the mini React
204+
// registry) as a server-authored view. `force` lets React StrictMode
205+
// re-run this effect safely.
206+
const clientJsonRenderDock = clientHost.context.docks.register<DevframeJsonRenderDockEntry>({
207+
id: 'client-playground',
208+
title: 'Client Playground',
209+
icon: 'ph:sliders-horizontal-duotone',
210+
type: 'json-render',
211+
view: { spec: createClientPlaygroundSpec(clientHost.context.clientType) },
212+
category: 'app',
213+
}, true)
214+
123215
const docksState = await rpc.sharedState.get<DevframeDockEntry[]>(
124216
'devframe:docks',
125217
{ initialValue: [] },
@@ -165,8 +257,9 @@ export default function Page() {
165257

166258
cleanup = () => {
167259
window.clearInterval(interval)
168-
// Remove the client-only dock, then tear down the host.
260+
// Remove the client-only docks, then tear down the host.
169261
clientDock.dispose()
262+
clientJsonRenderDock.dispose()
170263
clientHost.dispose()
171264
}
172265
}

‎examples/minimal-next-devframe-hub/src/client/json-render/dock-renderer.tsx‎

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import type { JsonRenderViewRef, Spec } from '@devframes/json-render'
44
import type { ComponentRegistry } from '@json-render/react'
55
import type { ReactNode } from 'react'
6-
import { basePropSchemas, JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
6+
import { basePropSchemas } from '@devframes/json-render'
77
import { JSONUIProvider, Renderer } from '@json-render/react'
88
import { useMemo } from 'react'
99
import { createRoot } from 'react-dom/client'
@@ -63,18 +63,17 @@ interface JsonRenderViewProps {
6363
rpc: { call: (method: string, ...args: unknown[]) => Promise<unknown> }
6464
registry: ComponentRegistry
6565
viewId: string
66-
upstreamVersion?: string
6766
}
6867

69-
function JsonRenderView({ spec, rpc, registry, viewId, upstreamVersion }: JsonRenderViewProps): ReactNode {
68+
function JsonRenderView({ spec, rpc, registry, viewId }: JsonRenderViewProps): ReactNode {
7069
const handlers = useMemo(() => createActionBridge(rpc), [rpc])
7170
const effective = useMemo(() => (spec ? sanitizeSpec(spec) : null), [spec])
7271
if (!spec)
7372
return <div className="p4 color-faint text-sm">No view to render.</div>
7473
return (
7574
<JSONUIProvider
76-
// Reset the provider (reseed state) only on identity/version change.
77-
key={`${viewId}::${upstreamVersion ?? JSON_RENDER_UPSTREAM_VERSION}`}
75+
// Reset the provider (reseed state) only on identity change.
76+
key={viewId}
7877
registry={registry}
7978
handlers={handlers}
8079
initialState={spec.state ?? {}}
@@ -95,22 +94,37 @@ export interface ReactDockMountOptions {
9594
* A hub-compatible dock renderer that renders a `json-render` dock with this
9695
* example's mini **React** registry (registry replacement) instead of the Vue
9796
* reference frontend. Mounts a React root into the container the client host
98-
* provides, subscribes to the view's shared state, and disposes cleanly.
97+
* provides. For a shared-state view it subscribes to the live spec; for an
98+
* inline view (`entry.view.spec`) it renders the embedded spec directly, with
99+
* no shared-state round-trip. Disposes cleanly either way.
99100
*/
100101
export function createReactJsonRenderDockRenderer() {
101102
return async ({ entry, container, context }: ReactDockMountOptions): Promise<{ dispose: () => void }> => {
102103
const view = (entry as { view: JsonRenderViewRef }).view
103104
const rpc = context.rpc
104-
const state = await rpc.sharedState.get(view.stateKey, { initialValue: null })
105+
const viewId = 'stateKey' in view ? view.stateKey : ((entry as { id?: string }).id ?? 'inline')
105106
const root = createRoot(container)
107+
108+
// Inline view: render the embedded spec once, no shared state involved.
109+
if ('spec' in view) {
110+
root.render(
111+
<JsonRenderView spec={view.spec} rpc={rpc} registry={baseReactRegistry} viewId={viewId} />,
112+
)
113+
return {
114+
dispose() {
115+
root.unmount()
116+
},
117+
}
118+
}
119+
120+
const state = await rpc.sharedState.get(view.stateKey, { initialValue: null })
106121
const render = (): void => {
107122
root.render(
108123
<JsonRenderView
109124
spec={state.value() as Spec | null}
110125
rpc={rpc}
111126
registry={baseReactRegistry}
112-
viewId={view.stateKey}
113-
upstreamVersion={view.upstreamVersion}
127+
viewId={viewId}
114128
/>,
115129
)
116130
}

0 commit comments

Comments
 (0)