Skip to content

Commit 98e943c

Browse files
committed
feat(json-render)!: accept an inline spec in the view ref; drop upstreamVersion
The `json-render` dock's `view` field now accepts either `{ stateKey }` (subscribe to a live shared state) or `{ spec }` (an inline spec rendered directly), so a client host can synthesize a view entirely in the browser with no shared-state round-trip. Both dock renderers (Vue + the example's React registry) render the inline spec statically and keep subscribing for the shared-state variant. Removes the `upstreamVersion` field and the `JSON_RENDER_UPSTREAM_VERSION` export along with the renderer's version-mismatch warning, simplifying the serializable view contract. BREAKING CHANGE: `JsonRenderViewRef` no longer carries `upstreamVersion`; `JSON_RENDER_UPSTREAM_VERSION` and the `JsonRenderView` `upstreamVersion` prop are removed.
1 parent a73faf8 commit 98e943c

20 files changed

Lines changed: 109 additions & 139 deletions

File tree

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +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 a `json-render` dock whose spec is authored in the browser and seeded into a client-local shared state — rendered by the same React registry as the server-authored view, yet never syncing to the hub or other viewers.
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 a `json-render` dock whose spec is authored in the browser and carried inline in the dock entry (`view: { spec }`) — rendered by the same React registry as the server-authored view, with no shared state, yet never syncing to the hub or other viewers.
2020

2121
## Run it
2222

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +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 a `json-render` dock whose spec is authored in the browser and seeded into a client-local shared state — rendered by the same renderer as the server-authored view, yet never syncing to the hub or other viewers.
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 a `json-render` dock whose spec is authored in the browser and carried inline in the dock entry (`view: { spec }`) — rendered by the same renderer as the server-authored view, with no shared state, yet never syncing to the hub or other viewers.
2020

2121
## Run it
2222

‎docs/guide/client-context.md‎

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -90,24 +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. Seed the spec as this client's local value for the dock's `stateKey` — passing only `initialValue` and never mutating it keeps the spec local to this page — then register a `json-render` dock pointing at that key. With a `json-render` renderer registered at boot, it renders through the same path as a server-authored view:
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:
9494

9595
```ts
96-
import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
97-
98-
await ctx.rpc.sharedState.get('client:json-render:metrics', {
99-
initialValue: spec, // a DevframeJsonRenderSpec built in the browser
100-
})
96+
const spec = { /* a DevframeJsonRenderSpec built in the browser */ }
10197

10298
ctx.docks.register({
10399
id: 'client-metrics',
104100
title: 'Client Metrics',
105101
icon: 'ph:gauge-duotone',
106102
type: 'json-render',
107-
view: { stateKey: 'client:json-render:metrics', upstreamVersion: JSON_RENDER_UPSTREAM_VERSION },
103+
view: { spec },
108104
})
109105
```
110106

107+
The `view` field accepts either `{ spec }` (rendered inline, static) or `{ stateKey }` (subscribed to a live shared state, the shape `createJsonRenderView` produces server-side).
108+
111109
## Dock client scripts
112110

