From ff314de1de8bd28792ab5eed610dbe422f31f243 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 14 Aug 2026 06:01:07 +0000 Subject: [PATCH] feat(json-render-ui): session-persist uncontrolled state and scroll position MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Tabs`/`Select`/`Switch`/`TextInput` fall back to a local, uncontrolled value when a spec element has no `$bindState` binding on it — that fallback used to be lost on every reload (and, for `Switch`/`TextInput`, didn't exist at all: an unbound `value` had no working fallback since `useBoundProp`'s setter is a no-op without a binding). `useSessionStorage` now backs all four, keyed by the current dock (via a new `DOCK_ENTRY_ID_KEY` `provide()`d by `JsonRenderView`) plus a signature of the element's own static props, so it survives a reload without bleeding into a different element of the same kind. `JsonRenderView` also restores/persists its own scroll position per dock, the same way. Context: ports vitejs/devtools#527's `sessionStorage`-keyed uncontrolled- value and scroll restoration to this package's registry component shape (no `ctx.element` here, so the key is a caller-supplied signature instead of an element id). Co-authored-by: dvcolomban <90617742+dvcolomban@users.noreply.github.com> --- packages/json-render-ui/package.json | 3 +- .../json-render-ui/src/components/Select.ts | 14 +++- .../json-render-ui/src/components/Switch.ts | 56 ++++++++++--- .../json-render-ui/src/components/Tabs.ts | 13 ++- .../src/components/TextInput.ts | 81 ++++++++++++++----- .../src/composables/dock-entry-id.ts | 11 +++ .../src/composables/useUncontrolledValue.ts | 26 ++++++ packages/json-render-ui/src/renderer.ts | 33 +++++++- pnpm-lock.yaml | 3 + 9 files changed, 197 insertions(+), 43 deletions(-) create mode 100644 packages/json-render-ui/src/composables/dock-entry-id.ts create mode 100644 packages/json-render-ui/src/composables/useUncontrolledValue.ts diff --git a/packages/json-render-ui/package.json b/packages/json-render-ui/package.json index 69e6aa9e..2b499934 100644 --- a/packages/json-render-ui/package.json +++ b/packages/json-render-ui/package.json @@ -51,7 +51,8 @@ } }, "dependencies": { - "@json-render/vue": "catalog:frontend" + "@json-render/vue": "catalog:frontend", + "@vueuse/core": "catalog:frontend" }, "devDependencies": { "@antfu/design": "catalog:frontend", diff --git a/packages/json-render-ui/src/components/Select.ts b/packages/json-render-ui/src/components/Select.ts index 15bcb68b..342b85e1 100644 --- a/packages/json-render-ui/src/components/Select.ts +++ b/packages/json-render-ui/src/components/Select.ts @@ -3,7 +3,8 @@ import type { JrComponent } from './_shared' import FormCombobox from '@antfu/design/components/Form/FormCombobox.vue' import FormSelect from '@antfu/design/components/Form/FormSelect.vue' import { useBoundProp } from '@json-render/vue' -import { computed, defineComponent, h, ref } from 'vue' +import { computed, defineComponent, h } from 'vue' +import { useUncontrolledValue } from '../composables/useUncontrolledValue' interface SelectOption { value: string @@ -28,8 +29,9 @@ function normalize(option: string | SelectOption): { value: string, label?: stri } // Stateful inner component: a JrComponent render fn can't hold a ref, so the -// uncontrolled selection (no `$bindState` on `value`) lives here; when the spec -// binds `value`, `bindingPath` is set and writes flow back to the state store. +// uncontrolled selection (no `$bindState` on `value`) lives here, session- +// persisted so it survives a reload; when the spec binds `value`, +// `bindingPath` is set and writes flow back to the state store instead. const SelectImpl = defineComponent({ name: 'JrSelectImpl', props: { @@ -47,7 +49,11 @@ const SelectImpl = defineComponent({ // on store change); `useBoundProp` is used only for its store setter. const [, setBound] = useBoundProp(props.value, props.bindingPath) const controlled = props.bindingPath != null - const local = ref(props.value) + const local = useUncontrolledValue( + 'Select', + { options: props.options, searchable: props.searchable }, + props.value, + ) const model = computed(() => (controlled ? props.value : local.value)) const setModel = (next: string | undefined) => { if (controlled) diff --git a/packages/json-render-ui/src/components/Switch.ts b/packages/json-render-ui/src/components/Switch.ts index 2b9ed5f5..97318a4e 100644 --- a/packages/json-render-ui/src/components/Switch.ts +++ b/packages/json-render-ui/src/components/Switch.ts @@ -1,7 +1,9 @@ +import type { PropType } from 'vue' import type { JrComponent } from './_shared' import FormSwitch from '@antfu/design/components/Form/FormSwitch.vue' import { useBoundProp } from '@json-render/vue' -import { h } from 'vue' +import { defineComponent, h } from 'vue' +import { useUncontrolledValue } from '../composables/useUncontrolledValue' interface SwitchProps { value?: boolean @@ -9,15 +11,45 @@ interface SwitchProps { disabled?: boolean } -export const Switch: JrComponent = ({ props, on, bindings }) => { - const [value, setValue] = useBoundProp(props.value, bindings?.value) - return h(FormSwitch, { - 'modelValue': !!value, - 'onUpdate:modelValue': (next: boolean) => { - setValue(next) - on('change').emit() - }, - 'label': props.label, - 'disabled': props.disabled, +// Stateful inner component: a JrComponent render fn can't hold a ref, so the +// uncontrolled value (no `$bindState` on `value`) lives here, session- +// persisted so it survives a reload; when the spec binds `value`, +// `bindingPath` is set and writes flow back to the state store instead. +const SwitchImpl = defineComponent({ + name: 'JrSwitchImpl', + props: { + value: { type: Boolean, default: undefined }, + label: { type: String, default: undefined }, + disabled: { type: Boolean, default: undefined }, + bindingPath: { type: String, default: undefined }, + onChange: { type: Function as PropType<() => void>, default: undefined }, + }, + setup(props) { + // `props.value` is already the live resolved value; `useBoundProp` is used + // only for its store setter. + const [, setBound] = useBoundProp(props.value, props.bindingPath) + const controlled = props.bindingPath != null + const local = useUncontrolledValue('Switch', { label: props.label }, props.value ?? false) + const setModel = (next: boolean) => { + if (controlled) + setBound(next) + else local.value = next + props.onChange?.() + } + return () => h(FormSwitch, { + 'modelValue': !!(controlled ? props.value : local.value), + 'onUpdate:modelValue': setModel, + 'label': props.label, + 'disabled': props.disabled, + }) + }, +}) + +export const Switch: JrComponent = ({ props, on, bindings }) => + h(SwitchImpl, { + value: props.value, + label: props.label, + disabled: props.disabled, + bindingPath: bindings?.value, + onChange: () => on('change').emit(), }) -} diff --git a/packages/json-render-ui/src/components/Tabs.ts b/packages/json-render-ui/src/components/Tabs.ts index 57c0c013..2a63d051 100644 --- a/packages/json-render-ui/src/components/Tabs.ts +++ b/packages/json-render-ui/src/components/Tabs.ts @@ -1,7 +1,8 @@ import type { PropType, VNode } from 'vue' import type { JrComponent } from './_shared' import { useBoundProp } from '@json-render/vue' -import { computed, defineComponent, h, ref } from 'vue' +import { computed, defineComponent, h } from 'vue' +import { useUncontrolledValue } from '../composables/useUncontrolledValue' import { Badge } from './Badge' import { Icon } from './Icon' @@ -28,7 +29,8 @@ interface TabsProps { // are runtime-resolved *names* — so this is a thin custom component over the // shared semantic tokens (like Text/Stack), using the Icon component. Stateful // so the uncontrolled selection persists across renders (a JrComponent render -// fn can't hold a ref); binds to the state store when `bindingPath` is set. +// fn can't hold a ref) and across a reload (session-persisted); binds to the +// state store when `bindingPath` is set. const TabsImpl = defineComponent({ name: 'JrTabsImpl', props: { @@ -44,7 +46,12 @@ const TabsImpl = defineComponent({ // only for its store setter. const [, setBound] = useBoundProp(props.value, props.bindingPath) const controlled = props.bindingPath != null - const local = ref(props.defaultValue ?? props.value ?? props.tabs[0]?.value) + // Session-persisted so the uncontrolled selection survives a reload. + const local = useUncontrolledValue( + 'Tabs', + { tabs: props.tabs, orientation: props.orientation }, + props.defaultValue ?? props.value ?? props.tabs[0]?.value, + ) const active = computed(() => (controlled ? props.value : local.value)) const isVertical = computed(() => props.orientation === 'vertical') diff --git a/packages/json-render-ui/src/components/TextInput.ts b/packages/json-render-ui/src/components/TextInput.ts index cc284c6b..b819200b 100644 --- a/packages/json-render-ui/src/components/TextInput.ts +++ b/packages/json-render-ui/src/components/TextInput.ts @@ -1,7 +1,9 @@ +import type { PropType } from 'vue' import type { JrComponent } from './_shared' import FormTextInput from '@antfu/design/components/Form/FormTextInput.vue' import { useBoundProp } from '@json-render/vue' -import { h } from 'vue' +import { defineComponent, h } from 'vue' +import { useUncontrolledValue } from '../composables/useUncontrolledValue' interface TextInputProps { value?: string @@ -12,24 +14,61 @@ interface TextInputProps { loading?: boolean } -export const TextInput: JrComponent = ({ props, on, bindings }) => { - const [value, setValue] = useBoundProp(props.value, bindings?.value) - const input = h(FormTextInput, { - 'modelValue': value ?? '', - 'onUpdate:modelValue': (next: string) => { - // Carry the new value into bound state, then fire the `change` action. - setValue(next) - on('change').emit() - }, - 'placeholder': props.placeholder, - 'type': props.type ?? 'text', - 'disabled': props.disabled || props.loading, +// Stateful inner component: a JrComponent render fn can't hold a ref, so the +// uncontrolled value (no `$bindState` on `value`) lives here, session- +// persisted so it survives a reload; when the spec binds `value`, +// `bindingPath` is set and writes flow back to the state store instead. +const TextInputImpl = defineComponent({ + name: 'JrTextInputImpl', + props: { + value: { type: String, default: undefined }, + placeholder: { type: String, default: undefined }, + label: { type: String, default: undefined }, + disabled: { type: Boolean, default: undefined }, + type: { type: String as PropType, default: 'text' }, + loading: { type: Boolean, default: undefined }, + bindingPath: { type: String, default: undefined }, + onChange: { type: Function as PropType<() => void>, default: undefined }, + }, + setup(props) { + // `props.value` is already the live resolved value; `useBoundProp` is used + // only for its store setter. + const [, setBound] = useBoundProp(props.value, props.bindingPath) + const controlled = props.bindingPath != null + const local = useUncontrolledValue('TextInput', { placeholder: props.placeholder, type: props.type }, props.value ?? '') + const setModel = (next: string) => { + if (controlled) + setBound(next) + else local.value = next + props.onChange?.() + } + return () => { + const input = h(FormTextInput, { + 'modelValue': (controlled ? props.value : local.value) ?? '', + 'onUpdate:modelValue': setModel, + 'placeholder': props.placeholder, + 'type': props.type ?? 'text', + 'disabled': props.disabled || props.loading, + }) + if (props.label) { + return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [ + h('span', props.label), + input, + ]) + } + return input + } + }, +}) + +export const TextInput: JrComponent = ({ props, on, bindings }) => + h(TextInputImpl, { + value: props.value, + placeholder: props.placeholder, + label: props.label, + disabled: props.disabled, + type: props.type, + loading: props.loading, + bindingPath: bindings?.value, + onChange: () => on('change').emit(), }) - if (props.label) { - return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [ - h('span', props.label), - input, - ]) - } - return input -} diff --git a/packages/json-render-ui/src/composables/dock-entry-id.ts b/packages/json-render-ui/src/composables/dock-entry-id.ts new file mode 100644 index 00000000..957f7e9e --- /dev/null +++ b/packages/json-render-ui/src/composables/dock-entry-id.ts @@ -0,0 +1,11 @@ +import type { InjectionKey } from 'vue' + +/** + * Injection key for the current dock's own identity — the `viewId` + * {@link JsonRenderView} is mounted with (a shared-state `stateKey`, or a + * client-synthesized dock id). `JsonRenderView` `provide()`s it once per + * mounted view; {@link useUncontrolledValue}'s session-persistence key + * `inject()`s it instead of threading the id through every registry + * component's props. + */ +export const DOCK_ENTRY_ID_KEY: InjectionKey = Symbol('devframes:json-render:dock-entry-id') diff --git a/packages/json-render-ui/src/composables/useUncontrolledValue.ts b/packages/json-render-ui/src/composables/useUncontrolledValue.ts new file mode 100644 index 00000000..dec55fb4 --- /dev/null +++ b/packages/json-render-ui/src/composables/useUncontrolledValue.ts @@ -0,0 +1,26 @@ +import type { Ref } from 'vue' +import { useSessionStorage } from '@vueuse/core' +import { inject } from 'vue' +import { DOCK_ENTRY_ID_KEY } from './dock-entry-id' + +/** + * Session-persisted fallback for a json-render element's own *uncontrolled* + * value — the local state `Tabs`/`Select`/`Switch`/`TextInput` fall back to + * when the bindable prop has no `$bindState` binding (`useBoundProp`'s setter + * is a no-op without one). On by default: calling this instead of a plain + * `ref(defaultValue)` survives a reload within the same tab. + * + * The key combines the current dock's id ({@link DOCK_ENTRY_ID_KEY}, absent + * outside a devframe dock) with a caller-supplied `signature` identifying the + * element within that dock — `kind` (the component, e.g. `'Tabs'`) plus the + * element's own static props. There is no element id to key off directly here + * (unlike some other json-render integrations' render context): a shape + * change yields a different key, so persistence falls back to `defaultValue` + * instead of restoring a stale value for a different element — intended, not + * a bug. + */ +export function useUncontrolledValue(kind: string, signature: Record, defaultValue: T): Ref { + const dockEntryId = inject(DOCK_ENTRY_ID_KEY, undefined) + const key = `devframes-json-render-uncontrolled:${dockEntryId ?? '~'}:${kind}:${JSON.stringify(signature)}` + return useSessionStorage(key, defaultValue) +} diff --git a/packages/json-render-ui/src/renderer.ts b/packages/json-render-ui/src/renderer.ts index 9dedc1f0..8474e7a1 100644 --- a/packages/json-render-ui/src/renderer.ts +++ b/packages/json-render-ui/src/renderer.ts @@ -4,8 +4,10 @@ import type { Component, PropType } from 'vue' import type { ActionBridgeRpc } from './action-bridge' import { basePropSchemas } from '@devframes/json-render' import { JSONUIProvider, Renderer } from '@json-render/vue' -import { computed, defineComponent, h } from 'vue' +import { useDebounceFn, useSessionStorage } from '@vueuse/core' +import { computed, defineComponent, h, provide, ref, watchEffect } from 'vue' import { createActionBridge } from './action-bridge' +import { DOCK_ENTRY_ID_KEY } from './composables/dock-entry-id' import { baseRegistry, ERROR_COMPONENT_TYPE, UNSUPPORTED_COMPONENT_TYPE } from './registry' // Upstream ships these as heavily-typed `DefineComponent`s; render them through @@ -82,10 +84,37 @@ export const JsonRenderView = defineComponent({ setup(props) { const bridge = createActionBridge(props.rpc, { interactive: props.interactive }) + /** + * Descendants (e.g. `useUncontrolledValue`) `inject()` this to scope + * session-persisted state to "this dock" — the mounted view's own id, + * stable while a given `resetKey` subtree is alive (a `viewId` change + * remounts that subtree below, via the `key` on `ProviderC`). + */ + provide(DOCK_ENTRY_ID_KEY, props.viewId) + // 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)) + /** + * Restores/persists the scroll position of this view, per tab, across a + * reload — keyed by `viewId` so switching views doesn't bleed one view's + * scroll into another's. The key is a getter (not a plain string) since, + * unlike the registry components below, this component instance itself is + * not guaranteed to remount when `viewId` changes (e.g. the reference SPA + * keeps one `JsonRenderView` alive across its own view switcher) — the + * `watchEffect` below re-fires on both a fresh mount and a `viewId` change. + */ + const scrollEl = ref(null) + const scrollTop = useSessionStorage(() => `devframes-json-render-scroll:${props.viewId}`, 0) + watchEffect(() => { + if (scrollEl.value) + scrollEl.value.scrollTop = scrollTop.value + }) + const persistScrollTop = useDebounceFn(() => { + scrollTop.value = scrollEl.value?.scrollTop ?? 0 + }, 200) + return () => { if (props.loading) return h('div', { class: surface }, 'Loading…') @@ -107,7 +136,7 @@ export const JsonRenderView = defineComponent({ }, 'Interactive actions are unavailable in static output.') : null - return h('div', { class: 'color-base' }, [ + return h('div', { class: 'color-base w-full h-full overflow-auto', ref: scrollEl, onScroll: persistScrollTop }, [ staticNote, banner, h( diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c1662466..7b20fc92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1354,6 +1354,9 @@ importers: '@json-render/vue': specifier: catalog:frontend version: 0.19.0(vue@3.5.41(typescript@6.0.3))(zod@4.4.3) + '@vueuse/core': + specifier: catalog:frontend + version: 14.4.0(vue@3.5.41(typescript@6.0.3)) devDependencies: '@antfu/design': specifier: catalog:frontend