diff --git a/packages/hub-ui/src/client/components/dock/DockEmbedded.vue b/packages/hub-ui/src/client/components/dock/DockEmbedded.vue index b3d94b9e..730d49af 100644 --- a/packages/hub-ui/src/client/components/dock/DockEmbedded.vue +++ b/packages/hub-ui/src/client/components/dock/DockEmbedded.vue @@ -2,7 +2,7 @@ import type { DocksContext } from '@devframes/hub/client' import type { DockLayout } from './dock-layout' import { useEventListener } from '@vueuse/core' -import { onUnmounted } from 'vue' +import { onUnmounted, watch } from 'vue' import { sharedStateToRef } from '../../state/docks' import { closeDockPopup, useIsDockPopupOpen } from '../../state/popup' import { useIsRpcTrusted } from '../../utils/useIsRpcTrusted' @@ -21,12 +21,28 @@ const props = defineProps<{ layout?: Partial }>() +const context = props.context + const isDockPopupOpen = useIsDockPopupOpen() const settings = sharedStateToRef(props.context.docks.settings) // Force float mode when unauthorized, regardless of store setting const isRpcTrusted = useIsRpcTrusted(props.context) +/** + * If the panel is open but nothing valid is selected (e.g. a restored + * `selectedId` didn't resolve to a real entry), fall back to the first + * available one — mirrors `DockStandalone`'s own boot guard. + */ +watch( + () => context.docks.entries, + () => { + if (context.panel.store.open) + context.docks.selectedId ||= context.docks.entries[0]?.id ?? null + }, + { immediate: true }, +) + // Close the dock when clicking outside of it useEventListener(window, 'mousedown', (e: MouseEvent) => { if (!settings.value.closeOnOutsideClick) diff --git a/packages/hub-ui/src/client/embedded/index.ts b/packages/hub-ui/src/client/embedded/index.ts index b069a6af..ff53bb7c 100644 --- a/packages/hub-ui/src/client/embedded/index.ts +++ b/packages/hub-ui/src/client/embedded/index.ts @@ -1,4 +1,4 @@ -import type { DockPanelStorage } from '@devframes/hub/client' +import type { HubDockPanelStorage } from '../state/docks' import { getDevframeRpcClient, setDevframeClientContext } from '@devframes/hub/client' import { useLocalStorage } from '@vueuse/core' import { HUB_UI_HIDE_EVENT } from '../constants' @@ -39,7 +39,7 @@ async function mountDock(): Promise { simpleAuth: false, }) - const state = useLocalStorage( + const state = useLocalStorage( 'devframes-dock-state', DEFAULT_DOCK_PANEL_STORE(), { mergeDefaults: true }, diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 1df7ed40..31052144 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -1,9 +1,10 @@ import type { DevframeClientCommand, DevframeDockEntry, DevframeDockUserEntry, DevframeRpcClientFunctions, DevframeViewIframe } from '@devframes/hub' -import type { CommandsContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockPanelStorage, DockRegistration, DockRendererManifest, DocksContext } from '@devframes/hub/client' +import type { CommandsContext, DevframeRpcClient, DockClientScriptContext, DockEntryState, DockRegistration, DockRendererManifest, DocksContext } from '@devframes/hub/client' import type { SharedState } from 'devframe/utils/shared-state' import type { WhenContext } from 'devframe/utils/when' import type { Ref } from 'vue' import type { HubDocksUserSettings } from './dock-settings' +import type { HubDockPanelStorage } from './docks' import { attachFrameNavClient } from '@devframes/hub/client' import { DEFAULT_STATE_USER_SETTINGS, DOCK_RENDERERS_STATE_KEY } from '@devframes/hub/constants' import { computed, markRaw, reactive, ref, toRefs, watch, watchEffect } from 'vue' @@ -21,7 +22,7 @@ const docksContextByRpc = new WeakMap() export async function createDocksContext( clientType: 'embedded' | 'standalone', rpc: DevframeRpcClient, - panelStore?: Ref, + panelStore?: Ref, ): Promise { if (docksContextByRpc.has(rpc)) { return docksContextByRpc.get(rpc)! @@ -73,13 +74,58 @@ export async function createDocksContext( return [...base, BUILTIN_ENTRY_SETTINGS] }) - const selectedId = ref(null) + panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE()) + + /** + * `selectedId` lives in `panelStore` (localStorage in the embedded client), + * alongside `open`/mode/geometry — so it's restored across a reload and + * shared cross-tab like the rest of that value, instead of resetting to + * nothing every time the dock mounts. + */ + const selectedId = computed({ + get: () => panelStore.value.selectedId, + set: (value) => { panelStore.value.selectedId = value }, + }) const selected = computed( () => entries.value.find(entry => entry.id === selectedId.value) ?? BUILTIN_ENTRIES.find(entry => entry.id === selectedId.value) ?? null, ) + /** + * A restored `selectedId` may point at a non-selectable entry (a group, or + * a `subTabs` anchor) — `switchEntry` would fix that on click, but routing + * through it here would force `panelStore.value.open = true`, reopening a + * closed panel. So validate once, on boot, directly instead. Past boot, + * `switchEntry` may itself land `selectedId` on a group/anchor (e.g. + * mid-redirect, or a `subTabs` anchor with no live member yet) — that's not + * something to keep correcting. + */ + const isSelectableEntry = (id: string): boolean => { + if (BUILTIN_ENTRIES.some(entry => entry.id === id)) + return true + const entry = entries.value.find(e => e.id === id) + if (!entry) + return false + if (entry.type === 'group') + return false + if (entry.type === 'iframe' && entry.subTabs) + return false + return true + } + let bootRestoreChecked = false + watch( + entries, + () => { + if (bootRestoreChecked) + return + bootRestoreChecked = true + if (selectedId.value != null && !isSelectableEntry(selectedId.value)) + selectedId.value = null + }, + { immediate: true }, + ) + const dockEntryStateMap: Map = reactive(new Map()) watchEffect(() => { for (const entry of entries.value) { @@ -131,7 +177,6 @@ export async function createDocksContext( clientDocks.set(entry.id, entry as DevframeDockEntry) } - panelStore ||= ref(DEFAULT_DOCK_PANEL_STORE()) let docksContext: DocksContext let _settingsStorePromise: Promise> | undefined diff --git a/packages/hub-ui/src/client/state/docks.ts b/packages/hub-ui/src/client/state/docks.ts index 88f70b6a..709fd6da 100644 --- a/packages/hub-ui/src/client/state/docks.ts +++ b/packages/hub-ui/src/client/state/docks.ts @@ -5,7 +5,18 @@ import type { Ref, ShallowRef } from 'vue' import { createEventEmitter } from 'devframe/utils/events' import { markRaw, reactive, shallowRef, watch } from 'vue' -export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage { +/** + * {@link DockPanelStorage} (hub's own type — geometry/mode/`open`) plus + * `selectedId`, which the hub has no concept of. Both persist in the same + * `devframes-dock-state` localStorage value (the embedded dock's own store), + * so both survive a reload and are shared cross-tab like the rest of that + * value — a dock left open/selected in one tab shows the same way in the next. + */ +export interface HubDockPanelStorage extends DockPanelStorage { + selectedId: string | null +} + +export function DEFAULT_DOCK_PANEL_STORE(): HubDockPanelStorage { return { mode: 'float', width: 80, @@ -15,6 +26,7 @@ export function DEFAULT_DOCK_PANEL_STORE(): DockPanelStorage { position: 'bottom', open: false, inactiveTimeout: 3_000, + selectedId: null, } } diff --git a/packages/hub-ui/src/client/stories/mock-context.ts b/packages/hub-ui/src/client/stories/mock-context.ts index e4bc87d3..9e2f683c 100644 --- a/packages/hub-ui/src/client/stories/mock-context.ts +++ b/packages/hub-ui/src/client/stories/mock-context.ts @@ -1,5 +1,6 @@ import type { DevframeDockEntry } from '@devframes/hub' -import type { DevframeRpcClient, DockPanelStorage, DocksContext, RpcClientEvents } from '@devframes/hub/client' +import type { DevframeRpcClient, DocksContext, RpcClientEvents } from '@devframes/hub/client' +import type { HubDockPanelStorage } from '../state/docks' import type { HubDocksUserSettings } from '../types' import { DEFAULT_STATE_USER_SETTINGS } from '@devframes/hub/constants' import { createEventEmitter } from 'devframe/utils/events' @@ -25,7 +26,7 @@ export interface CreateMockContextOptions { /** Which client shell the context represents. */ clientType?: 'embedded' | 'standalone' /** Overrides merged over the default panel store (mode, position, open, ...). */ - panel?: Partial + panel?: Partial /** Overrides merged over the default user settings (hidden, pinned, order, ...). */ settings?: Partial /** Entry id to pre-select (also opens the panel). */ @@ -117,7 +118,7 @@ export async function createMockDocksContext( } = options const rpc = createMockRpc(entries, settings, isTrusted) - const panelStore = ref({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel }) + const panelStore = ref({ ...DEFAULT_DOCK_PANEL_STORE(), ...panel }) const context = await createDocksContext(clientType, rpc, panelStore) diff --git a/packages/hub-ui/test/dock-panel-restore.test.ts b/packages/hub-ui/test/dock-panel-restore.test.ts new file mode 100644 index 00000000..e41620c4 --- /dev/null +++ b/packages/hub-ui/test/dock-panel-restore.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from 'vitest' +import { iframe } from '../src/client/stories/fixtures' +import { createMockDocksContext } from '../src/client/stories/mock-context' + +/** + * `selectedId` lives on the same `panelStore` ref as `open`/mode/geometry + * (`state/docks.ts`'s `HubDockPanelStorage`) — restored from localStorage in + * the real embedded client, seeded here via `createMockDocksContext`'s + * `panel`/`selectedId` options instead of a separate session store. + */ +describe('restored dock panel state (selectedId on the shared panelStore)', () => { + it('keeps a restored selectedId that resolves to a real leaf entry', async () => { + const context = await createMockDocksContext({ + entries: [iframe('a', 'A', 'ph:cube-duotone')], + panel: { selectedId: 'a', open: true }, + }) + + expect(context.docks.selectedId).toBe('a') + expect(context.panel.store.open).toBe(true) + }) + + it('keeps a restored selectedId of a `~builtin` pseudo-entry (e.g. Settings)', async () => { + const context = await createMockDocksContext({ + entries: [], + panel: { selectedId: '~settings', open: true }, + }) + + expect(context.docks.selectedId).toBe('~settings') + }) + + it('clears a restored selectedId pointing at a group (not a selectable leaf)', async () => { + const context = await createMockDocksContext({ + entries: [{ id: 'nuxt', type: 'group', title: 'Nuxt', icon: 'ph:cube-duotone' } as any], + panel: { selectedId: 'nuxt' }, + }) + + expect(context.docks.selectedId).toBeNull() + }) + + it('clears a restored selectedId pointing at a subTabs anchor (not a selectable leaf)', async () => { + const context = await createMockDocksContext({ + entries: [iframe('nuxt', 'Nuxt', 'ph:cube-duotone', { subTabs: { protocol: 'postmessage' } } as any)], + panel: { selectedId: 'nuxt' }, + }) + + expect(context.docks.selectedId).toBeNull() + }) + + it('clears a restored selectedId that no longer resolves to any entry, without forcing the panel open', async () => { + const context = await createMockDocksContext({ + entries: [iframe('a', 'A', 'ph:cube-duotone')], + panel: { selectedId: 'gone', open: false }, + }) + + expect(context.docks.selectedId).toBeNull() + // Clearing an invalid restored id must not route through `switchEntry` + // (which would force `open = true`) — the panel stays exactly as restored. + expect(context.panel.store.open).toBe(false) + }) + + it('does not clear an id `switchEntry` itself legitimately selects later (a subTabs anchor with no live member yet)', async () => { + const context = await createMockDocksContext({ + entries: [iframe('nuxt', 'Nuxt', 'ph:cube-duotone', { subTabs: { protocol: 'postmessage' } } as any)], + }) + + await context.docks.switchEntry('nuxt') + + expect(context.docks.selectedId).toBe('nuxt') + }) + + it('sets selectedId and open on the same panel store that carries geometry (mode)', async () => { + const context = await createMockDocksContext({ + entries: [], + panel: { mode: 'float' }, + }) + + context.panel.store.open = true + context.docks.selectedId = null + + expect(context.panel.store.open).toBe(true) + expect(context.panel.store.mode).toBe('float') + }) +}) diff --git a/packages/hub-ui/vitest.config.ts b/packages/hub-ui/vitest.config.ts new file mode 100644 index 00000000..7cb85851 --- /dev/null +++ b/packages/hub-ui/vitest.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from 'vitest/config' +import { alias } from '../../alias' + +// The dock-context tests cross-import `@devframes/hub`'s types/constants — +// resolve them to source rather than the (possibly stale/unbuilt) `dist`. +export default defineConfig({ + resolve: { alias }, + test: { + name: '@devframes/hub-ui', + }, +}) 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 diff --git a/vitest.config.ts b/vitest.config.ts index fe703b86..1e58dff0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -15,6 +15,7 @@ export default defineConfig({ projects: [ 'packages/devframe', 'packages/hub', + 'packages/hub-ui', 'packages/json-render', 'packages/json-render-ui', 'plugins/code-server',