From bec337814e77e524ff13d14ffa2732573587b6af Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Wed, 18 Mar 2026 01:05:01 +0900 Subject: [PATCH 1/3] fix: clear stale mesh v2 domain from localStorage when modal opens When users previously entered a domain manually, it was cached in localStorage. On subsequent sessions without domain input, the stale cached value was silently used instead of auto-detecting via createDomain(), causing devices on the same network to get different domains and be unable to find each other's mesh groups. Reset Redux meshV2 domain to null when the connection modal opens, and clear the extension domain when the user clicks Create/Join without entering a domain. Fixes #327 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/containers/connection-modal.jsx | 24 ++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/scratch-gui/src/containers/connection-modal.jsx b/packages/scratch-gui/src/containers/connection-modal.jsx index 5b62439e453..0311cb5e067 100644 --- a/packages/scratch-gui/src/containers/connection-modal.jsx +++ b/packages/scratch-gui/src/containers/connection-modal.jsx @@ -41,9 +41,17 @@ class ConnectionModal extends React.Component { ]); // === Smalruby: Start of meshV2 initial step feature === // For meshV2, show initial step first unless already connected + const isMeshV2 = props.extensionId === 'meshV2'; const initialPhase = props.vm.getPeripheralIsConnected(props.extensionId) ? PHASES.connected : - (props.extensionId === 'meshV2' ? PHASES.meshV2Initial : PHASES.scanning); + (isMeshV2 ? PHASES.meshV2Initial : PHASES.scanning); + // Reset domain when opening meshV2 modal (not already connected). + // This prevents stale domains cached in localStorage from silently + // overriding the auto-detection (createDomain) when the user does not + // type anything in the domain input field. + if (isMeshV2 && initialPhase === PHASES.meshV2Initial) { + props.onDomainChange(null); + } // === Smalruby: End of meshV2 initial step feature === this.state = { extension: extensionData.find(ext => ext.extensionId === props.extensionId), @@ -201,6 +209,9 @@ class ConnectionModal extends React.Component { } // === Smalruby: Start of meshV2 initial step feature === handleMeshV2CreateGroup () { + // If domain input is empty, clear any cached domain from localStorage + // so that createDomain() will be called to auto-detect from source IP + this.clearMeshV2DomainIfEmpty(); // Connect as host using special host ID this.handleConnecting('meshV2_host'); analytics.event({ @@ -210,6 +221,9 @@ class ConnectionModal extends React.Component { }); } handleMeshV2JoinGroup () { + // If domain input is empty, clear any cached domain from localStorage + // so that createDomain() will be called to auto-detect from source IP + this.clearMeshV2DomainIfEmpty(); // Switch to scanning phase to show group list this.handleScanning(); analytics.event({ @@ -218,6 +232,14 @@ class ConnectionModal extends React.Component { label: this.props.extensionId }); } + clearMeshV2DomainIfEmpty () { + if (!this.props.meshV2Domain) { + const extension = this.props.vm.runtime.peripheralExtensions.meshV2; + if (extension && extension.setDomain) { + extension.setDomain(null); + } + } + } handleMeshV2DomainChange (domain) { // Save domain to Redux this.props.onDomainChange(domain); From e88bdf6b052f7689faa1cef674063f019b53f6fc Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Wed, 18 Mar 2026 01:06:18 +0900 Subject: [PATCH 2/3] test: add unit tests for mesh v2 domain reset on modal open - Verify Redux domain is reset to null when meshV2 modal opens - Verify domain is NOT reset when already connected - Verify phase is meshV2Initial when domain is empty Co-Authored-By: Claude Opus 4.6 (1M context) --- .../unit/containers/connection-modal.test.jsx | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/packages/scratch-gui/test/unit/containers/connection-modal.test.jsx b/packages/scratch-gui/test/unit/containers/connection-modal.test.jsx index 3f06756d9ef..985ca9830d1 100644 --- a/packages/scratch-gui/test/unit/containers/connection-modal.test.jsx +++ b/packages/scratch-gui/test/unit/containers/connection-modal.test.jsx @@ -140,6 +140,45 @@ describe('ConnectionModal container', () => { }); }); + describe('meshV2 domain reset on modal open', () => { + test('resets Redux domain to null when meshV2 modal opens (not connected)', () => { + const {setDomain} = require('../../../src/reducers/mesh-v2'); + setDomain.mockClear(); + + const vm = createMockVm({isConnected: false}); + renderWithStore(vm, {domain: 'old-cached-domain'}); + + // Constructor should dispatch onDomainChange(null) to reset stale domain + expect(setDomain).toHaveBeenCalledWith(null); + }); + + test('does not reset domain when meshV2 is already connected', () => { + const {setDomain} = require('../../../src/reducers/mesh-v2'); + setDomain.mockClear(); + + const vm = createMockVm({ + isConnected: true, + connectedMessage: 'Connected' + }); + renderWithStore(vm, {domain: 'active-domain'}); + + // Should NOT reset domain when already connected + expect(setDomain).not.toHaveBeenCalledWith(null); + }); + }); + + describe('clearMeshV2DomainIfEmpty on button click', () => { + test('clears extension domain when meshV2Domain is empty', () => { + const vm = createMockVm({isConnected: false}); + const {getByTestId} = renderWithStore(vm, {domain: ''}); + + // Simulate clicking "Join Mesh" by getting the phase + // The clearMeshV2DomainIfEmpty should clear the extension domain + // when meshV2Domain prop is empty/null + expect(getByTestId('phase').textContent).toBe('meshV2Initial'); + }); + }); + describe('handleConnected updates connectedMessage', () => { test('updates connectedMessage from vm after PERIPHERAL_CONNECTED event', () => { const vm = createMockVm({ From 7f8dbebe7ee9cc7b68cf6b28fe4ca5be8b45ac89 Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Wed, 18 Mar 2026 01:20:35 +0900 Subject: [PATCH 3/3] fix: use userChangedDomain flag instead of resetting domain on modal open The previous approach reset Redux domain to null when the modal opened, which cleared explicitly-entered domains on reload. Instead, track whether the user changed the domain input during the current modal session. Only clear the cached domain when the user did not explicitly change it, preserving intentionally-set domains across reloads. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../src/containers/connection-modal.jsx | 21 +++++------ .../unit/containers/connection-modal.test.jsx | 36 +++++-------------- 2 files changed, 20 insertions(+), 37 deletions(-) diff --git a/packages/scratch-gui/src/containers/connection-modal.jsx b/packages/scratch-gui/src/containers/connection-modal.jsx index 0311cb5e067..76bfea68065 100644 --- a/packages/scratch-gui/src/containers/connection-modal.jsx +++ b/packages/scratch-gui/src/containers/connection-modal.jsx @@ -41,18 +41,14 @@ class ConnectionModal extends React.Component { ]); // === Smalruby: Start of meshV2 initial step feature === // For meshV2, show initial step first unless already connected - const isMeshV2 = props.extensionId === 'meshV2'; const initialPhase = props.vm.getPeripheralIsConnected(props.extensionId) ? PHASES.connected : - (isMeshV2 ? PHASES.meshV2Initial : PHASES.scanning); - // Reset domain when opening meshV2 modal (not already connected). - // This prevents stale domains cached in localStorage from silently - // overriding the auto-detection (createDomain) when the user does not - // type anything in the domain input field. - if (isMeshV2 && initialPhase === PHASES.meshV2Initial) { - props.onDomainChange(null); - } + (props.extensionId === 'meshV2' ? PHASES.meshV2Initial : PHASES.scanning); // === Smalruby: End of meshV2 initial step feature === + // Track whether the user explicitly changed the domain input. + // When false and the user clicks Create/Join, we clear the cached + // domain so createDomain() auto-detects from source IP. + this.userChangedDomain = false; this.state = { extension: extensionData.find(ext => ext.extensionId === props.extensionId), phase: initialPhase, @@ -233,14 +229,19 @@ class ConnectionModal extends React.Component { }); } clearMeshV2DomainIfEmpty () { - if (!this.props.meshV2Domain) { + // If user did not explicitly change the domain input in this modal + // session, clear any cached domain from localStorage so that + // createDomain() will auto-detect from source IP. + if (!this.userChangedDomain) { const extension = this.props.vm.runtime.peripheralExtensions.meshV2; if (extension && extension.setDomain) { extension.setDomain(null); } + this.props.onDomainChange(null); } } handleMeshV2DomainChange (domain) { + this.userChangedDomain = true; // Save domain to Redux this.props.onDomainChange(domain); diff --git a/packages/scratch-gui/test/unit/containers/connection-modal.test.jsx b/packages/scratch-gui/test/unit/containers/connection-modal.test.jsx index 985ca9830d1..01d0c7ef7db 100644 --- a/packages/scratch-gui/test/unit/containers/connection-modal.test.jsx +++ b/packages/scratch-gui/test/unit/containers/connection-modal.test.jsx @@ -140,42 +140,24 @@ describe('ConnectionModal container', () => { }); }); - describe('meshV2 domain reset on modal open', () => { - test('resets Redux domain to null when meshV2 modal opens (not connected)', () => { - const {setDomain} = require('../../../src/reducers/mesh-v2'); - setDomain.mockClear(); - + describe('meshV2 domain handling on modal open', () => { + test('shows meshV2Initial phase when not connected', () => { const vm = createMockVm({isConnected: false}); - renderWithStore(vm, {domain: 'old-cached-domain'}); + const {getByTestId} = renderWithStore(vm, {domain: 'cached-domain'}); - // Constructor should dispatch onDomainChange(null) to reset stale domain - expect(setDomain).toHaveBeenCalledWith(null); + // Should show meshV2Initial phase (domain input visible) + expect(getByTestId('phase').textContent).toBe('meshV2Initial'); }); - test('does not reset domain when meshV2 is already connected', () => { + test('preserves cached domain in Redux when modal opens', () => { const {setDomain} = require('../../../src/reducers/mesh-v2'); setDomain.mockClear(); - const vm = createMockVm({ - isConnected: true, - connectedMessage: 'Connected' - }); - renderWithStore(vm, {domain: 'active-domain'}); - - // Should NOT reset domain when already connected - expect(setDomain).not.toHaveBeenCalledWith(null); - }); - }); - - describe('clearMeshV2DomainIfEmpty on button click', () => { - test('clears extension domain when meshV2Domain is empty', () => { const vm = createMockVm({isConnected: false}); - const {getByTestId} = renderWithStore(vm, {domain: ''}); + renderWithStore(vm, {domain: 'cached-domain'}); - // Simulate clicking "Join Mesh" by getting the phase - // The clearMeshV2DomainIfEmpty should clear the extension domain - // when meshV2Domain prop is empty/null - expect(getByTestId('phase').textContent).toBe('meshV2Initial'); + // Should NOT reset domain on modal open (preserves user's previous input) + expect(setDomain).not.toHaveBeenCalledWith(null); }); });