Skip to content

Commit 333fead

Browse files
RodrigoHamuyclaude
andcommitted
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 <noreply@anthropic.com>
1 parent 2a7243e commit 333fead

6 files changed

Lines changed: 77 additions & 16 deletions

File tree

packages/leva/src/types/internal.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export type MappedPaths = Record<
2020
onEditStart?: (...args: any) => void
2121
onEditEnd?: (...args: any) => void
2222
transient: boolean
23+
clearOnUnmount: boolean
2324
}
2425
>
2526

packages/leva/src/types/public.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ export type InputOptions = GenericSchemaItemOptions &
185185
disabled?: boolean
186186
onEditStart?: (value: any, path: string, context: OnHandlerContext) => void
187187
onEditEnd?: (value: any, path: string, context: OnHandlerContext) => void
188+
clearOnUnmount?: boolean
188189
}
189190

190191
type SchemaItemWithOptions =

packages/leva/src/useControls.test.tsx

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ vi.mock('@stitches/react', () => ({
1818
}),
1919
}))
2020

21-
import React from 'react'
21+
import React, { useEffect } from 'react'
2222
import { describe, it, expect, vi, afterEach } from 'vitest'
2323
import { render, act } from '@testing-library/react'
2424
import { useControls } from './useControls'
@@ -38,6 +38,17 @@ function NestedNumberComponent({ id }: { id?: string }) {
3838
return <div data-testid={id ?? 'value'}>{myNumber}</div>
3939
}
4040

