From 48370e61dc1155468467771f0095c9ec33a49040 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 17:39:20 +0200 Subject: [PATCH 1/9] fix: fire run completed once per run Run completed was dispatched whenever error, errorDetails, or friendlyError changed after a run had already ended, so a single Python run could produce multiple Project - Run completed events. Dispatch completion only on the codeRunTriggered true-to-false transition, matching how run started is tracked. --- .../WebComponentProject.jsx | 11 +- .../WebComponentProject.test.js | 194 +++++++++++++----- 2 files changed, 150 insertions(+), 55 deletions(-) diff --git a/src/components/WebComponentProject/WebComponentProject.jsx b/src/components/WebComponentProject/WebComponentProject.jsx index dcebad3ce..856b50008 100644 --- a/src/components/WebComponentProject/WebComponentProject.jsx +++ b/src/components/WebComponentProject/WebComponentProject.jsx @@ -122,7 +122,9 @@ const WebComponentProject = ({ }, [dispatch, projectInstructions, permitInstructionsOverride]); useEffect(() => { - if (codeRunTriggered && !prevCodeRunTriggeredRef.current) { + const wasTriggered = prevCodeRunTriggeredRef.current; + + if (codeRunTriggered && !wasTriggered) { document.dispatchEvent( runStartedEvent({ step: currentStepPosition, @@ -132,11 +134,8 @@ const WebComponentProject = ({ ); setCodeHasRun(true); } - prevCodeRunTriggeredRef.current = codeRunTriggered; - }, [codeRunTriggered, currentStepPosition, projectIdentifier, projectType]); - useEffect(() => { - if (!codeRunTriggered && codeHasRun) { + if (!codeRunTriggered && wasTriggered && codeHasRun) { const mz_criteria = Sk.sense_hat ? Sk.sense_hat.mz_criteria : { ...defaultMZCriteria }; @@ -160,6 +159,8 @@ const WebComponentProject = ({ document.dispatchEvent(runCompletedEvent(payload)); } + + prevCodeRunTriggeredRef.current = codeRunTriggered; }, [ codeRunTriggered, codeHasRun, diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index 3f8f8132d..5b1efd5c6 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -246,12 +246,65 @@ describe("When overriding instructions is not permitted", () => { }); describe("When code run finishes", () => { - test("Triggers runCompletedEvent", () => { - renderWebComponentProject({ - projectType: "python", + const renderRunCompletion = (editorOverrides = {}, props = {}) => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const baseEditor = { + project: { + identifier: "test-project", + project_type: "python", + components: [ + { name: "main", extension: "py", content: "print('hello')" }, + ], + image_list: [], + }, + loading: "success", + openFiles: [], + focussedFileIndices: [], + codeRunTriggered: true, codeHasBeenRun: true, + error: "", + errorDetails: undefined, + friendlyError: undefined, + ...editorOverrides, + }; + const initialState = { + editor: baseEditor, + instructions: { + currentStepPosition: 3, + permitOverride: true, + }, + auth: {}, + }; + store = mockStore(initialState); + + const view = render( + + + , + ); + + store = mockStore({ + ...initialState, + editor: { ...baseEditor, codeRunTriggered: false }, }); - expect(runCompletedHandler).toHaveBeenCalled(); + + view.rerender( + + + , + ); + + return view; + }; + + beforeEach(() => { + runCompletedHandler.mockClear(); + }); + + test("Triggers runCompletedEvent", () => { + renderRunCompletion(); + expect(runCompletedHandler).toHaveBeenCalledTimes(1); expect(runCompletedHandler.mock.lastCall[0].detail).toEqual( expect.objectContaining({ isErrorFree: true, @@ -265,49 +318,20 @@ describe("When code run finishes", () => { }); test("includes error details and friendly error state when a run failed", () => { - const middlewares = []; - const mockStore = configureStore(middlewares); - const initialState = { - editor: { - project: { - identifier: "test-project", - project_type: "python", - components: [ - { name: "main", extension: "py", content: "print('hello')" }, - ], - image_list: [], - }, - loading: "success", - openFiles: [], - focussedFileIndices: [], - codeRunTriggered: false, - codeHasBeenRun: true, - error: "NameError: name 'kettle' is not defined on line 5 of main.py", - errorDetails: { - type: "NameError", - file: "main.py", - line: 5, - description: "name 'kettle' is not defined", - }, - friendlyError: { - html: "

Friendly error

", - }, + renderRunCompletion({ + error: "NameError: name 'kettle' is not defined on line 5 of main.py", + errorDetails: { + type: "NameError", + file: "main.py", + line: 5, + description: "name 'kettle' is not defined", }, - instructions: { - currentStepPosition: 3, - permitOverride: true, + friendlyError: { + html: "

Friendly error

", }, - auth: {}, - }; - store = mockStore(initialState); - - render( - - - , - ); + }); - expect(runCompletedHandler).toHaveBeenCalled(); + expect(runCompletedHandler).toHaveBeenCalledTimes(1); expect(runCompletedHandler.mock.lastCall[0].detail).toEqual( expect.objectContaining({ isErrorFree: false, @@ -325,12 +349,8 @@ describe("When code run finishes", () => { }); test("Triggers runCompletedEvent with error details when outputOnly is true", () => { - renderWebComponentProject({ - projectType: "python", - codeHasBeenRun: true, - props: { outputOnly: true }, - }); - expect(runCompletedHandler).toHaveBeenCalled(); + renderRunCompletion({}, { outputOnly: true }); + expect(runCompletedHandler).toHaveBeenCalledTimes(1); expect(runCompletedHandler.mock.lastCall[0].detail).toEqual( expect.objectContaining({ errorDetails: undefined, @@ -340,6 +360,80 @@ describe("When code run finishes", () => { }), ); }); + + test("does not trigger runCompletedEvent again when error state updates after the run ends", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const editor = { + project: { + identifier: "test-project", + project_type: "python", + components: [ + { name: "main", extension: "py", content: "print('hello')" }, + ], + image_list: [], + }, + loading: "success", + openFiles: [], + focussedFileIndices: [], + codeRunTriggered: true, + codeHasBeenRun: true, + error: "", + errorDetails: undefined, + friendlyError: undefined, + }; + const instructions = { + currentStepPosition: 3, + permitOverride: true, + }; + + store = mockStore({ editor, instructions, auth: {} }); + + const view = render( + + + , + ); + + store = mockStore({ + editor: { ...editor, codeRunTriggered: false }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runCompletedHandler).toHaveBeenCalledTimes(1); + + store = mockStore({ + editor: { + ...editor, + codeRunTriggered: false, + error: "NameError: name 'kettle' is not defined on line 5 of main.py", + errorDetails: { + type: "NameError", + file: "main.py", + line: 5, + description: "name 'kettle' is not defined", + }, + friendlyError: { + html: "

Friendly error

", + }, + }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runCompletedHandler).toHaveBeenCalledTimes(1); + }); }); describe("When withSidebar is true", () => { From 2e2a1848db37850009fb7179dcac1397dd75965a Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 17:57:12 +0200 Subject: [PATCH 2/9] fix: clean up HtmlRunner iframe message listeners Preview iframe messages were handled by anonymous window listeners that were never removed on unmount, and showModal registered more listeners on each external-link error. Remounts could stack handlers so one RELOAD message triggered multiple code runs and duplicate Ran code events. --- .../Editor/Runners/HtmlRunner/HtmlRunner.jsx | 80 +++++++++---------- .../Runners/HtmlRunner/HtmlRunner.test.js | 73 +++++++++++++++++ 2 files changed, 112 insertions(+), 41 deletions(-) diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx index 6cee298da..ed4c541bf 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.jsx @@ -88,10 +88,9 @@ function HtmlRunner() { const [runningFile, setRunningFile] = useState(previewFile); const previewFileRef = useRef(previewFile); - const showModal = () => { + const showModal = useCallback(() => { dispatch(showErrorModal()); - eventListener(); - }; + }, [dispatch]); const { externalLink, @@ -100,34 +99,48 @@ function HtmlRunner() { handleExternalLinkError, } = useExternalLinkState(showModal); - const eventListener = () => { - window.addEventListener("message", (event) => { - if (event.data?.type === MSG_HTML_PREVIEW_EVENT) { - if (event.data?.msg === "ERROR: External link") { - handleExternalLinkError(showModal); - } else if (event.data?.msg === "Allowed external link") { - handleAllowedExternalLink(event.data.payload.linkTo); - } else if (event.data?.msg === "RELOAD") { - const nextPreviewFile = `${event.data.payload.linkTo}.html`; - setExternalLink(null); - - if (nextPreviewFile === previewFileRef.current) { - reloadAfterPreviewChange.current = false; - dispatch(triggerCodeRun()); - } else { - reloadAfterPreviewChange.current = true; - setPreviewFile(nextPreviewFile); - } + const handleAllowedExternalLinkRef = useRef(handleAllowedExternalLink); + const handleExternalLinkErrorRef = useRef(handleExternalLinkError); + handleAllowedExternalLinkRef.current = handleAllowedExternalLink; + handleExternalLinkErrorRef.current = handleExternalLinkError; + + useEffect(() => { + const handleIframeMessage = (event) => { + const { data } = event; + + if (data?.type === MSG_HTML_PREVIEW_READY) { + setRendererReady(true); + return; + } + + if (data?.type !== MSG_HTML_PREVIEW_EVENT) { + return; + } + + if (data.msg === "ERROR: External link") { + handleExternalLinkErrorRef.current(); + } else if (data.msg === "Allowed external link") { + handleAllowedExternalLinkRef.current(data.payload.linkTo); + } else if (data.msg === "RELOAD") { + const nextPreviewFile = `${data.payload.linkTo}.html`; + setExternalLink(null); + + if (nextPreviewFile === previewFileRef.current) { + reloadAfterPreviewChange.current = false; + dispatch(triggerCodeRun()); + } else { + reloadAfterPreviewChange.current = true; + setPreviewFile(nextPreviewFile); } } - }); - }; + }; - useEffect(() => { - eventListener(); + window.addEventListener("message", handleIframeMessage); dispatch(loadingRunner("html")); dispatch(setLoadedRunner("html")); - }, []); + + return () => window.removeEventListener("message", handleIframeMessage); + }, [dispatch, setExternalLink]); let timeout; @@ -172,21 +185,6 @@ function HtmlRunner() { } }, [runningFile]); - useEffect(() => { - window.addEventListener("message", listener); - return () => window.removeEventListener("message", listener); - }); - - const listener = useCallback( - (event) => { - const message = event.data; - if (message?.type === MSG_HTML_PREVIEW_READY) { - setRendererReady(true); - } - }, - [setRendererReady], - ); - const runCode = () => { setRunningFile(previewFile); diff --git a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js index 1da74758b..f5aec0e94 100644 --- a/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js +++ b/src/components/Editor/Runners/HtmlRunner/HtmlRunner.test.js @@ -16,6 +16,7 @@ import { MemoryRouter } from "react-router-dom"; import { matchMedia, setMedia } from "mock-match-media"; import { MOBILE_BREAKPOINT } from "../../../../utils/mediaQueryBreakpoints"; import { + MSG_HTML_PREVIEW_EVENT, MSG_HTML_PREVIEW_READY, MSG_HTML_PROJECT_UPDATE, } from "../../../../utils/iframeUtils"; @@ -444,3 +445,75 @@ describe("When run is triggered", () => { }); }); }); + +describe("iframe message listener", () => { + const htmlRunnerState = { + project: { + components: [indexPage], + }, + focussedFileIndices: [0], + openFiles: [["index.html"]], + codeHasBeenRun: true, + errorModalShowing: false, + }; + + const renderHtmlRunner = (store) => + render( + + +
+ +
+
+
, + ); + + test("does not handle preview messages after unmount", () => { + const mockStore = configureStore([]); + const store = mockStore({ editor: htmlRunnerState }); + const { unmount } = renderHtmlRunner(store); + const actionsAfterMount = store.getActions().length; + + unmount(); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: MSG_HTML_PREVIEW_EVENT, + msg: "RELOAD", + payload: { linkTo: "index" }, + }, + }), + ); + }); + + expect(store.getActions().length).toBe(actionsAfterMount); + }); + + test("dispatches triggerCodeRun once per RELOAD message after remount", () => { + const mockStore = configureStore([]); + const store = mockStore({ editor: htmlRunnerState }); + const first = renderHtmlRunner(store); + first.unmount(); + + renderHtmlRunner(store); + + act(() => { + window.dispatchEvent( + new MessageEvent("message", { + data: { + type: MSG_HTML_PREVIEW_EVENT, + msg: "RELOAD", + payload: { linkTo: "index" }, + }, + }), + ); + }); + + const runActions = store + .getActions() + .filter((action) => action.type === triggerCodeRun().type); + expect(runActions).toHaveLength(1); + }); +}); From fd4c06c4340b174bb57553488e8d2484458ad619 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 18:22:37 +0200 Subject: [PATCH 3/9] fix: avoid duplicate run started events on remount WebComponentProject tracked the previous codeRunTriggered value in a ref that reset when the component remounted while a run was still active, causing a second Project - Ran code event. Persist run edge tracking in module state, reset it when the web component disconnects or the project identifier changes. --- .../WebComponentProject.jsx | 18 ++++-- .../WebComponentProject.test.js | 60 ++++++++++++++++++- .../runEventTrackingState.js | 27 +++++++++ .../runEventTrackingState.test.js | 36 +++++++++++ src/web-component.js | 2 + 5 files changed, 138 insertions(+), 5 deletions(-) create mode 100644 src/components/WebComponentProject/runEventTrackingState.js create mode 100644 src/components/WebComponentProject/runEventTrackingState.test.js diff --git a/src/components/WebComponentProject/WebComponentProject.jsx b/src/components/WebComponentProject/WebComponentProject.jsx index 856b50008..daeee6f1d 100644 --- a/src/components/WebComponentProject/WebComponentProject.jsx +++ b/src/components/WebComponentProject/WebComponentProject.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useRef, useState } from "react"; +import React, { useEffect, useState } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useMediaQuery } from "react-responsive"; import { marked } from "marked"; @@ -25,6 +25,13 @@ import { runStartedEvent, stepChangedEvent, } from "../../events/WebComponentCustomEvents"; +import { + getPrevCodeRunTriggered, + setPrevCodeRunTriggered, + syncRunEventTrackingProject, +} from "./runEventTrackingState"; + +export { resetCodeRunEventTracking } from "./runEventTrackingState"; const WebComponentProject = ({ withProjectbar = false, @@ -65,7 +72,6 @@ const WebComponentProject = ({ ); const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); const [codeHasRun, setCodeHasRun] = useState(codeHasBeenRun); - const prevCodeRunTriggeredRef = useRef(false); const dispatch = useDispatch(); const renderer = new marked.Renderer(); @@ -122,7 +128,11 @@ const WebComponentProject = ({ }, [dispatch, projectInstructions, permitInstructionsOverride]); useEffect(() => { - const wasTriggered = prevCodeRunTriggeredRef.current; + syncRunEventTrackingProject(projectIdentifier, codeRunTriggered); + }, [projectIdentifier, codeRunTriggered]); + + useEffect(() => { + const wasTriggered = getPrevCodeRunTriggered(); if (codeRunTriggered && !wasTriggered) { document.dispatchEvent( @@ -160,7 +170,7 @@ const WebComponentProject = ({ document.dispatchEvent(runCompletedEvent(payload)); } - prevCodeRunTriggeredRef.current = codeRunTriggered; + setPrevCodeRunTriggered(codeRunTriggered); }, [ codeRunTriggered, codeHasRun, diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index 5b1efd5c6..ae032616d 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -2,7 +2,9 @@ import React from "react"; import { act, render, screen } from "@testing-library/react"; import { Provider } from "react-redux"; import configureStore from "redux-mock-store"; -import WebComponentProject from "./WebComponentProject"; +import WebComponentProject, { + resetCodeRunEventTracking, +} from "./WebComponentProject"; const codeChangedHandler = jest.fn(); const runStartedHandler = jest.fn(); @@ -18,6 +20,10 @@ beforeAll(() => { jest.useFakeTimers(); +beforeEach(() => { + resetCodeRunEventTracking(); +}); + let store; const renderWebComponentProject = ({ @@ -436,6 +442,58 @@ describe("When code run finishes", () => { }); }); +describe("When remounted during a run", () => { + beforeEach(() => { + runStartedHandler.mockClear(); + }); + + test("does not trigger runStarted again", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const initialState = { + editor: { + project: { + identifier: "test-project", + project_type: "python", + components: [ + { name: "main", extension: "py", content: "print('hello')" }, + ], + image_list: [], + }, + loading: "success", + openFiles: [], + focussedFileIndices: [], + codeRunTriggered: true, + codeHasBeenRun: true, + }, + instructions: { + currentStepPosition: 3, + permitOverride: true, + }, + auth: {}, + }; + const store = mockStore(initialState); + + const view = render( + + + , + ); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + + view.unmount(); + + render( + + + , + ); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + }); +}); + describe("When withSidebar is true", () => { beforeEach(() => { renderWebComponentProject({ diff --git a/src/components/WebComponentProject/runEventTrackingState.js b/src/components/WebComponentProject/runEventTrackingState.js new file mode 100644 index 000000000..2ec6a0c32 --- /dev/null +++ b/src/components/WebComponentProject/runEventTrackingState.js @@ -0,0 +1,27 @@ +let prevCodeRunTriggered = false; +let trackedProjectIdentifier = null; + +export const getPrevCodeRunTriggered = () => prevCodeRunTriggered; + +export const setPrevCodeRunTriggered = (value) => { + prevCodeRunTriggered = value; +}; + +export const resetCodeRunEventTracking = () => { + prevCodeRunTriggered = false; + trackedProjectIdentifier = null; +}; + +export const syncRunEventTrackingProject = ( + projectIdentifier, + codeRunTriggered, +) => { + if ( + trackedProjectIdentifier !== null && + trackedProjectIdentifier !== projectIdentifier + ) { + prevCodeRunTriggered = codeRunTriggered; + } + + trackedProjectIdentifier = projectIdentifier ?? null; +}; diff --git a/src/components/WebComponentProject/runEventTrackingState.test.js b/src/components/WebComponentProject/runEventTrackingState.test.js new file mode 100644 index 000000000..c18bca2a4 --- /dev/null +++ b/src/components/WebComponentProject/runEventTrackingState.test.js @@ -0,0 +1,36 @@ +import { + getPrevCodeRunTriggered, + resetCodeRunEventTracking, + setPrevCodeRunTriggered, + syncRunEventTrackingProject, +} from "./runEventTrackingState"; + +describe("runEventTrackingState", () => { + beforeEach(() => { + resetCodeRunEventTracking(); + }); + + test("tracks the previous codeRunTriggered value across calls", () => { + expect(getPrevCodeRunTriggered()).toBe(false); + setPrevCodeRunTriggered(true); + expect(getPrevCodeRunTriggered()).toBe(true); + }); + + test("resets tracking state", () => { + setPrevCodeRunTriggered(true); + syncRunEventTrackingProject("project-a", true); + + resetCodeRunEventTracking(); + + expect(getPrevCodeRunTriggered()).toBe(false); + }); + + test("resets prev state when the project identifier changes", () => { + syncRunEventTrackingProject("project-a", true); + setPrevCodeRunTriggered(true); + + syncRunEventTrackingProject("project-b", false); + + expect(getPrevCodeRunTriggered()).toBe(false); + }); +}); diff --git a/src/web-component.js b/src/web-component.js index 537a4d267..aa9e90281 100644 --- a/src/web-component.js +++ b/src/web-component.js @@ -10,6 +10,7 @@ import camelCase from "camelcase"; import { stopCodeRun, stopDraw, triggerCodeRun } from "./redux/EditorSlice"; import { BrowserRouter } from "react-router-dom"; import { resetStore } from "./redux/RootSlice"; +import { resetCodeRunEventTracking } from "./components/WebComponentProject/runEventTrackingState"; import dedupeDesignSystemWarnings from "./utils/dedupeDesignSystemWarnings"; import { setUser } from "./redux/WebComponentAuthSlice"; import { projectHasChangedSinceInitialLoad } from "./utils/projectHelpers"; @@ -45,6 +46,7 @@ class WebComponent extends HTMLElement { console.log("Unmounted web-component..."); this.root.unmount(); } + resetCodeRunEventTracking(); store.dispatch(resetStore()); } From bd6a36ad9e6110cad583a8f911b6fb5e43334d9e Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 19:58:52 +0200 Subject: [PATCH 4/9] feat: gate run analytics on code changes with read-only bypass Only dispatch run analytics events (editor-runStarted / editor-runCompleted) when a project's code differs from the last counted run, so repeated runs of unchanged code no longer generate noise. - Add buildProjectCodeSnapshot to produce a deterministic, order-independent snapshot string from a project's components. - Add runEventCodeSnapshot to gate each run cycle: suppress runStarted when the snapshot is unchanged, reset on project change, and pair runCompleted to the counted start via a per-cycle flag. - Reset snapshot state alongside existing run-event tracking on web-component disconnect so remounts stay safe. - Bypass the snapshot check in read-only mode (teachers viewing student work) so deliberate re-runs of the same code are still tracked. --- .../WebComponentProject.jsx | 84 +++++--- .../WebComponentProject.test.js | 200 ++++++++++++++++++ .../buildProjectCodeSnapshot.js | 13 ++ .../buildProjectCodeSnapshot.test.js | 29 +++ .../runEventCodeSnapshot.js | 44 ++++ .../runEventCodeSnapshot.test.js | 59 ++++++ .../runEventTrackingState.js | 3 + 7 files changed, 398 insertions(+), 34 deletions(-) create mode 100644 src/components/WebComponentProject/buildProjectCodeSnapshot.js create mode 100644 src/components/WebComponentProject/buildProjectCodeSnapshot.test.js create mode 100644 src/components/WebComponentProject/runEventCodeSnapshot.js create mode 100644 src/components/WebComponentProject/runEventCodeSnapshot.test.js diff --git a/src/components/WebComponentProject/WebComponentProject.jsx b/src/components/WebComponentProject/WebComponentProject.jsx index daeee6f1d..29a47c58d 100644 --- a/src/components/WebComponentProject/WebComponentProject.jsx +++ b/src/components/WebComponentProject/WebComponentProject.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from "react"; +import React, { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useMediaQuery } from "react-responsive"; import { marked } from "marked"; @@ -25,6 +25,11 @@ import { runStartedEvent, stepChangedEvent, } from "../../events/WebComponentCustomEvents"; +import { + beginRunEventCycle, + endRunEventCycle, + shouldEmitRunCompletedEvent, +} from "./runEventCodeSnapshot"; import { getPrevCodeRunTriggered, setPrevCodeRunTriggered, @@ -60,7 +65,10 @@ const WebComponentProject = ({ const error = useSelector((state) => state.editor.error); const errorDetails = useSelector((state) => state.editor.errorDetails); const friendlyError = useSelector((state) => state.editor.friendlyError); - const codeHasBeenRun = useSelector((state) => state.editor.codeHasBeenRun); + const projectComponents = useSelector( + (state) => state.editor.project.components, + ); + const readOnly = useSelector((state) => state.editor.readOnly); const projectInstructions = useSelector( (state) => state.editor.project.instructions, ); @@ -71,7 +79,6 @@ const WebComponentProject = ({ (state) => state.instructions.permitOverride, ); const isMobile = useMediaQuery({ query: MOBILE_MEDIA_QUERY }); - const [codeHasRun, setCodeHasRun] = useState(codeHasBeenRun); const dispatch = useDispatch(); const renderer = new marked.Renderer(); @@ -83,7 +90,6 @@ const WebComponentProject = ({ }, [editableInstructions, outputSplitView, outputOnly, dispatch]); useEffect(() => { - setCodeHasRun(false); const timeout = setTimeout(() => { document.dispatchEvent(codeChangedEvent({ step: currentStepPosition })); }, 2000); @@ -135,45 +141,53 @@ const WebComponentProject = ({ const wasTriggered = getPrevCodeRunTriggered(); if (codeRunTriggered && !wasTriggered) { - document.dispatchEvent( - runStartedEvent({ - step: currentStepPosition, - projectIdentifier, - projectType, - }), - ); - setCodeHasRun(true); - } - - if (!codeRunTriggered && wasTriggered && codeHasRun) { - const mz_criteria = Sk.sense_hat - ? Sk.sense_hat.mz_criteria - : { ...defaultMZCriteria }; - - const payload = outputOnly - ? { - errorDetails, + if ( + beginRunEventCycle(projectIdentifier, projectComponents, { + bypassSnapshot: readOnly, + }) + ) { + document.dispatchEvent( + runStartedEvent({ step: currentStepPosition, projectIdentifier, projectType, - } - : { - isErrorFree: error === "", - step: currentStepPosition, - errorDetails, - friendlyErrorShown: Boolean(friendlyError?.html), - projectIdentifier, - projectType, - ...mz_criteria, - }; + }), + ); + } + } - document.dispatchEvent(runCompletedEvent(payload)); + if (!codeRunTriggered && wasTriggered) { + if (shouldEmitRunCompletedEvent()) { + const mz_criteria = Sk.sense_hat + ? Sk.sense_hat.mz_criteria + : { ...defaultMZCriteria }; + + const payload = outputOnly + ? { + errorDetails, + step: currentStepPosition, + projectIdentifier, + projectType, + } + : { + isErrorFree: error === "", + step: currentStepPosition, + errorDetails, + friendlyErrorShown: Boolean(friendlyError?.html), + projectIdentifier, + projectType, + ...mz_criteria, + }; + + document.dispatchEvent(runCompletedEvent(payload)); + } + + endRunEventCycle(); } setPrevCodeRunTriggered(codeRunTriggered); }, [ codeRunTriggered, - codeHasRun, outputOnly, error, errorDetails, @@ -181,6 +195,8 @@ const WebComponentProject = ({ currentStepPosition, projectIdentifier, projectType, + readOnly, + projectComponents, ]); useEffect(() => { diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index ae032616d..d514816f9 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -442,6 +442,206 @@ describe("When code run finishes", () => { }); }); +describe("When code is unchanged between runs", () => { + const middlewares = []; + const mockStoreFactory = configureStore(middlewares); + + const baseEditor = { + project: { + identifier: "test-project", + project_type: "python", + components: [ + { name: "main", extension: "py", content: "print('hello')" }, + ], + image_list: [], + }, + loading: "success", + openFiles: [], + focussedFileIndices: [], + error: "", + errorDetails: undefined, + friendlyError: undefined, + }; + + const renderRunCycle = (editorOverrides = {}) => { + const editor = { ...baseEditor, ...editorOverrides }; + const instructions = { + currentStepPosition: 3, + permitOverride: true, + }; + + store = mockStoreFactory({ editor, instructions, auth: {} }); + + const view = render( + + + , + ); + + return { view, editor, instructions }; + }; + + beforeEach(() => { + runStartedHandler.mockClear(); + runCompletedHandler.mockClear(); + }); + + test("does not emit run events on repeated runs with the same code", () => { + const { view, editor, instructions } = renderRunCycle({ + codeRunTriggered: true, + }); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + + store = mockStoreFactory({ + editor: { ...editor, codeRunTriggered: false, codeHasBeenRun: true }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runCompletedHandler).toHaveBeenCalledTimes(1); + + store = mockStoreFactory({ + editor: { ...editor, codeRunTriggered: true, codeHasBeenRun: true }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + + store = mockStoreFactory({ + editor: { ...editor, codeRunTriggered: false }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runCompletedHandler).toHaveBeenCalledTimes(1); + }); + + test("emits run events again after code changes", () => { + const { view, editor, instructions } = renderRunCycle({ + codeRunTriggered: true, + }); + + store = mockStoreFactory({ + editor: { ...editor, codeRunTriggered: false, codeHasBeenRun: true }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + const updatedEditor = { + ...editor, + project: { + ...editor.project, + components: [ + { name: "main", extension: "py", content: "print('world')" }, + ], + }, + codeRunTriggered: true, + codeHasBeenRun: true, + }; + + store = mockStoreFactory({ + editor: updatedEditor, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runStartedHandler).toHaveBeenCalledTimes(2); + + store = mockStoreFactory({ + editor: { ...updatedEditor, codeRunTriggered: false }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runCompletedHandler).toHaveBeenCalledTimes(2); + }); + + test("emits run events on repeated runs when read only", () => { + const { view, editor, instructions } = renderRunCycle({ + codeRunTriggered: true, + readOnly: true, + }); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + + store = mockStoreFactory({ + editor: { ...editor, codeRunTriggered: false, codeHasBeenRun: true }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runCompletedHandler).toHaveBeenCalledTimes(1); + + store = mockStoreFactory({ + editor: { + ...editor, + codeRunTriggered: true, + codeHasBeenRun: true, + readOnly: true, + }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runStartedHandler).toHaveBeenCalledTimes(2); + + store = mockStoreFactory({ + editor: { ...editor, codeRunTriggered: false, readOnly: true }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runCompletedHandler).toHaveBeenCalledTimes(2); + }); +}); + describe("When remounted during a run", () => { beforeEach(() => { runStartedHandler.mockClear(); diff --git a/src/components/WebComponentProject/buildProjectCodeSnapshot.js b/src/components/WebComponentProject/buildProjectCodeSnapshot.js new file mode 100644 index 000000000..b564b5f74 --- /dev/null +++ b/src/components/WebComponentProject/buildProjectCodeSnapshot.js @@ -0,0 +1,13 @@ +export const buildProjectCodeSnapshot = (components = []) => { + if (!Array.isArray(components)) { + return ""; + } + + return components + .map( + ({ name, extension, content }) => + `${name}.${extension}\0${content ?? ""}`, + ) + .sort() + .join("\n"); +}; diff --git a/src/components/WebComponentProject/buildProjectCodeSnapshot.test.js b/src/components/WebComponentProject/buildProjectCodeSnapshot.test.js new file mode 100644 index 000000000..ed0ac080a --- /dev/null +++ b/src/components/WebComponentProject/buildProjectCodeSnapshot.test.js @@ -0,0 +1,29 @@ +import { buildProjectCodeSnapshot } from "./buildProjectCodeSnapshot"; + +describe("buildProjectCodeSnapshot", () => { + test("builds a stable snapshot from project components", () => { + const components = [ + { name: "main", extension: "py", content: "print('hello')" }, + { name: "utils", extension: "py", content: "def foo(): pass" }, + ]; + + expect(buildProjectCodeSnapshot(components)).toBe( + buildProjectCodeSnapshot([...components].reverse()), + ); + }); + + test("returns an empty string for non-array input", () => { + expect(buildProjectCodeSnapshot(null)).toBe(""); + }); + + test("changes when component content changes", () => { + const before = buildProjectCodeSnapshot([ + { name: "main", extension: "py", content: "print(1)" }, + ]); + const after = buildProjectCodeSnapshot([ + { name: "main", extension: "py", content: "print(2)" }, + ]); + + expect(before).not.toBe(after); + }); +}); diff --git a/src/components/WebComponentProject/runEventCodeSnapshot.js b/src/components/WebComponentProject/runEventCodeSnapshot.js new file mode 100644 index 000000000..038079eab --- /dev/null +++ b/src/components/WebComponentProject/runEventCodeSnapshot.js @@ -0,0 +1,44 @@ +import { buildProjectCodeSnapshot } from "./buildProjectCodeSnapshot"; + +let lastEmittedCodeSnapshot = null; +let trackedProjectIdentifier = null; +let emitRunEventsForCurrentCycle = false; + +export const beginRunEventCycle = ( + projectIdentifier, + components, + { bypassSnapshot = false } = {}, +) => { + if (bypassSnapshot) { + emitRunEventsForCurrentCycle = true; + return true; + } + + const snapshot = buildProjectCodeSnapshot(components); + + if (trackedProjectIdentifier !== projectIdentifier) { + lastEmittedCodeSnapshot = null; + trackedProjectIdentifier = projectIdentifier ?? null; + } + + if (snapshot === lastEmittedCodeSnapshot) { + emitRunEventsForCurrentCycle = false; + return false; + } + + lastEmittedCodeSnapshot = snapshot; + emitRunEventsForCurrentCycle = true; + return true; +}; + +export const shouldEmitRunCompletedEvent = () => emitRunEventsForCurrentCycle; + +export const endRunEventCycle = () => { + emitRunEventsForCurrentCycle = false; +}; + +export const resetRunEventCodeSnapshot = () => { + lastEmittedCodeSnapshot = null; + trackedProjectIdentifier = null; + emitRunEventsForCurrentCycle = false; +}; diff --git a/src/components/WebComponentProject/runEventCodeSnapshot.test.js b/src/components/WebComponentProject/runEventCodeSnapshot.test.js new file mode 100644 index 000000000..edc9c9007 --- /dev/null +++ b/src/components/WebComponentProject/runEventCodeSnapshot.test.js @@ -0,0 +1,59 @@ +import { + beginRunEventCycle, + endRunEventCycle, + resetRunEventCodeSnapshot, + shouldEmitRunCompletedEvent, +} from "./runEventCodeSnapshot"; + +const components = [ + { name: "main", extension: "py", content: "print('hello')" }, +]; + +describe("runEventCodeSnapshot", () => { + beforeEach(() => { + resetRunEventCodeSnapshot(); + }); + + test("allows the first run for a project", () => { + expect(beginRunEventCycle("project-a", components)).toBe(true); + expect(shouldEmitRunCompletedEvent()).toBe(true); + }); + + test("suppresses repeated runs with unchanged code", () => { + beginRunEventCycle("project-a", components); + endRunEventCycle(); + + expect(beginRunEventCycle("project-a", components)).toBe(false); + expect(shouldEmitRunCompletedEvent()).toBe(false); + }); + + test("allows a run after code changes", () => { + beginRunEventCycle("project-a", components); + endRunEventCycle(); + + const updatedComponents = [ + { name: "main", extension: "py", content: "print('world')" }, + ]; + + expect(beginRunEventCycle("project-a", updatedComponents)).toBe(true); + expect(shouldEmitRunCompletedEvent()).toBe(true); + }); + + test("resets snapshot when the project identifier changes", () => { + beginRunEventCycle("project-a", components); + endRunEventCycle(); + + expect(beginRunEventCycle("project-b", components)).toBe(true); + expect(shouldEmitRunCompletedEvent()).toBe(true); + }); + + test("allows repeated runs when snapshot bypass is enabled", () => { + beginRunEventCycle("project-a", components); + endRunEventCycle(); + + expect( + beginRunEventCycle("project-a", components, { bypassSnapshot: true }), + ).toBe(true); + expect(shouldEmitRunCompletedEvent()).toBe(true); + }); +}); diff --git a/src/components/WebComponentProject/runEventTrackingState.js b/src/components/WebComponentProject/runEventTrackingState.js index 2ec6a0c32..acab2a663 100644 --- a/src/components/WebComponentProject/runEventTrackingState.js +++ b/src/components/WebComponentProject/runEventTrackingState.js @@ -1,3 +1,5 @@ +import { resetRunEventCodeSnapshot } from "./runEventCodeSnapshot"; + let prevCodeRunTriggered = false; let trackedProjectIdentifier = null; @@ -10,6 +12,7 @@ export const setPrevCodeRunTriggered = (value) => { export const resetCodeRunEventTracking = () => { prevCodeRunTriggered = false; trackedProjectIdentifier = null; + resetRunEventCodeSnapshot(); }; export const syncRunEventTrackingProject = ( From e4d222e35744b5d8e27de6c0aee97366f322d5f7 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 20:44:02 +0200 Subject: [PATCH 5/9] feat: debounce rapid run analytics events with a 250ms quiet period Add trailing debounce so bursts of run attempts collapse into a single counted analytics dispatch after 250ms of quiet, while deliberate runs still emit once the debounce window has passed. - Replace synchronous run-start gating with scheduleRunEventCycle, which resets a debounce timer on each run start and evaluates the snapshot check when the timer fires. - Pair runCompleted with the debounced runStarted, including when the run finishes before the debounce fires. - Apply the same debounce path to Scratch run-started events in ScratchContainer. - Reset debounce state on project identifier change and web-component disconnect. --- .../Editor/Project/ScratchContainer.jsx | 14 ++- .../Editor/Project/ScratchContainer.test.js | 119 +++++++++++++++++- .../WebComponentProject.jsx | 86 +++++++------ .../WebComponentProject.test.js | 66 ++++++++++ .../runEventCodeSnapshot.js | 107 ++++++++++++++-- .../runEventCodeSnapshot.test.js | 117 ++++++++++++++--- 6 files changed, 441 insertions(+), 68 deletions(-) diff --git a/src/components/Editor/Project/ScratchContainer.jsx b/src/components/Editor/Project/ScratchContainer.jsx index 7c5748843..4c3e755c2 100644 --- a/src/components/Editor/Project/ScratchContainer.jsx +++ b/src/components/Editor/Project/ScratchContainer.jsx @@ -4,6 +4,7 @@ import { ClickScrollPlugin, OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { applyScratchProjectIdentifierUpdate } from "../../../redux/EditorSlice"; import { runStartedEvent } from "../../../events/WebComponentCustomEvents"; +import { scheduleRunEventCycle } from "../../WebComponentProject/runEventCodeSnapshot"; import { subscribeToScratchProjectIdentifierUpdates, postMessageToScratchIframe, @@ -74,14 +75,23 @@ export default function ScratchContainer() { if (event.origin !== allowedOrigin) return; if (event.data?.type !== "scratch-gui-project-run-started") return; - document.dispatchEvent(runStartedEvent({})); + scheduleRunEventCycle( + projectIdentifier, + null, + { bypassSnapshot: true }, + { + onRunStarted: () => { + document.dispatchEvent(runStartedEvent({})); + }, + }, + ); }; window.addEventListener("message", handleScratchRunStarted); return () => { window.removeEventListener("message", handleScratchRunStarted); }; - }, []); + }, [projectIdentifier]); useEffect(() => { const allowedOrigin = getScratchAllowedOrigin(); diff --git a/src/components/Editor/Project/ScratchContainer.test.js b/src/components/Editor/Project/ScratchContainer.test.js index 77c410abf..03f815ff4 100644 --- a/src/components/Editor/Project/ScratchContainer.test.js +++ b/src/components/Editor/Project/ScratchContainer.test.js @@ -7,6 +7,7 @@ import * as scratchIframeUtils from "../../../utils/scratchIframe"; import webComponentStore from "../../../redux/stores/WebComponentStore"; import { setUser } from "../../../redux/WebComponentAuthSlice"; import { resetStore } from "../../../redux/RootSlice"; +import { resetRunEventCodeSnapshot } from "../../WebComponentProject/runEventCodeSnapshot"; jest.mock("../../../utils/scratchIframe", () => ({ ...jest.requireActual("../../../utils/scratchIframe"), @@ -51,14 +52,17 @@ describe("ScratchContainer", () => { scratchApiEndpoint: "https://api.example.com/v1", }; - const buildStore = ({ authReducer } = {}) => + const buildStore = ({ authReducer, editorOverrides } = {}) => configureStore({ reducer: { editor: EditorReducer, ...(authReducer ? { auth: authReducer } : {}), }, preloadedState: { - editor: defaultEditorState, + editor: { + ...defaultEditorState, + ...editorOverrides, + }, }, }); @@ -174,14 +178,23 @@ describe("ScratchContainer", () => { let runStartedHandler; beforeEach(() => { + jest.useFakeTimers(); + resetRunEventCodeSnapshot(); runStartedHandler = jest.fn(); document.addEventListener("editor-runStarted", runStartedHandler); }); afterEach(() => { + jest.useRealTimers(); document.removeEventListener("editor-runStarted", runStartedHandler); }); + const flushScratchRunDebounce = () => { + act(() => { + jest.advanceTimersByTime(250); + }); + }; + test("dispatches editor-runStarted when scratch-gui-project-run-started is received", () => { renderScratchContainer(); @@ -191,6 +204,8 @@ describe("ScratchContainer", () => { }); }); + flushScratchRunDebounce(); + expect(runStartedHandler).toHaveBeenCalledTimes(1); expect(runStartedHandler.mock.calls[0][0].detail).toEqual({}); }); @@ -211,6 +226,106 @@ describe("ScratchContainer", () => { expect(runStartedHandler).not.toHaveBeenCalled(); }); + + test("collapses rapid scratch runs into one debounced dispatch", () => { + renderScratchContainer(); + + act(() => { + dispatchMessage({ + type: "scratch-gui-project-run-started", + }); + }); + + act(() => { + dispatchMessage({ + type: "scratch-gui-project-run-started", + }); + }); + + expect(runStartedHandler).toHaveBeenCalledTimes(0); + + flushScratchRunDebounce(); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + }); + + test("allows separate scratch bursts after debounce quiet period", () => { + renderScratchContainer(); + + act(() => { + dispatchMessage({ + type: "scratch-gui-project-run-started", + }); + }); + + flushScratchRunDebounce(); + + act(() => { + jest.advanceTimersByTime(250); + }); + + act(() => { + dispatchMessage({ + type: "scratch-gui-project-run-started", + }); + }); + + flushScratchRunDebounce(); + + expect(runStartedHandler).toHaveBeenCalledTimes(2); + }); + + test("collapses rapid read-only scratch runs into one debounced dispatch", () => { + renderScratchContainer( + buildStore({ editorOverrides: { readOnly: true } }), + ); + + act(() => { + dispatchMessage({ + type: "scratch-gui-project-run-started", + }); + }); + + act(() => { + dispatchMessage({ + type: "scratch-gui-project-run-started", + }); + }); + + expect(runStartedHandler).toHaveBeenCalledTimes(0); + + flushScratchRunDebounce(); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + }); + + test("allows read-only scratch runs after the debounce window", () => { + renderScratchContainer( + buildStore({ editorOverrides: { readOnly: true } }), + ); + + act(() => { + dispatchMessage({ + type: "scratch-gui-project-run-started", + }); + }); + + flushScratchRunDebounce(); + + act(() => { + jest.advanceTimersByTime(250); + }); + + act(() => { + dispatchMessage({ + type: "scratch-gui-project-run-started", + }); + }); + + flushScratchRunDebounce(); + + expect(runStartedHandler).toHaveBeenCalledTimes(2); + }); }); test("updates the parent project identifier without reloading the iframe project_id", () => { diff --git a/src/components/WebComponentProject/WebComponentProject.jsx b/src/components/WebComponentProject/WebComponentProject.jsx index 29a47c58d..f61fb0481 100644 --- a/src/components/WebComponentProject/WebComponentProject.jsx +++ b/src/components/WebComponentProject/WebComponentProject.jsx @@ -26,9 +26,9 @@ import { stepChangedEvent, } from "../../events/WebComponentCustomEvents"; import { - beginRunEventCycle, endRunEventCycle, - shouldEmitRunCompletedEvent, + handleRunEndedForEventCycle, + scheduleRunEventCycle, } from "./runEventCodeSnapshot"; import { getPrevCodeRunTriggered, @@ -140,47 +140,59 @@ const WebComponentProject = ({ useEffect(() => { const wasTriggered = getPrevCodeRunTriggered(); - if (codeRunTriggered && !wasTriggered) { - if ( - beginRunEventCycle(projectIdentifier, projectComponents, { - bypassSnapshot: readOnly, - }) - ) { - document.dispatchEvent( - runStartedEvent({ + const buildRunCompletedPayload = () => { + const mz_criteria = Sk.sense_hat + ? Sk.sense_hat.mz_criteria + : { ...defaultMZCriteria }; + + return outputOnly + ? { + errorDetails, + step: currentStepPosition, + projectIdentifier, + projectType, + } + : { + isErrorFree: error === "", step: currentStepPosition, + errorDetails, + friendlyErrorShown: Boolean(friendlyError?.html), projectIdentifier, projectType, - }), - ); - } + ...mz_criteria, + }; + }; + + if (codeRunTriggered && !wasTriggered) { + scheduleRunEventCycle( + projectIdentifier, + projectComponents, + { bypassSnapshot: readOnly }, + { + onRunStarted: () => { + document.dispatchEvent( + runStartedEvent({ + step: currentStepPosition, + projectIdentifier, + projectType, + }), + ); + }, + onRunCompletedIfRunAlreadyEnded: () => { + document.dispatchEvent( + runCompletedEvent(buildRunCompletedPayload()), + ); + }, + }, + ); } if (!codeRunTriggered && wasTriggered) { - if (shouldEmitRunCompletedEvent()) { - const mz_criteria = Sk.sense_hat - ? Sk.sense_hat.mz_criteria - : { ...defaultMZCriteria }; - - const payload = outputOnly - ? { - errorDetails, - step: currentStepPosition, - projectIdentifier, - projectType, - } - : { - isErrorFree: error === "", - step: currentStepPosition, - errorDetails, - friendlyErrorShown: Boolean(friendlyError?.html), - projectIdentifier, - projectType, - ...mz_criteria, - }; - - document.dispatchEvent(runCompletedEvent(payload)); - } + handleRunEndedForEventCycle({ + onRunCompleted: () => { + document.dispatchEvent(runCompletedEvent(buildRunCompletedPayload())); + }, + }); endRunEventCycle(); } diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index d514816f9..e4f057571 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -20,6 +20,12 @@ beforeAll(() => { jest.useFakeTimers(); +const flushRunEventDebounce = () => { + act(() => { + jest.advanceTimersByTime(250); + }); +}; + beforeEach(() => { resetCodeRunEventTracking(); }); @@ -84,6 +90,7 @@ describe("When state set", () => { instructions: "My amazing instructions", codeRunTriggered: true, }); + flushRunEventDebounce(); }); test("Renders", () => { @@ -301,6 +308,8 @@ describe("When code run finishes", () => { , ); + flushRunEventDebounce(); + return view; }; @@ -412,6 +421,8 @@ describe("When code run finishes", () => { , ); + flushRunEventDebounce(); + expect(runCompletedHandler).toHaveBeenCalledTimes(1); store = mockStore({ @@ -490,6 +501,7 @@ describe("When code is unchanged between runs", () => { const { view, editor, instructions } = renderRunCycle({ codeRunTriggered: true, }); + flushRunEventDebounce(); expect(runStartedHandler).toHaveBeenCalledTimes(1); @@ -516,6 +528,7 @@ describe("When code is unchanged between runs", () => { , ); + flushRunEventDebounce(); expect(runStartedHandler).toHaveBeenCalledTimes(1); @@ -537,6 +550,7 @@ describe("When code is unchanged between runs", () => { const { view, editor, instructions } = renderRunCycle({ codeRunTriggered: true, }); + flushRunEventDebounce(); store = mockStoreFactory({ editor: { ...editor, codeRunTriggered: false, codeHasBeenRun: true }, @@ -549,6 +563,8 @@ describe("When code is unchanged between runs", () => { , ); + flushRunEventDebounce(); + const updatedEditor = { ...editor, project: { @@ -571,6 +587,7 @@ describe("When code is unchanged between runs", () => { , ); + flushRunEventDebounce(); expect(runStartedHandler).toHaveBeenCalledTimes(2); @@ -593,6 +610,7 @@ describe("When code is unchanged between runs", () => { codeRunTriggered: true, readOnly: true, }); + flushRunEventDebounce(); expect(runStartedHandler).toHaveBeenCalledTimes(1); @@ -609,6 +627,8 @@ describe("When code is unchanged between runs", () => { expect(runCompletedHandler).toHaveBeenCalledTimes(1); + flushRunEventDebounce(); + store = mockStoreFactory({ editor: { ...editor, @@ -624,6 +644,7 @@ describe("When code is unchanged between runs", () => { , ); + flushRunEventDebounce(); expect(runStartedHandler).toHaveBeenCalledTimes(2); @@ -640,6 +661,49 @@ describe("When code is unchanged between runs", () => { expect(runCompletedHandler).toHaveBeenCalledTimes(2); }); + + test("collapses rapid read-only reruns into one debounced dispatch", () => { + const { view, editor, instructions } = renderRunCycle({ + codeRunTriggered: true, + readOnly: true, + }); + flushRunEventDebounce(); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + + store = mockStoreFactory({ + editor: { ...editor, codeRunTriggered: false, codeHasBeenRun: true }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + store = mockStoreFactory({ + editor: { + ...editor, + codeRunTriggered: true, + codeHasBeenRun: true, + readOnly: true, + }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + + flushRunEventDebounce(); + + expect(runStartedHandler).toHaveBeenCalledTimes(2); + }); }); describe("When remounted during a run", () => { @@ -680,6 +744,8 @@ describe("When remounted during a run", () => { , ); + flushRunEventDebounce(); + expect(runStartedHandler).toHaveBeenCalledTimes(1); view.unmount(); diff --git a/src/components/WebComponentProject/runEventCodeSnapshot.js b/src/components/WebComponentProject/runEventCodeSnapshot.js index 038079eab..610e295ad 100644 --- a/src/components/WebComponentProject/runEventCodeSnapshot.js +++ b/src/components/WebComponentProject/runEventCodeSnapshot.js @@ -1,44 +1,125 @@ import { buildProjectCodeSnapshot } from "./buildProjectCodeSnapshot"; +export const RUN_EVENT_DEBOUNCE_MS = 250; + let lastEmittedCodeSnapshot = null; let trackedProjectIdentifier = null; let emitRunEventsForCurrentCycle = false; +let debounceTimer = null; +let runEndedWhileDebouncing = false; +let pendingCallbacks = null; -export const beginRunEventCycle = ( - projectIdentifier, - components, - { bypassSnapshot = false } = {}, -) => { +const syncTrackedProject = (projectIdentifier) => { + if (trackedProjectIdentifier !== projectIdentifier) { + lastEmittedCodeSnapshot = null; + trackedProjectIdentifier = projectIdentifier ?? null; + } +}; + +const clearDebounceTimer = () => { + if (debounceTimer) { + clearTimeout(debounceTimer); + debounceTimer = null; + } +}; + +const shouldAllowRunEvent = (components, bypassSnapshot) => { if (bypassSnapshot) { - emitRunEventsForCurrentCycle = true; return true; } const snapshot = buildProjectCodeSnapshot(components); - if (trackedProjectIdentifier !== projectIdentifier) { - lastEmittedCodeSnapshot = null; - trackedProjectIdentifier = projectIdentifier ?? null; - } - if (snapshot === lastEmittedCodeSnapshot) { - emitRunEventsForCurrentCycle = false; return false; } lastEmittedCodeSnapshot = snapshot; - emitRunEventsForCurrentCycle = true; return true; }; +const flushDebouncedRunEvent = () => { + debounceTimer = null; + const callbacks = pendingCallbacks; + pendingCallbacks = null; + + if (!callbacks) { + return; + } + + const { + projectIdentifier, + components, + bypassSnapshot, + onRunStarted, + onRunCompletedIfRunAlreadyEnded, + } = callbacks; + + syncTrackedProject(projectIdentifier); + + if (!shouldAllowRunEvent(components, bypassSnapshot)) { + emitRunEventsForCurrentCycle = false; + runEndedWhileDebouncing = false; + return; + } + + emitRunEventsForCurrentCycle = true; + onRunStarted?.(); + + if (runEndedWhileDebouncing) { + onRunCompletedIfRunAlreadyEnded?.(); + emitRunEventsForCurrentCycle = false; + runEndedWhileDebouncing = false; + } +}; + +export const scheduleRunEventCycle = ( + projectIdentifier, + components, + { bypassSnapshot = false } = {}, + { onRunStarted, onRunCompletedIfRunAlreadyEnded } = {}, +) => { + syncTrackedProject(projectIdentifier); + runEndedWhileDebouncing = false; + pendingCallbacks = { + projectIdentifier, + components, + bypassSnapshot, + onRunStarted, + onRunCompletedIfRunAlreadyEnded, + }; + + clearDebounceTimer(); + debounceTimer = setTimeout(flushDebouncedRunEvent, RUN_EVENT_DEBOUNCE_MS); +}; + +export const handleRunEndedForEventCycle = ({ onRunCompleted }) => { + if (debounceTimer) { + runEndedWhileDebouncing = true; + return; + } + + if (emitRunEventsForCurrentCycle) { + onRunCompleted?.(); + } +}; + export const shouldEmitRunCompletedEvent = () => emitRunEventsForCurrentCycle; export const endRunEventCycle = () => { + if (debounceTimer) { + return; + } + emitRunEventsForCurrentCycle = false; + runEndedWhileDebouncing = false; }; export const resetRunEventCodeSnapshot = () => { + clearDebounceTimer(); + pendingCallbacks = null; lastEmittedCodeSnapshot = null; trackedProjectIdentifier = null; emitRunEventsForCurrentCycle = false; + runEndedWhileDebouncing = false; }; diff --git a/src/components/WebComponentProject/runEventCodeSnapshot.test.js b/src/components/WebComponentProject/runEventCodeSnapshot.test.js index edc9c9007..eaa4a5c02 100644 --- a/src/components/WebComponentProject/runEventCodeSnapshot.test.js +++ b/src/components/WebComponentProject/runEventCodeSnapshot.test.js @@ -1,7 +1,9 @@ import { - beginRunEventCycle, endRunEventCycle, + handleRunEndedForEventCycle, resetRunEventCodeSnapshot, + RUN_EVENT_DEBOUNCE_MS, + scheduleRunEventCycle, shouldEmitRunCompletedEvent, } from "./runEventCodeSnapshot"; @@ -9,51 +11,138 @@ const components = [ { name: "main", extension: "py", content: "print('hello')" }, ]; +const flushDebounce = () => { + jest.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); +}; + describe("runEventCodeSnapshot", () => { beforeEach(() => { + jest.useFakeTimers(); resetRunEventCodeSnapshot(); }); - test("allows the first run for a project", () => { - expect(beginRunEventCycle("project-a", components)).toBe(true); + afterEach(() => { + jest.useRealTimers(); + }); + + test("allows the first run for a project after debounce", () => { + const onRunStarted = jest.fn(); + + scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); + flushDebounce(); + + expect(onRunStarted).toHaveBeenCalledTimes(1); expect(shouldEmitRunCompletedEvent()).toBe(true); }); test("suppresses repeated runs with unchanged code", () => { - beginRunEventCycle("project-a", components); + const onRunStarted = jest.fn(); + + scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); + flushDebounce(); endRunEventCycle(); - expect(beginRunEventCycle("project-a", components)).toBe(false); + scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); + flushDebounce(); + + expect(onRunStarted).toHaveBeenCalledTimes(1); expect(shouldEmitRunCompletedEvent()).toBe(false); }); test("allows a run after code changes", () => { - beginRunEventCycle("project-a", components); + const onRunStarted = jest.fn(); + + scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); + flushDebounce(); endRunEventCycle(); const updatedComponents = [ { name: "main", extension: "py", content: "print('world')" }, ]; - expect(beginRunEventCycle("project-a", updatedComponents)).toBe(true); + scheduleRunEventCycle("project-a", updatedComponents, {}, { onRunStarted }); + flushDebounce(); + + expect(onRunStarted).toHaveBeenCalledTimes(2); expect(shouldEmitRunCompletedEvent()).toBe(true); }); + test("collapses a rapid burst into one run event", () => { + const onRunStarted = jest.fn(); + const onRunCompletedIfRunAlreadyEnded = jest.fn(); + + scheduleRunEventCycle( + "project-a", + components, + { bypassSnapshot: true }, + { onRunStarted, onRunCompletedIfRunAlreadyEnded }, + ); + scheduleRunEventCycle( + "project-a", + components, + { bypassSnapshot: true }, + { onRunStarted, onRunCompletedIfRunAlreadyEnded }, + ); + handleRunEndedForEventCycle({ + onRunCompleted: onRunCompletedIfRunAlreadyEnded, + }); + flushDebounce(); + + expect(onRunStarted).toHaveBeenCalledTimes(1); + expect(onRunCompletedIfRunAlreadyEnded).toHaveBeenCalledTimes(1); + expect(shouldEmitRunCompletedEvent()).toBe(false); + }); + + test("emits run completed on run end after debounced start", () => { + const onRunStarted = jest.fn(); + const onRunCompleted = jest.fn(); + + scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); + flushDebounce(); + + handleRunEndedForEventCycle({ onRunCompleted: onRunCompleted }); + + expect(onRunStarted).toHaveBeenCalledTimes(1); + expect(onRunCompleted).toHaveBeenCalledTimes(1); + }); + test("resets snapshot when the project identifier changes", () => { - beginRunEventCycle("project-a", components); + const onRunStarted = jest.fn(); + + scheduleRunEventCycle("project-a", components, {}, { onRunStarted }); + flushDebounce(); endRunEventCycle(); - expect(beginRunEventCycle("project-b", components)).toBe(true); + scheduleRunEventCycle("project-b", components, {}, { onRunStarted }); + flushDebounce(); + + expect(onRunStarted).toHaveBeenCalledTimes(2); expect(shouldEmitRunCompletedEvent()).toBe(true); }); - test("allows repeated runs when snapshot bypass is enabled", () => { - beginRunEventCycle("project-a", components); + test("allows separate bursts after debounce quiet period", () => { + const onRunStarted = jest.fn(); + + scheduleRunEventCycle( + "project-a", + components, + { bypassSnapshot: true }, + { onRunStarted }, + ); + flushDebounce(); endRunEventCycle(); - expect( - beginRunEventCycle("project-a", components, { bypassSnapshot: true }), - ).toBe(true); + jest.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); + + scheduleRunEventCycle( + "project-a", + components, + { bypassSnapshot: true }, + { onRunStarted }, + ); + flushDebounce(); + + expect(onRunStarted).toHaveBeenCalledTimes(2); expect(shouldEmitRunCompletedEvent()).toBe(true); }); }); From 8e2474eedc56ade6d666b2731a32f2dbb667f428 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 21:45:45 +0200 Subject: [PATCH 6/9] chore: review - use latest run outcome when debounced runCompleted fires after fast runs --- .../WebComponentProject.jsx | 55 +++++++------ .../WebComponentProject.test.js | 82 +++++++++++++++++++ 2 files changed, 111 insertions(+), 26 deletions(-) diff --git a/src/components/WebComponentProject/WebComponentProject.jsx b/src/components/WebComponentProject/WebComponentProject.jsx index f61fb0481..fc4501618 100644 --- a/src/components/WebComponentProject/WebComponentProject.jsx +++ b/src/components/WebComponentProject/WebComponentProject.jsx @@ -1,4 +1,4 @@ -import React, { useEffect } from "react"; +import React, { useEffect, useRef } from "react"; import { useDispatch, useSelector } from "react-redux"; import { useMediaQuery } from "react-responsive"; import { marked } from "marked"; @@ -82,6 +82,30 @@ const WebComponentProject = ({ const dispatch = useDispatch(); const renderer = new marked.Renderer(); + const buildRunCompletedPayloadRef = useRef(() => ({})); + buildRunCompletedPayloadRef.current = () => { + const mz_criteria = Sk.sense_hat + ? Sk.sense_hat.mz_criteria + : { ...defaultMZCriteria }; + + return outputOnly + ? { + errorDetails, + step: currentStepPosition, + projectIdentifier, + projectType, + } + : { + isErrorFree: error === "", + step: currentStepPosition, + errorDetails, + friendlyErrorShown: Boolean(friendlyError?.html), + projectIdentifier, + projectType, + ...mz_criteria, + }; + }; + useEffect(() => { dispatch(setIsSplitView(outputSplitView)); dispatch(setWebComponent(true)); @@ -140,29 +164,6 @@ const WebComponentProject = ({ useEffect(() => { const wasTriggered = getPrevCodeRunTriggered(); - const buildRunCompletedPayload = () => { - const mz_criteria = Sk.sense_hat - ? Sk.sense_hat.mz_criteria - : { ...defaultMZCriteria }; - - return outputOnly - ? { - errorDetails, - step: currentStepPosition, - projectIdentifier, - projectType, - } - : { - isErrorFree: error === "", - step: currentStepPosition, - errorDetails, - friendlyErrorShown: Boolean(friendlyError?.html), - projectIdentifier, - projectType, - ...mz_criteria, - }; - }; - if (codeRunTriggered && !wasTriggered) { scheduleRunEventCycle( projectIdentifier, @@ -180,7 +181,7 @@ const WebComponentProject = ({ }, onRunCompletedIfRunAlreadyEnded: () => { document.dispatchEvent( - runCompletedEvent(buildRunCompletedPayload()), + runCompletedEvent(buildRunCompletedPayloadRef.current()), ); }, }, @@ -190,7 +191,9 @@ const WebComponentProject = ({ if (!codeRunTriggered && wasTriggered) { handleRunEndedForEventCycle({ onRunCompleted: () => { - document.dispatchEvent(runCompletedEvent(buildRunCompletedPayload())); + document.dispatchEvent( + runCompletedEvent(buildRunCompletedPayloadRef.current()), + ); }, }); diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index e4f057571..5d357127b 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -451,6 +451,88 @@ describe("When code run finishes", () => { expect(runCompletedHandler).toHaveBeenCalledTimes(1); }); + + test("emits runCompleted with latest error state when run ends during debounce", () => { + const middlewares = []; + const mockStore = configureStore(middlewares); + const editor = { + project: { + identifier: "test-project", + project_type: "python", + components: [ + { name: "main", extension: "py", content: "print('hello')" }, + ], + image_list: [], + }, + loading: "success", + openFiles: [], + focussedFileIndices: [], + codeRunTriggered: true, + codeHasBeenRun: true, + error: "", + errorDetails: undefined, + friendlyError: undefined, + }; + const instructions = { + currentStepPosition: 3, + permitOverride: true, + }; + + store = mockStore({ editor, instructions, auth: {} }); + + const view = render( + + + , + ); + + store = mockStore({ + editor: { ...editor, codeRunTriggered: false }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + store = mockStore({ + editor: { + ...editor, + codeRunTriggered: false, + error: "NameError: name 'kettle' is not defined on line 5 of main.py", + errorDetails: { + type: "NameError", + file: "main.py", + line: 5, + description: "name 'kettle' is not defined", + }, + }, + instructions, + auth: {}, + }); + view.rerender( + + + , + ); + + flushRunEventDebounce(); + + expect(runCompletedHandler).toHaveBeenCalledTimes(1); + expect(runCompletedHandler.mock.lastCall[0].detail).toEqual( + expect.objectContaining({ + isErrorFree: false, + errorDetails: { + type: "NameError", + file: "main.py", + line: 5, + description: "name 'kettle' is not defined", + }, + }), + ); + }); }); describe("When code is unchanged between runs", () => { From c7cdebd68d6378fae325eb0551b304396ce59f02 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 21:48:03 +0200 Subject: [PATCH 7/9] chore: cancel pending scratch run analytics on unmount --- .../Editor/Project/ScratchContainer.jsx | 6 +++++- .../Editor/Project/ScratchContainer.test.js | 18 ++++++++++++++++++ .../runEventCodeSnapshot.js | 8 ++++++-- .../runEventCodeSnapshot.test.js | 17 +++++++++++++++++ 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/components/Editor/Project/ScratchContainer.jsx b/src/components/Editor/Project/ScratchContainer.jsx index 4c3e755c2..36e869807 100644 --- a/src/components/Editor/Project/ScratchContainer.jsx +++ b/src/components/Editor/Project/ScratchContainer.jsx @@ -4,7 +4,10 @@ import { ClickScrollPlugin, OverlayScrollbars } from "overlayscrollbars"; import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; import { applyScratchProjectIdentifierUpdate } from "../../../redux/EditorSlice"; import { runStartedEvent } from "../../../events/WebComponentCustomEvents"; -import { scheduleRunEventCycle } from "../../WebComponentProject/runEventCodeSnapshot"; +import { + cancelPendingRunEventDebounce, + scheduleRunEventCycle, +} from "../../WebComponentProject/runEventCodeSnapshot"; import { subscribeToScratchProjectIdentifierUpdates, postMessageToScratchIframe, @@ -90,6 +93,7 @@ export default function ScratchContainer() { window.addEventListener("message", handleScratchRunStarted); return () => { window.removeEventListener("message", handleScratchRunStarted); + cancelPendingRunEventDebounce(); }; }, [projectIdentifier]); diff --git a/src/components/Editor/Project/ScratchContainer.test.js b/src/components/Editor/Project/ScratchContainer.test.js index 03f815ff4..f69714f82 100644 --- a/src/components/Editor/Project/ScratchContainer.test.js +++ b/src/components/Editor/Project/ScratchContainer.test.js @@ -227,6 +227,24 @@ describe("ScratchContainer", () => { expect(runStartedHandler).not.toHaveBeenCalled(); }); + test("does not dispatch editor-runStarted when unmounted before debounce fires", () => { + const store = buildStore(); + const { unmount } = render( + + + , + ); + + act(() => { + dispatchMessage({ type: "scratch-gui-project-run-started" }); + }); + + unmount(); + flushScratchRunDebounce(); + + expect(runStartedHandler).not.toHaveBeenCalled(); + }); + test("collapses rapid scratch runs into one debounced dispatch", () => { renderScratchContainer(); diff --git a/src/components/WebComponentProject/runEventCodeSnapshot.js b/src/components/WebComponentProject/runEventCodeSnapshot.js index 610e295ad..9f78cb792 100644 --- a/src/components/WebComponentProject/runEventCodeSnapshot.js +++ b/src/components/WebComponentProject/runEventCodeSnapshot.js @@ -115,11 +115,15 @@ export const endRunEventCycle = () => { runEndedWhileDebouncing = false; }; -export const resetRunEventCodeSnapshot = () => { +export const cancelPendingRunEventDebounce = () => { clearDebounceTimer(); pendingCallbacks = null; + runEndedWhileDebouncing = false; +}; + +export const resetRunEventCodeSnapshot = () => { + cancelPendingRunEventDebounce(); lastEmittedCodeSnapshot = null; trackedProjectIdentifier = null; emitRunEventsForCurrentCycle = false; - runEndedWhileDebouncing = false; }; diff --git a/src/components/WebComponentProject/runEventCodeSnapshot.test.js b/src/components/WebComponentProject/runEventCodeSnapshot.test.js index eaa4a5c02..3a6fb1e11 100644 --- a/src/components/WebComponentProject/runEventCodeSnapshot.test.js +++ b/src/components/WebComponentProject/runEventCodeSnapshot.test.js @@ -1,4 +1,5 @@ import { + cancelPendingRunEventDebounce, endRunEventCycle, handleRunEndedForEventCycle, resetRunEventCodeSnapshot, @@ -145,4 +146,20 @@ describe("runEventCodeSnapshot", () => { expect(onRunStarted).toHaveBeenCalledTimes(2); expect(shouldEmitRunCompletedEvent()).toBe(true); }); + + test("does not fire pending callbacks after debounce is cancelled", () => { + const onRunStarted = jest.fn(); + + scheduleRunEventCycle( + "project-a", + components, + { bypassSnapshot: true }, + { onRunStarted }, + ); + cancelPendingRunEventDebounce(); + flushDebounce(); + + expect(onRunStarted).not.toHaveBeenCalled(); + expect(shouldEmitRunCompletedEvent()).toBe(false); + }); }); From be17ed8e98d1987cc48bd307600c12a016cf9fc3 Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 21:52:11 +0200 Subject: [PATCH 8/9] chore: address PR review feedback for run analytics for WebComponentProject --- .../WebComponentProject/WebComponentProject.jsx | 2 +- .../WebComponentProject.test.js | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/components/WebComponentProject/WebComponentProject.jsx b/src/components/WebComponentProject/WebComponentProject.jsx index fc4501618..582f6e4a4 100644 --- a/src/components/WebComponentProject/WebComponentProject.jsx +++ b/src/components/WebComponentProject/WebComponentProject.jsx @@ -62,7 +62,7 @@ const WebComponentProject = ({ (state) => state.editor.codeRunTriggered, ); - const error = useSelector((state) => state.editor.error); + const error = useSelector((state) => state.editor.error) ?? ""; const errorDetails = useSelector((state) => state.editor.errorDetails); const friendlyError = useSelector((state) => state.editor.friendlyError); const projectComponents = useSelector( diff --git a/src/components/WebComponentProject/WebComponentProject.test.js b/src/components/WebComponentProject/WebComponentProject.test.js index 5d357127b..a529b09cf 100644 --- a/src/components/WebComponentProject/WebComponentProject.test.js +++ b/src/components/WebComponentProject/WebComponentProject.test.js @@ -5,6 +5,7 @@ import configureStore from "redux-mock-store"; import WebComponentProject, { resetCodeRunEventTracking, } from "./WebComponentProject"; +import { RUN_EVENT_DEBOUNCE_MS } from "./runEventCodeSnapshot"; const codeChangedHandler = jest.fn(); const runStartedHandler = jest.fn(); @@ -22,7 +23,7 @@ jest.useFakeTimers(); const flushRunEventDebounce = () => { act(() => { - jest.advanceTimersByTime(250); + jest.advanceTimersByTime(RUN_EVENT_DEBOUNCE_MS); }); }; @@ -332,6 +333,17 @@ describe("When code run finishes", () => { ); }); + test("reports isErrorFree when error state is undefined", () => { + renderRunCompletion({ error: undefined }); + + expect(runCompletedHandler).toHaveBeenCalledTimes(1); + expect(runCompletedHandler.mock.lastCall[0].detail).toEqual( + expect.objectContaining({ + isErrorFree: true, + }), + ); + }); + test("includes error details and friendly error state when a run failed", () => { renderRunCompletion({ error: "NameError: name 'kettle' is not defined on line 5 of main.py", From f9f4ec411aafb4d730fe48ee718c7f64d20f56dd Mon Sep 17 00:00:00 2001 From: Nathan Richards Date: Wed, 1 Jul 2026 21:59:53 +0200 Subject: [PATCH 9/9] chore: keep scratch run debounce alive when project identifier updates --- .../Editor/Project/ScratchContainer.jsx | 6 ++-- .../Editor/Project/ScratchContainer.test.js | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+), 2 deletions(-) diff --git a/src/components/Editor/Project/ScratchContainer.jsx b/src/components/Editor/Project/ScratchContainer.jsx index 36e869807..db4a434f7 100644 --- a/src/components/Editor/Project/ScratchContainer.jsx +++ b/src/components/Editor/Project/ScratchContainer.jsx @@ -49,6 +49,8 @@ export default function ScratchContainer() { nonce: null, hadAccessToken: false, }); + const projectIdentifierRef = useRef(projectIdentifier); + projectIdentifierRef.current = projectIdentifier; useEffect(() => { return subscribeToScratchProjectIdentifierUpdates( @@ -79,7 +81,7 @@ export default function ScratchContainer() { if (event.data?.type !== "scratch-gui-project-run-started") return; scheduleRunEventCycle( - projectIdentifier, + projectIdentifierRef.current, null, { bypassSnapshot: true }, { @@ -95,7 +97,7 @@ export default function ScratchContainer() { window.removeEventListener("message", handleScratchRunStarted); cancelPendingRunEventDebounce(); }; - }, [projectIdentifier]); + }, []); useEffect(() => { const allowedOrigin = getScratchAllowedOrigin(); diff --git a/src/components/Editor/Project/ScratchContainer.test.js b/src/components/Editor/Project/ScratchContainer.test.js index f69714f82..5563c7647 100644 --- a/src/components/Editor/Project/ScratchContainer.test.js +++ b/src/components/Editor/Project/ScratchContainer.test.js @@ -245,6 +245,39 @@ describe("ScratchContainer", () => { expect(runStartedHandler).not.toHaveBeenCalled(); }); + test("dispatches editor-runStarted when project identifier changes during debounce", () => { + const store = buildStore(); + const view = render( + + + , + ); + + act(() => { + dispatchMessage({ type: "scratch-gui-project-run-started" }); + }); + + const updatedStore = buildStore({ + editorOverrides: { + project: { + identifier: "project-456", + project_type: "code_editor_scratch", + }, + scratchIframeProjectIdentifier: "project-456", + }, + }); + + view.rerender( + + + , + ); + + flushScratchRunDebounce(); + + expect(runStartedHandler).toHaveBeenCalledTimes(1); + }); + test("collapses rapid scratch runs into one debounced dispatch", () => { renderScratchContainer();