113111
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: 7 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import type {
1111
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
1212
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
1313
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
14-
import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
1514
import { useEffect, useMemo, useRef, useState } from 'react'
1615
import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer'
1716
import { iconClass } from './icons'
@@ -59,11 +58,6 @@ function createClientNotesUrl(): string {
5958
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
6059
}
6160

62-
// The shared-state key the client-only json-render dock renders from. It uses a
63-
// `client:` prefix rather than the server's `devframe:json-render:<scope>:<id>`
64-
// namespace to signal it is authored here, not by a node `createJsonRenderView`.
65-
const CLIENT_JSON_RENDER_KEY = 'client:json-render:metrics'
66-
6761
// A json-render spec synthesized entirely in the browser — the client-only
6862
// counterpart to a server-authored view. It reads real values captured from the
6963
// page at registration time and uses the same base-catalog components the hub's
@@ -170,22 +164,18 @@ export default function Page() {
170164
clientDock.update({ badge: clientHost.context.clientType })
171165

172166
// Register a second client-only dock — this one a *json-render* view the
173-
// page authors itself, the richer sibling of the iframe dock above.
174-
// First seed the spec as this client's local value for the dock's
175-
// `stateKey`: because we only pass `initialValue` and never mutate it,
176-
// the spec stays local to this page — it never syncs to the hub server
177-
// or other viewers. The `json-render` dock renderer registered above
178-
// then reads that same state and renders it with the mini React
179-
// registry. `force` lets React StrictMode re-run this effect safely.
180-
await rpc.sharedState.get<DevframeJsonRenderSpec>(CLIENT_JSON_RENDER_KEY, {
181-
initialValue: createClientMetricsSpec(clientHost.context.clientType),
182-
})
167+
// page authors itself, the richer sibling of the iframe dock above. Its
168+
// spec is carried **inline** in the dock entry (`view.spec`), so it needs
169+
// no shared state at all: it lives only in this page yet renders through
170+
// the same `json-render` dock renderer (the mini React registry) as a
171+
// server-authored view. `force` lets React StrictMode re-run this effect
172+
// safely.
183173
const clientJsonRenderDock = clientHost.context.docks.register<DevframeJsonRenderDockEntry>({
184174
id: 'client-metrics',
185175
title: 'Client Metrics',
186176
icon: 'ph:gauge-duotone',
187177
type: 'json-render',
188-
view: { stateKey: CLIENT_JSON_RENDER_KEY, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION },
178+
view: { spec: createClientMetricsSpec(clientHost.context.clientType) },
189179
category: 'app',
190180
}, true)
191181

‎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
}

‎examples/minimal-vite-devframe-hub/src/client/main.ts‎

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type {
88
import type { DevframeJsonRenderSpec } from '@devframes/json-render'
99
import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub'
1010
import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client'
11-
import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render'
1211
import { createJsonRenderDockRenderer } from '@devframes/json-render-ui'
1312
import { iconClass } from './icons'
1413
import 'virtual:uno.css'
@@ -83,11 +82,6 @@ function createClientNotesUrl(): string {
8382
return URL.createObjectURL(new Blob([html], { type: 'text/html' }))
8483
}
8584

86-
// The shared-state key the client-only json-render dock renders from. It uses a
87-
// `client:` prefix rather than the server's `devframe:json-render:<scope>:<id>`
88-
// namespace to signal it is authored here, not by a node `createJsonRenderView`.
89-
const CLIENT_JSON_RENDER_KEY = 'client:json-render:metrics'
90-
9185
// A json-render spec synthesized entirely in the browser — the client-only
9286
// counterpart to a server-authored view. It reads real values captured from the
9387
// page at registration time and uses the same base-catalog components the hub's
@@ -165,20 +159,16 @@ async function main() {
165159
clientDock.update({ badge: host.context.clientType })
166160

167161
// Register a second client-only dock — this one a *json-render* view the page
168-
// authors itself, the richer sibling of the iframe dock above. First seed the
169-
// spec as this client's local value for the dock's `stateKey`: because we only
170-
// pass `initialValue` and never mutate it, the spec stays local to this page —
171-
// it never syncs to the hub server or other viewers. The `json-render` dock
172-
// renderer registered above then reads that same state and renders it.
173-
await rpc.sharedState.get<DevframeJsonRenderSpec>(CLIENT_JSON_RENDER_KEY, {
174-
initialValue: createClientMetricsSpec(host.context.clientType),
175-
})
162+
// authors itself, the richer sibling of the iframe dock above. Its spec is
163+
// carried **inline** in the dock entry (`view.spec`), so it needs no shared
164+
// state at all: it lives only in this page yet renders through the very same
165+
// `json-render` dock renderer registered above as a server-authored view.
176166
host.context.docks.register<DevframeJsonRenderDockEntry>({
177167
id: 'client-metrics',
178168
title: 'Client Metrics',
179169
icon: 'ph:gauge-duotone',
180170
type: 'json-render',
181-
view: { stateKey: CLIENT_JSON_RENDER_KEY, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION },
171+
view: { spec: createClientMetricsSpec(host.context.clientType) },
182172
category: 'app',
183173
})
184174

‎packages/hub/src/client/__tests__/renderers.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ const jsonRenderEntry = {
4949
title: 'Metrics',
5050
icon: 'ph:cube',
5151
type: 'json-render',
52-
view: { stateKey: 'devframe:json-render:global:metrics', upstreamVersion: '0.19.0' },
52+
view: { stateKey: 'devframe:json-render:global:metrics' },
5353
} as unknown as DevframeDockEntry
5454

5555
const container = {} as HTMLElement

‎packages/json-render-ui/src/dock-renderer.ts‎

Lines changed: 20 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,13 @@ export interface JsonRenderDockRendererOptions {
3838
* })
3939
* ```
4040
*
41-
* It subscribes to the view's shared state (`entry.view.stateKey`), mounts a
42-
* Vue app rendering {@link JsonRenderView}, and disposes cleanly — unmounting
43-
* the app and unsubscribing the shared-state listener — when the dock
44-
* deactivates (the client host drives that).
41+
* For a shared-state view (`entry.view.stateKey`) it subscribes to the live
42+
* spec and re-renders on every update; for an inline view (`entry.view.spec`)
43+
* it renders the embedded spec directly, with no shared-state round-trip — the
44+
* path a client-synthesized view takes. Either way it mounts a Vue app
45+
* rendering {@link JsonRenderView} and disposes cleanly — unmounting the app and
46+
* unsubscribing any shared-state listener — when the dock deactivates (the
47+
* client host drives that).
4548
*/
4649
export function createJsonRenderDockRenderer(
4750
options: JsonRenderDockRendererOptions = {},
@@ -51,28 +54,34 @@ export function createJsonRenderDockRenderer(
5154
const view = (entry as { view: JsonRenderViewRef }).view
5255
const rpc = context.rpc
5356
const interactive = rpc.connectionMeta?.backend !== 'static'
54-
const state = await rpc.sharedState.get(view.stateKey, { initialValue: null })
57+
const viewId = 'stateKey' in view ? view.stateKey : ((entry as { id?: string }).id ?? 'inline')
5558

56-
const specRef = shallowRef<Spec | null>(state.value() as Spec | null)
57-
const off = state.on('updated', () => {
59+
// Inline view: render the embedded spec as-is; shared-state view: subscribe
60+
// to the live spec and track updates through `specRef`.
61+
const specRef = shallowRef<Spec | null>('spec' in view ? view.spec : null)
62+
let off: (() => void) | undefined
63+
if ('stateKey' in view) {
64+
const state = await rpc.sharedState.get(view.stateKey, { initialValue: null })
5865
specRef.value = state.value() as Spec | null
59-
})
66+
off = state.on('updated', () => {
67+
specRef.value = state.value() as Spec | null
68+
})
69+
}
6070

6171
const app = createApp({
6272
render: () => h(JsonRenderView, {
6373
spec: specRef.value,
6474
rpc: rpc as ActionBridgeRpc,
6575
registry,
66-
viewId: view.stateKey,
67-
upstreamVersion: view.upstreamVersion,
76+
viewId,
6877
interactive,
6978
}),
7079
})
7180
app.mount(container)
7281

7382
return {
7483
dispose() {
75-
off()
84+
off?.()
7685
app.unmount()
7786
},
7887
}

0 commit comments

Comments
 (0)