diff --git a/docs/examples/minimal-next-devframe-hub.md b/docs/examples/minimal-next-devframe-hub.md index 6d42588..10b4cb1 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 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 1d27f4b..3eab1f4 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 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 f1750a2..293f976 100644 --- a/docs/guide/client-context.md +++ b/docs/guide/client-context.md @@ -90,6 +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. 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 +const spec = { /* a DevframeJsonRenderSpec built in the browser */ } + +ctx.docks.register({ + id: 'client-playground', + title: 'Client Playground', + icon: 'ph:sliders-horizontal-duotone', + type: 'json-render', + view: { spec }, +}) +``` + +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 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 81a80ba..a93959d 100644 --- a/examples/minimal-next-devframe-hub/src/client/app/page.tsx +++ b/examples/minimal-next-devframe-hub/src/client/app/page.tsx @@ -8,6 +8,8 @@ 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 { useEffect, useMemo, useRef, useState } from 'react' import { createReactJsonRenderDockRenderer } from '../json-render/dock-renderer' @@ -56,6 +58,79 @@ function createClientNotesUrl(): string { return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } +// 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', 'hello', 'notes', 'env'] }, + + head: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['icon', 'title', 'badge'] }, + 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: [] }, + + // ── 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', collapsible: true, defaultCollapsed: true }, children: ['envTable'] }, + envTable: { + type: 'KeyValueTable', + props: { + data: { + clientType, + language: navigator.language, + viewport: `${window.innerWidth}×${window.innerHeight}`, + }, + }, + children: [], + }, + }, + state: { + form: { name: '' }, + prefs: { compact: false }, + draft: '', + notes: [{ text: 'Authored entirely in the browser' }], + }, + } +} + /** 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 +195,23 @@ 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. 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 — 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-playground', + title: 'Client Playground', + icon: 'ph:sliders-horizontal-duotone', + type: 'json-render', + view: { spec: createClientPlaygroundSpec(clientHost.context.clientType) }, + category: 'app', + }, true) + const docksState = await rpc.sharedState.get( 'devframe:docks', { initialValue: [] }, @@ -165,8 +257,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-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 72b2655..382a925 100644 --- a/examples/minimal-vite-devframe-hub/src/client/main.ts +++ b/examples/minimal-vite-devframe-hub/src/client/main.ts @@ -5,6 +5,8 @@ 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 { createJsonRenderDockRenderer } from '@devframes/json-render-ui' import { iconClass } from './icons' @@ -80,6 +82,79 @@ function createClientNotesUrl(): string { return URL.createObjectURL(new Blob([html], { type: 'text/html' })) } +// 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', 'hello', 'notes', 'env'] }, + + head: { type: 'Stack', props: { direction: 'row', gap: 8, align: 'center' }, children: ['icon', 'title', 'badge'] }, + 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: [] }, + + // ── 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', collapsible: true, defaultCollapsed: true }, children: ['envTable'] }, + envTable: { + type: 'KeyValueTable', + props: { + data: { + clientType, + language: navigator.language, + viewport: `${window.innerWidth}×${window.innerHeight}`, + }, + }, + children: [], + }, + }, + state: { + form: { name: '' }, + prefs: { compact: false }, + draft: '', + notes: [{ text: 'Authored entirely in the browser' }], + }, + } +} + async function main() { setStatus('Connecting…') @@ -115,6 +190,22 @@ 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. 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 — 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-playground', + title: 'Client Playground', + icon: 'ph:sliders-horizontal-duotone', + type: 'json-render', + view: { spec: createClientPlaygroundSpec(host.context.clientType) }, + 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. 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/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/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.`) 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