diff --git a/.changeset/clear-path-on-unmount.md b/.changeset/clear-path-on-unmount.md new file mode 100644 index 00000000..6d5cd7c0 --- /dev/null +++ b/.changeset/clear-path-on-unmount.md @@ -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. 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/docs/getting-started/configuration.md b/docs/getting-started/configuration.md index 90758ef2..b0186d97 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 }} + noCache // 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 +- `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 @@ -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 `` (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 `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 +} +``` + ### 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..abd51016 100644 --- a/docs/getting-started/inputs.md +++ b/docs/getting-started/inputs.md @@ -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: diff --git a/packages/leva/src/components/Leva/LevaRoot.tsx b/packages/leva/src/components/Leva/LevaRoot.tsx index a8395fe2..3cff58b2 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,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) diff --git a/packages/leva/src/store.ts b/packages/leva/src/store.ts index 73ad34f5..6db26eb7 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.noCache = false + this.setNoCache = (flag) => { + this.noCache = flag + } /** * Folders will hold the folder settings for the pane. * @note possibly make this reactive @@ -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] || {} } diff --git a/packages/leva/src/types/internal.ts b/packages/leva/src/types/internal.ts index e2467e5d..7ca8aef3 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 + noCache: boolean } > @@ -28,10 +29,13 @@ type Dispose = () => void export type StoreType = { useStore: UseBoundStore> & SubscribeWithSelectorAPI 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 diff --git a/packages/leva/src/types/public.ts b/packages/leva/src/types/public.ts index 254b678d..4e4fb63d 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 + noCache?: boolean } type SchemaItemWithOptions = diff --git a/packages/leva/src/useControls.test.tsx b/packages/leva/src/useControls.test.tsx new file mode 100644 index 00000000..1ce8d1c4 --- /dev/null +++ b/packages/leva/src/useControls.test.tsx @@ -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
{myNumber}
+} + +function NestedNumberComponent({ id }: { id?: string }) { + const { myNumber } = useControls('myFolder', { myNumber: 5 }, { store: levaStore, headless: true }) + return
{myNumber}
+} + +function NumberComponentNoCache({ id }: { id?: string }) { + const { myNumber } = useControls({ myNumber: 5 }, { headless: true }) + useEffect( + () => () => { + levaStore.clearPath('myNumber') + }, + [] + ) + return
{myNumber}
+} + +function NoCacheOptionComponent({ id }: { id?: string }) { + const { myNumber } = useControls({ myNumber: { value: 5, noCache: true } }, { 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', () => { + 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('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('noCache 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('store-level noCache resets all inputs on remount', () => { + levaStore.setNoCache(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('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() + + 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') + + act(() => { + levaStore.setValueAtPath('myNumber', 42, true) + }) + expect(getByTestId('value').textContent).toBe('42') + + unmount() + levaStore.clearPath('myNumber') + + 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..f3b7278c 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, noCachePaths] = useMemo(() => { const allPaths: string[] = [] const renderPaths: string[] = [] const onChangePaths: Record = {} const onEditStartPaths: Record void> = {} const onEditEndPaths: Record void> = {} + const noCachePaths = new Set() - Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient }) => { + Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient, noCache }) => { allPaths.push(path) if (onChange) { onChangePaths[path] = onChange @@ -157,8 +158,11 @@ export function useControls | string, if (onEditEnd) { onEditEndPaths[path] = onEditEnd } + if (noCache) { + noCachePaths.add(path) + } }) - return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths] + return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, noCachePaths] }, [mappedPaths]) // Extracts the paths from the initialData and ensures order of paths. @@ -199,8 +203,14 @@ 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) + const pathsToClear = store.noCache ? paths : [...noCachePaths] + for (const path of pathsToClear) { + store.clearPath(path) + } + } + }, [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 1e1af690..3bacaadd 100644 --- a/packages/leva/src/utils/data.ts +++ b/packages/leva/src/utils/data.ts @@ -65,9 +65,16 @@ export function getDataFromSchema( if (normalizedInput) { const { type, options, input } = normalizedInput // @ts-ignore - const { onChange, transient, onEditStart, onEditEnd, ..._options } = options + const { onChange, transient, onEditStart, onEditEnd, noCache, ..._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, + 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 75c1de17..1e70ef56 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, + noCache, ...inputWithType } = _input @@ -79,6 +80,7 @@ export function parseOptions( disabled, optional, order, + noCache, ...mergedOptions, } 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..b982dfbe --- /dev/null +++ b/packages/leva/stories/clear-on-unmount.stories.tsx @@ -0,0 +1,178 @@ +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 noCache +// --------------------------------------------------------------------------- + +const PerInputControls = () => { + const values = useControls({ + persistent: { value: 10 }, + clearable: { value: 42, noCache: 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 `noCache: 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 = 'noCache (per-input)' + +// --------------------------------------------------------------------------- +// Panel-level noCache via +// --------------------------------------------------------------------------- + +const PanelLevelControls = () => { + const values = useControls({ num: 5, color: '#f00' }) + + return ( +
+

All inputs clear on unmount because the panel has noCache.

+
{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 `noCache` 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 = 'noCache (panel-level)' + +// --------------------------------------------------------------------------- +// Panel-level noCache overrides per-input false +// --------------------------------------------------------------------------- + +const MixedControls = () => { + const values = useControls({ + willClear: { value: 99 }, + alsoWillClear: { value: '#0f0', noCache: false }, + }) + + return ( +
+

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

+
{JSON.stringify(values, null, '  ')}
+
+ ) +} + +/** + * 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 = () => { + const [mounted, toggle] = React.useState(true) + + return ( +
+ + + {mounted && } +
+ ) +} + +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. + * + * 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 [mounted, setMounted] = React.useState(true) + + return ( +
+ +
+

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

+ {mounted && } +
+
+ + +
+
+ ) +} + +ClearPath.storyName = 'store.clearPath (imperative)' diff --git a/packages/plugin-plot/src/plot-plugin.ts b/packages/plugin-plot/src/plot-plugin.ts index 7e876a56..7e62d675 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 'packages/leva/src/types' import * as math from 'mathjs' import { parseExpression } from './plot-utils' import type { PlotInput, InternalPlot, InternalPlotSettings } from './plot-types'