Skip to content

Commit 292ae3c

Browse files
antfubotdvcolomban
andcommitted
feat(json-render-ui): session-persist uncontrolled state and scroll position
`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>
1 parent 55b2431 commit 292ae3c

9 files changed

Lines changed: 197 additions & 43 deletions

File tree

packages/json-render-ui/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,8 @@
5151
}
5252
},
5353
"dependencies": {
54-
"@json-render/vue": "catalog:frontend"
54+
"@json-render/vue": "catalog:frontend",
55+
"@vueuse/core": "catalog:frontend"
5556
},
5657
"devDependencies": {
5758
"@antfu/design": "catalog:frontend",

packages/json-render-ui/src/components/Select.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,8 @@ import type { JrComponent } from './_shared'
33
import FormCombobox from '@antfu/design/components/Form/FormCombobox.vue'
44
import FormSelect from '@antfu/design/components/Form/FormSelect.vue'
55
import { useBoundProp } from '@json-render/vue'
6-
import { computed, defineComponent, h, ref } from 'vue'
6+
import { computed, defineComponent, h } from 'vue'
7+
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
78

89
interface SelectOption {
910
value: string
@@ -28,8 +29,9 @@ function normalize(option: string | SelectOption): { value: string, label?: stri
2829
}
2930

3031
// Stateful inner component: a JrComponent render fn can't hold a ref, so the
31-
// uncontrolled selection (no `$bindState` on `value`) lives here; when the spec
32-
// binds `value`, `bindingPath` is set and writes flow back to the state store.
32+
// uncontrolled selection (no `$bindState` on `value`) lives here, session-
33+
// persisted so it survives a reload; when the spec binds `value`,
34+
// `bindingPath` is set and writes flow back to the state store instead.
3335
const SelectImpl = defineComponent({
3436
name: 'JrSelectImpl',
3537
props: {
@@ -47,7 +49,11 @@ const SelectImpl = defineComponent({
4749
// on store change); `useBoundProp` is used only for its store setter.
4850
const [, setBound] = useBoundProp<string>(props.value, props.bindingPath)
4951
const controlled = props.bindingPath != null
50-
const local = ref<string | undefined>(props.value)
52+
const local = useUncontrolledValue<string | undefined>(
53+
'Select',
54+
{ options: props.options, searchable: props.searchable },
55+
props.value,
56+
)
5157
const model = computed(() => (controlled ? props.value : local.value))
5258
const setModel = (next: string | undefined) => {
5359
if (controlled)

packages/json-render-ui/src/components/Switch.ts

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,55 @@
1+
import type { PropType } from 'vue'
12
import type { JrComponent } from './_shared'
23
import FormSwitch from '@antfu/design/components/Form/FormSwitch.vue'
34
import { useBoundProp } from '@json-render/vue'
4-
import { h } from 'vue'
5+
import { defineComponent, h } from 'vue'
6+
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
57

68
interface SwitchProps {
79
value?: boolean
810
label?: string
911
disabled?: boolean
1012
}
1113

12-
export const Switch: JrComponent<SwitchProps> = ({ props, on, bindings }) => {
13-
const [value, setValue] = useBoundProp(props.value, bindings?.value)
14-
return h(FormSwitch, {
15-
'modelValue': !!value,
16-
'onUpdate:modelValue': (next: boolean) => {
17-
setValue(next)
18-
on('change').emit()
19-
},
20-
'label': props.label,
21-
'disabled': props.disabled,
14+
// Stateful inner component: a JrComponent render fn can't hold a ref, so the
15+
// uncontrolled value (no `$bindState` on `value`) lives here, session-
16+
// persisted so it survives a reload; when the spec binds `value`,
17+
// `bindingPath` is set and writes flow back to the state store instead.
18+
const SwitchImpl = defineComponent({
19+
name: 'JrSwitchImpl',
20+
props: {
21+
value: { type: Boolean, default: undefined },
22+
label: { type: String, default: undefined },
23+
disabled: { type: Boolean, default: undefined },
24+
bindingPath: { type: String, default: undefined },
25+
onChange: { type: Function as PropType<() => void>, default: undefined },
26+
},
27+
setup(props) {
28+
// `props.value` is already the live resolved value; `useBoundProp` is used
29+
// only for its store setter.
30+
const [, setBound] = useBoundProp<boolean>(props.value, props.bindingPath)
31+
const controlled = props.bindingPath != null
32+
const local = useUncontrolledValue('Switch', { label: props.label }, props.value ?? false)
33+
const setModel = (next: boolean) => {
34+
if (controlled)
35+
setBound(next)
36+
else local.value = next
37+
props.onChange?.()
38+
}
39+
return () => h(FormSwitch, {
40+
'modelValue': !!(controlled ? props.value : local.value),
41+
'onUpdate:modelValue': setModel,
42+
'label': props.label,
43+
'disabled': props.disabled,
44+
})
45+
},
46+
})
47+
48+
export const Switch: JrComponent<SwitchProps> = ({ props, on, bindings }) =>
49+
h(SwitchImpl, {
50+
value: props.value,
51+
label: props.label,
52+
disabled: props.disabled,
53+
bindingPath: bindings?.value,
54+
onChange: () => on('change').emit(),
2255
})
23-
}

packages/json-render-ui/src/components/Tabs.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import type { PropType, VNode } from 'vue'
22
import type { JrComponent } from './_shared'
33
import { useBoundProp } from '@json-render/vue'
4-
import { computed, defineComponent, h, ref } from 'vue'
4+
import { computed, defineComponent, h } from 'vue'
5+
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
56
import { Badge } from './Badge'
67
import { Icon } from './Icon'
78

@@ -28,7 +29,8 @@ interface TabsProps {
2829
// are runtime-resolved *names* — so this is a thin custom component over the
2930
// shared semantic tokens (like Text/Stack), using the Icon component. Stateful
3031
// so the uncontrolled selection persists across renders (a JrComponent render
31-
// fn can't hold a ref); binds to the state store when `bindingPath` is set.
32+
// fn can't hold a ref) and across a reload (session-persisted); binds to the
33+
// state store when `bindingPath` is set.
3234
const TabsImpl = defineComponent({
3335
name: 'JrTabsImpl',
3436
props: {
@@ -44,7 +46,12 @@ const TabsImpl = defineComponent({
4446
// only for its store setter.
4547
const [, setBound] = useBoundProp<string>(props.value, props.bindingPath)
4648
const controlled = props.bindingPath != null
47-
const local = ref<string | undefined>(props.defaultValue ?? props.value ?? props.tabs[0]?.value)
49+
// Session-persisted so the uncontrolled selection survives a reload.
50+
const local = useUncontrolledValue<string | undefined>(
51+
'Tabs',
52+
{ tabs: props.tabs, orientation: props.orientation },
53+
props.defaultValue ?? props.value ?? props.tabs[0]?.value,
54+
)
4855
const active = computed(() => (controlled ? props.value : local.value))
4956
const isVertical = computed(() => props.orientation === 'vertical')
5057

packages/json-render-ui/src/components/TextInput.ts

Lines changed: 60 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
import type { PropType } from 'vue'
12
import type { JrComponent } from './_shared'
23
import FormTextInput from '@antfu/design/components/Form/FormTextInput.vue'
34
import { useBoundProp } from '@json-render/vue'
4-
import { h } from 'vue'
5+
import { defineComponent, h } from 'vue'
6+
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
57

68
interface TextInputProps {
79
value?: string
@@ -12,24 +14,61 @@ interface TextInputProps {
1214
loading?: boolean
1315
}
1416

15-
export const TextInput: JrComponent<TextInputProps> = ({ props, on, bindings }) => {
16-
const [value, setValue] = useBoundProp(props.value, bindings?.value)
17-
const input = h(FormTextInput, {
18-
'modelValue': value ?? '',
19-
'onUpdate:modelValue': (next: string) => {
20-
// Carry the new value into bound state, then fire the `change` action.
21-
setValue(next)
22-
on('change').emit()
23-
},
24-
'placeholder': props.placeholder,
25-
'type': props.type ?? 'text',
26-
'disabled': props.disabled || props.loading,
17+
// Stateful inner component: a JrComponent render fn can't hold a ref, so the
18+
// uncontrolled value (no `$bindState` on `value`) lives here, session-
19+
// persisted so it survives a reload; when the spec binds `value`,
20+
// `bindingPath` is set and writes flow back to the state store instead.
21+
const TextInputImpl = defineComponent({
22+
name: 'JrTextInputImpl',
23+
props: {
24+
value: { type: String, default: undefined },
25+
placeholder: { type: String, default: undefined },
26+
label: { type: String, default: undefined },
27+
disabled: { type: Boolean, default: undefined },
28+
type: { type: String as PropType<TextInputProps['type']>, default: 'text' },
29+
loading: { type: Boolean, default: undefined },
30+
bindingPath: { type: String, default: undefined },
31+
onChange: { type: Function as PropType<() => void>, default: undefined },
32+
},
33+
setup(props) {
34+
// `props.value` is already the live resolved value; `useBoundProp` is used
35+
// only for its store setter.
36+
const [, setBound] = useBoundProp<string>(props.value, props.bindingPath)
37+
const controlled = props.bindingPath != null
38+
const local = useUncontrolledValue('TextInput', { placeholder: props.placeholder, type: props.type }, props.value ?? '')
39+
const setModel = (next: string) => {
40+
if (controlled)
41+
setBound(next)
42+
else local.value = next
43+
props.onChange?.()
44+
}
45+
return () => {
46+
const input = h(FormTextInput, {
47+
'modelValue': (controlled ? props.value : local.value) ?? '',
48+
'onUpdate:modelValue': setModel,
49+
'placeholder': props.placeholder,
50+
'type': props.type ?? 'text',
51+
'disabled': props.disabled || props.loading,
52+
})
53+
if (props.label) {
54+
return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [
55+
h('span', props.label),
56+
input,
57+
])
58+
}
59+
return input
60+
}
61+
},
62+
})
63+
64+
export const TextInput: JrComponent<TextInputProps> = ({ props, on, bindings }) =>
65+
h(TextInputImpl, {
66+
value: props.value,
67+
placeholder: props.placeholder,
68+
label: props.label,
69+
disabled: props.disabled,
70+
type: props.type,
71+
loading: props.loading,
72+
bindingPath: bindings?.value,
73+
onChange: () => on('change').emit(),
2774
})
28-
if (props.label) {
29-
return h('label', { class: 'flex flex-col gap-1 text-sm color-muted' }, [
30-
h('span', props.label),
31-
input,
32-
])
33-
}
34-
return input
35-
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import type { InjectionKey } from 'vue'
2+
3+
/**
4+
* Injection key for the current dock's own identity — the `viewId`
5+
* {@link JsonRenderView} is mounted with (a shared-state `stateKey`, or a
6+
* client-synthesized dock id). `JsonRenderView` `provide()`s it once per
7+
* mounted view; {@link useUncontrolledValue}'s session-persistence key
8+
* `inject()`s it instead of threading the id through every registry
9+
* component's props.
10+
*/
11+
export const DOCK_ENTRY_ID_KEY: InjectionKey<string | undefined> = Symbol('devframes:json-render:dock-entry-id')
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import type { Ref } from 'vue'
2+
import { useSessionStorage } from '@vueuse/core'
3+
import { inject } from 'vue'
4+
import { DOCK_ENTRY_ID_KEY } from './dock-entry-id'
5+
6+
/**
7+
* Session-persisted fallback for a json-render element's own *uncontrolled*
8+
* value — the local state `Tabs`/`Select`/`Switch`/`TextInput` fall back to
9+
* when the bindable prop has no `$bindState` binding (`useBoundProp`'s setter
10+
* is a no-op without one). On by default: calling this instead of a plain
11+
* `ref(defaultValue)` survives a reload within the same tab.
12+
*
13+
* The key combines the current dock's id ({@link DOCK_ENTRY_ID_KEY}, absent
14+
* outside a devframe dock) with a caller-supplied `signature` identifying the
15+
* element within that dock — `kind` (the component, e.g. `'Tabs'`) plus the
16+
* element's own static props. There is no element id to key off directly here
17+
* (unlike some other json-render integrations' render context): a shape
18+
* change yields a different key, so persistence falls back to `defaultValue`
19+
* instead of restoring a stale value for a different element — intended, not
20+
* a bug.
21+
*/
22+
export function useUncontrolledValue<T>(kind: string, signature: Record<string, unknown>, defaultValue: T): Ref<T> {
23+
const dockEntryId = inject(DOCK_ENTRY_ID_KEY, undefined)
24+
const key = `devframes-json-render-uncontrolled:${dockEntryId ?? '~'}:${kind}:${JSON.stringify(signature)}`
25+
return useSessionStorage<T>(key, defaultValue)
26+
}

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

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import type { Component, PropType } from 'vue'
44
import type { ActionBridgeRpc } from './action-bridge'
55
import { basePropSchemas } from '@devframes/json-render'
66
import { JSONUIProvider, Renderer } from '@json-render/vue'
7-
import { computed, defineComponent, h } from 'vue'
7+
import { useDebounceFn, useSessionStorage } from '@vueuse/core'
8+
import { computed, defineComponent, h, provide, ref, watchEffect } from 'vue'
89
import { createActionBridge } from './action-bridge'
10+
import { DOCK_ENTRY_ID_KEY } from './composables/dock-entry-id'
911
import { baseRegistry, ERROR_COMPONENT_TYPE, UNSUPPORTED_COMPONENT_TYPE } from './registry'
1012

1113
// Upstream ships these as heavily-typed `DefineComponent`s; render them through
@@ -82,10 +84,37 @@ export const JsonRenderView = defineComponent({
8284
setup(props) {
8385
const bridge = createActionBridge(props.rpc, { interactive: props.interactive })
8486

87+
/**
88+
* Descendants (e.g. `useUncontrolledValue`) `inject()` this to scope
89+
* session-persisted state to "this dock" — the mounted view's own id,
90+
* stable while a given `resetKey` subtree is alive (a `viewId` change
91+
* remounts that subtree below, via the `key` on `ProviderC`).
92+
*/
93+
provide(DOCK_ENTRY_ID_KEY, props.viewId)
94+
8595
// Reset the provider (reseed state) only on identity change.
8696
const resetKey = computed(() => props.viewId)
8797
const effectiveSpec = computed(() => (props.spec ? sanitizeSpec(props.spec, props.registry) : null))
8898

99+
/**
100+
* Restores/persists the scroll position of this view, per tab, across a
101+
* reload — keyed by `viewId` so switching views doesn't bleed one view's
102+
* scroll into another's. The key is a getter (not a plain string) since,
103+
* unlike the registry components below, this component instance itself is
104+
* not guaranteed to remount when `viewId` changes (e.g. the reference SPA
105+
* keeps one `JsonRenderView` alive across its own view switcher) — the
106+
* `watchEffect` below re-fires on both a fresh mount and a `viewId` change.
107+
*/
108+
const scrollEl = ref<HTMLElement | null>(null)
109+
const scrollTop = useSessionStorage(() => `devframes-json-render-scroll:${props.viewId}`, 0)
110+
watchEffect(() => {
111+
if (scrollEl.value)
112+
scrollEl.value.scrollTop = scrollTop.value
113+
})
114+
const persistScrollTop = useDebounceFn(() => {
115+
scrollTop.value = scrollEl.value?.scrollTop ?? 0
116+
}, 200)
117+
89118
return () => {
90119
if (props.loading)
91120
return h('div', { class: surface }, 'Loading…')
@@ -107,7 +136,7 @@ export const JsonRenderView = defineComponent({
107136
}, 'Interactive actions are unavailable in static output.')
108137
: null
109138

110-
return h('div', { class: 'color-base' }, [
139+
return h('div', { class: 'color-base w-full h-full overflow-auto', ref: scrollEl, onScroll: persistScrollTop }, [
111140
staticNote,
112141
banner,
113142
h(

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)