From d04a664bd5b19498efaba2d188f2a010679056ea Mon Sep 17 00:00:00 2001 From: Marco Massarelli Date: Thu, 29 Oct 2026 14:56:13 -0400 Subject: [PATCH 1/2] fix(analytics): stop preview_loaded firing on every regeneration The preview tracking effect in Ergogen.tsx depended on `configContext.results`, which is replaced on every auto-generation (300ms debounce while typing) and every background STL conversion. As a result `preview_loaded` re-fired for the same, unchanged preview file on each regeneration. In GA4 (Jul 15 - Oct 28) this produced ~209k events, about 37% of all traffic, making the event useless for measuring which outputs users actually inspect and inflating event volume. The effect now depends only on whether results exist, and remembers the last tracked preview (key + extension) in a ref. It fires once when results first become available, and again only when the user switches to a different preview. Tests: added coverage in Ergogen.test.tsx asserting a single event on first results and no re-fire across repeated regenerations (fails on the previous implementation with 4 events instead of 1). --- src/Ergogen.test.tsx | 76 ++++++++++++++++++++++++++++++++++++++++++++ src/Ergogen.tsx | 24 +++++++++----- 2 files changed, 92 insertions(+), 8 deletions(-) diff --git a/src/Ergogen.test.tsx b/src/Ergogen.test.tsx index 0beb88c3..e6826fd9 100644 --- a/src/Ergogen.test.tsx +++ b/src/Ergogen.test.tsx @@ -133,3 +133,79 @@ describe('Ergogen Subheader Buttons', () => { ); }); }); + +describe('Ergogen preview_loaded tracking', () => { + const baseContext = { + configs: [], + activeConfigId: '1', + activeConfigName: 'My Awesome Board', + isPreview: false, + results: null as Record | null, + configInput: 'points: {}', + injectionInput: [], + debug: false, + stlPreview: false, + isGenerating: false, + isJscadConverting: false, + showSettings: false, + showSideNav: false, + showConfig: true, + showDownloads: false, + setShowSettings: jest.fn(), + setShowSideNav: jest.fn(), + setShowConfig: jest.fn(), + setShowDownloads: jest.fn(), + generateNow: jest.fn(), + }; + + const countPreviewLoadedCalls = () => + mockTrackEvent.mock.calls.filter(([name]) => name === 'preview_loaded') + .length; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('fires preview_loaded once when results first become available', () => { + // Arrange + (useConfigContext as jest.Mock).mockReturnValue(baseContext); + const { rerender } = render(); + + // Act + (useConfigContext as jest.Mock).mockReturnValue({ + ...baseContext, + results: { demo: { svg: '' } }, + }); + rerender(); + + // Assert + expect(countPreviewLoadedCalls()).toBe(1); + expect(mockTrackEvent).toHaveBeenCalledWith('preview_loaded', { + preview_type: 'svg', + preview_key: 'demo.svg', + }); + }); + + it('does not re-fire preview_loaded when results regenerate for the same preview', () => { + // Arrange + (useConfigContext as jest.Mock).mockReturnValue({ + ...baseContext, + results: { demo: { svg: '' } }, + }); + const { rerender } = render(); + expect(countPreviewLoadedCalls()).toBe(1); + + // Act: simulate several auto-generation / STL completion cycles, each + // producing a new results object for the unchanged preview. + for (let i = 0; i < 3; i++) { + (useConfigContext as jest.Mock).mockReturnValue({ + ...baseContext, + results: { demo: { svg: `` } }, + }); + rerender(); + } + + // Assert + expect(countPreviewLoadedCalls()).toBe(1); + }); +}); diff --git a/src/Ergogen.tsx b/src/Ergogen.tsx index fe5b2404..48b6c493 100644 --- a/src/Ergogen.tsx +++ b/src/Ergogen.tsx @@ -1,5 +1,6 @@ import { useEffect, + useRef, useState, ChangeEvent, SetStateAction, @@ -407,15 +408,22 @@ const Ergogen = ({ pwaState }: ErgogenProps) => { configContext?.setInjectionInput(injections); }, [configContext, injectionToEdit]); - // Track preview changes + // Track preview changes. + // `results` is replaced on every (auto-)generation and STL conversion, so we + // remember the last tracked preview and only fire when the user actually + // switches to a different file, rather than on every regeneration. + const lastTrackedPreviewRef = useRef(null); + const hasResults = Boolean(configContext?.results); useEffect(() => { - if (configContext?.results && preview.key && preview.extension) { - trackEvent('preview_loaded', { - preview_type: preview.extension, - preview_key: preview.key, - }); - } - }, [preview.key, preview.extension, configContext?.results]); + if (!hasResults || !preview.key || !preview.extension) return; + const previewId = `${preview.key}|${preview.extension}`; + if (lastTrackedPreviewRef.current === previewId) return; + lastTrackedPreviewRef.current = previewId; + trackEvent('preview_loaded', { + preview_type: preview.extension, + preview_key: preview.key, + }); + }, [preview.key, preview.extension, hasResults]); if (!configContext) return null; let result = null; From 992d5ca6ebe96cbc2805e5ed580e796098b4eff6 Mon Sep 17 00:00:00 2001 From: Marco Massarelli Date: Thu, 29 Oct 2026 15:05:39 -0400 Subject: [PATCH 2/2] fix(analytics): re-track preview_loaded after config switches The previous commit remembered the last tracked preview to avoid re-firing on every regeneration. Ergogen stays mounted when a config is selected, created, duplicated or loaded from a shared link from the side nav; those actions clear `results` and regenerate, typically showing the same `demo.svg` preview. Because the remembered preview matched, those loads stopped emitting `preview_loaded`, whereas they correctly fired once before. Reset the remembered preview whenever results are cleared, restoring one event per config load while still suppressing regeneration repeats. Generation failures keep the previous results, so they don't reset it. Tests: added a case covering results -> null -> results with the same preview, which fails without the reset (1 event instead of 2). --- src/Ergogen.test.tsx | 28 ++++++++++++++++++++++++++++ src/Ergogen.tsx | 9 ++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/Ergogen.test.tsx b/src/Ergogen.test.tsx index e6826fd9..2bd4e05c 100644 --- a/src/Ergogen.test.tsx +++ b/src/Ergogen.test.tsx @@ -208,4 +208,32 @@ describe('Ergogen preview_loaded tracking', () => { // Assert expect(countPreviewLoadedCalls()).toBe(1); }); + + it('fires preview_loaded again after results are cleared, e.g. when switching configs', () => { + // Arrange: a config is loaded and its preview has been tracked. + (useConfigContext as jest.Mock).mockReturnValue({ + ...baseContext, + results: { demo: { svg: '' } }, + }); + const { rerender } = render(); + expect(countPreviewLoadedCalls()).toBe(1); + + // Act: selecting, creating or duplicating a config clears the results + // before the new config generates, while Ergogen stays mounted. + (useConfigContext as jest.Mock).mockReturnValue({ + ...baseContext, + activeConfigId: '2', + results: null, + }); + rerender(); + (useConfigContext as jest.Mock).mockReturnValue({ + ...baseContext, + activeConfigId: '2', + results: { demo: { svg: '' } }, + }); + rerender(); + + // Assert + expect(countPreviewLoadedCalls()).toBe(2); + }); }); diff --git a/src/Ergogen.tsx b/src/Ergogen.tsx index 48b6c493..c883ec59 100644 --- a/src/Ergogen.tsx +++ b/src/Ergogen.tsx @@ -412,10 +412,17 @@ const Ergogen = ({ pwaState }: ErgogenProps) => { // `results` is replaced on every (auto-)generation and STL conversion, so we // remember the last tracked preview and only fire when the user actually // switches to a different file, rather than on every regeneration. + // Results are cleared (set to null) when a config is selected, created, + // duplicated or loaded from a shared link, so we reset the memory then to + // track the first preview of the newly loaded config. const lastTrackedPreviewRef = useRef(null); const hasResults = Boolean(configContext?.results); useEffect(() => { - if (!hasResults || !preview.key || !preview.extension) return; + if (!hasResults) { + lastTrackedPreviewRef.current = null; + return; + } + if (!preview.key || !preview.extension) return; const previewId = `${preview.key}|${preview.extension}`; if (lastTrackedPreviewRef.current === previewId) return; lastTrackedPreviewRef.current = previewId;