41+
function NumberComponentClearOnUnmount({ id }: { id?: string }) {
42+
const { myNumber } = useControls({ myNumber: 5 }, { headless: true })
43+
useEffect(() => () => { levaStore.clearPath('myNumber') }, [])
44+
return <div data-testid={id ?? 'value'}>{myNumber}</div>
45+
}
46+
47+
function ClearOnUnmountOptionComponent({ id }: { id?: string }) {
48+
const { myNumber } = useControls({ myNumber: { value: 5, clearOnUnmount: true } }, { headless: true })
49+
return <div data-testid={id ?? 'value'}>{myNumber}</div>
50+
}
51+
4152
describe('useControls mount/unmount lifecycle', () => {
4253
it('does not clear a path that is still mounted', () => {
4354
const { unmount } = render(<NumberComponent id="value" />)
@@ -53,7 +64,7 @@ describe('useControls mount/unmount lifecycle', () => {
5364
unmount()
5465
})
5566

56-
it('works with nested folder paths', async () => {
67+
it('works with nested folder paths', () => {
5768
const { getByTestId, unmount } = render(<NestedNumberComponent id="value" />)
5869
expect(getByTestId('value').textContent).toBe('5')
5970

@@ -69,24 +80,61 @@ describe('useControls mount/unmount lifecycle', () => {
6980
expect(getByTestId2('value2').textContent).toBe('5')
7081
})
7182

72-
it('resets to the initial value when remounted after clearPath', async () => {
73-
// Mount the component
83+
it('preserves the value on remount when not cleared', () => {
84+
const { unmount } = render(<NumberComponent id="value" />)
85+
86+
act(() => {
87+
levaStore.setValueAtPath('myNumber', 42, true)
88+
})
89+
90+
act(() => unmount())
91+
92+
// value survives unmount without clearing
93+
expect(levaStore.get('myNumber')).toBe(42)
94+
})
95+
96+
it('useEffect clearPath resets the value on remount', () => {
97+
const { getByTestId, unmount } = render(<NumberComponentClearOnUnmount id="value" />)
98+
expect(getByTestId('value').textContent).toBe('5')
99+
100+
act(() => {
101+
levaStore.setValueAtPath('myNumber', 42, true)
102+
})
103+
expect(getByTestId('value').textContent).toBe('42')
104+
105+
act(() => unmount())
106+
107+
const { getByTestId: getByTestId2 } = render(<NumberComponentClearOnUnmount id="value2" />)
108+
expect(getByTestId2('value2').textContent).toBe('5')
109+
})
110+
111+
it('clearOnUnmount option resets the value on remount', () => {
112+
const { getByTestId, unmount } = render(<ClearOnUnmountOptionComponent id="value" />)
113+
expect(getByTestId('value').textContent).toBe('5')
114+
115+
act(() => {
116+
levaStore.setValueAtPath('myNumber', 42, true)
117+
})
118+
expect(getByTestId('value').textContent).toBe('42')
119+
120+
act(() => unmount())
121+
122+
const { getByTestId: getByTestId2 } = render(<ClearOnUnmountOptionComponent id="value2" />)
123+
expect(getByTestId2('value2').textContent).toBe('5')
124+
})
125+
126+
it('resets to the initial value when remounted after clearPath', () => {
74127
const { getByTestId, unmount } = render(<NumberComponent id="value" />)
75128
expect(getByTestId('value').textContent).toBe('5')
76129

77-
// Simulate a value change via the store (as if the user dragged the slider)
78130
act(() => {
79131
levaStore.setValueAtPath('myNumber', 42, true)
80132
})
81133
expect(getByTestId('value').textContent).toBe('42')
82134

83-
// Unmount – disposePaths decrements __refCount to 0 but the value stays in the store
84135
unmount()
85-
86-
// Clear the cached value so the next mount starts fresh
87136
levaStore.clearPath('myNumber')
88137

89-
// Remount – useControls reads from the schema (value: 5) because the path is gone
90138
const { getByTestId: getByTestId2 } = render(<NumberComponent id="value2" />)
91139
expect(getByTestId2('value2').textContent).toBe('5')
92140
})

packages/leva/src/useControls.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,15 @@ export function useControls<S extends Schema, F extends SchemaOrFn<S> | string,
133133
* parses the schema inside nested folder.
134134
*/
135135
const [initialData, mappedPaths] = useMemo(() => store.getDataFromSchema(_schema), [store, _schema])
136-
const [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths] = useMemo(() => {
136+
const [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, clearOnUnmountPaths] = useMemo(() => {
137137
const allPaths: string[] = []
138138
const renderPaths: string[] = []
139139
const onChangePaths: Record<string, OnChangeHandler> = {}
140140
const onEditStartPaths: Record<string, (...args: any) => void> = {}
141141
const onEditEndPaths: Record<string, (...args: any) => void> = {}
142+
const clearOnUnmountPaths = new Set<string>()
142143

143-
Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient }) => {
144+
Object.values(mappedPaths).forEach(({ path, onChange, onEditStart, onEditEnd, transient, clearOnUnmount }) => {
144145
allPaths.push(path)
145146
if (onChange) {
146147
onChangePaths[path] = onChange
@@ -157,8 +158,13 @@ export function useControls<S extends Schema, F extends SchemaOrFn<S> | string,
157158
if (onEditEnd) {
158159
onEditEndPaths[path] = onEditEnd
159160
}
161+
if (clearOnUnmount) {
162+
clearOnUnmountPaths.add(path)
163+
} else {
164+
clearOnUnmountPaths.delete(path)
165+
}
160166
})
161-
return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths]
167+
return [allPaths, renderPaths, onChangePaths, onEditStartPaths, onEditEndPaths, clearOnUnmountPaths]
162168
}, [mappedPaths])
163169

164170
// Extracts the paths from the initialData and ensures order of paths.
@@ -199,8 +205,11 @@ export function useControls<S extends Schema, F extends SchemaOrFn<S> | string,
199205
store.addData(initialData, shouldOverrideSettings)
200206
firstRender.current = false
201207
depsChanged.current = false
202-
return () => store.disposePaths(paths)
203-
}, [store, paths, initialData])
208+
return () => {
209+
store.disposePaths(paths)
210+
clearOnUnmountPaths.forEach((path) => store.clearPath(path))
211+
}
212+
}, [store, paths, initialData, clearOnUnmountPaths])
204213

205214
useEffect(() => {
206215
// let's handle transient subscriptions

packages/leva/src/utils/data.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -65,9 +65,9 @@ export function getDataFromSchema(
6565
if (normalizedInput) {
6666
const { type, options, input } = normalizedInput
6767
// @ts-ignore
68-
const { onChange, transient, onEditStart, onEditEnd, ..._options } = options
68+
const { onChange, transient, onEditStart, onEditEnd, clearOnUnmount, ..._options } = options
6969
data[newPath] = { type, ..._options, ...input, fromPanel: true }
70-
mappedPaths[key] = { path: newPath, onChange, transient, onEditStart, onEditEnd }
70+
mappedPaths[key] = { path: newPath, onChange, transient, onEditStart, onEditEnd, clearOnUnmount: clearOnUnmount ?? false }
7171
} else {
7272
warn(LevaErrors.UNKNOWN_INPUT, newPath, rawInput)
7373
}

packages/leva/src/utils/input.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function parseOptions(
6565
onEditStart,
6666
onEditEnd,
6767
transient,
68+
clearOnUnmount,
6869
...inputWithType
6970
} = _input
7071

@@ -79,6 +80,7 @@ export function parseOptions(
7980
disabled,
8081
optional,
8182
order,
83+
clearOnUnmount,
8284
...mergedOptions,
8385
}
8486

0 commit comments

Comments
 (0)