From 1e2632eeca2fa3dcc7f33a6974029758577eeebb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:05:23 +0000 Subject: [PATCH 1/7] Initial plan From 1ee407b6d05605a93ceba52c15dc3abce7891ca4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:11:17 +0000 Subject: [PATCH 2/7] Initial commit - planning fix for StrictMode rendering issue Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- yarn.lock | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 yarn.lock diff --git a/yarn.lock b/yarn.lock new file mode 100644 index 00000000..e69de29b From 95e75c0bdda5efdfa9a593f19aeac1f0d6d35326 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:14:59 +0000 Subject: [PATCH 3/7] Fix StrictMode rendering issue in useToggle hook Update useToggle hook to properly handle React StrictMode's double-invocation of effects. Use prevToggledRef to track actual state changes and prevent animation on initial render, fixing controls not rendering correctly inside R3F Canvas with StrictMode enabled. Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- packages/leva/src/hooks/useToggle.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/leva/src/hooks/useToggle.ts b/packages/leva/src/hooks/useToggle.ts index b5676b03..277b673a 100644 --- a/packages/leva/src/hooks/useToggle.ts +++ b/packages/leva/src/hooks/useToggle.ts @@ -80,7 +80,7 @@ import { useRef, useEffect, useLayoutEffect } from 'react' export function useToggle(toggled: boolean) { const wrapperRef = useRef(null) const contentRef = useRef(null) - const firstRender = useRef(true) + const prevToggledRef = useRef(null) // this should be fine for SSR since the store is set in useEffect and // therefore the pane doesn't show on first render. @@ -94,12 +94,16 @@ export function useToggle(toggled: boolean) { }, []) useEffect(() => { - // prevents first animation - if (firstRender.current) { - firstRender.current = false + // On initial render (including StrictMode's double-invocation), + // prevToggledRef.current is null, so we skip animation. + // We only animate when toggled actually changes from a previous value. + if (prevToggledRef.current === null || prevToggledRef.current === toggled) { + prevToggledRef.current = toggled return } + prevToggledRef.current = toggled + let timeout: number const ref = wrapperRef.current! From 7f39ac5dc7d848d1f3d44ae72f140d91d7eb47dc Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:17:02 +0000 Subject: [PATCH 4/7] Address code review feedback - remove duplicate assignment Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- packages/leva/src/hooks/useToggle.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/leva/src/hooks/useToggle.ts b/packages/leva/src/hooks/useToggle.ts index 277b673a..7438f093 100644 --- a/packages/leva/src/hooks/useToggle.ts +++ b/packages/leva/src/hooks/useToggle.ts @@ -94,7 +94,7 @@ export function useToggle(toggled: boolean) { }, []) useEffect(() => { - // On initial render (including StrictMode's double-invocation), + // On initial render (including StrictMode's double-invocation), // prevToggledRef.current is null, so we skip animation. // We only animate when toggled actually changes from a previous value. if (prevToggledRef.current === null || prevToggledRef.current === toggled) { @@ -102,8 +102,6 @@ export function useToggle(toggled: boolean) { return } - prevToggledRef.current = toggled - let timeout: number const ref = wrapperRef.current! @@ -124,6 +122,8 @@ export function useToggle(toggled: boolean) { timeout = window.setTimeout(() => (ref.style.height = '0px'), 50) } + prevToggledRef.current = toggled + return () => { ref.removeEventListener('transitionend', fixHeight) clearTimeout(timeout) From 2867f9f6ea33da2a251710eb5465fc3b3464c78d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 2 Nov 2025 15:24:03 +0000 Subject: [PATCH 5/7] Remove accidentally added empty yarn.lock file Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- yarn.lock | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 yarn.lock diff --git a/yarn.lock b/yarn.lock deleted file mode 100644 index e69de29b..00000000 From d55010cd127ccb9617e0df72e96f3eaeb215bb66 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 8 Nov 2025 18:03:54 +0000 Subject: [PATCH 6/7] Add StrictMode reproduction story Add comprehensive Storybook story demonstrating the StrictMode fix with async component mounting and nested folders. Shows controls render correctly in StrictMode with delayed content layout, reproducing and validating the fix for issue #552. Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- .../stories/advanced/StrictMode.stories.tsx | 205 ++++++++++++++++++ 1 file changed, 205 insertions(+) create mode 100644 packages/leva/stories/advanced/StrictMode.stories.tsx diff --git a/packages/leva/stories/advanced/StrictMode.stories.tsx b/packages/leva/stories/advanced/StrictMode.stories.tsx new file mode 100644 index 00000000..61e19cd7 --- /dev/null +++ b/packages/leva/stories/advanced/StrictMode.stories.tsx @@ -0,0 +1,205 @@ +import React, { StrictMode, useState, useEffect } from 'react' +import { StoryFn, Meta } from '@storybook/react' +import { expect, within, waitFor } from 'storybook/test' + +import Reset from '../components/decorator-reset' +import { useControls, folder } from '../../src' + +export default { + title: 'Advanced/StrictMode', + decorators: [Reset], +} as Meta + +/** + * This story reproduces the issue where controls don't render correctly + * in StrictMode when used with dynamically mounted components (like R3F Canvas). + * The issue was that the useToggle hook's height calculation would run + * prematurely during StrictMode's double-invocation. + */ + +// Simulates a component that mounts asynchronously (like R3F Canvas content) +const AsyncMountedComponent = ({ delay = 100 }: { delay?: number }) => { + const [mounted, setMounted] = useState(false) + + useEffect(() => { + const timer = setTimeout(() => setMounted(true), delay) + return () => clearTimeout(timer) + }, [delay]) + + const values = useControls('Async Component', { + position: { value: { x: 0, y: 0, z: 0 }, step: 0.1 }, + scale: { value: 1, min: 0.1, max: 2, step: 0.1 }, + color: '#ff0000', + visible: true, + settings: folder({ + wireframe: false, + castShadow: true, + receiveShadow: true, + }), + }) + + if (!mounted) return
Loading...
+ + return ( +
+

Async Mounted Component

+
{JSON.stringify(values, null, 2)}
+
+ ) +} + +const NestedFoldersComponent = () => { + const values = useControls('Nested Folders', { + basic: 1, + folder1: folder({ + value1: 'test', + value2: 42, + nested: folder({ + deep: true, + color: '#00ff00', + }), + }), + folder2: folder( + { + collapsed: 'initial', + data: [1, 2, 3], + }, + { collapsed: true } + ), + }) + + return ( +
+

Nested Folders Component

+
{JSON.stringify(values, null, 2)}
+
+ ) +} + +const BaseTemplate: StoryFn<{ useStrictMode: boolean; delay?: number }> = ({ useStrictMode, delay = 100 }) => { + const Wrapper = useStrictMode ? StrictMode : React.Fragment + + return ( + +
+
+ Mode: {useStrictMode ? 'StrictMode Enabled' : 'Normal Mode'} +

+ {useStrictMode + ? 'React StrictMode causes effects to run twice. Controls should still render correctly.' + : 'Running in normal mode without StrictMode.'} +

+
+ + +
+
+ ) +} + +export const WithStrictMode = BaseTemplate.bind({}) +WithStrictMode.args = { + useStrictMode: true, + delay: 100, +} +WithStrictMode.play = async ({ canvasElement }) => { + const canvas = within(canvasElement) + + // Wait for the async component to mount + await waitFor( + () => { + expect(canvas.getByText(/Async Mounted Component/i)).toBeInTheDocument() + }, + { timeout: 3000 } + ) + + // Verify the Leva panel is rendered and visible + await waitFor( + () => { + const levaPanel = within(document.body).queryByText(/Async Component/i) + expect(levaPanel).toBeInTheDocument() + }, + { timeout: 3000 } + ) + + // Verify controls are interactive - find a control by its label + await waitFor( + () => { + const scaleInput = within(document.body).queryByLabelText(/scale/i) + expect(scaleInput).toBeInTheDocument() + }, + { timeout: 3000 } + ) + + // Verify nested folders are rendered + await waitFor( + () => { + const nestedPanel = within(document.body).queryByText(/Nested Folders/i) + expect(nestedPanel).toBeInTheDocument() + }, + { timeout: 3000 } + ) +} + +export const WithoutStrictMode = BaseTemplate.bind({}) +WithoutStrictMode.args = { + useStrictMode: false, + delay: 100, +} +WithoutStrictMode.play = WithStrictMode.play + +export const StrictModeWithSlowMount = BaseTemplate.bind({}) +StrictModeWithSlowMount.args = { + useStrictMode: true, + delay: 500, +} +StrictModeWithSlowMount.parameters = { + docs: { + description: { + story: + 'Tests the fix with a slower async mount to ensure controls render correctly even with delayed content layout.', + }, + }, +} + +// Component that toggles between StrictMode and normal mode +export const InteractiveModeToggle: StoryFn = () => { + const [strictMode, setStrictMode] = useState(true) + const Wrapper = strictMode ? StrictMode : React.Fragment + + return ( +
+ + + + + +
+ ) +} +InteractiveModeToggle.parameters = { + docs: { + description: { + story: 'Interactive story that allows toggling between StrictMode and normal mode to verify the fix works in both cases.', + }, + }, +} From 860b2654d62f23167de9513daadc592001786548 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 9 Nov 2025 00:28:34 +0000 Subject: [PATCH 7/7] Add R3F Canvas reproduction to bug repros story - Add @react-three/fiber and three as dev dependencies - Create realistic StrictMode reproduction with R3F Canvas in bug-repros.stories.tsx - Remove old StrictMode.stories.tsx in favor of the new R3F-based repro - Story demonstrates issue #552 with actual R3F Canvas component - Follows the pattern of other bug repro stories with issue number citation Co-authored-by: gsimone <1862172+gsimone@users.noreply.github.com> --- package.json | 4 +- .../stories/advanced/StrictMode.stories.tsx | 205 ------------------ packages/leva/stories/bug-repros.stories.tsx | 79 ++++++- 3 files changed, 80 insertions(+), 208 deletions(-) delete mode 100644 packages/leva/stories/advanced/StrictMode.stories.tsx diff --git a/package.json b/package.json index fd8f9830..9699996d 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,7 @@ "@emotion/react": "^11.10.5", "@preconstruct/cli": "^2.3.0", "@radix-ui/react-icons": "^1.1.1", + "@react-three/fiber": "^9.3.0", "@size-limit/preset-big-lib": "^8.1.0", "@stitches/react": "^1.2.8", "@storybook/addon-docs": "10.0.2", @@ -91,6 +92,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-storybook": "10.0.2", "husky": "^8.0.3", + "jsdom": "^27.1.0", "playwright": "^1.56.1", "playwright-core": "^1.56.1", "pnpm": "^7.25.0", @@ -100,9 +102,9 @@ "react-dom": "^18.2.0", "size-limit": "^8.1.0", "storybook": "^10.0.2", + "three": "^0.169.0", "tsd": "^0.25.0", "typescript": "catalog:", - "jsdom": "^27.1.0", "vitest": "^4.0.8" }, "prettier": { diff --git a/packages/leva/stories/advanced/StrictMode.stories.tsx b/packages/leva/stories/advanced/StrictMode.stories.tsx deleted file mode 100644 index 61e19cd7..00000000 --- a/packages/leva/stories/advanced/StrictMode.stories.tsx +++ /dev/null @@ -1,205 +0,0 @@ -import React, { StrictMode, useState, useEffect } from 'react' -import { StoryFn, Meta } from '@storybook/react' -import { expect, within, waitFor } from 'storybook/test' - -import Reset from '../components/decorator-reset' -import { useControls, folder } from '../../src' - -export default { - title: 'Advanced/StrictMode', - decorators: [Reset], -} as Meta - -/** - * This story reproduces the issue where controls don't render correctly - * in StrictMode when used with dynamically mounted components (like R3F Canvas). - * The issue was that the useToggle hook's height calculation would run - * prematurely during StrictMode's double-invocation. - */ - -// Simulates a component that mounts asynchronously (like R3F Canvas content) -const AsyncMountedComponent = ({ delay = 100 }: { delay?: number }) => { - const [mounted, setMounted] = useState(false) - - useEffect(() => { - const timer = setTimeout(() => setMounted(true), delay) - return () => clearTimeout(timer) - }, [delay]) - - const values = useControls('Async Component', { - position: { value: { x: 0, y: 0, z: 0 }, step: 0.1 }, - scale: { value: 1, min: 0.1, max: 2, step: 0.1 }, - color: '#ff0000', - visible: true, - settings: folder({ - wireframe: false, - castShadow: true, - receiveShadow: true, - }), - }) - - if (!mounted) return
Loading...
- - return ( -
-

Async Mounted Component

-
{JSON.stringify(values, null, 2)}
-
- ) -} - -const NestedFoldersComponent = () => { - const values = useControls('Nested Folders', { - basic: 1, - folder1: folder({ - value1: 'test', - value2: 42, - nested: folder({ - deep: true, - color: '#00ff00', - }), - }), - folder2: folder( - { - collapsed: 'initial', - data: [1, 2, 3], - }, - { collapsed: true } - ), - }) - - return ( -
-

Nested Folders Component

-
{JSON.stringify(values, null, 2)}
-
- ) -} - -const BaseTemplate: StoryFn<{ useStrictMode: boolean; delay?: number }> = ({ useStrictMode, delay = 100 }) => { - const Wrapper = useStrictMode ? StrictMode : React.Fragment - - return ( - -
-
- Mode: {useStrictMode ? 'StrictMode Enabled' : 'Normal Mode'} -

- {useStrictMode - ? 'React StrictMode causes effects to run twice. Controls should still render correctly.' - : 'Running in normal mode without StrictMode.'} -

-
- - -
-
- ) -} - -export const WithStrictMode = BaseTemplate.bind({}) -WithStrictMode.args = { - useStrictMode: true, - delay: 100, -} -WithStrictMode.play = async ({ canvasElement }) => { - const canvas = within(canvasElement) - - // Wait for the async component to mount - await waitFor( - () => { - expect(canvas.getByText(/Async Mounted Component/i)).toBeInTheDocument() - }, - { timeout: 3000 } - ) - - // Verify the Leva panel is rendered and visible - await waitFor( - () => { - const levaPanel = within(document.body).queryByText(/Async Component/i) - expect(levaPanel).toBeInTheDocument() - }, - { timeout: 3000 } - ) - - // Verify controls are interactive - find a control by its label - await waitFor( - () => { - const scaleInput = within(document.body).queryByLabelText(/scale/i) - expect(scaleInput).toBeInTheDocument() - }, - { timeout: 3000 } - ) - - // Verify nested folders are rendered - await waitFor( - () => { - const nestedPanel = within(document.body).queryByText(/Nested Folders/i) - expect(nestedPanel).toBeInTheDocument() - }, - { timeout: 3000 } - ) -} - -export const WithoutStrictMode = BaseTemplate.bind({}) -WithoutStrictMode.args = { - useStrictMode: false, - delay: 100, -} -WithoutStrictMode.play = WithStrictMode.play - -export const StrictModeWithSlowMount = BaseTemplate.bind({}) -StrictModeWithSlowMount.args = { - useStrictMode: true, - delay: 500, -} -StrictModeWithSlowMount.parameters = { - docs: { - description: { - story: - 'Tests the fix with a slower async mount to ensure controls render correctly even with delayed content layout.', - }, - }, -} - -// Component that toggles between StrictMode and normal mode -export const InteractiveModeToggle: StoryFn = () => { - const [strictMode, setStrictMode] = useState(true) - const Wrapper = strictMode ? StrictMode : React.Fragment - - return ( -
- - - - - -
- ) -} -InteractiveModeToggle.parameters = { - docs: { - description: { - story: 'Interactive story that allows toggling between StrictMode and normal mode to verify the fix works in both cases.', - }, - }, -} diff --git a/packages/leva/stories/bug-repros.stories.tsx b/packages/leva/stories/bug-repros.stories.tsx index 118568e9..7769abcd 100644 --- a/packages/leva/stories/bug-repros.stories.tsx +++ b/packages/leva/stories/bug-repros.stories.tsx @@ -1,7 +1,7 @@ -import React, { useState } from 'react' +import React, { StrictMode, useState } from 'react' import Reset from './components/decorator-reset' import { Meta } from '@storybook/react' -import { Leva, LevaPanel, useControls, useCreateStore } from '../src' +import { Leva, LevaPanel, useControls, useCreateStore, folder } from '../src' export default { title: 'Dev/BugRepro', @@ -102,3 +102,78 @@ export const ConditionalControls = () => { } ConditionalControls.storyName = '540 / conditional controls should work' + +// repro for https://github.com/pmndrs/leva/issues/552 +// Dynamic import to avoid build errors if @react-three/fiber is not installed +let Canvas: any = null +let Box: any = null + +try { + const fiber = require('@react-three/fiber') + Canvas = fiber.Canvas + + // Simple box component that uses leva controls + Box = ({ position }: { position: [number, number, number] }) => { + const { scale, color, wireframe, rotation } = useControls('Mesh Settings', { + scale: { value: 1, min: 0.1, max: 3, step: 0.1 }, + color: '#ff6b6b', + rotation: { value: { x: 0, y: 0, z: 0 }, step: 0.01 }, + wireframe: false, + }) + + return ( + + + + + ) + } +} catch (e) { + // R3F not installed, story will show error message +} + +export const StrictModeWithR3FCanvas = () => { + if (!Canvas) { + return ( +
+

@react-three/fiber not installed

+

+ This story requires @react-three/fiber and three to be installed as dev dependencies. +
+ Run: npm install --save-dev @react-three/fiber three +

+
+ ) + } + + return ( + +
+
+ StrictMode Enabled +

+ Controls should render correctly despite StrictMode's double-invocation. +

+
+ + + + + + +
+
+ ) +} + +StrictModeWithR3FCanvas.storyName = '552 / StrictMode with R3F Canvas should render controls'