From 40188e142e8912c98d52dee5de82448130c59440 Mon Sep 17 00:00:00 2001 From: rodri Date: Wed, 25 Feb 2026 15:53:27 +0000 Subject: [PATCH 01/15] add `.clearPath` --- packages/leva/src/store.ts | 8 ++++ packages/leva/src/types/internal.ts | 1 + packages/leva/src/useControls.test.tsx | 58 ++++++++++++++++++++++++++ 3 files changed, 67 insertions(+) create mode 100644 packages/leva/src/useControls.test.tsx diff --git a/packages/leva/src/store.ts b/packages/leva/src/store.ts index 73ad34f5..21eb6a46 100644 --- a/packages/leva/src/store.ts +++ b/packages/leva/src/store.ts @@ -114,6 +114,14 @@ export const Store = function (this: StoreType) { }) } + this.clearPath = (path) => { + store.setState((s) => { + const data = { ...s.data } + delete data[path] + return { data } + }) + } + this.getFolderSettings = (path) => { return folders[path] || {} } diff --git a/packages/leva/src/types/internal.ts b/packages/leva/src/types/internal.ts index e2467e5d..220eed71 100644 --- a/packages/leva/src/types/internal.ts +++ b/packages/leva/src/types/internal.ts @@ -32,6 +32,7 @@ export type StoreType = { setOrderedPaths: (newPaths: string[]) => void disposePaths: (paths: string[]) => void dispose: () => void + clearPath: (path: string) => void getVisiblePaths: () => string[] getFolderSettings: (path: string) => FolderSettings getData: () => Data diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx new file mode 100644 index 00000000..1ee013b9 --- /dev/null +++ b/packages/leva/src/useControls.test.tsx @@ -0,0 +1,58 @@ +/** + * Integration tests for useControls with store lifecycle + */ + +// 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. +vi.mock('@stitches/react', () => ({ + createStitches: () => ({ + styled: () => () => null, + css: () => () => '', + globalCss: () => () => {}, + keyframes: () => '', + getCssText: () => '', + theme: {}, + createTheme: () => ({}), + config: {}, + }), +})) + +import React 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' + +afterEach(() => { + levaStore.dispose() +}) + +function NumberComponent({ id }: { id?: string }) { + const { myNumber } = useControls({ myNumber: 5 }, { headless: true }) + return
{myNumber}
+} + +describe('useControls mount/unmount lifecycle', () => { + it('resets to the initial value when remounted after clearPath', async () => { + // Mount the component + const { getByTestId, unmount } = render() + expect(getByTestId('value').textContent).toBe('5') + + // Simulate a value change via the store (as if the user dragged the slider) + act(() => { + levaStore.setValueAtPath('myNumber', 42, true) + }) + expect(getByTestId('value').textContent).toBe('42') + + // Unmount – disposePaths decrements __refCount to 0 but the value stays in the store + unmount() + + // Clear the cached value so the next mount starts fresh + levaStore.clearPath('myNumber') + + // Remount – useControls reads from the schema (value: 5) because the path is gone + const { getByTestId: getByTestId2 } = render() + expect(getByTestId2('value2').textContent).toBe('5') + }) +}) From 2a7243eb5ca3efcc5641863d0936b463a3b1d8eb Mon Sep 17 00:00:00 2001 From: rodri Date: Wed, 25 Feb 2026 16:01:41 +0000 Subject: [PATCH 02/15] fix some small bugs --- packages/leva/src/store.ts | 2 ++ packages/leva/src/useControls.test.tsx | 35 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/packages/leva/src/store.ts b/packages/leva/src/store.ts index 21eb6a46..e58e14d0 100644 --- a/packages/leva/src/store.ts +++ b/packages/leva/src/store.ts @@ -116,6 +116,8 @@ 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 } diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx index 1ee013b9..219d78d9 100644 --- a/packages/leva/src/useControls.test.tsx +++ b/packages/leva/src/useControls.test.tsx @@ -33,7 +33,42 @@ function NumberComponent({ id }: { id?: string }) { return
{myNumber}
} +function NestedNumberComponent({ id }: { id?: string }) { + const { myNumber } = useControls('myFolder', { myNumber: 5 }, { store: levaStore, headless: true }) + return
{myNumber}
+} + describe('useControls mount/unmount lifecycle', () => { + it('does not clear a path that is still mounted', () => { + const { unmount } = render() + + 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', async () => { + const { getByTestId, unmount } = render() + 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() + expect(getByTestId2('value2').textContent).toBe('5') + }) + it('resets to the initial value when remounted after clearPath', async () => { // Mount the component const { getByTestId, unmount } = render() From 333fead0fc116a7767496b1d144ad3620a005f86 Mon Sep 17 00:00:00 2001 From: Rodri Date: Wed, 25 Feb 2026 16:29:34 +0000 Subject: [PATCH 03/15] feat: add clearPath, clearOnUnmount option, and store lifecycle tests - Add `store.clearPath(path)` to hard-delete a cached path, guarded by `__refCount` so it's a no-op while the path is still mounted - Add `clearOnUnmount?: boolean` per-input schema option that automatically clears the path when the component unmounts - Use a `Set` for clearOnUnmountPaths so add/remove correctly handles dynamic changes to the option between renders - Add integration tests covering mount/unmount lifecycle, nested folder paths, value preservation, and both clearOnUnmount mechanisms Co-Authored-By: Claude Sonnet 4.6 --- packages/leva/src/types/internal.ts | 1 + packages/leva/src/types/public.ts | 1 + packages/leva/src/useControls.test.tsx | 66 ++++++++++++++++++++++---- packages/leva/src/useControls.ts | 19 ++++++-- packages/leva/src/utils/data.ts | 4 +- packages/leva/src/utils/input.ts | 2 + 6 files changed, 77 insertions(+), 16 deletions(-) diff --git a/packages/leva/src/types/internal.ts b/packages/leva/src/types/internal.ts index 220eed71..7506ed90 100644 --- a/packages/leva/src/types/internal.ts +++ b/packages/leva/src/types/internal.ts @@ -20,6 +20,7 @@ export type MappedPaths = Record< onEditStart?: (...args: any) => void onEditEnd?: (...args: any) => void transient: boolean + clearOnUnmount: boolean } > diff --git a/packages/leva/src/types/public.ts b/packages/leva/src/types/public.ts index 254b678d..4105b307 100644 --- a/packages/leva/src/types/public.ts +++ b/packages/leva/src/types/public.ts @@ -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 + clearOnUnmount?: boolean } type SchemaItemWithOptions = diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx index 219d78d9..8aac9605 100644 --- a/packages/leva/src/useControls.test.tsx +++ b/packages/leva/src/useControls.test.tsx @@ -18,7 +18,7 @@ vi.mock('@stitches/react', () => ({ }), })) -import React from 'react' +import React, { useEffect } from 'react' import { describe, it, expect, vi, afterEach } from 'vitest' import { render, act } from '@testing-library/react' import { useControls } from './useControls' @@ -38,6 +38,17 @@ function NestedNumberComponent({ id }: { id?: string }) { return
{myNumber}
} +function NumberComponentClearOnUnmount({ id }: { id?: string }) { + const { myNumber } = useControls({ myNumber: 5 }, { headless: true }) + useEffect(() => () => { levaStore.clearPath('myNumber') }, []) + return
{myNumber}
+} + +function ClearOnUnmountOptionComponent({ id }: { id?: string }) { + const { myNumber } = useControls({ myNumber: { value: 5, clearOnUnmount: true } }, { headless: true }) + return
{myNumber}
+} + describe('useControls mount/unmount lifecycle', () => { it('does not clear a path that is still mounted', () => { const { unmount } = render() @@ -53,7 +64,7 @@ describe('useControls mount/unmount lifecycle', () => { unmount() }) - it('works with nested folder paths', async () => { + it('works with nested folder paths', () => { const { getByTestId, unmount } = render() expect(getByTestId('value').textContent).toBe('5') @@ -69,24 +80,61 @@ describe('useControls mount/unmount lifecycle', () => { expect(getByTestId2('value2').textContent).toBe('5') }) - it('resets to the initial value when remounted after clearPath', async () => { - // Mount the component + it('preserves the value on remount when not cleared', () => { + const { unmount } = render() + + 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() + expect(getByTestId('value').textContent).toBe('5') + + act(() => { + levaStore.setValueAtPath('myNumber', 42, true) + }) + expect(getByTestId('value').textContent).toBe('42') + + act(() => unmount()) + + const { getByTestId: getByTestId2 } = render() + expect(getByTestId2('value2').textContent).toBe('5') + }) + + it('clearOnUnmount option resets the value on remount', () => { + const { getByTestId, unmount } = render() + expect(getByTestId('value').textContent).toBe('5') + + act(() => { + levaStore.setValueAtPath('myNumber', 42, true) + }) + expect(getByTestId('value').textContent).toBe('42') + + act(() => unmount()) + + const { getByTestId: getByTestId2 } = render() + expect(getByTestId2('value2').textContent).toBe('5') + }) + + it('resets to the initial value when remounted after clearPath', () => { const { getByTestId, unmount } = render() expect(getByTestId('value').textContent).toBe('5') - // Simulate a value change via the store (as if the user dragged the slider) act(() => { levaStore.setValueAtPath('myNumber', 42, true) }) expect(getByTestId('value').textContent).toBe('42') - // Unmount – disposePaths decrements __refCount to 0 but the value stays in the store unmount() - - // Clear the cached value so the next mount starts fresh levaStore.clearPath('myNumber') - // Remount – useControls reads from the schema (value: 5) because the path is gone const { getByTestId: getByTestId2 } = render() expect(getByTestId2('value2').textContent).toBe('5') }) diff --git a/packages/leva/src/useControls.ts b/packages/leva/src/useControls.ts index 7f762c92..14a56e75 100644 --- a/packages/leva/src/useControls.ts +++ b/packages/leva/src/useControls.ts @@ -133,14 +133,15 @@ export function useControls | string, * parses the schema inside nested folder. */ const [initialData, mappedPaths] = useMemo(() => store.getDataFromSchema(_schema), [store, _schema]) - const [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths] = useMemo(() => { + const [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, clearOnUnmountPaths] = useMemo(() => { const allPaths: string[] = [] const renderPaths: string[] = [] const onChangePaths: Record = {} const onEditStartPaths: Record void> = {} const onEditEndPaths: Record void> = {} + const clearOnUnmountPaths = new Set() - Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient }) => { + Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient, clearOnUnmount }) => { allPaths.push(path) if (onChange) { onChangePaths[path] = onChange @@ -157,8 +158,13 @@ export function useControls | string, if (onEditEnd) { onEditEndPaths[path] = onEditEnd } + if (clearOnUnmount) { + clearOnUnmountPaths.add(path) + } else { + clearOnUnmountPaths.delete(path) + } }) - return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths] + return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, clearOnUnmountPaths] }, [mappedPaths]) // Extracts the paths from the initialData and ensures order of paths. @@ -199,8 +205,11 @@ export function useControls | string, store.addData(initialData, shouldOverrideSettings) firstRender.current = false depsChanged.current = false - return () => store.disposePaths(paths) - }, [store, paths, initialData]) + return () => { + store.disposePaths(paths) + clearOnUnmountPaths.forEach((path) => store.clearPath(path)) + } + }, [store, paths, initialData, clearOnUnmountPaths]) useEffect(() => { // let's handle transient subscriptions diff --git a/packages/leva/src/utils/data.ts b/packages/leva/src/utils/data.ts index 1e1af690..2127bdc4 100644 --- a/packages/leva/src/utils/data.ts +++ b/packages/leva/src/utils/data.ts @@ -65,9 +65,9 @@ export function getDataFromSchema( if (normalizedInput) { const { type, options, input } = normalizedInput // @ts-ignore - const { onChange, transient, onEditStart, onEditEnd, ..._options } = options + const { onChange, transient, onEditStart, onEditEnd, clearOnUnmount, ..._options } = options data[newPath] = { type, ..._options, ...input, fromPanel: true } - mappedPaths[key] = { path: newPath, onChange, transient, onEditStart, onEditEnd } + mappedPaths[key] = { path: newPath, onChange, transient, onEditStart, onEditEnd, clearOnUnmount: clearOnUnmount ?? false } } else { warn(LevaErrors.UNKNOWN_INPUT, newPath, rawInput) } diff --git a/packages/leva/src/utils/input.ts b/packages/leva/src/utils/input.ts index 75c1de17..de65df9d 100644 --- a/packages/leva/src/utils/input.ts +++ b/packages/leva/src/utils/input.ts @@ -65,6 +65,7 @@ export function parseOptions( onEditStart, onEditEnd, transient, + clearOnUnmount, ...inputWithType } = _input @@ -79,6 +80,7 @@ export function parseOptions( disabled, optional, order, + clearOnUnmount, ...mergedOptions, } From a1f6056c9680f36409ec5baec6bde1a5d9e23f38 Mon Sep 17 00:00:00 2001 From: Rodri Date: Wed, 25 Feb 2026 19:29:53 +0000 Subject: [PATCH 04/15] feat: add LevaRootProps.clearOnUnmount to clear all inputs on unmount - Add `clearOnUnmount` prop to `LevaRootProps` that overrides individual input settings, clearing all paths from the store on component unmount - Add `clearOnUnmount` / `setClearOnUnmount` to `StoreType` and `Store` so the flag is readable at cleanup time without needing reactive state - Read `store.clearOnUnmount` in the useControls cleanup, falling back to the per-input Set when the store-level flag is false - Add test covering the store-level flag via `levaStore.setClearOnUnmount` Co-Authored-By: Claude Sonnet 4.6 --- packages/leva/src/components/Leva/LevaRoot.tsx | 14 ++++++++++++-- packages/leva/src/store.ts | 4 ++++ packages/leva/src/types/internal.ts | 2 ++ packages/leva/src/useControls.test.tsx | 18 ++++++++++++++++++ packages/leva/src/useControls.ts | 3 ++- 5 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/leva/src/components/Leva/LevaRoot.tsx b/packages/leva/src/components/Leva/LevaRoot.tsx index a8395fe2..616e542b 100644 --- a/packages/leva/src/components/Leva/LevaRoot.tsx +++ b/packages/leva/src/components/Leva/LevaRoot.tsx @@ -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' @@ -90,10 +90,20 @@ 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 clearOnUnmount setting. + */ + clearOnUnmount?: boolean } -export function LevaRoot({ store, hidden = false, theme, collapsed = false, ...props }: LevaRootProps) { +export function LevaRoot({ store, hidden = false, theme, collapsed = false, clearOnUnmount = false, ...props }: LevaRootProps) { const themeContext = useDeepMemo(() => mergeTheme(theme), [theme]) + + useEffect(() => { + if (store) store.setClearOnUnmount(clearOnUnmount) + }, [store, clearOnUnmount]) + // collapsible const [toggled, setToggle] = useState(!collapsed) diff --git a/packages/leva/src/store.ts b/packages/leva/src/store.ts index e58e14d0..e5048f60 100644 --- a/packages/leva/src/store.ts +++ b/packages/leva/src/store.ts @@ -13,6 +13,10 @@ export const Store = function (this: StoreType) { this.storeId = getUid() this.useStore = store + this.clearOnUnmount = false + this.setClearOnUnmount = (flag) => { + this.clearOnUnmount = flag + } /** * Folders will hold the folder settings for the pane. * @note possibly make this reactive diff --git a/packages/leva/src/types/internal.ts b/packages/leva/src/types/internal.ts index 7506ed90..53356587 100644 --- a/packages/leva/src/types/internal.ts +++ b/packages/leva/src/types/internal.ts @@ -29,6 +29,8 @@ type Dispose = () => void export type StoreType = { useStore: UseBoundStore> & SubscribeWithSelectorAPI storeId: string + clearOnUnmount: boolean + setClearOnUnmount: (flag: boolean) => void orderPaths: (paths: string[]) => string[] setOrderedPaths: (newPaths: string[]) => void disposePaths: (paths: string[]) => void diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx index 8aac9605..6b1171cd 100644 --- a/packages/leva/src/useControls.test.tsx +++ b/packages/leva/src/useControls.test.tsx @@ -26,6 +26,7 @@ import { levaStore } from './store' afterEach(() => { levaStore.dispose() + levaStore.setClearOnUnmount(false) }) function NumberComponent({ id }: { id?: string }) { @@ -123,6 +124,23 @@ describe('useControls mount/unmount lifecycle', () => { expect(getByTestId2('value2').textContent).toBe('5') }) + it('store-level clearOnUnmount resets all inputs on remount', () => { + levaStore.setClearOnUnmount(true) + + const { getByTestId, unmount } = render() + expect(getByTestId('value').textContent).toBe('5') + + act(() => { + levaStore.setValueAtPath('myNumber', 42, true) + }) + expect(getByTestId('value').textContent).toBe('42') + + act(() => unmount()) + + const { getByTestId: getByTestId2 } = render() + expect(getByTestId2('value2').textContent).toBe('5') + }) + it('resets to the initial value when remounted after clearPath', () => { const { getByTestId, unmount } = render() expect(getByTestId('value').textContent).toBe('5') diff --git a/packages/leva/src/useControls.ts b/packages/leva/src/useControls.ts index 14a56e75..2e83d2c8 100644 --- a/packages/leva/src/useControls.ts +++ b/packages/leva/src/useControls.ts @@ -207,7 +207,8 @@ export function useControls | string, depsChanged.current = false return () => { store.disposePaths(paths) - clearOnUnmountPaths.forEach((path) => store.clearPath(path)) + const pathsToClear = store.clearOnUnmount ? paths : [...clearOnUnmountPaths] + pathsToClear.forEach((path) => store.clearPath(path)) } }, [store, paths, initialData, clearOnUnmountPaths]) From 901895e0364cbdeae3c8db37b97e06b25c8b4c97 Mon Sep 17 00:00:00 2001 From: Rodri Date: Thu, 26 Feb 2026 11:59:19 +0000 Subject: [PATCH 05/15] chore: add changeset for clearOnUnmount feature Co-Authored-By: Claude Sonnet 4.6 --- .changeset/clear-path-on-unmount.md | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 .changeset/clear-path-on-unmount.md diff --git a/.changeset/clear-path-on-unmount.md b/.changeset/clear-path-on-unmount.md new file mode 100644 index 00000000..2c34212e --- /dev/null +++ b/.changeset/clear-path-on-unmount.md @@ -0,0 +1,8 @@ +--- +"leva": minor +--- + +Add `clearOnUnmount` option to remove inputs from the store when their component unmounts. + +- `InputOptions.clearOnUnmount` — per-input flag; when `true` the input is removed from the store on unmount. +- `LevaRootProps.clearOnUnmount` — panel-level flag; when `true` all inputs managed by that panel are cleared on unmount, overriding the per-input setting. From 582900c65415812d87cfb21ea920aef20d60f174 Mon Sep 17 00:00:00 2001 From: Rodri Date: Thu, 26 Feb 2026 12:04:45 +0000 Subject: [PATCH 06/15] =?UTF-8?q?chore:=20update=20changeset=20=E2=80=94?= =?UTF-8?q?=20clearPath=20is=20new,=20not=20a=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .changeset/clear-path-on-unmount.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.changeset/clear-path-on-unmount.md b/.changeset/clear-path-on-unmount.md index 2c34212e..db893b52 100644 --- a/.changeset/clear-path-on-unmount.md +++ b/.changeset/clear-path-on-unmount.md @@ -2,7 +2,8 @@ "leva": minor --- -Add `clearOnUnmount` option to remove inputs from the store when their component unmounts. +Add `clearOnUnmount` option and `store.clearPath` to remove inputs from the store when their component unmounts. +- `store.clearPath(path)` — new method to imperatively remove a single input from the store. - `InputOptions.clearOnUnmount` — per-input flag; when `true` the input is removed from the store on unmount. - `LevaRootProps.clearOnUnmount` — panel-level flag; when `true` all inputs managed by that panel are cleared on unmount, overriding the per-input setting. From 6b30955f12256b7bf61bbe7cee7e5111705b6d8e Mon Sep 17 00:00:00 2001 From: Rodri Date: Thu, 26 Feb 2026 12:05:12 +0000 Subject: [PATCH 07/15] =?UTF-8?q?chore:=20clarify=20changeset=20=E2=80=94?= =?UTF-8?q?=20clears=20cached=20value,=20not=20the=20input=20itself?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- .changeset/clear-path-on-unmount.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/clear-path-on-unmount.md b/.changeset/clear-path-on-unmount.md index db893b52..e03a7627 100644 --- a/.changeset/clear-path-on-unmount.md +++ b/.changeset/clear-path-on-unmount.md @@ -2,8 +2,8 @@ "leva": minor --- -Add `clearOnUnmount` option and `store.clearPath` to remove inputs from the store when their component unmounts. +Add `clearOnUnmount` option and `store.clearPath` to discard cached input values when they are no longer in use. -- `store.clearPath(path)` — new method to imperatively remove a single input from the store. -- `InputOptions.clearOnUnmount` — per-input flag; when `true` the input is removed from the store on unmount. -- `LevaRootProps.clearOnUnmount` — panel-level flag; when `true` all inputs managed by that panel are cleared on unmount, overriding the per-input setting. +- `store.clearPath(path)` — new method to imperatively discard the cached value of an input that is no longer mounted. +- `InputOptions.clearOnUnmount` — per-input flag; when `true` the input's cached value is discarded from the store on unmount. +- `LevaRootProps.clearOnUnmount` — panel-level flag; when `true` all inputs managed by that panel have their cached values discarded on unmount, overriding the per-input setting. From 7fb2c20e282b443d3442b3b4e64cacf329e94b54 Mon Sep 17 00:00:00 2001 From: Rodri Date: Thu, 26 Feb 2026 12:08:58 +0000 Subject: [PATCH 08/15] chore: fix prettier formatting Co-Authored-By: Claude Sonnet 4.6 --- packages/leva/src/components/Leva/LevaRoot.tsx | 9 ++++++++- packages/leva/src/useControls.test.tsx | 7 ++++++- packages/leva/src/utils/data.ts | 9 ++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/leva/src/components/Leva/LevaRoot.tsx b/packages/leva/src/components/Leva/LevaRoot.tsx index 616e542b..7fef55e5 100644 --- a/packages/leva/src/components/Leva/LevaRoot.tsx +++ b/packages/leva/src/components/Leva/LevaRoot.tsx @@ -97,7 +97,14 @@ export type LevaRootProps = { clearOnUnmount?: boolean } -export function LevaRoot({ store, hidden = false, theme, collapsed = false, clearOnUnmount = false, ...props }: LevaRootProps) { +export function LevaRoot({ + store, + hidden = false, + theme, + collapsed = false, + clearOnUnmount = false, + ...props +}: LevaRootProps) { const themeContext = useDeepMemo(() => mergeTheme(theme), [theme]) useEffect(() => { diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx index 6b1171cd..e3091c15 100644 --- a/packages/leva/src/useControls.test.tsx +++ b/packages/leva/src/useControls.test.tsx @@ -41,7 +41,12 @@ function NestedNumberComponent({ id }: { id?: string }) { function NumberComponentClearOnUnmount({ id }: { id?: string }) { const { myNumber } = useControls({ myNumber: 5 }, { headless: true }) - useEffect(() => () => { levaStore.clearPath('myNumber') }, []) + useEffect( + () => () => { + levaStore.clearPath('myNumber') + }, + [] + ) return
{myNumber}
} diff --git a/packages/leva/src/utils/data.ts b/packages/leva/src/utils/data.ts index 2127bdc4..befdf4c8 100644 --- a/packages/leva/src/utils/data.ts +++ b/packages/leva/src/utils/data.ts @@ -67,7 +67,14 @@ export function getDataFromSchema( // @ts-ignore const { onChange, transient, onEditStart, onEditEnd, clearOnUnmount, ..._options } = options data[newPath] = { type, ..._options, ...input, fromPanel: true } - mappedPaths[key] = { path: newPath, onChange, transient, onEditStart, onEditEnd, clearOnUnmount: clearOnUnmount ?? false } + mappedPaths[key] = { + path: newPath, + onChange, + transient, + onEditStart, + onEditEnd, + clearOnUnmount: clearOnUnmount ?? false, + } } else { warn(LevaErrors.UNKNOWN_INPUT, newPath, rawInput) } From c9e334b4dce1988da9832834261947650a1bf91c Mon Sep 17 00:00:00 2001 From: Rodri Date: Thu, 26 Feb 2026 12:15:01 +0000 Subject: [PATCH 09/15] lint --- packages/leva/src/useControls.test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx index e3091c15..8fcbcbfc 100644 --- a/packages/leva/src/useControls.test.tsx +++ b/packages/leva/src/useControls.test.tsx @@ -2,9 +2,16 @@ * 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' + // 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, @@ -18,12 +25,6 @@ vi.mock('@stitches/react', () => ({ }), })) -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' - afterEach(() => { levaStore.dispose() levaStore.setClearOnUnmount(false) From 2940869753eeeae059f6a56aef88b7ea12b319b1 Mon Sep 17 00:00:00 2001 From: Rodri Date: Fri, 27 Feb 2026 10:58:45 +0000 Subject: [PATCH 10/15] docs: add clearOnUnmount story and documentation - Add clear-on-unmount.stories.tsx with 4 stories covering per-input, panel-level, and imperative (store.clearPath) usage - Document clearOnUnmount input option in inputs.md - Document clearOnUnmount panel prop and store.clearPath in configuration.md Co-Authored-By: Claude Sonnet 4.6 --- docs/getting-started/configuration.md | 33 ++++ docs/getting-started/inputs.md | 13 ++ .../leva/stories/clear-on-unmount.stories.tsx | 170 ++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 packages/leva/stories/clear-on-unmount.stories.tsx diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 90758ef2..3380480f 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -32,6 +32,7 @@ export default function MyApp() { position: { x: 0, y: 0 }, // Initial position (when drag is enabled) onDrag: (position) => {}, // Callback when dragged }} + clearOnUnmount // default = false, discards cached input values when inputs unmount /> ) @@ -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 +- `clearOnUnmount`: When `true`, all inputs managed by this panel discard their cached values when they unmount (overrides the per-input `clearOnUnmount` setting) - `titleBar`: Object with title bar configuration: - `title`: Custom title string - `drag`: Enable/disable dragging @@ -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 `clearOnUnmount` to `` (or ``). Every input managed by that panel will discard its cached value when its component unmounts. This takes priority over the per-input setting. + +```jsx + +``` + +**Per-input** — set `clearOnUnmount: true` inside the schema for individual inputs (see [Input Options](inputs.md#clearonunmount)). + +```jsx +useControls({ + sessionValue: { value: 0, clearOnUnmount: 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 +} +``` + ### Controlled Collapsed State You can control the collapsed state from outside: diff --git a/docs/getting-started/inputs.md b/docs/getting-started/inputs.md index 38dfbef0..200a1413 100644 --- a/docs/getting-started/inputs.md +++ b/docs/getting-started/inputs.md @@ -381,6 +381,19 @@ const values = useControls({ }) ``` +### clearOnUnmount + +By default, Leva caches input values so they persist across unmount/remount cycles. Set `clearOnUnmount: 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, clearOnUnmount: true }, + persistentValue: { value: 0 }, +}) +``` + +See also the panel-level [`clearOnUnmount`](configuration.md#clearonunmount) option to apply this behaviour to all inputs at once. + ### Enforcing Input Type Force a specific input type even if Leva would infer differently: diff --git a/packages/leva/stories/clear-on-unmount.stories.tsx b/packages/leva/stories/clear-on-unmount.stories.tsx new file mode 100644 index 00000000..4b8fa65d --- /dev/null +++ b/packages/leva/stories/clear-on-unmount.stories.tsx @@ -0,0 +1,170 @@ +import React from 'react' +import { Meta, StoryFn } from '@storybook/react' +import Reset from './components/decorator-reset' + +import { Leva, useControls, useCreateStore, LevaPanel } from '../src' + +export default { + title: 'Dev/Hook/Clear on Unmount', + decorators: [Reset], +} as Meta + +// --------------------------------------------------------------------------- +// Per-input clearOnUnmount +// --------------------------------------------------------------------------- + +const PerInputControls = () => { + const values = useControls({ + persistent: { value: 10 }, + clearable: { value: 42, clearOnUnmount: true }, + }) + + return ( +
+

+ persistent keeps its value across unmount/remount +

+

+ clearable resets to its initial value after unmount +

+
{JSON.stringify(values, null, '  ')}
+
+ ) +} + +/** + * Only the input marked with `clearOnUnmount: true` resets when the component + * unmounts. The other input retains its value. + * + * Steps to verify: + * 1. Change both `persistent` and `clearable` in the panel. + * 2. Click "Unmount" then "Mount". + * 3. `persistent` should show the last value you set; `clearable` should be back to 42. + */ +export const PerInput: StoryFn = () => { + const [mounted, toggle] = React.useState(true) + + return ( +
+ + {mounted && } +
+ ) +} + +PerInput.storyName = 'clearOnUnmount (per-input)' + +// --------------------------------------------------------------------------- +// Panel-level clearOnUnmount via +// --------------------------------------------------------------------------- + +const PanelLevelControls = () => { + const values = useControls({ num: 5, color: '#f00' }) + + return ( +
+

All inputs clear on unmount because the panel has clearOnUnmount.

+
{JSON.stringify(values, null, '  ')}
+
+ ) +} + +/** + * When `` is set, ALL inputs managed by the default panel + * have their cached values discarded when they unmount, regardless of the + * per-input `clearOnUnmount` setting. + * + * Steps to verify: + * 1. Change `num` and `color` in the panel. + * 2. Click "Unmount" then "Mount". + * 3. Both values should be back to their initial defaults. + */ +export const PanelLevel: StoryFn = () => { + const [mounted, toggle] = React.useState(true) + + return ( +
+ + + {mounted && } +
+ ) +} + +PanelLevel.storyName = 'clearOnUnmount (panel-level)' + +// --------------------------------------------------------------------------- +// Panel-level clearOnUnmount overrides per-input false +// --------------------------------------------------------------------------- + +const MixedControls = () => { + const values = useControls({ + willClear: { value: 99 }, + alsoWillClear: { value: '#0f0', clearOnUnmount: false }, + }) + + return ( +
+

Panel-level clearOnUnmount overrides per-input clearOnUnmount: false.

+
{JSON.stringify(values, null, '  ')}
+
+ ) +} + +/** + * Panel-level `clearOnUnmount` takes priority over the per-input setting. + * Even `alsoWillClear` (which sets `clearOnUnmount: false`) will be cleared + * because the panel flag overrides it. + */ +export const PanelOverridesPerInput: StoryFn = () => { + const [mounted, toggle] = React.useState(true) + + return ( +
+ + + {mounted && } +
+ ) +} + +PanelOverridesPerInput.storyName = 'panel clearOnUnmount overrides per-input' + +// --------------------------------------------------------------------------- +// store.clearPath — custom store, imperative usage +// --------------------------------------------------------------------------- + +/** + * `store.clearPath(path)` lets you imperatively discard the cached value of a + * single input. This is useful when you manage your own store and want fine- + * grained control over cache invalidation without unmounting the component. + * + * Click "Clear position cache" — the next time the component remounts it will + * start from the initial value again. + */ +export const ClearPath: StoryFn = () => { + const store = useCreateStore() + + const values = useControls({ position: { x: 0, y: 0 }, speed: 1 }, { store }) + + return ( +
+ +
+

+ store.clearPath imperatively discards a single cached input. +

+
{JSON.stringify(values, null, '  ')}
+
+ +
+ ) +} + +ClearPath.storyName = 'store.clearPath (imperative)' From e3e49e0d0ec636052df94197935fc968f5a03907 Mon Sep 17 00:00:00 2001 From: Rodri Date: Fri, 27 Feb 2026 11:01:43 +0000 Subject: [PATCH 11/15] fix: restore store.clearOnUnmount to false when LevaRoot unmounts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The useEffect cleanup now resets the store flag to false when the panel unmounts (or the store prop changes), preventing stale clearOnUnmount state on custom stores. Also adds a test that exercises the prop→store wiring through LevaPanel rather than calling setClearOnUnmount directly. Co-Authored-By: Claude Sonnet 4.6 --- .../leva/src/components/Leva/LevaRoot.tsx | 5 +++- packages/leva/src/useControls.test.tsx | 24 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/packages/leva/src/components/Leva/LevaRoot.tsx b/packages/leva/src/components/Leva/LevaRoot.tsx index 7fef55e5..e7107a41 100644 --- a/packages/leva/src/components/Leva/LevaRoot.tsx +++ b/packages/leva/src/components/Leva/LevaRoot.tsx @@ -108,7 +108,10 @@ export function LevaRoot({ const themeContext = useDeepMemo(() => mergeTheme(theme), [theme]) useEffect(() => { - if (store) store.setClearOnUnmount(clearOnUnmount) + if (store) { + store.setClearOnUnmount(clearOnUnmount) + return () => store.setClearOnUnmount(false) + } }, [store, clearOnUnmount]) // collapsible diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx index 8fcbcbfc..9bb06ba2 100644 --- a/packages/leva/src/useControls.test.tsx +++ b/packages/leva/src/useControls.test.tsx @@ -7,6 +7,7 @@ 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. @@ -147,6 +148,29 @@ describe('useControls mount/unmount lifecycle', () => { expect(getByTestId2('value2').textContent).toBe('5') }) + it('LevaPanel clearOnUnmount 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 clearOnUnmount to false before the + // component's own cleanup can call clearPath). + const { unmount: unmountPanel } = render() + + const { getByTestId, unmount: unmountComponent } = render() + expect(getByTestId('value').textContent).toBe('5') + + act(() => { + levaStore.setValueAtPath('myNumber', 42, true) + }) + expect(getByTestId('value').textContent).toBe('42') + + act(() => unmountComponent()) + + const { getByTestId: getByTestId2 } = render() + expect(getByTestId2('value2').textContent).toBe('5') + + unmountPanel() + }) + it('resets to the initial value when remounted after clearPath', () => { const { getByTestId, unmount } = render() expect(getByTestId('value').textContent).toBe('5') From aa68a779728a39833bf2089cbd0d97807c9de070 Mon Sep 17 00:00:00 2001 From: rodri Date: Mon, 2 Mar 2026 16:25:25 +0000 Subject: [PATCH 12/15] clearOnUnmount > noCache --- .changeset/clear-path-on-unmount.md | 6 ++-- docs/getting-started/configuration.md | 12 +++---- docs/getting-started/inputs.md | 8 ++--- .../leva/src/components/Leva/LevaRoot.tsx | 12 +++---- packages/leva/src/store.ts | 6 ++-- packages/leva/src/types/internal.ts | 6 ++-- packages/leva/src/types/public.ts | 2 +- packages/leva/src/useControls.test.tsx | 28 +++++++-------- packages/leva/src/useControls.ts | 18 +++++----- packages/leva/src/utils/data.ts | 4 +-- packages/leva/src/utils/input.ts | 4 +-- .../leva/stories/clear-on-unmount.stories.tsx | 34 +++++++++---------- 12 files changed, 70 insertions(+), 70 deletions(-) diff --git a/.changeset/clear-path-on-unmount.md b/.changeset/clear-path-on-unmount.md index e03a7627..6d5cd7c0 100644 --- a/.changeset/clear-path-on-unmount.md +++ b/.changeset/clear-path-on-unmount.md @@ -2,8 +2,8 @@ "leva": minor --- -Add `clearOnUnmount` option and `store.clearPath` to discard cached input values when they are no longer in use. +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.clearOnUnmount` — per-input flag; when `true` the input's cached value is discarded from the store on unmount. -- `LevaRootProps.clearOnUnmount` — panel-level flag; when `true` all inputs managed by that panel have their cached values discarded on unmount, overriding the per-input setting. +- `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. diff --git a/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 3380480f..b0186d97 100644 --- a/docs/getting-started/configuration.md +++ b/docs/getting-started/configuration.md @@ -32,7 +32,7 @@ export default function MyApp() { position: { x: 0, y: 0 }, // Initial position (when drag is enabled) onDrag: (position) => {}, // Callback when dragged }} - clearOnUnmount // default = false, discards cached input values when inputs unmount + noCache // default = false, discards cached input values when inputs unmount /> ) @@ -50,7 +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 -- `clearOnUnmount`: When `true`, all inputs managed by this panel discard their cached values when they unmount (overrides the per-input `clearOnUnmount` setting) +- `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 @@ -107,17 +107,17 @@ export default function MyApp() { 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 `clearOnUnmount` to `` (or ``). Every input managed by that panel will discard its cached value when its component unmounts. This takes priority over the per-input setting. +**Panel-level** — pass `noCache` to `` (or ``). Every input managed by that panel will discard its cached value when its component unmounts. This takes priority over the per-input setting. ```jsx - + ``` -**Per-input** — set `clearOnUnmount: true` inside the schema for individual inputs (see [Input Options](inputs.md#clearonunmount)). +**Per-input** — set `noCache: true` inside the schema for individual inputs (see [Input Options](inputs.md#nocache)). ```jsx useControls({ - sessionValue: { value: 0, clearOnUnmount: true }, + sessionValue: { value: 0, noCache: true }, persistentValue: { value: 0 }, }) ``` diff --git a/docs/getting-started/inputs.md b/docs/getting-started/inputs.md index 200a1413..abd51016 100644 --- a/docs/getting-started/inputs.md +++ b/docs/getting-started/inputs.md @@ -381,18 +381,18 @@ const values = useControls({ }) ``` -### clearOnUnmount +### noCache -By default, Leva caches input values so they persist across unmount/remount cycles. Set `clearOnUnmount: true` on a specific input to discard its cached value when its component unmounts, so the next mount starts fresh from the initial value: +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, clearOnUnmount: true }, + sessionValue: { value: 0, noCache: true }, persistentValue: { value: 0 }, }) ``` -See also the panel-level [`clearOnUnmount`](configuration.md#clearonunmount) option to apply this behaviour to all inputs at once. +See also the panel-level [`noCache`](configuration.md#nocache) option to apply this behaviour to all inputs at once. ### Enforcing Input Type diff --git a/packages/leva/src/components/Leva/LevaRoot.tsx b/packages/leva/src/components/Leva/LevaRoot.tsx index e7107a41..3cff58b2 100644 --- a/packages/leva/src/components/Leva/LevaRoot.tsx +++ b/packages/leva/src/components/Leva/LevaRoot.tsx @@ -92,9 +92,9 @@ export type LevaRootProps = { hideCopyButton?: boolean /** * If true, all inputs will be cleared from the store when their component unmounts, - * regardless of the per-input clearOnUnmount setting. + * regardless of the per-input noCache setting. */ - clearOnUnmount?: boolean + noCache?: boolean } export function LevaRoot({ @@ -102,17 +102,17 @@ export function LevaRoot({ hidden = false, theme, collapsed = false, - clearOnUnmount = false, + noCache = false, ...props }: LevaRootProps) { const themeContext = useDeepMemo(() => mergeTheme(theme), [theme]) useEffect(() => { if (store) { - store.setClearOnUnmount(clearOnUnmount) - return () => store.setClearOnUnmount(false) + store.setNoCache(noCache) + return () => store.setNoCache(false) } - }, [store, clearOnUnmount]) + }, [store, noCache]) // collapsible const [toggled, setToggle] = useState(!collapsed) diff --git a/packages/leva/src/store.ts b/packages/leva/src/store.ts index e5048f60..6db26eb7 100644 --- a/packages/leva/src/store.ts +++ b/packages/leva/src/store.ts @@ -13,9 +13,9 @@ export const Store = function (this: StoreType) { this.storeId = getUid() this.useStore = store - this.clearOnUnmount = false - this.setClearOnUnmount = (flag) => { - this.clearOnUnmount = flag + this.noCache = false + this.setNoCache = (flag) => { + this.noCache = flag } /** * Folders will hold the folder settings for the pane. diff --git a/packages/leva/src/types/internal.ts b/packages/leva/src/types/internal.ts index 53356587..7ca8aef3 100644 --- a/packages/leva/src/types/internal.ts +++ b/packages/leva/src/types/internal.ts @@ -20,7 +20,7 @@ export type MappedPaths = Record< onEditStart?: (...args: any) => void onEditEnd?: (...args: any) => void transient: boolean - clearOnUnmount: boolean + noCache: boolean } > @@ -29,8 +29,8 @@ type Dispose = () => void export type StoreType = { useStore: UseBoundStore> & SubscribeWithSelectorAPI storeId: string - clearOnUnmount: boolean - setClearOnUnmount: (flag: boolean) => void + noCache: boolean + setNoCache: (flag: boolean) => void orderPaths: (paths: string[]) => string[] setOrderedPaths: (newPaths: string[]) => void disposePaths: (paths: string[]) => void diff --git a/packages/leva/src/types/public.ts b/packages/leva/src/types/public.ts index 4105b307..4e4fb63d 100644 --- a/packages/leva/src/types/public.ts +++ b/packages/leva/src/types/public.ts @@ -185,7 +185,7 @@ export type InputOptions = GenericSchemaItemOptions & disabled?: boolean onEditStart?: (value: any, path: string, context: OnHandlerContext) => void onEditEnd?: (value: any, path: string, context: OnHandlerContext) => void - clearOnUnmount?: boolean + noCache?: boolean } type SchemaItemWithOptions = diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx index 9bb06ba2..1ce8d1c4 100644 --- a/packages/leva/src/useControls.test.tsx +++ b/packages/leva/src/useControls.test.tsx @@ -28,7 +28,7 @@ vi.mock('@stitches/react', () => ({ afterEach(() => { levaStore.dispose() - levaStore.setClearOnUnmount(false) + levaStore.setNoCache(false) }) function NumberComponent({ id }: { id?: string }) { @@ -41,7 +41,7 @@ function NestedNumberComponent({ id }: { id?: string }) { return
{myNumber}
} -function NumberComponentClearOnUnmount({ id }: { id?: string }) { +function NumberComponentNoCache({ id }: { id?: string }) { const { myNumber } = useControls({ myNumber: 5 }, { headless: true }) useEffect( () => () => { @@ -52,8 +52,8 @@ function NumberComponentClearOnUnmount({ id }: { id?: string }) { return
{myNumber}
} -function ClearOnUnmountOptionComponent({ id }: { id?: string }) { - const { myNumber } = useControls({ myNumber: { value: 5, clearOnUnmount: true } }, { headless: true }) +function NoCacheOptionComponent({ id }: { id?: string }) { + const { myNumber } = useControls({ myNumber: { value: 5, noCache: true } }, { headless: true }) return
{myNumber}
} @@ -102,7 +102,7 @@ describe('useControls mount/unmount lifecycle', () => { }) it('useEffect clearPath resets the value on remount', () => { - const { getByTestId, unmount } = render() + const { getByTestId, unmount } = render() expect(getByTestId('value').textContent).toBe('5') act(() => { @@ -112,12 +112,12 @@ describe('useControls mount/unmount lifecycle', () => { act(() => unmount()) - const { getByTestId: getByTestId2 } = render() + const { getByTestId: getByTestId2 } = render() expect(getByTestId2('value2').textContent).toBe('5') }) - it('clearOnUnmount option resets the value on remount', () => { - const { getByTestId, unmount } = render() + it('noCache option resets the value on remount', () => { + const { getByTestId, unmount } = render() expect(getByTestId('value').textContent).toBe('5') act(() => { @@ -127,12 +127,12 @@ describe('useControls mount/unmount lifecycle', () => { act(() => unmount()) - const { getByTestId: getByTestId2 } = render() + const { getByTestId: getByTestId2 } = render() expect(getByTestId2('value2').textContent).toBe('5') }) - it('store-level clearOnUnmount resets all inputs on remount', () => { - levaStore.setClearOnUnmount(true) + it('store-level noCache resets all inputs on remount', () => { + levaStore.setNoCache(true) const { getByTestId, unmount } = render() expect(getByTestId('value').textContent).toBe('5') @@ -148,12 +148,12 @@ describe('useControls mount/unmount lifecycle', () => { expect(getByTestId2('value2').textContent).toBe('5') }) - it('LevaPanel clearOnUnmount prop wires to store and resets inputs on remount', () => { + 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 clearOnUnmount to false before the + // trigger the cleanup that resets noCache to false before the // component's own cleanup can call clearPath). - const { unmount: unmountPanel } = render() + const { unmount: unmountPanel } = render() const { getByTestId, unmount: unmountComponent } = render() expect(getByTestId('value').textContent).toBe('5') diff --git a/packages/leva/src/useControls.ts b/packages/leva/src/useControls.ts index 2e83d2c8..cda560b7 100644 --- a/packages/leva/src/useControls.ts +++ b/packages/leva/src/useControls.ts @@ -133,15 +133,15 @@ export function useControls | string, * parses the schema inside nested folder. */ const [initialData, mappedPaths] = useMemo(() => store.getDataFromSchema(_schema), [store, _schema]) - const [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, clearOnUnmountPaths] = useMemo(() => { + const [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, noCachePaths] = useMemo(() => { const allPaths: string[] = [] const renderPaths: string[] = [] const onChangePaths: Record = {} const onEditStartPaths: Record void> = {} const onEditEndPaths: Record void> = {} - const clearOnUnmountPaths = new Set() + const noCachePaths = new Set() - Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient, clearOnUnmount }) => { + Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient, noCache }) => { allPaths.push(path) if (onChange) { onChangePaths[path] = onChange @@ -158,13 +158,13 @@ export function useControls | string, if (onEditEnd) { onEditEndPaths[path] = onEditEnd } - if (clearOnUnmount) { - clearOnUnmountPaths.add(path) + if (noCache) { + noCachePaths.add(path) } else { - clearOnUnmountPaths.delete(path) + noCachePaths.delete(path) } }) - return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, clearOnUnmountPaths] + return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, noCachePaths] }, [mappedPaths]) // Extracts the paths from the initialData and ensures order of paths. @@ -207,10 +207,10 @@ export function useControls | string, depsChanged.current = false return () => { store.disposePaths(paths) - const pathsToClear = store.clearOnUnmount ? paths : [...clearOnUnmountPaths] + const pathsToClear = store.noCache ? paths : [...noCachePaths] pathsToClear.forEach((path) => store.clearPath(path)) } - }, [store, paths, initialData, clearOnUnmountPaths]) + }, [store, paths, initialData, noCachePaths]) useEffect(() => { // let's handle transient subscriptions diff --git a/packages/leva/src/utils/data.ts b/packages/leva/src/utils/data.ts index befdf4c8..3bacaadd 100644 --- a/packages/leva/src/utils/data.ts +++ b/packages/leva/src/utils/data.ts @@ -65,7 +65,7 @@ export function getDataFromSchema( if (normalizedInput) { const { type, options, input } = normalizedInput // @ts-ignore - const { onChange, transient, onEditStart, onEditEnd, clearOnUnmount, ..._options } = options + const { onChange, transient, onEditStart, onEditEnd, noCache, ..._options } = options data[newPath] = { type, ..._options, ...input, fromPanel: true } mappedPaths[key] = { path: newPath, @@ -73,7 +73,7 @@ export function getDataFromSchema( transient, onEditStart, onEditEnd, - clearOnUnmount: clearOnUnmount ?? false, + noCache: noCache ?? false, } } else { warn(LevaErrors.UNKNOWN_INPUT, newPath, rawInput) diff --git a/packages/leva/src/utils/input.ts b/packages/leva/src/utils/input.ts index de65df9d..1e70ef56 100644 --- a/packages/leva/src/utils/input.ts +++ b/packages/leva/src/utils/input.ts @@ -65,7 +65,7 @@ export function parseOptions( onEditStart, onEditEnd, transient, - clearOnUnmount, + noCache, ...inputWithType } = _input @@ -80,7 +80,7 @@ export function parseOptions( disabled, optional, order, - clearOnUnmount, + noCache, ...mergedOptions, } diff --git a/packages/leva/stories/clear-on-unmount.stories.tsx b/packages/leva/stories/clear-on-unmount.stories.tsx index 4b8fa65d..30e86342 100644 --- a/packages/leva/stories/clear-on-unmount.stories.tsx +++ b/packages/leva/stories/clear-on-unmount.stories.tsx @@ -10,13 +10,13 @@ export default { } as Meta // --------------------------------------------------------------------------- -// Per-input clearOnUnmount +// Per-input noCache // --------------------------------------------------------------------------- const PerInputControls = () => { const values = useControls({ persistent: { value: 10 }, - clearable: { value: 42, clearOnUnmount: true }, + clearable: { value: 42, noCache: true }, }) return ( @@ -33,7 +33,7 @@ const PerInputControls = () => { } /** - * Only the input marked with `clearOnUnmount: true` resets when the component + * Only the input marked with `noCache: true` resets when the component * unmounts. The other input retains its value. * * Steps to verify: @@ -52,10 +52,10 @@ export const PerInput: StoryFn = () => { ) } -PerInput.storyName = 'clearOnUnmount (per-input)' +PerInput.storyName = 'noCache (per-input)' // --------------------------------------------------------------------------- -// Panel-level clearOnUnmount via +// Panel-level noCache via // --------------------------------------------------------------------------- const PanelLevelControls = () => { @@ -63,16 +63,16 @@ const PanelLevelControls = () => { return (
-

All inputs clear on unmount because the panel has clearOnUnmount.

+

All inputs clear on unmount because the panel has noCache.

{JSON.stringify(values, null, '  ')}
) } /** - * When `` is set, ALL inputs managed by the default panel + * When `` is set, ALL inputs managed by the default panel * have their cached values discarded when they unmount, regardless of the - * per-input `clearOnUnmount` setting. + * per-input `noCache` setting. * * Steps to verify: * 1. Change `num` and `color` in the panel. @@ -84,36 +84,36 @@ export const PanelLevel: StoryFn = () => { return (
- + {mounted && }
) } -PanelLevel.storyName = 'clearOnUnmount (panel-level)' +PanelLevel.storyName = 'noCache (panel-level)' // --------------------------------------------------------------------------- -// Panel-level clearOnUnmount overrides per-input false +// Panel-level noCache overrides per-input false // --------------------------------------------------------------------------- const MixedControls = () => { const values = useControls({ willClear: { value: 99 }, - alsoWillClear: { value: '#0f0', clearOnUnmount: false }, + alsoWillClear: { value: '#0f0', noCache: false }, }) return (
-

Panel-level clearOnUnmount overrides per-input clearOnUnmount: false.

+

Panel-level noCache overrides per-input noCache: false.

{JSON.stringify(values, null, '  ')}
) } /** - * Panel-level `clearOnUnmount` takes priority over the per-input setting. - * Even `alsoWillClear` (which sets `clearOnUnmount: false`) will be cleared + * Panel-level `noCache` takes priority over the per-input setting. + * Even `alsoWillClear` (which sets `noCache: false`) will be cleared * because the panel flag overrides it. */ export const PanelOverridesPerInput: StoryFn = () => { @@ -121,14 +121,14 @@ export const PanelOverridesPerInput: StoryFn = () => { return (
- + {mounted && }
) } -PanelOverridesPerInput.storyName = 'panel clearOnUnmount overrides per-input' +PanelOverridesPerInput.storyName = 'panel noCache overrides per-input' // --------------------------------------------------------------------------- // store.clearPath — custom store, imperative usage From 27d6bcf62f7b56e06585347dd86ca65269e93df9 Mon Sep 17 00:00:00 2001 From: Rodri Date: Tue, 3 Mar 2026 15:00:29 +0000 Subject: [PATCH 13/15] fix: resolve StoreType mismatch in plugin-plot and address coderabbit feedback - Export StoreType and Data from leva/plugin entrypoint so plugin-plot resolves the same type identity as the sanitize function signature - Fix plugin-plot import to use leva/plugin instead of bare path alias - Remove redundant noCachePaths.delete in useMemo (fresh Set each run) - Replace forEach with for...of in cleanup to avoid implicit-return lint - Add mount/unmount toggle to ClearPath story so clearPath behavior is demonstrable without leaving the story Co-Authored-By: Claude Sonnet 4.6 --- packages/leva/src/plugin/index.ts | 1 + packages/leva/src/useControls.ts | 6 ++-- .../leva/stories/clear-on-unmount.stories.tsx | 32 ++++++++++++------- packages/plugin-plot/src/plot-plugin.ts | 2 +- 4 files changed, 25 insertions(+), 16 deletions(-) diff --git a/packages/leva/src/plugin/index.ts b/packages/leva/src/plugin/index.ts index 301d28db..a578b95b 100644 --- a/packages/leva/src/plugin/index.ts +++ b/packages/leva/src/plugin/index.ts @@ -44,5 +44,6 @@ export { styled, keyframes, useTh } from '../styles' // export types export * from '../types/public' +export type { StoreType, Data } from '../types/internal' export type { InternalVectorSettings } from '../plugins/Vector/vector-types' export type { InternalNumberSettings } from '../plugins/Number/number-types' diff --git a/packages/leva/src/useControls.ts b/packages/leva/src/useControls.ts index cda560b7..f3b7278c 100644 --- a/packages/leva/src/useControls.ts +++ b/packages/leva/src/useControls.ts @@ -160,8 +160,6 @@ export function useControls | string, } if (noCache) { noCachePaths.add(path) - } else { - noCachePaths.delete(path) } }) return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, noCachePaths] @@ -208,7 +206,9 @@ export function useControls | string, return () => { store.disposePaths(paths) const pathsToClear = store.noCache ? paths : [...noCachePaths] - pathsToClear.forEach((path) => store.clearPath(path)) + for (const path of pathsToClear) { + store.clearPath(path) + } } }, [store, paths, initialData, noCachePaths]) diff --git a/packages/leva/stories/clear-on-unmount.stories.tsx b/packages/leva/stories/clear-on-unmount.stories.tsx index 30e86342..b982dfbe 100644 --- a/packages/leva/stories/clear-on-unmount.stories.tsx +++ b/packages/leva/stories/clear-on-unmount.stories.tsx @@ -134,18 +134,24 @@ PanelOverridesPerInput.storyName = 'panel noCache overrides per-input' // store.clearPath — custom store, imperative usage // --------------------------------------------------------------------------- +const ClearPathControls = ({ store }: { store: ReturnType }) => { + const values = useControls({ position: { x: 0, y: 0 }, speed: 1 }, { store }) + return
{JSON.stringify(values, null, '  ')}
+} + /** * `store.clearPath(path)` lets you imperatively discard the cached value of a * single input. This is useful when you manage your own store and want fine- * grained control over cache invalidation without unmounting the component. * - * Click "Clear position cache" — the next time the component remounts it will - * start from the initial value again. + * 1. Change `position` in the panel. + * 2. Click "Unmount controls" — the component unmounts but the cache is preserved. + * 3. Click "Clear position cache" — discards the cached value. + * 4. Click "Mount controls" — position restarts from `{ x: 0, y: 0 }`. */ export const ClearPath: StoryFn = () => { const store = useCreateStore() - - const values = useControls({ position: { x: 0, y: 0 }, speed: 1 }, { store }) + const [mounted, setMounted] = React.useState(true) return (
@@ -154,15 +160,17 @@ export const ClearPath: StoryFn = () => {

store.clearPath imperatively discards a single cached input.

-
{JSON.stringify(values, null, '  ')}
+ {mounted && } +
+
+ +
- ) } diff --git a/packages/plugin-plot/src/plot-plugin.ts b/packages/plugin-plot/src/plot-plugin.ts index 7e876a56..7c6bcc77 100644 --- a/packages/plugin-plot/src/plot-plugin.ts +++ b/packages/plugin-plot/src/plot-plugin.ts @@ -1,4 +1,4 @@ -import { Data, StoreType } from 'packages/leva/src/types' +import type { Data, StoreType } from 'leva/plugin' import * as math from 'mathjs' import { parseExpression } from './plot-utils' import type { PlotInput, InternalPlot, InternalPlotSettings } from './plot-types' From 7fb5f99dec15ae71f2a49641b2082ae4271d6a7f Mon Sep 17 00:00:00 2001 From: Rodri Date: Tue, 3 Mar 2026 15:09:40 +0000 Subject: [PATCH 14/15] fix: avoid StoreType identity conflict in plugin-plot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The CI build cache (key: Linux-pnpm-build-) is never busted, so CI runs with a compiled leva-plugin.cjs.d.ts that pre-dates the noCache/setNoCache additions to StoreType. This causes a structural mismatch when plot-plugin imports StoreType and passes it to createPlugin's sanitize. Fix: replace the explicit StoreType annotation with a structural inline type { get: (path: string) => any } — only the .get method is used in sanitize, so any version of StoreType satisfies it. Also revert the unnecessary StoreType/Data re-export from leva/plugin. Co-Authored-By: Claude Sonnet 4.6 --- packages/leva/src/plugin/index.ts | 1 - packages/plugin-plot/src/plot-plugin.ts | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/leva/src/plugin/index.ts b/packages/leva/src/plugin/index.ts index a578b95b..301d28db 100644 --- a/packages/leva/src/plugin/index.ts +++ b/packages/leva/src/plugin/index.ts @@ -44,6 +44,5 @@ export { styled, keyframes, useTh } from '../styles' // export types export * from '../types/public' -export type { StoreType, Data } from '../types/internal' export type { InternalVectorSettings } from '../plugins/Vector/vector-types' export type { InternalNumberSettings } from '../plugins/Number/number-types' diff --git a/packages/plugin-plot/src/plot-plugin.ts b/packages/plugin-plot/src/plot-plugin.ts index 7c6bcc77..1f3d4d76 100644 --- a/packages/plugin-plot/src/plot-plugin.ts +++ b/packages/plugin-plot/src/plot-plugin.ts @@ -1,4 +1,4 @@ -import type { Data, StoreType } from 'leva/plugin' +import type { Data } from 'packages/leva/src/types' import * as math from 'mathjs' import { parseExpression } from './plot-utils' import type { PlotInput, InternalPlot, InternalPlotSettings } from './plot-types' @@ -8,7 +8,7 @@ export const sanitize = ( _settings: InternalPlotSettings, _prevValue: math.MathNode, _path: string, - store: StoreType + store: { get: (path: string) => any } ) => { if (expression === '') throw Error('Empty mathjs expression') try { From 41b0ed95a34a81f64bbac5841a4cfc2c845f8bf2 Mon Sep 17 00:00:00 2001 From: Rodri Date: Tue, 3 Mar 2026 15:16:50 +0000 Subject: [PATCH 15/15] fix: bust stale CI build cache and restore StoreType annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The build cache key used hashFiles('/packages/**/*') with an absolute path — the leading slash makes it resolve to the runner root, not the repo, so it always produced the same (empty) hash. The cache therefore never busted, leaving CI with a compiled leva-plugin.cjs.d.ts that pre-dates the noCache/setNoCache additions to StoreType. Fix the glob to hashFiles('packages/**/*.ts', 'packages/**/*.tsx') so the key changes whenever TypeScript sources change. Also restore the explicit store: StoreType annotation in plot-plugin.ts that was incorrectly weakened in the previous commit. Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/main.yml | 2 +- packages/plugin-plot/src/plot-plugin.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 4cf57198..13c253a8 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -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- diff --git a/packages/plugin-plot/src/plot-plugin.ts b/packages/plugin-plot/src/plot-plugin.ts index 1f3d4d76..7e62d675 100644 --- a/packages/plugin-plot/src/plot-plugin.ts +++ b/packages/plugin-plot/src/plot-plugin.ts @@ -1,4 +1,4 @@ -import type { Data } from 'packages/leva/src/types' +import type { Data, StoreType } from 'packages/leva/src/types' import * as math from 'mathjs' import { parseExpression } from './plot-utils' import type { PlotInput, InternalPlot, InternalPlotSettings } from './plot-types' @@ -8,7 +8,7 @@ export const sanitize = ( _settings: InternalPlotSettings, _prevValue: math.MathNode, _path: string, - store: { get: (path: string) => any } + store: StoreType ) => { if (expression === '') throw Error('Empty mathjs expression') try {