From 062311b175fa16fcd8789f95e36d64e9d4aa0772 Mon Sep 17 00:00:00 2001 From: Ndacyayisenga-droid Date: Wed, 23 Sep 2026 00:50:08 +0300 Subject: [PATCH 1/9] Add profiles to formulus --- desktop/public/formulus-injection.js | 79 +- .../scripts/copy-formplayer-to-desktop.mjs | 11 + desktop/src-tauri/src/lib.rs | 29 + desktop/src/components/CustomAppEmbed.tsx | 22 +- desktop/src/components/FormplayerEmbed.tsx | 20 +- desktop/src/lib/formPreviewBridge.ts | 2 +- .../src/lib/profileStorageInjection.test.ts | 177 +++ desktop/src/lib/profileStorageInjection.ts | 18 + desktop/src/types/domain.ts | 2 + .../formulus-profile-native-lifecycle.md | 540 ++++++++ formulus-formplayer/scripts/copy-to-rn.js | 2 +- formulus-formplayer/src/App.tsx | 99 +- .../src/components/DraftSelector.tsx | 20 +- formulus-formplayer/src/locales/en.json | 3 +- formulus-formplayer/src/locales/fr.json | 3 +- formulus-formplayer/src/locales/pt.json | 3 +- formulus-formplayer/src/mocks/webview-mock.ts | 16 + .../SignatureQuestionRenderer.test.tsx | 72 ++ .../renderers/SignatureQuestionRenderer.tsx | 52 +- .../src/services/DraftFlushQueue.test.ts | 36 + .../src/services/DraftFlushQueue.ts | 11 + .../DraftService.profileStorage.test.ts | 77 ++ .../src/services/DraftService.ts | 72 +- .../src/services/ProfileStorage.test.ts | 142 ++ .../src/services/ProfileStorage.ts | 111 ++ .../src/services/StickyService.ts | 6 +- .../src/services/nativeDraftFlush.test.ts | 94 ++ .../SignatureQuestionRenderer.stories.tsx | 12 + .../src/types/FormulusInterfaceDefinition.ts | 32 +- .../src/utils/formObservationData.test.ts | 1 + .../src/utils/formObservationData.ts | 1 + formulus/App.tsx | 22 +- .../formulus/ProfileDatabaseLifecycle.java | 130 ++ .../formulus/UserAppModule.kt | 79 ++ .../ProfileDatabaseLifecycleTest.java | 297 +++++ .../assets/webview/FormulusInjectionScript.js | 77 +- formulus/assets/webview/formulus-api.js | 27 +- formulus/index.js | 4 +- .../ios/Formulus.xcodeproj/project.pbxproj | 10 + .../ios/Formulus/ProfileDatabaseLifecycle.h | 25 + .../ios/Formulus/ProfileDatabaseLifecycle.m | 156 +++ formulus/ios/Formulus/UserAppModule.m | 141 ++ .../ios/Tests/ProfileDatabaseLifecycleTests.m | 286 ++++ formulus/scripts/generateInjectionScript.ts | 64 +- formulus/src/api/synkronus/Auth.ts | 357 +++-- .../src/api/synkronus/__tests__/Auth.test.ts | 159 ++- .../attachmentManifestResume.test.ts | 114 +- .../synkronus/__tests__/downloadPool.test.ts | 26 + .../__tests__/profileRequests.test.ts | 99 ++ formulus/src/api/synkronus/download.ts | 15 +- formulus/src/api/synkronus/downloadPool.ts | 21 +- .../src/api/synkronus/generated/common.ts | 14 +- formulus/src/api/synkronus/index.ts | 310 +++-- formulus/src/components/CustomAppWebView.tsx | 41 +- formulus/src/components/FormplayerModal.tsx | 877 +++++++------ formulus/src/components/MenuDrawer.tsx | 67 +- .../src/components/SignatureCaptureModal.tsx | 47 +- formulus/src/database/DatabaseService.ts | 6 +- .../src/database/FormObservationRepository.ts | 307 ++--- .../__tests__/observationIndex.test.ts | 9 + .../__tests__/observationIndexGuards.test.ts | 9 + .../__tests__/probeSqliteEngine.test.ts | 26 + .../__tests__/profileDatabase.test.ts | 167 +++ .../profileStorageRepository.test.ts | 84 ++ formulus/src/database/database.ts | 98 +- .../database/repositories/WatermelonDBRepo.ts | 1148 +++++++++-------- .../__tests__/WatermelonDBRepo.test.ts | 9 + .../profileRepositoryActivity.test.ts | 140 ++ formulus/src/diagnostics/exportDiagnostics.ts | 48 +- formulus/src/locales/en.json | 55 + formulus/src/locales/fr.json | 55 + formulus/src/locales/pt.json | 55 + formulus/src/navigation/MainAppNavigator.tsx | 53 +- formulus/src/navigation/MainTabNavigator.tsx | 15 +- .../src/navigation/ProfileNavigationIntent.ts | 51 + .../src/navigation/ProfileNavigationTypes.ts | 17 + .../__tests__/ProfileNavigationIntent.test.ts | 87 ++ formulus/src/navigation/useProfiles.ts | 21 + formulus/src/profiles/ProfileActivity.ts | 49 + formulus/src/profiles/ProfileBootstrap.ts | 21 + formulus/src/profiles/ProfileKeychain.ts | 20 + formulus/src/profiles/ProfileMigration.ts | 83 ++ formulus/src/profiles/ProfilePaths.ts | 45 + formulus/src/profiles/ProfileRegistry.ts | 212 +++ formulus/src/profiles/ProfileRoot.tsx | 58 + formulus/src/profiles/ProfileRuntime.ts | 28 + formulus/src/profiles/ProfileStorage.ts | 39 + formulus/src/profiles/ProfileTransitions.ts | 69 + formulus/src/profiles/ProfileTypes.ts | 44 + .../__tests__/ProfileActivity.test.ts | 74 ++ .../__tests__/ProfileMigration.test.ts | 388 ++++++ .../profiles/__tests__/ProfilePaths.test.ts | 80 ++ .../__tests__/ProfileRegistry.test.ts | 687 ++++++++++ .../profiles/__tests__/ProfileRuntime.test.ts | 43 + .../profiles/__tests__/ProfileStorage.test.ts | 127 ++ .../__tests__/ProfileTransitions.test.ts | 256 ++++ .../__tests__/fixtures/profileHarness.cjs | 380 ++++++ .../src/profiles/nativeProfileLifecycle.ts | 109 ++ formulus/src/screens/HomeScreen.tsx | 5 +- formulus/src/screens/MoreScreen.tsx | 8 +- formulus/src/screens/ProfileConnection.tsx | 338 +++++ formulus/src/screens/ProfilesScreen.tsx | 301 +++++ formulus/src/screens/SettingsScreen.tsx | 472 +------ formulus/src/screens/SyncScreen.tsx | 58 +- formulus/src/screens/WelcomeScreen.tsx | 12 +- .../screens/__tests__/ProfilesScreen.test.tsx | 642 +++++++++ formulus/src/screens/profileUiErrors.ts | 12 + formulus/src/screens/readPendingUploads.ts | 16 + formulus/src/services/AppConfigService.ts | 16 +- .../src/services/AttachmentExportService.ts | 49 +- formulus/src/services/ClientIdService.ts | 11 +- formulus/src/services/ExtensionService.ts | 22 + .../src/services/FormLocaleIndexService.ts | 54 +- formulus/src/services/FormService.ts | 83 +- .../src/services/NetworkProfileService.ts | 45 +- formulus/src/services/NotificationService.ts | 22 +- .../src/services/ObservationExportService.ts | 69 +- .../src/services/ObservationIndexService.ts | 228 ++-- formulus/src/services/QRSettingsService.ts | 2 +- .../src/services/RepositoryRecoveryService.ts | 15 +- .../src/services/SequenceCounterService.ts | 12 +- formulus/src/services/ServerConfigService.ts | 171 +-- formulus/src/services/ServerSwitchDecision.ts | 12 +- formulus/src/services/ServerSwitchService.ts | 84 +- .../src/services/SettingsHydrationCache.ts | 24 +- formulus/src/services/SyncService.ts | 55 +- .../src/services/WebViewFileUrlResolver.ts | 47 +- .../services/__tests__/FormService.test.ts | 15 + .../RepositoryRecoveryService.test.ts | 66 + .../__tests__/ServerSwitchService.test.ts | 163 +-- .../__tests__/SyncService.autoLogin.test.ts | 85 +- .../__tests__/attachmentStorage.test.ts | 18 +- .../services/__tests__/profileFileIO.test.ts | 98 ++ .../__tests__/profileServices.test.ts | 94 ++ formulus/src/services/attachmentStorage.ts | 250 ++-- formulus/src/services/normalizeServerUrl.ts | 77 ++ formulus/src/services/profileFileAccess.ts | 22 + formulus/src/services/saveZipToDevice.ts | 47 +- .../src/services/testUtils/profileMocks.ts | 65 + .../webview/FormulusInterfaceDefinition.ts | 32 +- .../src/webview/FormulusMessageHandlers.ts | 695 +++++----- .../webview/FormulusMessageHandlers.types.ts | 19 + .../src/webview/FormulusWebViewHandler.ts | 55 +- .../webview/__tests__/profileBridge.test.ts | 271 ++++ formulus/src/webview/profileBridgeActivity.ts | 23 + 145 files changed, 12306 insertions(+), 3176 deletions(-) create mode 100644 desktop/src/lib/profileStorageInjection.test.ts create mode 100644 desktop/src/lib/profileStorageInjection.ts create mode 100644 documentation/formulus-profile-native-lifecycle.md create mode 100644 formulus-formplayer/src/renderers/SignatureQuestionRenderer.test.tsx create mode 100644 formulus-formplayer/src/services/DraftFlushQueue.test.ts create mode 100644 formulus-formplayer/src/services/DraftFlushQueue.ts create mode 100644 formulus-formplayer/src/services/DraftService.profileStorage.test.ts create mode 100644 formulus-formplayer/src/services/ProfileStorage.test.ts create mode 100644 formulus-formplayer/src/services/ProfileStorage.ts create mode 100644 formulus-formplayer/src/services/nativeDraftFlush.test.ts create mode 100644 formulus/android/app/src/main/java/org/opendataensemble/formulus/ProfileDatabaseLifecycle.java create mode 100644 formulus/android/app/src/test/java/org/opendataensemble/formulus/ProfileDatabaseLifecycleTest.java create mode 100644 formulus/ios/Formulus/ProfileDatabaseLifecycle.h create mode 100644 formulus/ios/Formulus/ProfileDatabaseLifecycle.m create mode 100644 formulus/ios/Formulus/UserAppModule.m create mode 100644 formulus/ios/Tests/ProfileDatabaseLifecycleTests.m create mode 100644 formulus/src/api/synkronus/__tests__/profileRequests.test.ts create mode 100644 formulus/src/database/__tests__/profileDatabase.test.ts create mode 100644 formulus/src/database/__tests__/profileStorageRepository.test.ts create mode 100644 formulus/src/database/repositories/__tests__/profileRepositoryActivity.test.ts create mode 100644 formulus/src/navigation/ProfileNavigationIntent.ts create mode 100644 formulus/src/navigation/ProfileNavigationTypes.ts create mode 100644 formulus/src/navigation/__tests__/ProfileNavigationIntent.test.ts create mode 100644 formulus/src/navigation/useProfiles.ts create mode 100644 formulus/src/profiles/ProfileActivity.ts create mode 100644 formulus/src/profiles/ProfileBootstrap.ts create mode 100644 formulus/src/profiles/ProfileKeychain.ts create mode 100644 formulus/src/profiles/ProfileMigration.ts create mode 100644 formulus/src/profiles/ProfilePaths.ts create mode 100644 formulus/src/profiles/ProfileRegistry.ts create mode 100644 formulus/src/profiles/ProfileRoot.tsx create mode 100644 formulus/src/profiles/ProfileRuntime.ts create mode 100644 formulus/src/profiles/ProfileStorage.ts create mode 100644 formulus/src/profiles/ProfileTransitions.ts create mode 100644 formulus/src/profiles/ProfileTypes.ts create mode 100644 formulus/src/profiles/__tests__/ProfileActivity.test.ts create mode 100644 formulus/src/profiles/__tests__/ProfileMigration.test.ts create mode 100644 formulus/src/profiles/__tests__/ProfilePaths.test.ts create mode 100644 formulus/src/profiles/__tests__/ProfileRegistry.test.ts create mode 100644 formulus/src/profiles/__tests__/ProfileRuntime.test.ts create mode 100644 formulus/src/profiles/__tests__/ProfileStorage.test.ts create mode 100644 formulus/src/profiles/__tests__/ProfileTransitions.test.ts create mode 100644 formulus/src/profiles/__tests__/fixtures/profileHarness.cjs create mode 100644 formulus/src/profiles/nativeProfileLifecycle.ts create mode 100644 formulus/src/screens/ProfileConnection.tsx create mode 100644 formulus/src/screens/ProfilesScreen.tsx create mode 100644 formulus/src/screens/__tests__/ProfilesScreen.test.tsx create mode 100644 formulus/src/screens/profileUiErrors.ts create mode 100644 formulus/src/screens/readPendingUploads.ts create mode 100644 formulus/src/services/__tests__/RepositoryRecoveryService.test.ts create mode 100644 formulus/src/services/__tests__/profileFileIO.test.ts create mode 100644 formulus/src/services/__tests__/profileServices.test.ts create mode 100644 formulus/src/services/normalizeServerUrl.ts create mode 100644 formulus/src/services/profileFileAccess.ts create mode 100644 formulus/src/services/testUtils/profileMocks.ts create mode 100644 formulus/src/webview/__tests__/profileBridge.test.ts create mode 100644 formulus/src/webview/profileBridgeActivity.ts diff --git a/desktop/public/formulus-injection.js b/desktop/public/formulus-injection.js index 7207daf0d..c6ad1ddc7 100644 --- a/desktop/public/formulus-injection.js +++ b/desktop/public/formulus-injection.js @@ -1,8 +1,57 @@ // Auto-generated from FormulusInterfaceDefinition.ts // Do not edit directly - this file will be overwritten -// Last generated: 2026-06-19T12:32:54.430Z +// Last generated: 2026-09-18T16:52:28.301Z (function () { + const profileId = globalThis.__odeProfileId; + if (typeof profileId !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(profileId)) { + throw new Error( + 'Formulus requires a host profile ID before bridge initialization', + ); + } + const deletedIds = globalThis.__odeDeletedProfileIds || []; + if ( + !Array.isArray(deletedIds) || + deletedIds.some( + id => typeof id !== 'string' || !/^[a-zA-Z0-9_-]+$/.test(id), + ) + ) { + throw new Error('Invalid deleted profile IDs'); + } + // Tombstones are permanent: each origin is cleaned when it is next loaded. + // Never clear the whole origin; unrelated third-party storage is not ours. + function removePrefix(storage, prefix) { + for (let i = storage.length - 1; i >= 0; i--) { + const key = storage.key(i); + if (key !== null && key.startsWith(prefix)) storage.removeItem(key); + } + } + if (deletedIds.length) { + const storage = globalThis.localStorage; + deletedIds.forEach(id => removePrefix(storage, 'ode:' + id + ':')); + if (deletedIds.includes(globalThis.__odeLegacyWebStorageProfileId)) { + storage.removeItem('formulus_drafts'); + storage.removeItem('formulus_sticky_fields'); + } + } + if (deletedIds.includes(profileId)) + throw new Error('Active profile has been deleted'); + const appPrefix = 'ode:' + profileId + ':app:'; + const profileLocalStorage = Object.freeze({ + getItem: function (key) { + return globalThis.localStorage.getItem(appPrefix + String(key)); + }, + setItem: function (key, value) { + globalThis.localStorage.setItem(appPrefix + String(key), String(value)); + }, + removeItem: function (key) { + globalThis.localStorage.removeItem(appPrefix + String(key)); + }, + clear: function () { + removePrefix(globalThis.localStorage, appPrefix); + }, + }); + // Enhanced API availability detection and recovery function getFormulus() { // Check multiple locations where the API might exist @@ -14,8 +63,21 @@ function isFormulusAvailable() { const api = getFormulus(); + if ( + api && + typeof api.getProfileId === 'function' && + api.getProfileId() !== profileId + ) { + throw new Error( + 'A Formulus browser context cannot change profiles; remount it', + ); + } return ( - api && typeof api === 'object' && typeof api.getVersion === 'function' + api && + typeof api === 'object' && + typeof api.getVersion === 'function' && + typeof api.getProfileId === 'function' && + typeof api.getLocalStorageRef === 'function' ); } @@ -72,7 +134,7 @@ data = event.data; // Already an object } else { // console.warn('Global handleMessage: Received message with unexpected data type:', typeof event.data, event.data); - return; // Or handle error, but for now, just return to avoid breaking others. + return; // Or handle as an error, but for now, just return to avoid breaking others. } // Handle callbacks @@ -109,6 +171,13 @@ // Initialize the formulus interface globalThis.formulus = { + getProfileId: function () { + return profileId; + }, + getLocalStorageRef: function () { + return profileLocalStorage; + }, + // getVersion: => Promise getVersion: function () { return new Promise((resolve, reject) => { @@ -229,7 +298,7 @@ }); }, - // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; } => Promise + // openFormplayer: formType: string, params: Record, savedData: Record, options: { subObservationMode?: boolean; skipFinalize?: boolean; skipDraftSelection?: boolean; observationId?: string; } => Promise openFormplayer: function (formType, params, savedData, options) { return new Promise((resolve, reject) => { const messageId = @@ -356,7 +425,7 @@ }); }, - // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: ObservationFilter; whereClause?: string; } => Promise + // getObservationsByQuery: options: { formType: string; isDraft?: boolean; includeDeleted?: boolean; filter?: any; whereClause?: string; } => Promise getObservationsByQuery: function (options) { return new Promise((resolve, reject) => { const messageId = diff --git a/desktop/scripts/copy-formplayer-to-desktop.mjs b/desktop/scripts/copy-formplayer-to-desktop.mjs index 935f7a589..fc4cff9ff 100644 --- a/desktop/scripts/copy-formplayer-to-desktop.mjs +++ b/desktop/scripts/copy-formplayer-to-desktop.mjs @@ -49,5 +49,16 @@ if (!fs.existsSync(buildDir)) { fs.mkdirSync(targetDir, { recursive: true }); cleanDirectory(targetDir); copyRecursive(buildDir, targetDir); +fs.copyFileSync( + path.join( + formplayerRoot, + '..', + 'formulus', + 'assets', + 'webview', + 'FormulusInjectionScript.js', + ), + path.join(__dirname, '..', 'public', 'formulus-injection.js'), +); console.log(`✓ Copied formplayer build → ${targetDir}`); console.log(' Served by Vite as /formplayer_dist/ (base URL in dev).'); diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index da6baac78..78e03f11c 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -145,6 +145,9 @@ struct ServerProfile { struct AppConfigFile { #[serde(default = "schema_version_default")] schema_version: u32, + /// Permanent tombstones for cleanup when a browser origin is next loaded. + #[serde(default)] + deleted_profile_ids: Vec, active_profile_id: String, profiles: Vec, } @@ -487,6 +490,7 @@ struct AuthSession { #[serde(rename_all = "camelCase")] struct SettingsResponse { active_profile_id: String, + deleted_profile_ids: Vec, profiles: Vec, /// App data dir for constructing default per-profile DB paths in the UI. data_directory: String, @@ -837,6 +841,7 @@ fn default_app_config(data_dir: &Path) -> AppConfigFile { let db_path = sqlite_path_for_workspace(&workspace_dir); AppConfigFile { schema_version: 3, + deleted_profile_ids: Vec::new(), active_profile_id: id.clone(), profiles: vec![ServerProfile { id, @@ -862,6 +867,7 @@ fn migrate_legacy_workspace(workspace_path: &str, _data_dir: &Path) -> AppConfig let db = sqlite_path_for_workspace(&ws); AppConfigFile { schema_version: 3, + deleted_profile_ids: Vec::new(), active_profile_id: id.clone(), profiles: vec![ServerProfile { id, @@ -2300,6 +2306,7 @@ fn get_settings(ctx: tauri::State<'_, AppCtxHandle>) -> Result) -> Re if cfg.profiles.len() <= 1 { return Err("cannot delete the last profile".to_string()); } + if !cfg.profiles.iter().any(|p| p.id == profile_id) { + return Err("profile not found".to_string()); + } + if !cfg.deleted_profile_ids.contains(&profile_id) { + cfg.deleted_profile_ids.push(profile_id.clone()); + } cfg.profiles.retain(|p| p.id != profile_id); if cfg.active_profile_id == profile_id { cfg.active_profile_id = cfg.profiles[0].id.clone(); @@ -5866,6 +5882,19 @@ mod tests { use std::io::Read; use std::time::Instant; + #[test] + fn profile_browser_storage_tombstones_survive_config_round_trip() { + let mut cfg = super::default_app_config(Path::new("/tmp/ode-profile-test")); + cfg.deleted_profile_ids.push("deleted-id".to_string()); + let json = serde_json::to_string(&cfg).unwrap(); + let restored: super::AppConfigFile = serde_json::from_str(&json).unwrap(); + assert_eq!(restored.deleted_profile_ids, vec!["deleted-id"]); + let mut legacy = serde_json::to_value(&cfg).unwrap(); + legacy.as_object_mut().unwrap().remove("deletedProfileIds"); + let restored: super::AppConfigFile = serde_json::from_value(legacy).unwrap(); + assert!(restored.deleted_profile_ids.is_empty()); + } + #[test] fn attachment_copy_progress_step_scales_with_batch_size() { assert_eq!(attachment_copy_progress_step(5), 1); diff --git a/desktop/src/components/CustomAppEmbed.tsx b/desktop/src/components/CustomAppEmbed.tsx index 9672958fd..48ec79a00 100644 --- a/desktop/src/components/CustomAppEmbed.tsx +++ b/desktop/src/components/CustomAppEmbed.tsx @@ -16,6 +16,8 @@ import { } from '../lib/rewriteEmbeddedBundleHtml'; import { tauriClient } from '../lib/tauriClient'; import { WORKSPACE_BUNDLE_DEV_APP_INDEX } from '../lib/workspacePaths'; +import { buildProfileStorageInjection } from '../lib/profileStorageInjection'; +import { useCustodianStore } from '../store/useCustodianStore'; /** Matches Formulus: custom app entry under the extracted bundle (see `HomeScreen.tsx`). */ export const CUSTOM_APP_BUNDLE_INDEX_REL = 'bundles/active/app/index.html'; @@ -114,6 +116,8 @@ export const CustomAppEmbed = forwardRef< ref, ) { const innerRef = useRef(null); + const profileId = useCustodianStore(s => s.activeProfileId); + const mountGeneration = useRef(0); const onContentWindowReadyRef = useRef(onContentWindowReady); onContentWindowReadyRef.current = onContentWindowReady; const setRefs = useCallback( @@ -138,9 +142,16 @@ export const CustomAppEmbed = forwardRef< if (!el) { return; } + const generation = ++mountGeneration.current; + const isCurrent = () => generation === mountGeneration.current; setLoading(true); setError(null); try { + const settings = await tauriClient.getSettings(); + if (!isCurrent()) return; + if (settings.activeProfileId !== profileId) + throw new Error('Active profile changed while loading custom app'); + const profileStub = buildProfileStorageInjection(settings); const workspace = await tauriClient.getWorkspace(); if (!workspace) { throw new Error('No workspace configured for the active profile.'); @@ -160,10 +171,12 @@ export const CustomAppEmbed = forwardRef< const baseHref = appDirAssetUrl.endsWith('/') ? appDirAssetUrl : `${appDirAssetUrl}/`; - const stub = buildHostStub(devicePixelRatio); + if (!isCurrent()) return; + const stub = profileStub + buildHostStub(devicePixelRatio); const doc = injectIntoHead(html, stub, baseHref); const enc = new TextEncoder(); await tauriClient.writeWorkspaceFile(indexRel, enc.encode(doc)); + if (!isCurrent()) return; // Query busts document cache. Do not use a `#fragment` here: many SPAs use the // hash for routing (HashRouter or path), so `#ode-…` would break the initial route. const url = `${indexAssetUrl}?ode=${Date.now()}`; @@ -173,13 +186,17 @@ export const CustomAppEmbed = forwardRef< }; el.src = url; } catch (e) { + if (!isCurrent()) return; setError(e instanceof Error ? e.message : String(e)); setLoading(false); } - }, [indexRel, mode, devicePixelRatio]); + }, [indexRel, mode, devicePixelRatio, profileId]); useEffect(() => { void mountBlob(); + return () => { + mountGeneration.current++; + }; }, [mountKey, mountBlob]); const defaultLoadingLabel = @@ -195,6 +212,7 @@ export const CustomAppEmbed = forwardRef<

{loadingLabel ?? defaultLoadingLabel}

) : null}