From 0a5b9ce4cc054e2072c7312386716f2d530810cc Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 21 May 2026 13:58:09 +0900 Subject: [PATCH 1/4] fix(mobile-gui): dispatch resize event when orientation gate is dismissed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dismiss 時に window resize イベントを発火し、Blockly に toolbox / flyout の 再レイアウトを促す。これがないと、orientation gate に画面を覆われた状態で Blocks コンポーネントが初期化されたままになり、PaletteToggle が描画されない 不具合が dismiss 直後に発生する (タブ切替で復活する症状の根本原因)。 実機 portrait → landscape 回転時はブラウザが自動で resize を撃つため不要だが、 dismiss は viewport が変わらないので明示的に発火する必要がある。 Fixes #695 --- .../mobile-orientation-gate.jsx | 8 ++++++++ .../components/mobile-orientation-gate.test.jsx | 15 +++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx b/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx index ac887a9f6ab..c762cb28a05 100644 --- a/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx +++ b/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx @@ -120,6 +120,14 @@ const MobileOrientationGate = () => { // sessionStorage に書けなくても dismiss 自体は state で機能する } } + // Blockly に再レイアウトを促す。gate に覆われた状態で初期化された + // toolbox / flyout の幅計測は不正で、そのままだと Blocks コンポーネントの + // shouldComponentUpdate が dismiss を検知せず PaletteToggle が描画されない。 + // 実機の portrait → landscape 回転時はブラウザが自動で resize を撃つため + // 発生しないが、dismiss は viewport が変わらないので明示的に発火させる。 + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('resize')); + } }, []); if (typeof document === 'undefined') return null; if (!isPortrait) return null; diff --git a/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx b/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx index 7435c8d2cf7..83a64d01dae 100644 --- a/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx +++ b/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx @@ -150,4 +150,19 @@ describe('MobileOrientationGate', () => { mm.restore(); } }); + + test('dispatches a window resize event when the dismiss button is clicked', () => { + const mm = installMatchMedia(); + mm.setMatches(true); + const resizeSpy = jest.fn(); + window.addEventListener('resize', resizeSpy); + try { + const { getByTestId } = renderWithIntl(); + fireEvent.click(getByTestId('mobile-orientation-gate-dismiss')); + expect(resizeSpy).toHaveBeenCalledTimes(1); + } finally { + window.removeEventListener('resize', resizeSpy); + mm.restore(); + } + }); }); From e459e8f709fb8feee50715f18020e8f571a3ffff Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 21 May 2026 15:16:16 +0900 Subject: [PATCH 2/4] =?UTF-8?q?fix(mobile-gui):=20dispatch=20resize=20afte?= =?UTF-8?q?r=20MobileGui=E2=86=94GUI=20swap=20so=20Blockly=20re-measures?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit useIsNarrowScreen の値が変化したタイミング (= ResponsiveGui が を入れ替える瞬間) に、新しい React サブツリーで初期化される Blockly workspace へ明示的な resize イベントを撃つ。これがないと、新しい workspace が初期描画時の getBBox / getBoundingClientRect 結果のまま固まり、 PaletteToggle (◀/▶) の親要素が null 扱いになって描画されない。 ブラウザの resize イベントは swap 前に既に発火しているため、新しい workspace には届かない。useEffect で swap 後 (commit 後) に dispatch する ことでこの問題を回避する。 Refs #695 --- .../scratch-gui/src/lib/responsive-gui.jsx | 20 +++++++++- .../test/unit/lib/responsive-gui.test.jsx | 39 ++++++++++++++++++- 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/packages/scratch-gui/src/lib/responsive-gui.jsx b/packages/scratch-gui/src/lib/responsive-gui.jsx index b9200d4b8ae..890e3139753 100644 --- a/packages/scratch-gui/src/lib/responsive-gui.jsx +++ b/packages/scratch-gui/src/lib/responsive-gui.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useEffect, useRef } from 'react'; import MobileGui from '../components/mobile-gui/mobile-gui.jsx'; import GUI from '../containers/gui.jsx'; import useIsNarrowScreen from './use-is-narrow-screen.js'; @@ -6,11 +6,29 @@ import useIsNarrowScreen from './use-is-narrow-screen.js'; /** * 狭い viewport で 、そうでなければ を出し分けるラッパー。 * matchMedia でリアルタイムに切り替わるので resize / 端末回転に追従する。 + * + * `isNarrow` が変化した直後 (= MobileGui ↔ GUI を swap した直後) に明示的な + * `resize` イベントを撃つ。これは、新しい React サブツリーで初期化される + * Blockly workspace が初期描画時の getBBox / getBoundingClientRect 結果を + * もとに toolbox / flyout サイズを確定するためで、サブツリー mount 後に + * resize を流さないと PaletteToggle (◀/▶) の親要素が null 扱いになって描画 + * されない不具合が出る。ブラウザ自体の resize イベントは swap 前に発火する + * ため、新しい workspace には届かない。 * @param {object} props - / に渡す props * @returns {JSX.Element} 選択された GUI コンポーネント */ const ResponsiveGui = (props) => { const isNarrow = useIsNarrowScreen(); + const isFirstRenderRef = useRef(true); + useEffect(() => { + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + return; + } + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event('resize')); + } + }, [isNarrow]); if (isNarrow) { return ; } diff --git a/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx b/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx index 498ca85b891..6fe511514d5 100644 --- a/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx +++ b/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx @@ -1,6 +1,6 @@ /* eslint-env jest */ import '@testing-library/jest-dom'; -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; import React from 'react'; import ResponsiveGui from '../../../src/lib/responsive-gui.jsx'; @@ -51,4 +51,41 @@ describe('ResponsiveGui', () => { const { getByTestId } = render(); expect(getByTestId('mock-gui')).toHaveAttribute('data-prop', 'passed-through'); }); + + test('does not dispatch a resize event on the initial render', () => { + mockUseIsNarrowScreen.mockReturnValue(true); + const resizeSpy = jest.fn(); + window.addEventListener('resize', resizeSpy); + try { + render(); + expect(resizeSpy).not.toHaveBeenCalled(); + } finally { + window.removeEventListener('resize', resizeSpy); + } + }); + + test('dispatches a resize event when toggling between narrow and wide viewports', () => { + mockUseIsNarrowScreen.mockReturnValue(true); + const resizeSpy = jest.fn(); + window.addEventListener('resize', resizeSpy); + try { + const { rerender } = render(); + // initial mount should not trigger resize + expect(resizeSpy).not.toHaveBeenCalled(); + // switch to wide → MobileGui unmounts, GUI mounts + mockUseIsNarrowScreen.mockReturnValue(false); + act(() => { + rerender(); + }); + expect(resizeSpy).toHaveBeenCalledTimes(1); + // switch back to narrow + mockUseIsNarrowScreen.mockReturnValue(true); + act(() => { + rerender(); + }); + expect(resizeSpy).toHaveBeenCalledTimes(2); + } finally { + window.removeEventListener('resize', resizeSpy); + } + }); }); From 32af218d40ef2d37de8cb5421cb3639cdf50fdda Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 21 May 2026 23:05:12 +0900 Subject: [PATCH 3/4] fix(mobile): fix PaletteToggle disappearing after viewport orientation change `this.workspace` is an instance variable, not React state, so `inject()` in `componentDidMount` does not trigger a re-render. The first `render()` runs with `workspace=null`, skipping PaletteToggle. Calling `_applyPaletteVisibility` at the end of `componentDidMount` forces a re-render after the workspace is ready, so PaletteToggle appears on mount regardless of dismiss state or portrait/landscape cycle. The `window.resize` workarounds in `ResponsiveGui` and `MobileOrientationGate` are now redundant and removed. Fixes #695 --- .claude/rules/scratch-gui/smalruby-markers.md | 1 + .../mobile-orientation-gate.jsx | 8 ---- .../scratch-gui/src/containers/blocks.jsx | 10 +++++ .../scratch-gui/src/lib/responsive-gui.jsx | 20 +--------- .../mobile-orientation-gate.test.jsx | 14 ------- .../test/unit/lib/responsive-gui.test.jsx | 38 +------------------ 6 files changed, 13 insertions(+), 78 deletions(-) diff --git a/.claude/rules/scratch-gui/smalruby-markers.md b/.claude/rules/scratch-gui/smalruby-markers.md index 5bca4dde33c..b5656ee3051 100644 --- a/.claude/rules/scratch-gui/smalruby-markers.md +++ b/.claude/rules/scratch-gui/smalruby-markers.md @@ -97,6 +97,7 @@ upstream ファイルに追加した Smalruby 固有コードのマーカー一 | `src/containers/connection-modal.jsx` | mesh_v2/smalrubot_s1 disconnect analytics | 切断時に拡張別カテゴリで GA4 イベントを発火 (issue #645 Phase 1) | | `src/lib/calculatePopupPosition.js` | viewport-aware popup flip | LEFT/RIGHT 配置で配置側にポップアップが収まらない場合、反対側にフリップする (issue #671: SP モードのスプライト削除確認ポップアップが画面外で押せない問題への対策) | | `src/containers/menu.jsx` | iPad menu item click fix | メニュー項目クリック時の `setTimeout` 遅延を 0 → 100ms に拡大。iPadOS Safari は `pointerup` から `click` 発火まで ~16–32ms 程度のラグがあり、setTimeout(0) で close すると `
  • ` が click 発火前に unmount され React onClick が skip される問題への対応 | +| `src/containers/blocks.jsx` | palette-toggle initial render | `componentDidMount` 末尾で `_applyPaletteVisibility` を呼び `forceUpdate()` を起動。`this.workspace` はインスタンス変数なので `inject()` 後に再レンダーが走らず、初回 `render()` で workspace=null のまま PaletteToggle がスキップされる問題への対応 (issue #695) | ## 関連ファイル diff --git a/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx b/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx index c762cb28a05..ac887a9f6ab 100644 --- a/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx +++ b/packages/scratch-gui/src/components/mobile-orientation-gate/mobile-orientation-gate.jsx @@ -120,14 +120,6 @@ const MobileOrientationGate = () => { // sessionStorage に書けなくても dismiss 自体は state で機能する } } - // Blockly に再レイアウトを促す。gate に覆われた状態で初期化された - // toolbox / flyout の幅計測は不正で、そのままだと Blocks コンポーネントの - // shouldComponentUpdate が dismiss を検知せず PaletteToggle が描画されない。 - // 実機の portrait → landscape 回転時はブラウザが自動で resize を撃つため - // 発生しないが、dismiss は viewport が変わらないので明示的に発火させる。 - if (typeof window !== 'undefined') { - window.dispatchEvent(new Event('resize')); - } }, []); if (typeof document === 'undefined') return null; if (!isPortrait) return null; diff --git a/packages/scratch-gui/src/containers/blocks.jsx b/packages/scratch-gui/src/containers/blocks.jsx index 3cb4fe3a727..ba23ebdd68a 100644 --- a/packages/scratch-gui/src/containers/blocks.jsx +++ b/packages/scratch-gui/src/containers/blocks.jsx @@ -215,6 +215,16 @@ class Blocks extends React.Component { this.handleCategorySelected('faceSensing'); }); }); + + // === Smalruby: Start of palette-toggle initial render === + // this.workspace is an instance variable, not React state, so inject() + // above does not trigger a re-render. The first render() ran before + // componentDidMount with workspace=null, skipping PaletteToggle. + // Call _applyPaletteVisibility here to forceUpdate() after workspace is + // ready, so PaletteToggle appears immediately on mount (e.g. after the + // ResponsiveGui swaps MobileGui ↔ GUI on viewport orientation change). + this._applyPaletteVisibility(this.props.paletteVisible); + // === Smalruby: End of palette-toggle initial render === } shouldComponentUpdate (nextProps, nextState) { return ( diff --git a/packages/scratch-gui/src/lib/responsive-gui.jsx b/packages/scratch-gui/src/lib/responsive-gui.jsx index 890e3139753..b9200d4b8ae 100644 --- a/packages/scratch-gui/src/lib/responsive-gui.jsx +++ b/packages/scratch-gui/src/lib/responsive-gui.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef } from 'react'; +import React from 'react'; import MobileGui from '../components/mobile-gui/mobile-gui.jsx'; import GUI from '../containers/gui.jsx'; import useIsNarrowScreen from './use-is-narrow-screen.js'; @@ -6,29 +6,11 @@ import useIsNarrowScreen from './use-is-narrow-screen.js'; /** * 狭い viewport で 、そうでなければ を出し分けるラッパー。 * matchMedia でリアルタイムに切り替わるので resize / 端末回転に追従する。 - * - * `isNarrow` が変化した直後 (= MobileGui ↔ GUI を swap した直後) に明示的な - * `resize` イベントを撃つ。これは、新しい React サブツリーで初期化される - * Blockly workspace が初期描画時の getBBox / getBoundingClientRect 結果を - * もとに toolbox / flyout サイズを確定するためで、サブツリー mount 後に - * resize を流さないと PaletteToggle (◀/▶) の親要素が null 扱いになって描画 - * されない不具合が出る。ブラウザ自体の resize イベントは swap 前に発火する - * ため、新しい workspace には届かない。 * @param {object} props - / に渡す props * @returns {JSX.Element} 選択された GUI コンポーネント */ const ResponsiveGui = (props) => { const isNarrow = useIsNarrowScreen(); - const isFirstRenderRef = useRef(true); - useEffect(() => { - if (isFirstRenderRef.current) { - isFirstRenderRef.current = false; - return; - } - if (typeof window !== 'undefined') { - window.dispatchEvent(new Event('resize')); - } - }, [isNarrow]); if (isNarrow) { return ; } diff --git a/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx b/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx index 83a64d01dae..af79ce99b24 100644 --- a/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx +++ b/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx @@ -151,18 +151,4 @@ describe('MobileOrientationGate', () => { } }); - test('dispatches a window resize event when the dismiss button is clicked', () => { - const mm = installMatchMedia(); - mm.setMatches(true); - const resizeSpy = jest.fn(); - window.addEventListener('resize', resizeSpy); - try { - const { getByTestId } = renderWithIntl(); - fireEvent.click(getByTestId('mobile-orientation-gate-dismiss')); - expect(resizeSpy).toHaveBeenCalledTimes(1); - } finally { - window.removeEventListener('resize', resizeSpy); - mm.restore(); - } - }); }); diff --git a/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx b/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx index 6fe511514d5..f2b7268fecb 100644 --- a/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx +++ b/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx @@ -1,6 +1,6 @@ /* eslint-env jest */ import '@testing-library/jest-dom'; -import { act, render } from '@testing-library/react'; +import { render } from '@testing-library/react'; import React from 'react'; import ResponsiveGui from '../../../src/lib/responsive-gui.jsx'; @@ -52,40 +52,4 @@ describe('ResponsiveGui', () => { expect(getByTestId('mock-gui')).toHaveAttribute('data-prop', 'passed-through'); }); - test('does not dispatch a resize event on the initial render', () => { - mockUseIsNarrowScreen.mockReturnValue(true); - const resizeSpy = jest.fn(); - window.addEventListener('resize', resizeSpy); - try { - render(); - expect(resizeSpy).not.toHaveBeenCalled(); - } finally { - window.removeEventListener('resize', resizeSpy); - } - }); - - test('dispatches a resize event when toggling between narrow and wide viewports', () => { - mockUseIsNarrowScreen.mockReturnValue(true); - const resizeSpy = jest.fn(); - window.addEventListener('resize', resizeSpy); - try { - const { rerender } = render(); - // initial mount should not trigger resize - expect(resizeSpy).not.toHaveBeenCalled(); - // switch to wide → MobileGui unmounts, GUI mounts - mockUseIsNarrowScreen.mockReturnValue(false); - act(() => { - rerender(); - }); - expect(resizeSpy).toHaveBeenCalledTimes(1); - // switch back to narrow - mockUseIsNarrowScreen.mockReturnValue(true); - act(() => { - rerender(); - }); - expect(resizeSpy).toHaveBeenCalledTimes(2); - } finally { - window.removeEventListener('resize', resizeSpy); - } - }); }); From 3429ffe4c88b46034e6b851d4b509d8b3733d6ae Mon Sep 17 00:00:00 2001 From: Kouji Takao Date: Thu, 21 May 2026 23:13:38 +0900 Subject: [PATCH 4/4] style: fix Prettier formatting in test files after resize cleanup --- .../test/unit/components/mobile-orientation-gate.test.jsx | 1 - packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx b/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx index af79ce99b24..7435c8d2cf7 100644 --- a/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx +++ b/packages/scratch-gui/test/unit/components/mobile-orientation-gate.test.jsx @@ -150,5 +150,4 @@ describe('MobileOrientationGate', () => { mm.restore(); } }); - }); diff --git a/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx b/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx index f2b7268fecb..498ca85b891 100644 --- a/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx +++ b/packages/scratch-gui/test/unit/lib/responsive-gui.test.jsx @@ -51,5 +51,4 @@ describe('ResponsiveGui', () => { const { getByTestId } = render(); expect(getByTestId('mock-gui')).toHaveAttribute('data-prop', 'passed-through'); }); - });