From 13fd9debe76e71d6798519578d677daf6dca2fe9 Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Mon, 6 Oct 2025 13:46:37 -0400 Subject: [PATCH 1/3] :bug: Refactor generate-assets-wizard `useWizardReducer` to use `useImmerReducer` Following the pattern in discover-import-wizard from PR #2646, update generate-assets-wizard `useWizardReducer` to use immer and `useImmerReducer` for handling the initial state and all reducer actions. This helps keep the initial state clean and resettable, and helps keep the reducer logic simple and focused. Signed-off-by: Scott J Dickerson --- .../useWizardReducer.ts | 124 +++++++++++------- 1 file changed, 78 insertions(+), 46 deletions(-) diff --git a/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts b/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts index 7cdb468a2..38dc4442f 100644 --- a/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts +++ b/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts @@ -1,4 +1,6 @@ -import * as React from "react"; +import { useCallback, useRef } from "react"; +import { produce } from "immer"; +import { useImmerReducer } from "use-immer"; import { TargetProfile } from "@app/api/models"; @@ -28,72 +30,102 @@ const INITIAL_WIZARD_STATE: WizardState = { results: null, }; -type WizardReducer = (state: WizardState, action: WizardAction) => WizardState; +type WizardReducer = (draft: WizardState, action?: WizardAction) => void; type WizardAction = | { type: "SET_PROFILE"; payload: TargetProfile } | { type: "SET_PARAMETERS"; payload: ParameterState } | { type: "SET_ADVANCED_OPTIONS"; payload: AdvancedOptionsState } | { type: "SET_RESULTS"; payload: ResultsData | null } - | { type: "RESET" }; - -const validateWizardState = (state: WizardState): WizardState => { - const isReady = - !!state.profile && - state.parameters.isValid && - state.advancedOptions.isValid; - return { ...state, isReady }; + | { type: "RESET"; payload: WizardState }; + +const updateIsReady = (draft: WizardState) => { + draft.isReady = + !!draft.profile && + draft.parameters.isValid && + draft.advancedOptions.isValid; + return draft; }; -const wizardReducer: WizardReducer = (state, action) => { - switch (action.type) { - case "SET_PROFILE": - return { ...state, profile: action.payload }; - case "SET_PARAMETERS": - return { ...state, parameters: action.payload }; - case "SET_ADVANCED_OPTIONS": - return { ...state, advancedOptions: action.payload }; - case "SET_RESULTS": - return { ...state, results: action.payload }; - case "RESET": - return INITIAL_WIZARD_STATE; - default: - return state; +const wizardReducer: WizardReducer = (draft, action) => { + if (action) { + switch (action.type) { + case "SET_PROFILE": + draft.profile = action.payload; + break; + case "SET_PARAMETERS": + draft.parameters = action.payload; + break; + case "SET_ADVANCED_OPTIONS": + draft.advancedOptions = action.payload; + break; + case "SET_RESULTS": + draft.results = action.payload; + break; + case "RESET": + return updateIsReady(action.payload); + } } + + // Validate and update isReady state after any change + updateIsReady(draft); }; -const validatedReducer: WizardReducer = (state, action) => - validateWizardState(wizardReducer(state, action)); +export type InitialStateRecipe = (draftInitialState: WizardState) => void; -export const useWizardReducer = () => { - const [state, dispatch] = React.useReducer( - validatedReducer, - INITIAL_WIZARD_STATE, - validateWizardState - ); +const useImmerInitialState = ( + initialRecipe?: InitialStateRecipe +): WizardState => { + const initialRef = useRef(null); + if (initialRef.current === null) { + initialRef.current = produce(INITIAL_WIZARD_STATE, (draft) => { + initialRecipe?.(draft); + wizardReducer(draft); + }); + } + + return initialRef.current; +}; + +export const useWizardReducer = (init?: InitialStateRecipe) => { + // Ref: https://18.react.dev/reference/react/useReducer#avoiding-recreating-the-initial-state + // Allow RESET to have the same semantics as useReducer()'s initialState argument by just + // calculating the initial state once and storing it in a ref. + const firstInitialState = useImmerInitialState(init); + + const [state, dispatch] = useImmerReducer(wizardReducer, firstInitialState); // Create stable callbacks using useCallback - const setProfile = React.useCallback((profile: TargetProfile) => { - dispatch({ type: "SET_PROFILE", payload: profile }); - }, []); + const setProfile = useCallback( + (profile: TargetProfile) => { + dispatch({ type: "SET_PROFILE", payload: profile }); + }, + [dispatch] + ); - const setParameters = React.useCallback((parameters: ParameterState) => { - dispatch({ type: "SET_PARAMETERS", payload: parameters }); - }, []); + const setParameters = useCallback( + (parameters: ParameterState) => { + dispatch({ type: "SET_PARAMETERS", payload: parameters }); + }, + [dispatch] + ); - const setAdvancedOptions = React.useCallback( + const setAdvancedOptions = useCallback( (advancedOptions: AdvancedOptionsState) => { dispatch({ type: "SET_ADVANCED_OPTIONS", payload: advancedOptions }); }, - [] + [dispatch] ); - const setResults = React.useCallback((results: ResultsData | null) => { - dispatch({ type: "SET_RESULTS", payload: results }); - }, []); + const setResults = useCallback( + (results: ResultsData | null) => { + dispatch({ type: "SET_RESULTS", payload: results }); + }, + [dispatch] + ); - const reset = React.useCallback(() => { - dispatch({ type: "RESET" }); - }, []); + const reset = useCallback(() => { + dispatch({ type: "RESET", payload: firstInitialState }); + }, [firstInitialState, dispatch]); return { state, From 0b219ef835b83062033fd8b9e155cb758720e552 Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Mon, 6 Oct 2025 17:05:18 -0400 Subject: [PATCH 2/3] Update the wizardReducer typing to be consistent with immer Signed-off-by: Scott J Dickerson --- .../components/discover-import-wizard/useWizardReducer.ts | 6 ++++-- .../generate-assets-wizard/useWizardReducer.ts | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/client/src/app/components/discover-import-wizard/useWizardReducer.ts b/client/src/app/components/discover-import-wizard/useWizardReducer.ts index 9665a6c70..cbb92390c 100644 --- a/client/src/app/components/discover-import-wizard/useWizardReducer.ts +++ b/client/src/app/components/discover-import-wizard/useWizardReducer.ts @@ -24,7 +24,6 @@ const INITIAL_WIZARD_STATE: WizardState = { results: null, }; -type WizardReducer = (draft: WizardState, action?: WizardAction) => void; type WizardAction = | { type: "SET_PLATFORM"; payload: SourcePlatform | null } | { type: "SET_FILTERS"; payload: FilterState } @@ -36,7 +35,10 @@ const updateIsReady = (draft: WizardState) => { return draft; }; -const wizardReducer: WizardReducer = (draft, action) => { +const wizardReducer = ( + draft: WizardState, + action?: WizardAction +): WizardState | void => { if (action) { switch (action.type) { case "SET_PLATFORM": diff --git a/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts b/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts index 38dc4442f..71a9884fb 100644 --- a/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts +++ b/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts @@ -30,7 +30,6 @@ const INITIAL_WIZARD_STATE: WizardState = { results: null, }; -type WizardReducer = (draft: WizardState, action?: WizardAction) => void; type WizardAction = | { type: "SET_PROFILE"; payload: TargetProfile } | { type: "SET_PARAMETERS"; payload: ParameterState } @@ -38,7 +37,7 @@ type WizardAction = | { type: "SET_RESULTS"; payload: ResultsData | null } | { type: "RESET"; payload: WizardState }; -const updateIsReady = (draft: WizardState) => { +const updateIsReady = (draft: WizardState): WizardState => { draft.isReady = !!draft.profile && draft.parameters.isValid && @@ -46,7 +45,10 @@ const updateIsReady = (draft: WizardState) => { return draft; }; -const wizardReducer: WizardReducer = (draft, action) => { +const wizardReducer = ( + draft: WizardState, + action?: WizardAction +): WizardState | void => { if (action) { switch (action.type) { case "SET_PROFILE": From a90566051cced0458699a42a2a22f235ea747580 Mon Sep 17 00:00:00 2001 From: Scott J Dickerson Date: Thu, 9 Oct 2025 02:47:55 -0400 Subject: [PATCH 3/3] Update reset to just use the calculated value from the hook itself to avoid immer issues Signed-off-by: Scott J Dickerson --- .../discover-import-wizard/useWizardReducer.ts | 9 ++------- .../generate-assets-wizard/useWizardReducer.ts | 15 +++++---------- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/client/src/app/components/discover-import-wizard/useWizardReducer.ts b/client/src/app/components/discover-import-wizard/useWizardReducer.ts index cbb92390c..96e1654f3 100644 --- a/client/src/app/components/discover-import-wizard/useWizardReducer.ts +++ b/client/src/app/components/discover-import-wizard/useWizardReducer.ts @@ -30,11 +30,6 @@ type WizardAction = | { type: "SET_RESULTS"; payload: ResultsData | null } | { type: "RESET"; payload: WizardState }; -const updateIsReady = (draft: WizardState) => { - draft.isReady = !!draft.platform && draft.filters.isValid; - return draft; -}; - const wizardReducer = ( draft: WizardState, action?: WizardAction @@ -51,12 +46,12 @@ const wizardReducer = ( draft.results = action.payload; break; case "RESET": - return updateIsReady(action.payload); + return action.payload; } } // Validate and update isReady state after any change - updateIsReady(draft); + draft.isReady = !!draft.platform && draft.filters.isValid; }; export type InitialStateRecipe = (draftInitialState: WizardState) => void; diff --git a/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts b/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts index 71a9884fb..d00c81863 100644 --- a/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts +++ b/client/src/app/pages/applications/generate-assets-wizard/useWizardReducer.ts @@ -37,14 +37,6 @@ type WizardAction = | { type: "SET_RESULTS"; payload: ResultsData | null } | { type: "RESET"; payload: WizardState }; -const updateIsReady = (draft: WizardState): WizardState => { - draft.isReady = - !!draft.profile && - draft.parameters.isValid && - draft.advancedOptions.isValid; - return draft; -}; - const wizardReducer = ( draft: WizardState, action?: WizardAction @@ -64,12 +56,15 @@ const wizardReducer = ( draft.results = action.payload; break; case "RESET": - return updateIsReady(action.payload); + return action.payload; } } // Validate and update isReady state after any change - updateIsReady(draft); + draft.isReady = + !!draft.profile && + draft.parameters.isValid && + draft.advancedOptions.isValid; }; export type InitialStateRecipe = (draftInitialState: WizardState) => void;