From a73faf876ddde6bf39659a62bc15c34f77df79eb Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 23 Jul 2026 03:05:46 +0000 Subject: [PATCH 1/4] docs(examples): register a client-only json-render dock in the hub examples --- docs/examples/minimal-next-devframe-hub.md | 1 + docs/examples/minimal-vite-devframe-hub.md | 1 + docs/guide/client-context.md | 18 +++++ .../src/client/app/page.tsx | 72 ++++++++++++++++++- .../src/client/main.ts | 67 +++++++++++++++++ 5 files changed, 158 insertions(+), 1 deletion(-) diff --git a/docs/examples/minimal-next-devframe-hub.md b/docs/examples/minimal-next-devframe-hub.md index 6d42588..4279941 100644 --- a/docs/examples/minimal-next-devframe-hub.md +++ b/docs/examples/minimal-next-devframe-hub.md @@ -16,6 +16,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)** - The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. - 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. - 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. +- [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. ## Run it diff --git a/docs/examples/minimal-vite-devframe-hub.md b/docs/examples/minimal-vite-devframe-hub.md index 1d27f4b..d35b51c 100644 --- a/docs/examples/minimal-vite-devframe-hub.md +++ b/docs/examples/minimal-vite-devframe-hub.md @@ -16,6 +16,7 @@ Package: `minimal-vite-devframe-hub` · framework: **Vanilla TypeScript (Vite)** - The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. - The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the kit's `__connection.json` middleware. - 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 })`). +- [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. ## Run it diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index f1750a2..c8e33d0 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -90,6 +90,24 @@ handle.dispose() // remove it 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)`. +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: + +```ts +import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' + +await ctx.rpc.sharedState.get('client:json-render:metrics', { + initialValue: spec, // a DevframeJsonRenderSpec built in the browser +}) + +ctx.docks.register({ + id: 'client-metrics', + title: 'Client Metrics', + icon: 'ph:gauge-duotone', + type: 'json-render', + view: { stateKey: 'client:json-render:metrics', upstreamVersion: JSON_RENDER_UPSTREAM_VERSION }, +}) +``` + ## Dock client scripts A dock entry declares its client script as a `ClientScriptEntry` — `{ importFrom, importName? }`, where `importName` defaults to `'default'`. The field depends on the entry kind: diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx index 81a80ba..ec71910 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -8,7 +8,10 @@ import type { DevframeTerminalSession, DevframeViewIframe, } from '@devframes/hub/types' +import type { DevframeJsonRenderSpec } from '@devframes/json-render' +import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' +import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' import { useEffect, useMemo, useRef, useState } from 'react' import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer' import { iconClass } from './icons' @@ -56,6 +59,52 @@ function createClientNotesUrl(): string { return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } +// The shared-state key the client-only json-render dock renders from. It uses a +// `client:` prefix rather than the server's `devframe:json-render::` +// namespace to signal it is authored here, not by a node `createJsonRenderView`. +const CLIENT_JSON_RENDER_KEY = 'client:json-render:metrics' + +// A json-render spec synthesized entirely in the browser — the client-only +// counterpart to a server-authored view. It reads real values captured from the +// page at registration time and uses the same base-catalog components the hub's +// server view does, rendered here by the mini React registry. +function createClientMetricsSpec(clientType: string): DevframeJsonRenderSpec { + return { + root: 'root', + elements: { + root: { type: 'Stack', props: { gap: 14 }, children: ['head', 'about', 'env'] }, + head: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['icon', 'title', 'badge'] }, + icon: { type: 'Icon', props: { name: 'ph:gauge-duotone', size: 22 }, children: [] }, + title: { type: 'Text', props: { text: 'Client Metrics', variant: 'heading' }, children: [] }, + badge: { type: 'Badge', props: { text: 'client-only', variant: 'info' }, children: [] }, + about: { type: 'Card', props: { title: 'About this dock' }, children: ['aboutText'] }, + aboutText: { + type: 'Text', + props: { + text: 'This json-render view was authored in the browser and seeded into a client-local shared state — it never reaches the hub server or other viewers, yet renders through the same dock renderer as a server-authored view.', + variant: 'body', + color: 'muted', + }, + children: [], + }, + env: { type: 'Card', props: { title: 'Environment' }, children: ['envTable'] }, + envTable: { + type: 'KeyValueTable', + props: { + data: { + clientType, + language: navigator.language, + viewport: `${window.innerWidth}×${window.innerHeight}`, + online: navigator.onLine ? 'yes' : 'no', + }, + }, + children: [], + }, + }, + state: {}, + } +} + /** Render a dock icon, falling back to the title's initial when unmapped. */ function DockIcon({ entry }: { entry: DevframeDockEntry }) { const cls = iconClass(entry.icon) @@ -120,6 +169,26 @@ export default function Page() { // Patch it in place with the returned handle (the id is immutable). clientDock.update({ badge: clientHost.context.clientType }) + // Register a second client-only dock — this one a *json-render* view the + // page authors itself, the richer sibling of the iframe dock above. + // First seed the spec as this client's local value for the dock's + // `stateKey`: because we only pass `initialValue` and never mutate it, + // the spec stays local to this page — it never syncs to the hub server + // or other viewers. The `json-render` dock renderer registered above + // then reads that same state and renders it with the mini React + // registry. `force` lets React StrictMode re-run this effect safely. + await rpc.sharedState.get(CLIENT_JSON_RENDER_KEY, { + initialValue: createClientMetricsSpec(clientHost.context.clientType), + }) + const clientJsonRenderDock = clientHost.context.docks.register({ + id: 'client-metrics', + title: 'Client Metrics', + icon: 'ph:gauge-duotone', + type: 'json-render', + view: { stateKey: CLIENT_JSON_RENDER_KEY, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION }, + category: 'app', + }, true) + const docksState = await rpc.sharedState.get( 'devframe:docks', { initialValue: [] }, @@ -165,8 +234,9 @@ export default function Page() { cleanup = () => { window.clearInterval(interval) - // Remove the client-only dock, then tear down the host. + // Remove the client-only docks, then tear down the host. clientDock.dispose() + clientJsonRenderDock.dispose() clientHost.dispose() } } diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts index 72b2655..31b8b21 100644 --- a/examples/minimal-vite-devframe-hub/src/client/main.ts +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -5,7 +5,10 @@ import type { DevframeTerminalSession, DevframeViewIframe, } from '@devframes/hub/types' +import type { DevframeJsonRenderSpec } from '@devframes/json-render' +import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' +import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' import { createJsonRenderDockRenderer } from '@devframes/json-render-ui' import { iconClass } from './icons' import 'virtual:uno.css' @@ -80,6 +83,52 @@ function createClientNotesUrl(): string { return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } +// The shared-state key the client-only json-render dock renders from. It uses a +// `client:` prefix rather than the server's `devframe:json-render::` +// namespace to signal it is authored here, not by a node `createJsonRenderView`. +const CLIENT_JSON_RENDER_KEY = 'client:json-render:metrics' + +// A json-render spec synthesized entirely in the browser — the client-only +// counterpart to a server-authored view. It reads real values captured from the +// page at registration time and uses the same base-catalog components the hub's +// server view does, rendered by the very same `createJsonRenderDockRenderer`. +function createClientMetricsSpec(clientType: string): DevframeJsonRenderSpec { + return { + root: 'root', + elements: { + root: { type: 'Stack', props: { gap: 14 }, children: ['head', 'about', 'env'] }, + head: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['icon', 'title', 'badge'] }, + icon: { type: 'Icon', props: { name: 'ph:gauge-duotone', size: 22 }, children: [] }, + title: { type: 'Text', props: { text: 'Client Metrics', variant: 'heading' }, children: [] }, + badge: { type: 'Badge', props: { text: 'client-only', variant: 'info' }, children: [] }, + about: { type: 'Card', props: { title: 'About this dock' }, children: ['aboutText'] }, + aboutText: { + type: 'Text', + props: { + text: 'This json-render view was authored in the browser and seeded into a client-local shared state — it never reaches the hub server or other viewers, yet renders through the same dock renderer as a server-authored view.', + variant: 'body', + color: 'muted', + }, + children: [], + }, + env: { type: 'Card', props: { title: 'Environment' }, children: ['envTable'] }, + envTable: { + type: 'KeyValueTable', + props: { + data: { + clientType, + language: navigator.language, + viewport: `${window.innerWidth}×${window.innerHeight}`, + online: navigator.onLine ? 'yes' : 'no', + }, + }, + children: [], + }, + }, + state: {}, + } +} + async function main() { setStatus('Connecting…') @@ -115,6 +164,24 @@ async function main() { // `clientDock.dispose()` to remove it from the merged list again. clientDock.update({ badge: host.context.clientType }) + // Register a second client-only dock — this one a *json-render* view the page + // authors itself, the richer sibling of the iframe dock above. First seed the + // spec as this client's local value for the dock's `stateKey`: because we only + // pass `initialValue` and never mutate it, the spec stays local to this page — + // it never syncs to the hub server or other viewers. The `json-render` dock + // renderer registered above then reads that same state and renders it. + await rpc.sharedState.get(CLIENT_JSON_RENDER_KEY, { + initialValue: createClientMetricsSpec(host.context.clientType), + }) + host.context.docks.register({ + id: 'client-metrics', + title: 'Client Metrics', + icon: 'ph:gauge-duotone', + type: 'json-render', + view: { stateKey: CLIENT_JSON_RENDER_KEY, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION }, + category: 'app', + }) + // 1. Docks — the merged list from the client host: server docks (projected // from `devframe:docks` shared state) plus the client-only dock above. We // still subscribe to the shared state to re-render when server docks change. From 98e943c61a2c906c2d25ae77a1b601581fa4c667 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 23 Jul 2026 04:21:42 +0000 Subject: [PATCH 2/4] 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. --- docs/examples/minimal-next-devframe-hub.md | 2 +- docs/examples/minimal-vite-devframe-hub.md | 2 +- docs/guide/client-context.md | 12 ++--- docs/guide/json-render.md | 13 ++--- .../src/client/app/page.tsx | 24 +++------ .../src/client/json-render/dock-renderer.tsx | 32 ++++++++---- .../src/client/main.ts | 20 ++------ .../src/client/__tests__/renderers.test.ts | 2 +- packages/json-render-ui/src/dock-renderer.ts | 31 ++++++++---- packages/json-render-ui/src/renderer.ts | 26 ++-------- packages/json-render-ui/src/spa/main.ts | 1 - packages/json-render/src/index.ts | 3 +- packages/json-render/src/node/create-view.ts | 5 +- packages/json-render/src/types.ts | 6 +-- packages/json-render/src/view-index.ts | 2 - packages/json-render/src/view-ref.ts | 50 ++++++++++--------- packages/json-render/test/create-view.test.ts | 3 -- .../json-render-ui/index.snapshot.d.ts | 9 ---- .../json-render/index.snapshot.d.ts | 4 +- .../@devframes/json-render/index.snapshot.js | 1 - 20 files changed, 109 insertions(+), 139 deletions(-) diff --git a/docs/examples/minimal-next-devframe-hub.md b/docs/examples/minimal-next-devframe-hub.md index 4279941..8b5cee3 100644 --- a/docs/examples/minimal-next-devframe-hub.md +++ b/docs/examples/minimal-next-devframe-hub.md @@ -16,7 +16,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)** - The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. - 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. - 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. -- [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. +- [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. ## Run it diff --git a/docs/examples/minimal-vite-devframe-hub.md b/docs/examples/minimal-vite-devframe-hub.md index d35b51c..9991356 100644 --- a/docs/examples/minimal-vite-devframe-hub.md +++ b/docs/examples/minimal-vite-devframe-hub.md @@ -16,7 +16,7 @@ Package: `minimal-vite-devframe-hub` · framework: **Vanilla TypeScript (Vite)** - The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. - The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the kit's `__connection.json` middleware. - 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 })`). -- [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. +- [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. ## Run it diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index c8e33d0..419997c 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -90,24 +90,22 @@ handle.dispose() // remove it 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)`. -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: +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: ```ts -import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' - -await ctx.rpc.sharedState.get('client:json-render:metrics', { - initialValue: spec, // a DevframeJsonRenderSpec built in the browser -}) +const spec = { /* a DevframeJsonRenderSpec built in the browser */ } ctx.docks.register({ id: 'client-metrics', title: 'Client Metrics', icon: 'ph:gauge-duotone', type: 'json-render', - view: { stateKey: 'client:json-render:metrics', upstreamVersion: JSON_RENDER_UPSTREAM_VERSION }, + view: { spec }, }) ``` +The `view` field accepts either `{ spec }` (rendered inline, static) or `{ stateKey }` (subscribed to a live shared state, the shape `createJsonRenderView` produces server-side). + ## Dock client scripts A dock entry declares its client script as a `ClientScriptEntry` — `{ importFrom, importName? }`, where `importName` defaults to `'default'`. The field depends on the entry kind: diff --git a/docs/guide/json-render.md b/docs/guide/json-render.md index d6348ec..3509432 100644 --- a/docs/guide/json-render.md +++ b/docs/guide/json-render.md @@ -129,7 +129,6 @@ its SPA. Connect, read the view's shared state, and render it with `JsonRenderView`: ```ts -import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' import { JsonRenderView } from '@devframes/json-render-ui' import { connectDevframe } from 'devframe/client' import { createApp, h, shallowRef } from 'vue' @@ -145,7 +144,6 @@ createApp({ render: () => h(JsonRenderView, { spec: spec.value, rpc, - upstreamVersion: JSON_RENDER_UPSTREAM_VERSION, interactive: rpc.connectionMeta.backend !== 'static', }), }).mount('#app') @@ -205,10 +203,13 @@ const host = await createDevframeClientHost({ const dispose = await host.context.renderers.mount(entry, container) ``` -The dock carries only a serializable `JsonRenderViewRef` (`{ stateKey, -upstreamVersion }`) — no functions cross the wire. The client host disposes the -renderer when the dock deactivates. A renderer/upstream-version mismatch logs a -warning rather than blocking. +The dock carries only a serializable `JsonRenderViewRef` — no functions cross +the wire. It comes in two shapes: `{ stateKey }` points the client at a live +shared state to subscribe to (what `createJsonRenderView` produces), while +`{ spec }` embeds the whole spec inline, so a client can synthesize a view in +the browser and render it with no shared state at all (see [client-only +docks](./client-context#client-only-docks)). The client host disposes the +renderer when the dock deactivates. Both hub example shells dogfood this end to end: the [Vite hub](/examples/minimal-vite-devframe-hub) registers `@devframes/json-render-ui` (Vue), and the [Next hub](/examples/minimal-next-devframe-hub) diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx index ec71910..db34c61 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -11,7 +11,6 @@ import type { import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' -import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' import { useEffect, useMemo, useRef, useState } from 'react' import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer' import { iconClass } from './icons' @@ -59,11 +58,6 @@ function createClientNotesUrl(): string { return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } -// The shared-state key the client-only json-render dock renders from. It uses a -// `client:` prefix rather than the server's `devframe:json-render::` -// namespace to signal it is authored here, not by a node `createJsonRenderView`. -const CLIENT_JSON_RENDER_KEY = 'client:json-render:metrics' - // A json-render spec synthesized entirely in the browser — the client-only // counterpart to a server-authored view. It reads real values captured from the // page at registration time and uses the same base-catalog components the hub's @@ -170,22 +164,18 @@ export default function Page() { clientDock.update({ badge: clientHost.context.clientType }) // Register a second client-only dock — this one a *json-render* view the - // page authors itself, the richer sibling of the iframe dock above. - // First seed the spec as this client's local value for the dock's - // `stateKey`: because we only pass `initialValue` and never mutate it, - // the spec stays local to this page — it never syncs to the hub server - // or other viewers. The `json-render` dock renderer registered above - // then reads that same state and renders it with the mini React - // registry. `force` lets React StrictMode re-run this effect safely. - await rpc.sharedState.get(CLIENT_JSON_RENDER_KEY, { - initialValue: createClientMetricsSpec(clientHost.context.clientType), - }) + // page authors itself, the richer sibling of the iframe dock above. Its + // spec is carried **inline** in the dock entry (`view.spec`), so it needs + // no shared state at all: it lives only in this page yet renders through + // the same `json-render` dock renderer (the mini React registry) as a + // server-authored view. `force` lets React StrictMode re-run this effect + // safely. const clientJsonRenderDock = clientHost.context.docks.register({ id: 'client-metrics', title: 'Client Metrics', icon: 'ph:gauge-duotone', type: 'json-render', - view: { stateKey: CLIENT_JSON_RENDER_KEY, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION }, + view: { spec: createClientMetricsSpec(clientHost.context.clientType) }, category: 'app', }, true) diff --git a/examples/minimal-next-devframe-hub/src/client/json-render/dock-renderer.tsx b/examples/minimal-next-devframe-hub/src/client/json-render/dock-renderer.tsx index 0a8b17d..e6adc04 100644 --- a/examples/minimal-next-devframe-hub/src/client/json-render/dock-renderer.tsx +++ b/examples/minimal-next-devframe-hub/src/client/json-render/dock-renderer.tsx @@ -3,7 +3,7 @@ import type { JsonRenderViewRef, Spec } from '@devframes/json-render' import type { ComponentRegistry } from '@json-render/react' import type { ReactNode } from 'react' -import { basePropSchemas, JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' +import { basePropSchemas } from '@devframes/json-render' import { JSONUIProvider, Renderer } from '@json-render/react' import { useMemo } from 'react' import { createRoot } from 'react-dom/client' @@ -63,18 +63,17 @@ interface JsonRenderViewProps { rpc: { call: (method: string, ...args: unknown[]) => Promise } registry: ComponentRegistry viewId: string - upstreamVersion?: string } -function JsonRenderView({ spec, rpc, registry, viewId, upstreamVersion }: JsonRenderViewProps): ReactNode { +function JsonRenderView({ spec, rpc, registry, viewId }: JsonRenderViewProps): ReactNode { const handlers = useMemo(() => createActionBridge(rpc), [rpc]) const effective = useMemo(() => (spec ? sanitizeSpec(spec) : null), [spec]) if (!spec) return
No view to render.
return ( void }> => { const view = (entry as { view: JsonRenderViewRef }).view const rpc = context.rpc - const state = await rpc.sharedState.get(view.stateKey, { initialValue: null }) + const viewId = 'stateKey' in view ? view.stateKey : ((entry as { id?: string }).id ?? 'inline') const root = createRoot(container) + + // Inline view: render the embedded spec once, no shared state involved. + if ('spec' in view) { + root.render( + , + ) + return { + dispose() { + root.unmount() + }, + } + } + + const state = await rpc.sharedState.get(view.stateKey, { initialValue: null }) const render = (): void => { root.render( , ) } diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts index 31b8b21..2d5deba 100644 --- a/examples/minimal-vite-devframe-hub/src/client/main.ts +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -8,7 +8,6 @@ import type { import type { DevframeJsonRenderSpec } from '@devframes/json-render' import type { DevframeJsonRenderDockEntry } from '@devframes/json-render/hub' import { connectDevframe, createDevframeClientHost } from '@devframes/hub/client' -import { JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' import { createJsonRenderDockRenderer } from '@devframes/json-render-ui' import { iconClass } from './icons' import 'virtual:uno.css' @@ -83,11 +82,6 @@ function createClientNotesUrl(): string { return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } -// The shared-state key the client-only json-render dock renders from. It uses a -// `client:` prefix rather than the server's `devframe:json-render::` -// namespace to signal it is authored here, not by a node `createJsonRenderView`. -const CLIENT_JSON_RENDER_KEY = 'client:json-render:metrics' - // A json-render spec synthesized entirely in the browser — the client-only // counterpart to a server-authored view. It reads real values captured from the // page at registration time and uses the same base-catalog components the hub's @@ -165,20 +159,16 @@ async function main() { clientDock.update({ badge: host.context.clientType }) // Register a second client-only dock — this one a *json-render* view the page - // authors itself, the richer sibling of the iframe dock above. First seed the - // spec as this client's local value for the dock's `stateKey`: because we only - // pass `initialValue` and never mutate it, the spec stays local to this page — - // it never syncs to the hub server or other viewers. The `json-render` dock - // renderer registered above then reads that same state and renders it. - await rpc.sharedState.get(CLIENT_JSON_RENDER_KEY, { - initialValue: createClientMetricsSpec(host.context.clientType), - }) + // authors itself, the richer sibling of the iframe dock above. Its spec is + // carried **inline** in the dock entry (`view.spec`), so it needs no shared + // state at all: it lives only in this page yet renders through the very same + // `json-render` dock renderer registered above as a server-authored view. host.context.docks.register({ id: 'client-metrics', title: 'Client Metrics', icon: 'ph:gauge-duotone', type: 'json-render', - view: { stateKey: CLIENT_JSON_RENDER_KEY, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION }, + view: { spec: createClientMetricsSpec(host.context.clientType) }, category: 'app', }) diff --git a/packages/hub/src/client/__tests__/renderers.test.ts b/packages/hub/src/client/__tests__/renderers.test.ts index 2de4a13..1bbbe8c 100644 --- a/packages/hub/src/client/__tests__/renderers.test.ts +++ b/packages/hub/src/client/__tests__/renderers.test.ts @@ -49,7 +49,7 @@ const jsonRenderEntry = { title: 'Metrics', icon: 'ph:cube', type: 'json-render', - view: { stateKey: 'devframe:json-render:global:metrics', upstreamVersion: '0.19.0' }, + view: { stateKey: 'devframe:json-render:global:metrics' }, } as unknown as DevframeDockEntry const container = {} as HTMLElement diff --git a/packages/json-render-ui/src/dock-renderer.ts b/packages/json-render-ui/src/dock-renderer.ts index d2d8366..8936df5 100644 --- a/packages/json-render-ui/src/dock-renderer.ts +++ b/packages/json-render-ui/src/dock-renderer.ts @@ -38,10 +38,13 @@ export interface JsonRenderDockRendererOptions { * }) * ``` * - * It subscribes to the view's shared state (`entry.view.stateKey`), mounts a - * Vue app rendering {@link JsonRenderView}, and disposes cleanly — unmounting - * the app and unsubscribing the shared-state listener — when the dock - * deactivates (the client host drives that). + * For a shared-state view (`entry.view.stateKey`) it subscribes to the live + * spec and re-renders on every update; for an inline view (`entry.view.spec`) + * it renders the embedded spec directly, with no shared-state round-trip — the + * path a client-synthesized view takes. Either way it mounts a Vue app + * rendering {@link JsonRenderView} and disposes cleanly — unmounting the app and + * unsubscribing any shared-state listener — when the dock deactivates (the + * client host drives that). */ export function createJsonRenderDockRenderer( options: JsonRenderDockRendererOptions = {}, @@ -51,20 +54,26 @@ export function createJsonRenderDockRenderer( const view = (entry as { view: JsonRenderViewRef }).view const rpc = context.rpc const interactive = rpc.connectionMeta?.backend !== 'static' - const state = await rpc.sharedState.get(view.stateKey, { initialValue: null }) + const viewId = 'stateKey' in view ? view.stateKey : ((entry as { id?: string }).id ?? 'inline') - const specRef = shallowRef(state.value() as Spec | null) - const off = state.on('updated', () => { + // Inline view: render the embedded spec as-is; shared-state view: subscribe + // to the live spec and track updates through `specRef`. + const specRef = shallowRef('spec' in view ? view.spec : null) + let off: (() => void) | undefined + if ('stateKey' in view) { + const state = await rpc.sharedState.get(view.stateKey, { initialValue: null }) specRef.value = state.value() as Spec | null - }) + off = state.on('updated', () => { + specRef.value = state.value() as Spec | null + }) + } const app = createApp({ render: () => h(JsonRenderView, { spec: specRef.value, rpc: rpc as ActionBridgeRpc, registry, - viewId: view.stateKey, - upstreamVersion: view.upstreamVersion, + viewId, interactive, }), }) @@ -72,7 +81,7 @@ export function createJsonRenderDockRenderer( return { dispose() { - off() + off?.() app.unmount() }, } diff --git a/packages/json-render-ui/src/renderer.ts b/packages/json-render-ui/src/renderer.ts index b228b22..9dedc1f 100644 --- a/packages/json-render-ui/src/renderer.ts +++ b/packages/json-render-ui/src/renderer.ts @@ -2,9 +2,9 @@ import type { Spec } from '@devframes/json-render' import type { ComponentRegistry } from '@json-render/vue' import type { Component, PropType } from 'vue' import type { ActionBridgeRpc } from './action-bridge' -import { basePropSchemas, JSON_RENDER_UPSTREAM_VERSION } from '@devframes/json-render' +import { basePropSchemas } from '@devframes/json-render' import { JSONUIProvider, Renderer } from '@json-render/vue' -import { computed, defineComponent, h, watch } from 'vue' +import { computed, defineComponent, h } from 'vue' import { createActionBridge } from './action-bridge' import { baseRegistry, ERROR_COMPONENT_TYPE, UNSUPPORTED_COMPONENT_TYPE } from './registry' @@ -66,8 +66,7 @@ const surface = 'flex items-center justify-center p4 text-sm color-faint' * with the unrestricted {@link createActionBridge action bridge}, seeds * `spec.state`, isolates invalid elements, surfaces action errors, and owns * reset semantics: the provider is remounted (state reseeded) when the view - * identity or upstream version changes, and preserved across ordinary - * spec/state updates. + * identity changes, and preserved across ordinary spec/state updates. */ export const JsonRenderView = defineComponent({ name: 'JsonRenderView', @@ -76,7 +75,6 @@ export const JsonRenderView = defineComponent({ rpc: { type: Object as PropType, required: true }, registry: { type: Object as PropType, default: () => baseRegistry }, viewId: { type: String, default: 'default' }, - upstreamVersion: { type: String, default: undefined }, interactive: { type: Boolean, default: true }, loading: { type: Boolean, default: false }, connectionError: { type: String as PropType, default: null }, @@ -84,22 +82,8 @@ export const JsonRenderView = defineComponent({ setup(props) { const bridge = createActionBridge(props.rpc, { interactive: props.interactive }) - // A renderer/upstream-version mismatch warns rather than blocking. - watch( - () => props.upstreamVersion, - (version) => { - if (version && version !== JSON_RENDER_UPSTREAM_VERSION) { - console.warn( - `[@devframes/json-render-ui] view "${props.viewId}" was authored against @json-render ${version}, ` - + `but this renderer bundles ${JSON_RENDER_UPSTREAM_VERSION}. Rendering anyway.`, - ) - } - }, - { immediate: true }, - ) - - // Reset the provider (reseed state) only on identity / version change. - const resetKey = computed(() => `${props.viewId}::${props.upstreamVersion ?? JSON_RENDER_UPSTREAM_VERSION}`) + // Reset the provider (reseed state) only on identity change. + const resetKey = computed(() => props.viewId) const effectiveSpec = computed(() => (props.spec ? sanitizeSpec(props.spec, props.registry) : null)) return () => { diff --git a/packages/json-render-ui/src/spa/main.ts b/packages/json-render-ui/src/spa/main.ts index adb3a5d..dca8276 100644 --- a/packages/json-render-ui/src/spa/main.ts +++ b/packages/json-render-ui/src/spa/main.ts @@ -123,7 +123,6 @@ async function main(): Promise { spec: spec ?? null, rpc: rpc as unknown as ActionBridgeRpc, viewId: activeEntry.stateKey, - upstreamVersion: activeEntry.upstreamVersion, interactive, loading: spec === undefined, }), diff --git a/packages/json-render/src/index.ts b/packages/json-render/src/index.ts index f269774..862d95b 100644 --- a/packages/json-render/src/index.ts +++ b/packages/json-render/src/index.ts @@ -38,5 +38,4 @@ export { JSON_RENDER_INDEX_KEY } from './view-index' export type { JsonRenderIndex, JsonRenderIndexEntry } from './view-index' // ── Serializable view reference ────────────────────────────────────────── -export { JSON_RENDER_UPSTREAM_VERSION } from './view-ref' -export type { JsonRenderViewRef } from './view-ref' +export type { JsonRenderViewInlineRef, JsonRenderViewRef, JsonRenderViewStateRef } from './view-ref' diff --git a/packages/json-render/src/node/create-view.ts b/packages/json-render/src/node/create-view.ts index bf13899..3f3537a 100644 --- a/packages/json-render/src/node/create-view.ts +++ b/packages/json-render/src/node/create-view.ts @@ -5,7 +5,6 @@ import type { JsonRenderIndex } from '../view-index' import { createSharedState } from 'devframe/utils/shared-state' import { basePropSchemas } from '../prop-schemas' import { JSON_RENDER_INDEX_KEY } from '../view-index' -import { JSON_RENDER_UPSTREAM_VERSION } from '../view-ref' import { diagnostics } from './diagnostics' /** Options for {@link createJsonRenderView}. */ @@ -153,7 +152,7 @@ export function createJsonRenderView( // Publish the view into the shared index so a frontend can discover it. const index = indexStateFor(baseCtx) index.mutate((idx) => { - idx[stateKey] = { id, scope, stateKey, title, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION } + idx[stateKey] = { id, scope, stateKey, title } }) let disposed = false @@ -165,7 +164,7 @@ export function createJsonRenderView( return { id, title, - ref: { stateKey, upstreamVersion: JSON_RENDER_UPSTREAM_VERSION }, + ref: { stateKey }, value: () => state.value() as DevframeJsonRenderSpec, update(spec) { assertLive() diff --git a/packages/json-render/src/types.ts b/packages/json-render/src/types.ts index 9eec463..d0ed212 100644 --- a/packages/json-render/src/types.ts +++ b/packages/json-render/src/types.ts @@ -1,5 +1,5 @@ import type { Spec } from '@json-render/core' -import type { JsonRenderViewRef } from './view-ref' +import type { JsonRenderViewStateRef } from './view-ref' /** * A Devframes JSON-render spec **is** an `@json-render/core` `Spec`: a flat @@ -24,7 +24,7 @@ export interface JsonRenderStatePatch { /** * A JSON-render view handle, returned by `createJsonRenderView`. Owns a * server-side shared state carrying the live spec + state, and exposes the - * serializable {@link JsonRenderViewRef} that a hub dock (or any client + * serializable {@link JsonRenderViewStateRef} that a hub dock (or any client * transport) uses to locate it. */ export interface JsonRenderView { @@ -33,7 +33,7 @@ export interface JsonRenderView { /** Human-facing label published in the view index (defaults to `id`). */ readonly title: string /** The serializable reference clients subscribe through. */ - readonly ref: JsonRenderViewRef + readonly ref: JsonRenderViewStateRef /** Replace the entire spec (a structural change replaces the whole spec). */ update: (spec: DevframeJsonRenderSpec) => void /** diff --git a/packages/json-render/src/view-index.ts b/packages/json-render/src/view-index.ts index 4d9b8b8..d773d92 100644 --- a/packages/json-render/src/view-index.ts +++ b/packages/json-render/src/view-index.ts @@ -24,8 +24,6 @@ export interface JsonRenderIndexEntry { stateKey: string /** Human-facing label for the view (defaults to `id`). */ title: string - /** Upstream `@json-render/*` version the view was authored against. */ - upstreamVersion: string } /** The shape of the view-index shared state: entries keyed by `stateKey`. */ diff --git a/packages/json-render/src/view-ref.ts b/packages/json-render/src/view-ref.ts index 7fe8c31..762b1c9 100644 --- a/packages/json-render/src/view-ref.ts +++ b/packages/json-render/src/view-ref.ts @@ -1,31 +1,33 @@ +import type { DevframeJsonRenderSpec } from './types' + /** - * The `@json-render/core` / `@json-render/vue` version this build of - * `@devframes/json-render` is written and tested against. It is the sole - * compatibility signal carried across the wire — there is no separate - * Devframes protocol/catalog version stamp. A renderer compares its own - * upstream version against a view's {@link JsonRenderViewRef.upstreamVersion} - * and warns (rather than blocking) on a mismatch. - * - * Kept paired with the caret range on `@json-render/core` / - * `@json-render/vue` in this package's manifest; the committed lockfile is - * the guard against a breaking upstream upgrade. + * A view backed by **live shared state**: the client subscribes to `stateKey` + * for the spec + state and re-renders on every update. This is what a + * node-authored view ({@link import('./node/create-view').createJsonRenderView}) + * produces. */ -export const JSON_RENDER_UPSTREAM_VERSION = '0.19.0' +export interface JsonRenderViewStateRef { + /** Shared-state key the client subscribes to for the live spec + state. */ + stateKey: string +} + +/** + * A view whose spec is embedded **inline** in the reference. It carries no + * shared-state key, so a client can synthesize a view entirely in the browser + * and hand it straight to a renderer — no `sharedState` round-trip. The spec is + * rendered as-is (static: local state and bindings still work, but there is no + * server-driven live update stream). + */ +export interface JsonRenderViewInlineRef { + /** The full spec, carried in the reference itself. */ + spec: DevframeJsonRenderSpec +} /** * The serializable reference to a JSON-render view that crosses process / * static boundaries — e.g. projected onto a hub dock entry. It carries **no - * functions** and no Devframes catalog version: just the shared-state key the - * client subscribes to for the live spec + state, and the upstream version - * the view was authored against. - * - * This is the corrected projection contract: the previous hub implementation - * leaked an accidental `_stateKey` field and a non-serializable renderer - * handle; a `JsonRenderViewRef` is a plain, fully-serializable object. + * functions**: either a {@link JsonRenderViewStateRef.stateKey shared-state key} + * the client subscribes through, or an {@link JsonRenderViewInlineRef.spec + * inline spec} rendered directly. */ -export interface JsonRenderViewRef { - /** Shared-state key the client subscribes to for the live spec + state. */ - stateKey: string - /** Upstream `@json-render/*` version the view was authored against. */ - upstreamVersion: string -} +export type JsonRenderViewRef = JsonRenderViewStateRef | JsonRenderViewInlineRef diff --git a/packages/json-render/test/create-view.test.ts b/packages/json-render/test/create-view.test.ts index 00ca4e7..6f43401 100644 --- a/packages/json-render/test/create-view.test.ts +++ b/packages/json-render/test/create-view.test.ts @@ -7,7 +7,6 @@ import { createHostContext } from 'devframe/node' import { beforeEach, describe, expect, it } from 'vitest' import { createJsonRenderView } from '../src/node/index' import { JSON_RENDER_INDEX_KEY } from '../src/view-index' -import { JSON_RENDER_UPSTREAM_VERSION } from '../src/view-ref' function createHost(): DevframeHost { const storageDir = mkdtempSync(join(tmpdir(), 'devframe-jr-')) @@ -40,7 +39,6 @@ describe('createJsonRenderView identity', () => { const view = createJsonRenderView(ctx, { id: 'metrics', spec }) expect(view.ref).toEqual({ stateKey: 'devframe:json-render:global:metrics', - upstreamVersion: JSON_RENDER_UPSTREAM_VERSION, }) expect(JSON.parse(JSON.stringify(view.ref))).toEqual(view.ref) }) @@ -107,7 +105,6 @@ describe('createJsonRenderView index', () => { scope: 'global', stateKey: view.ref.stateKey, title: 'metrics', - upstreamVersion: JSON_RENDER_UPSTREAM_VERSION, }) view.dispose() diff --git a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts index 0f82f5d..3f5c7d0 100644 --- a/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/json-render-ui/index.snapshot.d.ts @@ -68,10 +68,6 @@ export declare const JsonRenderView: import("vue").DefineComponent Date: Thu, 23 Jul 2026 13:04:49 +0900 Subject: [PATCH 3/4] chore: update play script --- package.json | 1 + scripts/play.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index 1ee79ec..5b54587 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "build": "turbo run build --concurrency=3", "watch": "pnpm -r run watch", "play": "tsx scripts/play.ts", + "dev": "tsx scripts/play.ts", "docs": "pnpm -C docs run docs", "docs:build": "pnpm -C docs run docs:build", "docs:serve": "pnpm -C docs run docs:serve", diff --git a/scripts/play.ts b/scripts/play.ts index 515a8a2..adba648 100644 --- a/scripts/play.ts +++ b/scripts/play.ts @@ -12,13 +12,13 @@ import prompts from 'prompts' * new example, plugin, or other playground shows up here for free, with no * change to this script. */ -const WORKSPACE_PATTERNS = ['packages/*', 'plugins/*', 'examples/*', 'storybook', 'docs'] +const WORKSPACE_PATTERNS = ['examples/*', 'packages/*', 'plugins/*', 'storybook'] /** * Script names that make a workspace package runnable as a "play" — the * first one present in a package's `scripts` wins. */ -const RUN_SCRIPTS = ['dev', 'storybook', 'docs', 'start'] +const RUN_SCRIPTS = ['dev', 'storybook', 'start'] const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') @@ -38,6 +38,7 @@ function expandPattern(pattern: string): string[] { return readdirSync(baseDir, { withFileTypes: true }) .filter(entry => entry.isDirectory()) .map(entry => `${base}/${entry.name}`) + .sort((a, b) => a.localeCompare(b)) } function findPlay(dir: string): Play | undefined { @@ -61,7 +62,6 @@ async function main(): Promise { .flatMap(expandPattern) .map(findPlay) .filter((play): play is Play => play !== undefined) - .sort((a, b) => a.dir.localeCompare(b.dir)) if (plays.length === 0) { console.error(`No playgrounds found — none of ${WORKSPACE_PATTERNS.join(', ')} has a package.json with a ${RUN_SCRIPTS.join('/')} script.`) From 35e9f3cf0c6fe3610fad11c954925c930d5ad6a4 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Thu, 23 Jul 2026 04:34:59 +0000 Subject: [PATCH 4/4] docs(examples): make the client-only json-render dock interactive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Turns the inline client-authored view into a small interactive playground: `$bindState` text/switch inputs write into the view's own state, `$state` reads mirror them live, and Add / Clear buttons drive the built-in `pushState` / `setState` actions to mutate a notes list that re-renders — all client-side, with no server and no shared state. Applied to both the Vite (Vue) and Next (React) hub shells. --- docs/examples/minimal-next-devframe-hub.md | 2 +- docs/examples/minimal-vite-devframe-hub.md | 2 +- docs/guide/client-context.md | 8 +- .../src/client/app/page.tsx | 87 +++++++++++++------ .../src/client/main.ts | 84 ++++++++++++------ 5 files changed, 125 insertions(+), 58 deletions(-) diff --git a/docs/examples/minimal-next-devframe-hub.md b/docs/examples/minimal-next-devframe-hub.md index 8b5cee3..10b4cb1 100644 --- a/docs/examples/minimal-next-devframe-hub.md +++ b/docs/examples/minimal-next-devframe-hub.md @@ -16,7 +16,7 @@ Package: `minimal-next-devframe-hub` · framework: **React (Next.js)** - The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. - 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. - 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. -- [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. +- [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. ## Run it diff --git a/docs/examples/minimal-vite-devframe-hub.md b/docs/examples/minimal-vite-devframe-hub.md index 9991356..3eab1f4 100644 --- a/docs/examples/minimal-vite-devframe-hub.md +++ b/docs/examples/minimal-vite-devframe-hub.md @@ -16,7 +16,7 @@ Package: `minimal-vite-devframe-hub` · framework: **Vanilla TypeScript (Vite)** - The built-in `hub:commands:execute` RPC dispatches any registered server command, regardless of how the host was constructed. - The browser-side `connectDevframe({ baseURL: '/__hub/' })` discovers the WS endpoint via the kit's `__connection.json` middleware. - 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 })`). -- [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. +- [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. ## Run it diff --git a/docs/guide/client-context.md b/docs/guide/client-context.md index 419997c..293f976 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -96,15 +96,15 @@ A client-only dock can render a [JSON-render](./json-render) view the page autho const spec = { /* a DevframeJsonRenderSpec built in the browser */ } ctx.docks.register({ - id: 'client-metrics', - title: 'Client Metrics', - icon: 'ph:gauge-duotone', + id: 'client-playground', + title: 'Client Playground', + icon: 'ph:sliders-horizontal-duotone', type: 'json-render', view: { spec }, }) ``` -The `view` field accepts either `{ spec }` (rendered inline, static) or `{ stateKey }` (subscribed to a live shared state, the shape `createJsonRenderView` produces server-side). +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. ## Dock client scripts diff --git a/examples/minimal-next-devframe-hub/src/client/app/page.tsx b/examples/minimal-next-devframe-hub/src/client/app/page.tsx index db34c61..a93959d 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -58,30 +58,58 @@ function createClientNotesUrl(): string { return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } -// A json-render spec synthesized entirely in the browser — the client-only -// counterpart to a server-authored view. It reads real values captured from the -// page at registration time and uses the same base-catalog components the hub's -// server view does, rendered here by the mini React registry. -function createClientMetricsSpec(clientType: string): DevframeJsonRenderSpec { +// An *interactive* json-render spec synthesized entirely in the browser — the +// client-only counterpart to a server-authored view. Interactivity needs no +// server and no shared state: `{ $bindState }` inputs write straight into the +// view's own `state`, `{ $state }` reads mirror it live, and the buttons use the +// framework's built-in state actions (`pushState` / `setState`) to mutate that +// state — every change re-renders through the mini React registry. +function createClientPlaygroundSpec(clientType: string): DevframeJsonRenderSpec { return { root: 'root', elements: { - root: { type: 'Stack', props: { gap: 14 }, children: ['head', 'about', 'env'] }, + root: { type: 'Stack', props: { gap: 14 }, children: ['head', 'hello', 'notes', 'env'] }, + head: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['icon', 'title', 'badge'] }, - icon: { type: 'Icon', props: { name: 'ph:gauge-duotone', size: 22 }, children: [] }, - title: { type: 'Text', props: { text: 'Client Metrics', variant: 'heading' }, children: [] }, + icon: { type: 'Icon', props: { name: 'ph:sliders-horizontal-duotone', size: 22 }, children: [] }, + title: { type: 'Text', props: { text: 'Client Playground', variant: 'heading' }, children: [] }, badge: { type: 'Badge', props: { text: 'client-only', variant: 'info' }, children: [] }, - about: { type: 'Card', props: { title: 'About this dock' }, children: ['aboutText'] }, - aboutText: { - type: 'Text', - props: { - text: 'This json-render view was authored in the browser and seeded into a client-local shared state — it never reaches the hub server or other viewers, yet renders through the same dock renderer as a server-authored view.', - variant: 'body', - color: 'muted', - }, + + // ── Two-way binding: type a name, see it echoed live; toggle a switch ── + hello: { type: 'Card', props: { title: 'Say hello' }, children: ['helloBody'] }, + helloBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greetRow', 'compact'] }, + nameInput: { type: 'TextInput', props: { label: 'Your name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] }, + greetRow: { type: 'Stack', props: { direction: 'row', gap: 6, align: 'center' }, children: ['greetLabel', 'greetName'] }, + greetLabel: { type: 'Text', props: { text: 'Hello,', variant: 'body', color: 'muted' }, children: [] }, + greetName: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'body', color: 'primary' }, children: [] }, + compact: { type: 'Switch', props: { label: 'Compact mode', value: { $bindState: '/prefs/compact' } }, children: [] }, + + // ── Actions mutate state → the DataTable re-renders ── + notes: { type: 'Card', props: { title: 'Notes' }, children: ['notesBody'] }, + notesBody: { type: 'Stack', props: { gap: 10 }, children: ['draftRow', 'notesTable', 'clearBtn'] }, + draftRow: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'end' }, children: ['draftInput', 'addBtn'] }, + draftInput: { type: 'TextInput', props: { label: 'New note', placeholder: 'Write something…', value: { $bindState: '/draft' } }, children: [] }, + addBtn: { + type: 'Button', + props: { label: 'Add', variant: 'primary', icon: 'ph:plus' }, + // Built-in `pushState`: append the typed draft to /notes, then clear the input. + on: { press: { action: 'pushState', params: { statePath: '/notes', value: { text: { $state: '/draft' } }, clearStatePath: '/draft' } } }, + children: [], + }, + notesTable: { + type: 'DataTable', + props: { columns: [{ key: 'text', label: 'Note' }], rows: { $state: '/notes' }, height: 160 }, + children: [], + }, + clearBtn: { + type: 'Button', + props: { label: 'Clear all', variant: 'ghost', icon: 'ph:trash' }, + // Built-in `setState`: replace /notes with an empty array. + on: { press: { action: 'setState', params: { statePath: '/notes', value: [] } } }, children: [], }, - env: { type: 'Card', props: { title: 'Environment' }, children: ['envTable'] }, + + env: { type: 'Card', props: { title: 'Environment', collapsible: true, defaultCollapsed: true }, children: ['envTable'] }, envTable: { type: 'KeyValueTable', props: { @@ -89,13 +117,17 @@ function createClientMetricsSpec(clientType: string): DevframeJsonRenderSpec { clientType, language: navigator.language, viewport: `${window.innerWidth}×${window.innerHeight}`, - online: navigator.onLine ? 'yes' : 'no', }, }, children: [], }, }, - state: {}, + state: { + form: { name: '' }, + prefs: { compact: false }, + draft: '', + notes: [{ text: 'Authored entirely in the browser' }], + }, } } @@ -166,16 +198,17 @@ export default function Page() { // Register a second client-only dock — this one a *json-render* view the // page authors itself, the richer sibling of the iframe dock above. Its // spec is carried **inline** in the dock entry (`view.spec`), so it needs - // no shared state at all: it lives only in this page yet renders through - // the same `json-render` dock renderer (the mini React registry) as a - // server-authored view. `force` lets React StrictMode re-run this effect - // safely. + // no shared state at all: it lives only in this page yet renders — and + // stays fully interactive (inputs, toggles, and buttons that mutate its + // state) — through the same `json-render` dock renderer (the mini React + // registry) as a server-authored view. `force` lets React StrictMode + // re-run this effect safely. const clientJsonRenderDock = clientHost.context.docks.register({ - id: 'client-metrics', - title: 'Client Metrics', - icon: 'ph:gauge-duotone', + id: 'client-playground', + title: 'Client Playground', + icon: 'ph:sliders-horizontal-duotone', type: 'json-render', - view: { spec: createClientMetricsSpec(clientHost.context.clientType) }, + view: { spec: createClientPlaygroundSpec(clientHost.context.clientType) }, category: 'app', }, true) diff --git a/examples/minimal-vite-devframe-hub/src/client/main.ts b/examples/minimal-vite-devframe-hub/src/client/main.ts index 2d5deba..382a925 100644 --- a/examples/minimal-vite-devframe-hub/src/client/main.ts +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -82,30 +82,58 @@ function createClientNotesUrl(): string { return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } -// A json-render spec synthesized entirely in the browser — the client-only -// counterpart to a server-authored view. It reads real values captured from the -// page at registration time and uses the same base-catalog components the hub's -// server view does, rendered by the very same `createJsonRenderDockRenderer`. -function createClientMetricsSpec(clientType: string): DevframeJsonRenderSpec { +// An *interactive* json-render spec synthesized entirely in the browser — the +// client-only counterpart to a server-authored view. Interactivity needs no +// server and no shared state: `{ $bindState }` inputs write straight into the +// view's own `state`, `{ $state }` reads mirror it live, and the buttons use the +// framework's built-in state actions (`pushState` / `setState`) to mutate that +// state — every change re-renders through the same `createJsonRenderDockRenderer`. +function createClientPlaygroundSpec(clientType: string): DevframeJsonRenderSpec { return { root: 'root', elements: { - root: { type: 'Stack', props: { gap: 14 }, children: ['head', 'about', 'env'] }, + root: { type: 'Stack', props: { gap: 14 }, children: ['head', 'hello', 'notes', 'env'] }, + head: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['icon', 'title', 'badge'] }, - icon: { type: 'Icon', props: { name: 'ph:gauge-duotone', size: 22 }, children: [] }, - title: { type: 'Text', props: { text: 'Client Metrics', variant: 'heading' }, children: [] }, + icon: { type: 'Icon', props: { name: 'ph:sliders-horizontal-duotone', size: 22 }, children: [] }, + title: { type: 'Text', props: { text: 'Client Playground', variant: 'heading' }, children: [] }, badge: { type: 'Badge', props: { text: 'client-only', variant: 'info' }, children: [] }, - about: { type: 'Card', props: { title: 'About this dock' }, children: ['aboutText'] }, - aboutText: { - type: 'Text', - props: { - text: 'This json-render view was authored in the browser and seeded into a client-local shared state — it never reaches the hub server or other viewers, yet renders through the same dock renderer as a server-authored view.', - variant: 'body', - color: 'muted', - }, + + // ── Two-way binding: type a name, see it echoed live; toggle a switch ── + hello: { type: 'Card', props: { title: 'Say hello' }, children: ['helloBody'] }, + helloBody: { type: 'Stack', props: { gap: 10 }, children: ['nameInput', 'greetRow', 'compact'] }, + nameInput: { type: 'TextInput', props: { label: 'Your name', placeholder: 'Type your name…', value: { $bindState: '/form/name' } }, children: [] }, + greetRow: { type: 'Stack', props: { direction: 'row', gap: 6, align: 'center' }, children: ['greetLabel', 'greetName'] }, + greetLabel: { type: 'Text', props: { text: 'Hello,', variant: 'body', color: 'muted' }, children: [] }, + greetName: { type: 'Text', props: { text: { $state: '/form/name' }, variant: 'body', color: 'primary' }, children: [] }, + compact: { type: 'Switch', props: { label: 'Compact mode', value: { $bindState: '/prefs/compact' } }, children: [] }, + + // ── Actions mutate state → the DataTable re-renders ── + notes: { type: 'Card', props: { title: 'Notes' }, children: ['notesBody'] }, + notesBody: { type: 'Stack', props: { gap: 10 }, children: ['draftRow', 'notesTable', 'clearBtn'] }, + draftRow: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'end' }, children: ['draftInput', 'addBtn'] }, + draftInput: { type: 'TextInput', props: { label: 'New note', placeholder: 'Write something…', value: { $bindState: '/draft' } }, children: [] }, + addBtn: { + type: 'Button', + props: { label: 'Add', variant: 'primary', icon: 'ph:plus' }, + // Built-in `pushState`: append the typed draft to /notes, then clear the input. + on: { press: { action: 'pushState', params: { statePath: '/notes', value: { text: { $state: '/draft' } }, clearStatePath: '/draft' } } }, + children: [], + }, + notesTable: { + type: 'DataTable', + props: { columns: [{ key: 'text', label: 'Note' }], rows: { $state: '/notes' }, height: 160 }, + children: [], + }, + clearBtn: { + type: 'Button', + props: { label: 'Clear all', variant: 'ghost', icon: 'ph:trash' }, + // Built-in `setState`: replace /notes with an empty array. + on: { press: { action: 'setState', params: { statePath: '/notes', value: [] } } }, children: [], }, - env: { type: 'Card', props: { title: 'Environment' }, children: ['envTable'] }, + + env: { type: 'Card', props: { title: 'Environment', collapsible: true, defaultCollapsed: true }, children: ['envTable'] }, envTable: { type: 'KeyValueTable', props: { @@ -113,13 +141,17 @@ function createClientMetricsSpec(clientType: string): DevframeJsonRenderSpec { clientType, language: navigator.language, viewport: `${window.innerWidth}×${window.innerHeight}`, - online: navigator.onLine ? 'yes' : 'no', }, }, children: [], }, }, - state: {}, + state: { + form: { name: '' }, + prefs: { compact: false }, + draft: '', + notes: [{ text: 'Authored entirely in the browser' }], + }, } } @@ -161,14 +193,16 @@ async function main() { // Register a second client-only dock — this one a *json-render* view the page // authors itself, the richer sibling of the iframe dock above. Its spec is // carried **inline** in the dock entry (`view.spec`), so it needs no shared - // state at all: it lives only in this page yet renders through the very same - // `json-render` dock renderer registered above as a server-authored view. + // state at all: it lives only in this page yet renders — and stays fully + // interactive (inputs, toggles, and buttons that mutate its state) — through + // the very same `json-render` dock renderer registered above as a + // server-authored view. host.context.docks.register({ - id: 'client-metrics', - title: 'Client Metrics', - icon: 'ph:gauge-duotone', + id: 'client-playground', + title: 'Client Playground', + icon: 'ph:sliders-horizontal-duotone', type: 'json-render', - view: { spec: createClientMetricsSpec(host.context.clientType) }, + view: { spec: createClientPlaygroundSpec(host.context.clientType) }, category: 'app', })