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
9 changes: 9 additions & 0 deletions .changeset/clear-path-on-unmount.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"leva": minor
---

Add `noCache` option and `store.clearPath` to discard cached input values when they are no longer in use.

- `store.clearPath(path)` — new method to imperatively discard the cached value of an input that is no longer mounted.
- `InputOptions.noCache` — per-input flag; when `true` the input's cached value is discarded from the store on unmount.
- `LevaRootProps.noCache` — panel-level flag; when `true` all inputs managed by that panel have their cached values discarded on unmount, overriding the per-input setting.
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ jobs:
id: pnpm-build-cache
with:
path: packages/**/dist
key: ${{ runner.os }}-pnpm-build-${{ hashFiles('/packages/**/*') }}
key: ${{ runner.os }}-pnpm-build-${{ hashFiles('packages/**/*.ts', 'packages/**/*.tsx') }}
restore-keys: |
${{ runner.os }}-pnpm-build-

Expand Down
33 changes: 33 additions & 0 deletions docs/getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export default function MyApp() {
position: { x: 0, y: 0 }, // Initial position (when drag is enabled)
onDrag: (position) => {}, // Callback when dragged
}}
noCache // default = false, discards cached input values when inputs unmount
/>
</>
)
Expand All @@ -49,6 +50,7 @@ export default function MyApp() {
- `hidden`: Hides the GUI completely
- `neverHide`: Keeps GUI visible even when no controls are mounted
- `hideCopyButton`: Hides the copy button in the title bar
- `noCache`: When `true`, all inputs managed by this panel discard their cached values when they unmount (overrides the per-input `noCache` setting)
- `titleBar`: Object with title bar configuration:
- `title`: Custom title string
- `drag`: Enable/disable dragging
Expand Down Expand Up @@ -101,6 +103,37 @@ export default function MyApp() {
}
```

### Clearing Cached Values on Unmount

By default Leva caches input values so they survive unmount/remount cycles. You can opt out of this behaviour at two levels:

**Panel-level** — pass `noCache` to `<Leva>` (or `<LevaPanel>`). Every input managed by that panel will discard its cached value when its component unmounts. This takes priority over the per-input setting.

```jsx
<Leva noCache />
```

**Per-input** — set `noCache: true` inside the schema for individual inputs (see [Input Options](inputs.md#nocache)).

```jsx
useControls({
sessionValue: { value: 0, noCache: true },
persistentValue: { value: 0 },
})
```

**Imperative** — call `store.clearPath(path)` to discard the cached value of a single input at any time without unmounting it:

```jsx
import { levaStore, useControls } from 'leva'

function MyComponent() {
const { position } = useControls({ position: { x: 0, y: 0 } })

return <button onClick={() => levaStore.clearPath('position')}>Reset position cache</button>
}
```

### Controlled Collapsed State

You can control the collapsed state from outside:
Expand Down
13 changes: 13 additions & 0 deletions docs/getting-started/inputs.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,19 @@ const values = useControls({
})
```

### noCache

By default, Leva caches input values so they persist across unmount/remount cycles. Set `noCache: true` on a specific input to discard its cached value when its component unmounts, so the next mount starts fresh from the initial value:

```jsx
const { sessionValue, persistentValue } = useControls({
sessionValue: { value: 0, noCache: true },
persistentValue: { value: 0 },
})
```

See also the panel-level [`noCache`](configuration.md#nocache) option to apply this behaviour to all inputs at once.

### Enforcing Input Type

Force a specific input type even if Leva would infer differently:
Expand Down
24 changes: 22 additions & 2 deletions packages/leva/src/components/Leva/LevaRoot.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useMemo, useState } from 'react'
import React, { useEffect, useMemo, useState } from 'react'
import * as RadixTooltip from '@radix-ui/react-tooltip'
import { buildTree } from './tree'
import { TreeWrapper } from '../Folder'
Expand Down Expand Up @@ -90,10 +90,30 @@ export type LevaRootProps = {
* If true, the copy button will be hidden
*/
hideCopyButton?: boolean
/**
* If true, all inputs will be cleared from the store when their component unmounts,
* regardless of the per-input noCache setting.
*/
noCache?: boolean
}

export function LevaRoot({ store, hidden = false, theme, collapsed = false, ...props }: LevaRootProps) {
export function LevaRoot({
store,
hidden = false,
theme,
collapsed = false,
noCache = false,
...props
}: LevaRootProps) {
const themeContext = useDeepMemo(() => mergeTheme(theme), [theme])

useEffect(() => {
if (store) {
store.setNoCache(noCache)
return () => store.setNoCache(false)
}
}, [store, noCache])

// collapsible
const [toggled, setToggle] = useState(!collapsed)

Expand Down
14 changes: 14 additions & 0 deletions packages/leva/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ export const Store = function (this: StoreType) {

this.storeId = getUid()
this.useStore = store
this.noCache = false
this.setNoCache = (flag) => {
this.noCache = flag
}
/**
* Folders will hold the folder settings for the pane.
* @note possibly make this reactive
Expand Down Expand Up @@ -114,6 +118,16 @@ export const Store = function (this: StoreType) {
})
}

this.clearPath = (path) => {
store.setState((s) => {
const input = s.data[path]
if (!input || input.__refCount > 0) return s
const data = { ...s.data }
delete data[path]
return { data }
})
}

this.getFolderSettings = (path) => {
return folders[path] || {}
}
Expand Down
4 changes: 4 additions & 0 deletions packages/leva/src/types/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export type MappedPaths = Record<
onEditStart?: (...args: any) => void
onEditEnd?: (...args: any) => void
transient: boolean
noCache: boolean
}
>

Expand All @@ -28,10 +29,13 @@ type Dispose = () => void
export type StoreType = {
useStore: UseBoundStore<StoreApi<State>> & SubscribeWithSelectorAPI<State>
storeId: string
noCache: boolean
setNoCache: (flag: boolean) => void
orderPaths: (paths: string[]) => string[]
setOrderedPaths: (newPaths: string[]) => void
disposePaths: (paths: string[]) => void
dispose: () => void
clearPath: (path: string) => void
getVisiblePaths: () => string[]
getFolderSettings: (path: string) => FolderSettings
getData: () => Data
Expand Down
1 change: 1 addition & 0 deletions packages/leva/src/types/public.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ export type InputOptions = GenericSchemaItemOptions &
disabled?: boolean
onEditStart?: (value: any, path: string, context: OnHandlerContext) => void
onEditEnd?: (value: any, path: string, context: OnHandlerContext) => void
noCache?: boolean
}

type SchemaItemWithOptions =
Expand Down
189 changes: 189 additions & 0 deletions packages/leva/src/useControls.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/**
* Integration tests for useControls with store lifecycle
*/

import React, { useEffect } from 'react'
import { describe, it, expect, vi, afterEach } from 'vitest'
import { render, act } from '@testing-library/react'
import { useControls } from './useControls'
import { levaStore } from './store'
import { LevaPanel } from './components/Leva'

// Mock stitches to avoid CSS-in-JS insertRule errors in jsdom.
// @stitches/react is imported transitively via useControls -> components/Leva -> stitches.config.ts.
// The mock is scoped to this file and doesn't affect production code.
// NOTE: vi.mock is hoisted by Vitest's transformer at build time, so this runs before imports regardless of position.
vi.mock('@stitches/react', () => ({
createStitches: () => ({
styled: () => () => null,
css: () => () => '',
globalCss: () => () => {},
keyframes: () => '',
getCssText: () => '',
theme: {},
createTheme: () => ({}),
config: {},
}),
}))

afterEach(() => {
levaStore.dispose()
levaStore.setNoCache(false)
})

function NumberComponent({ id }: { id?: string }) {
const { myNumber } = useControls({ myNumber: 5 }, { headless: true })
return <div data-testid={id ?? 'value'}>{myNumber}</div>
}

function NestedNumberComponent({ id }: { id?: string }) {
const { myNumber } = useControls('myFolder', { myNumber: 5 }, { store: levaStore, headless: true })
return <div data-testid={id ?? 'value'}>{myNumber}</div>
}

function NumberComponentNoCache({ id }: { id?: string }) {
const { myNumber } = useControls({ myNumber: 5 }, { headless: true })
useEffect(
() => () => {
levaStore.clearPath('myNumber')
},
[]
)
return <div data-testid={id ?? 'value'}>{myNumber}</div>
}

function NoCacheOptionComponent({ id }: { id?: string }) {
const { myNumber } = useControls({ myNumber: { value: 5, noCache: true } }, { headless: true })
return <div data-testid={id ?? 'value'}>{myNumber}</div>
}

describe('useControls mount/unmount lifecycle', () => {
it('does not clear a path that is still mounted', () => {
const { unmount } = render(<NumberComponent id="value" />)

act(() => {
levaStore.setValueAtPath('myNumber', 42, true)
})

// clearPath is a no-op while the component is still mounted (__refCount > 0)
levaStore.clearPath('myNumber')
expect(levaStore.get('myNumber')).toBe(42)

unmount()
})

it('works with nested folder paths', () => {
const { getByTestId, unmount } = render(<NestedNumberComponent id="value" />)
expect(getByTestId('value').textContent).toBe('5')

act(() => {
levaStore.setValueAtPath('myFolder.myNumber', 42, true)
})
expect(getByTestId('value').textContent).toBe('42')

unmount()
levaStore.clearPath('myFolder.myNumber')

const { getByTestId: getByTestId2 } = render(<NestedNumberComponent id="value2" />)
expect(getByTestId2('value2').textContent).toBe('5')
})

it('preserves the value on remount when not cleared', () => {
const { unmount } = render(<NumberComponent id="value" />)

act(() => {
levaStore.setValueAtPath('myNumber', 42, true)
})

act(() => unmount())

// value survives unmount without clearing
expect(levaStore.get('myNumber')).toBe(42)
})

it('useEffect clearPath resets the value on remount', () => {
const { getByTestId, unmount } = render(<NumberComponentNoCache id="value" />)
expect(getByTestId('value').textContent).toBe('5')

act(() => {
levaStore.setValueAtPath('myNumber', 42, true)
})
expect(getByTestId('value').textContent).toBe('42')

act(() => unmount())

const { getByTestId: getByTestId2 } = render(<NumberComponentNoCache id="value2" />)
expect(getByTestId2('value2').textContent).toBe('5')
})

it('noCache option resets the value on remount', () => {
const { getByTestId, unmount } = render(<NoCacheOptionComponent id="value" />)
expect(getByTestId('value').textContent).toBe('5')

act(() => {
levaStore.setValueAtPath('myNumber', 42, true)
})
expect(getByTestId('value').textContent).toBe('42')

act(() => unmount())

const { getByTestId: getByTestId2 } = render(<NoCacheOptionComponent id="value2" />)
expect(getByTestId2('value2').textContent).toBe('5')
})

it('store-level noCache resets all inputs on remount', () => {
levaStore.setNoCache(true)

const { getByTestId, unmount } = render(<NumberComponent id="value" />)
expect(getByTestId('value').textContent).toBe('5')

act(() => {
levaStore.setValueAtPath('myNumber', 42, true)
})
expect(getByTestId('value').textContent).toBe('42')

act(() => unmount())

const { getByTestId: getByTestId2 } = render(<NumberComponent id="value2" />)
expect(getByTestId2('value2').textContent).toBe('5')
})

it('LevaPanel noCache prop wires to store and resets inputs on remount', () => {
// LevaPanel and the consuming component are rendered in separate trees so
// that unmounting the component doesn't also unmount LevaPanel (which would
// trigger the cleanup that resets noCache to false before the
// component's own cleanup can call clearPath).
const { unmount: unmountPanel } = render(<LevaPanel store={levaStore} noCache />)

const { getByTestId, unmount: unmountComponent } = render(<NumberComponent id="value" />)
expect(getByTestId('value').textContent).toBe('5')

act(() => {
levaStore.setValueAtPath('myNumber', 42, true)
})
expect(getByTestId('value').textContent).toBe('42')

act(() => unmountComponent())

const { getByTestId: getByTestId2 } = render(<NumberComponent id="value2" />)
expect(getByTestId2('value2').textContent).toBe('5')

unmountPanel()
})

it('resets to the initial value when remounted after clearPath', () => {
const { getByTestId, unmount } = render(<NumberComponent id="value" />)
expect(getByTestId('value').textContent).toBe('5')

act(() => {
levaStore.setValueAtPath('myNumber', 42, true)
})
expect(getByTestId('value').textContent).toBe('42')

unmount()
levaStore.clearPath('myNumber')

const { getByTestId: getByTestId2 } = render(<NumberComponent id="value2" />)
expect(getByTestId2('value2').textContent).toBe('5')
})
})
Loading
Loading