From f8973fd3356a62e4d123a788f315767444a73b8c Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Tue, 12 Aug 2025 22:19:09 -0700 Subject: [PATCH] fix: stabilize env config fetching --- .../checkDynamicRemotesRuntimesApps.spec.ts | 6 +++-- .../host/src/components/App.js | 26 +++++++++---------- .../host/src/hooks/useFetchJson.js | 8 +++--- .../remote/src/components/WidgetWrapper.js | 23 +++++++++------- .../remote/src/hooks/useFetchJson.js | 9 ++++--- 5 files changed, 39 insertions(+), 33 deletions(-) diff --git a/advanced-api/dynamic-remotes-runtime-environment-variables/e2e/checkDynamicRemotesRuntimesApps.spec.ts b/advanced-api/dynamic-remotes-runtime-environment-variables/e2e/checkDynamicRemotesRuntimesApps.spec.ts index 3b4d43414c..6dd5f54556 100644 --- a/advanced-api/dynamic-remotes-runtime-environment-variables/e2e/checkDynamicRemotesRuntimesApps.spec.ts +++ b/advanced-api/dynamic-remotes-runtime-environment-variables/e2e/checkDynamicRemotesRuntimesApps.spec.ts @@ -19,8 +19,10 @@ async function openLocalhost(page: Page, port: number) { // Wait for the page to load but don't wait for networkidle since env loading might be polling await page.waitForLoadState('load'); - // Wait for React to render - either the loading screen or the main content - await page.waitForSelector('body > div', { timeout: 10000 }); + // Wait for the root element to be attached. It may not be visible immediately + // (for example, the remote app shows an empty #root while it loads env config), + // so we only wait for the element to exist in the DOM. + await page.waitForSelector('#root', { state: 'attached', timeout: 10000 }); // Log any errors found if (pageErrors.length > 0) { diff --git a/advanced-api/dynamic-remotes-runtime-environment-variables/host/src/components/App.js b/advanced-api/dynamic-remotes-runtime-environment-variables/host/src/components/App.js index f82fed1817..bbf95ca851 100644 --- a/advanced-api/dynamic-remotes-runtime-environment-variables/host/src/components/App.js +++ b/advanced-api/dynamic-remotes-runtime-environment-variables/host/src/components/App.js @@ -1,23 +1,23 @@ -import React, { createContext } from 'react'; +import React, { createContext, useMemo } from 'react'; import Main from './Main'; import useFetchJson from '../hooks/useFetchJson'; export const EnvContext = createContext(); const App = () => { - const { data, loading, error, retry } = useFetchJson( - '/env-config.json', - { - maxRetries: 2, - retryDelay: 500, - timeout: 3000, - validateData: (data) => data && typeof data === 'object', - fallbackData: { - API_URL: 'https://fallback.api.com', - REMOTE_URL: 'http://localhost:3001/remoteEntry.js' - } + // Memoize options to avoid re-triggering fetch on each render in development + const fetchOptions = useMemo(() => ({ + maxRetries: 2, + retryDelay: 500, + timeout: 3000, + validateData: (data) => data && typeof data === 'object', + fallbackData: { + API_URL: 'https://fallback.api.com', + REMOTE_URL: 'http://localhost:3001/remoteEntry.js' } - ); + }), []); + + const { data, loading, error, retry } = useFetchJson('/env-config.json', fetchOptions); if (loading) { return ( diff --git a/advanced-api/dynamic-remotes-runtime-environment-variables/host/src/hooks/useFetchJson.js b/advanced-api/dynamic-remotes-runtime-environment-variables/host/src/hooks/useFetchJson.js index 1193494c7e..d75a9fdcbc 100644 --- a/advanced-api/dynamic-remotes-runtime-environment-variables/host/src/hooks/useFetchJson.js +++ b/advanced-api/dynamic-remotes-runtime-environment-variables/host/src/hooks/useFetchJson.js @@ -124,18 +124,18 @@ const useFetchJson = (path, options = {}) => { }, [fetchData]); useEffect(() => { + // Component may mount twice in React 18 strict mode. Ensure the ref is + // reset so subsequent fetches can update state correctly. + isMountedRef.current = true; fetchData(); - }, [fetchData]); - // Cleanup on unmount - useEffect(() => { return () => { isMountedRef.current = false; if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; - }, []); + }, [fetchData]); return { data, diff --git a/advanced-api/dynamic-remotes-runtime-environment-variables/remote/src/components/WidgetWrapper.js b/advanced-api/dynamic-remotes-runtime-environment-variables/remote/src/components/WidgetWrapper.js index 5486059b0f..bdd0e81c0a 100644 --- a/advanced-api/dynamic-remotes-runtime-environment-variables/remote/src/components/WidgetWrapper.js +++ b/advanced-api/dynamic-remotes-runtime-environment-variables/remote/src/components/WidgetWrapper.js @@ -1,4 +1,4 @@ -import React, { createContext } from 'react'; +import React, { createContext, useMemo } from 'react'; import Widget from './Widget'; import useFetchJson from '../hooks/useFetchJson'; @@ -6,17 +6,20 @@ export const EnvContext = createContext(); // Wraps the Widget component with the EnvContext const WidgetWrapper = () => { + // Memoize fetch options to prevent repeated fetching in React strict mode + const fetchOptions = useMemo(() => ({ + maxRetries: 3, + retryDelay: 1000, + timeout: 5000, + validateData: (data) => data && typeof data === 'object', + fallbackData: { + API_URL: 'https://remote.fallback.api.com' + } + }), []); + const { data, loading, error, retry } = useFetchJson( `${__webpack_public_path__}env-config.json`, - { - maxRetries: 3, - retryDelay: 1000, - timeout: 5000, - validateData: (data) => data && typeof data === 'object', - fallbackData: { - API_URL: 'https://remote.fallback.api.com' - } - } + fetchOptions ); if (loading) { diff --git a/advanced-api/dynamic-remotes-runtime-environment-variables/remote/src/hooks/useFetchJson.js b/advanced-api/dynamic-remotes-runtime-environment-variables/remote/src/hooks/useFetchJson.js index 0f4559df56..f6e97fd510 100644 --- a/advanced-api/dynamic-remotes-runtime-environment-variables/remote/src/hooks/useFetchJson.js +++ b/advanced-api/dynamic-remotes-runtime-environment-variables/remote/src/hooks/useFetchJson.js @@ -125,18 +125,19 @@ const useFetchJson = (path, options = {}) => { }, [fetchData]); useEffect(() => { + // Reset mount flag on each mount. React 18 strict mode mounts components + // twice in development, so without resetting this flag the fetch results + // would be ignored on the second mount. + isMountedRef.current = true; fetchData(); - }, [fetchData]); - // Cleanup on unmount - useEffect(() => { return () => { isMountedRef.current = false; if (abortControllerRef.current) { abortControllerRef.current.abort(); } }; - }, []); + }, [fetchData]); return { data,