Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions packages/core/src/client/inject/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/// <reference types="vite/client" />
/// <reference lib="dom" />

import type { DockPanelStorage } from '@vitejs/devtools-kit/client'
import type { DevToolsDockPanelStorage } from '../webcomponents/state/docks'
import { CLIENT_CONTEXT_KEY, getDevToolsRpcClient } from '@vitejs/devtools-kit/client'
import { DEVTOOLS_MOUNT_PATH } from '@vitejs/devtools-kit/constants'
import { useLocalStorage } from '@vueuse/core'
Expand Down Expand Up @@ -60,7 +60,12 @@ async function mountDock(): Promise<void> {
],
})

const state = useLocalStorage<DockPanelStorage>(
/**
* Defaults here are this injected client's own — deliberately different
* from `DEFAULT_DOCK_PANEL_STORE()`'s fallback used elsewhere, not a shared
* literal. See `DevToolsDockPanelStorage` for what's persisted and why.
*/
const state = useLocalStorage<DevToolsDockPanelStorage>(
'vite-devtools-dock-state',
{
mode: 'float',
Expand All @@ -71,6 +76,7 @@ async function mountDock(): Promise<void> {
position: 'left',
open: false,
inactiveTimeout: 3_000,
selectedId: null,
},
{ mergeDefaults: true },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import type { DocksContext } from '@vitejs/devtools-kit/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'
Expand All @@ -21,12 +21,28 @@ const props = defineProps<{
layout?: Partial<DockLayout>
}>()

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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,18 +3,42 @@ import type { Spec } from '@json-render/core'
import type { DevToolsViewJsonRender } from '@vitejs/devtools-kit'
import type { DocksContext } from '@vitejs/devtools-kit/client'
import { JSONUIProvider, Renderer } from '@json-render/vue'
import { computed, markRaw, onMounted, ref, shallowRef, watch } from 'vue'
import { useDebounceFn, useSessionStorage } from '@vueuse/core'
import { computed, markRaw, onMounted, provide, ref, shallowRef, useTemplateRef, watch } from 'vue'
import { DOCK_ENTRY_ID_KEY } from '../../json-render/composables/dock-entry-id'
import { devtoolsRegistry, UnsupportedComponent } from '../../json-render/registry'

const props = defineProps<{
context: DocksContext
entry: DevToolsViewJsonRender
}>()

/**
* Descendants (e.g. `useUncontrolledValue`) `inject()` this to scope
* session-persisted state to "this dock" — the entry's own id, stable for
* this component instance's lifetime (`ViewEntry` re-keys on entry change).
*/
provide(DOCK_ENTRY_ID_KEY, props.entry.id)

const spec = shallowRef<Spec | null>(null)
const isLoading = ref(true)
const error = ref<string | null>(null)

/**
* Restores/persists the scroll position of this dock's own view, per tab,
* across a reload — keyed by the dock entry id so switching docks doesn't
* bleed one dock's scroll into another's.
*/
const scrollContainer = useTemplateRef<HTMLElement>('scrollContainer')
const scrollTop = useSessionStorage(`vite-devtools-scroll:${props.entry.id}`, 0)
onMounted(() => {
if (scrollContainer.value)
scrollContainer.value.scrollTop = scrollTop.value
})
const persistScrollTop = useDebounceFn(() => {
scrollTop.value = scrollContainer.value?.scrollTop ?? 0
}, 200)

// Resolve spec from entry.ui._stateKey
async function loadSpec() {
try {
Expand Down Expand Up @@ -87,7 +111,7 @@ watch(() => props.entry.ui?._stateKey, loadSpec)
</script>

<template>
<div class="vite-devtools-view-json-render w-full h-full overflow-auto" style="padding: 16px; scrollbar-gutter: stable;">
<div ref="scrollContainer" class="vite-devtools-view-json-render w-full h-full overflow-auto" style="padding: 16px; scrollbar-gutter: stable;" @scroll="persistScrollTop">
<div v-if="isLoading" style="display: flex; align-items: center; justify-content: center; height: 100%; opacity: 0.5; font-size: 13px;">
Loading...
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { useBoundProp } from '@json-render/vue'
import { defineComponent, h, ref, useId, useTemplateRef, watch } from 'vue'
import DockIcon from '../../components/dock/DockIcon.vue'
import FloatingPopover from '../../components/floating/FloatingPopover'
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
import { bg, borderInput, borderSolid, surfaceSubtle } from './tokens'
import { registryProps } from './types'

Expand Down Expand Up @@ -42,6 +43,10 @@ export const Select = defineComponent({
const activeIndex = ref(0)
const listboxId = useId()

/** Local, session-persisted fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */
const uncontrolledValue = useUncontrolledValue(ctx, 'value', ctx.element.props.value)
const controlled = ctx.bindings?.value != null

const close = (options: { refocus?: boolean } = {}) => {
open.value = false
if (options.refocus)
Expand All @@ -57,14 +62,21 @@ export const Select = defineComponent({
return
query.value = ''
const options = (ctx.element.props.options ?? []).map(normalizeOption)
const index = options.findIndex(option => option.value === ctx.element.props.value)
const currentValue = controlled ? ctx.element.props.value : uncontrolledValue.value
const index = options.findIndex(option => option.value === currentValue)
activeIndex.value = index >= 0 ? index : 0
})

return () => {
const { placeholder, label, disabled, searchable } = ctx.element.props
const options = (ctx.element.props.options ?? []).map(normalizeOption)
const [value, setValue] = useBoundProp<string>(ctx.element.props.value, ctx.bindings?.value)
const [boundValue, setBoundValue] = useBoundProp<string>(ctx.element.props.value, ctx.bindings?.value)
const value = controlled ? boundValue : uncontrolledValue.value
const setValue = (next: string) => {
if (controlled)
setBoundValue(next)
else uncontrolledValue.value = next
}
const change = ctx.on('change')

const filtered = searchable && query.value
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useBoundProp } from '@json-render/vue'
import { defineComponent, h } from 'vue'
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
import { primary, surfaceSubtle } from './tokens'
import { registryProps } from './types'

Expand All @@ -14,9 +15,19 @@ export const Switch = defineComponent({
name: 'JrSwitch',
props: registryProps<'Switch', SwitchProps>(),
setup(ctx) {
/** Local, session-persisted fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */
const uncontrolledValue = useUncontrolledValue(ctx, 'value', ctx.element.props.value)
const controlled = ctx.bindings?.value != null

return () => {
const { label, disabled } = ctx.element.props
const [value, setValue] = useBoundProp<boolean>(ctx.element.props.value, ctx.bindings?.value)
const [boundValue, setBoundValue] = useBoundProp<boolean>(ctx.element.props.value, ctx.bindings?.value)
const value = controlled ? boundValue : uncontrolledValue.value
const setValue = (next: boolean) => {
if (controlled)
setBoundValue(next)
else uncontrolledValue.value = next
}
const change = ctx.on('change')
const checked = !!value

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useBoundProp } from '@json-render/vue'
import { defineComponent, h, ref, watchEffect } from 'vue'
import { getIconifySvg } from '../../utils/iconify'
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
import { colors, primary, surfaceSubtle } from './tokens'
import { registryProps } from './types'

Expand All @@ -26,8 +27,8 @@ export const Tabs = defineComponent({
name: 'JrTabs',
props: registryProps<'Tabs', TabsProps>(),
setup(ctx, { slots }) {
/** Local fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */
const uncontrolledValue = ref<string | undefined>(ctx.element.props.defaultValue ?? ctx.element.props.tabs?.[0]?.value)
/** Local, session-persisted fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */
const uncontrolledValue = useUncontrolledValue(ctx, 'value', ctx.element.props.defaultValue ?? ctx.element.props.tabs?.[0]?.value)

/** Icon SVGs keyed by name, resolved like `Icon.ts` — one tab's icon changing shouldn't refetch the others. */
const iconSvgs = ref<Record<string, string>>({})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useBoundProp } from '@json-render/vue'
import { defineComponent, h } from 'vue'
import DockIcon from '../../components/dock/DockIcon.vue'
import { useUncontrolledValue } from '../composables/useUncontrolledValue'
import { borderInput, borderSolid } from './tokens'
import { registryProps } from './types'

Expand All @@ -19,9 +20,19 @@ export const TextInput = defineComponent({
name: 'JrTextInput',
props: registryProps<'TextInput', TextInputProps>(),
setup(ctx) {
/** Local, session-persisted fallback for when `value` has no `$bindState` binding — `useBoundProp`'s setter is a no-op without one. */
const uncontrolledValue = useUncontrolledValue(ctx, 'value', ctx.element.props.value)
const controlled = ctx.bindings?.value != null

return () => {
const { placeholder, label, type = 'text', disabled, loading } = ctx.element.props
const [value, setValue] = useBoundProp<string>(ctx.element.props.value, ctx.bindings?.value)
const [boundValue, setBoundValue] = useBoundProp<string>(ctx.element.props.value, ctx.bindings?.value)
const value = controlled ? boundValue : uncontrolledValue.value
const setValue = (next: string) => {
if (controlled)
setBoundValue(next)
else uncontrolledValue.value = next
}
const change = ctx.on('change')

const input = h('input', {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { InjectionKey } from 'vue'

/**
* Injection key for the current dock entry's id. `ViewJsonRender.vue`
* `provide()`s it once per dock; any json-render component or composable that
* needs an identity scoped to "this dock" — e.g. {@link useUncontrolledValue}'s
* session-persistence key, or a per-dock scroll position — `inject()`s it
* instead of threading the id through every registry component's props.
*/
export const DOCK_ENTRY_ID_KEY: InjectionKey<string | undefined> = Symbol('vite-devtools:dock-entry-id')
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Ref } from 'vue'
import type { RegistryComponentProps } from '../components/types'
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`/`TextInput`/`Switch` fall back to
* when `value` has no `$bindState` binding. On by default: calling this
* instead of a plain `ref(default)` survives a reload within the same tab.
*
* The key is derived rather than passed in, since `UIElement` carries no id
* of its own: it combines the current dock's id ({@link DOCK_ENTRY_ID_KEY})
* with a signature of the element's own static props (minus the bound prop).
* 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<Type extends string, Props extends Record<string, any>, Prop extends keyof Props>(
ctx: RegistryComponentProps<Type, Props>,
prop: Prop,
defaultValue: Props[Prop],
): Ref<Props[Prop]> {
const dockEntryId = inject(DOCK_ENTRY_ID_KEY, undefined)

const staticShape: Record<string, unknown> = { ...ctx.element.props }
delete staticShape[prop as string]

const key = `vite-devtools-uncontrolled:${dockEntryId ?? '~'}:${ctx.element.type}:${JSON.stringify(staticShape)}`
return useSessionStorage<Props[Prop]>(key, defaultValue)
}
Loading
Loading