Skip to content

Commit a44d11b

Browse files
broodyclaude
andauthored
fix: show loading spinner while preset config loads (#2477)
## Summary - Show a loading spinner while preset config loads to prevent UI flash - Batch `verified` and `theme` state updates in the `loadConfig` callback so React 18 renders them in a single pass — eliminates the render gap where children briefly saw the default theme and `verified=false` - Extract verified computation into a reusable `computeVerifiedState` function - Use `originRef` to make origin available synchronously in async callbacks ## Test plan - [ ] Test with SessionConnector (redirect flow) with a preset — should not flash default theme or "not verified" warning - [ ] Test with ControllerConnector (iframe flow) with a preset — same behavior - [ ] Test without a preset — no behavior change expected 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 2b4a920 commit a44d11b

File tree

3 files changed

+95
-54
lines changed

3 files changed

+95
-54
lines changed

examples/next/src/components/providers/StarknetProvider.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -236,14 +236,14 @@ export const controllerConnector = new ControllerConnector({
236236
});
237237

238238
const session = new SessionConnector({
239-
policies,
240239
rpc: process.env.NEXT_PUBLIC_RPC_MAINNET!,
241-
chainId: constants.StarknetChainId.SN_MAIN,
242-
redirectUrl: typeof window !== "undefined" ? window.location.origin : "",
240+
chainId: shortString.encodeShortString("WP_JOKERS_CORE_SEASON2"),
241+
redirectUrl: "jokers://open",
243242
disconnectRedirectUrl: "whatsapp://",
244243
keychainUrl: getKeychainUrl(),
245244
apiUrl: process.env.NEXT_PUBLIC_CARTRIDGE_API_URL,
246245
signupOptions,
246+
preset: "jokers-of-neon",
247247
});
248248

249249
export function StarknetProvider({ children }: PropsWithChildren) {

packages/keychain/src/components/provider/index.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { IndexerAPIProvider } from "@cartridge/ui/utils/api/indexer";
2626
import { CartridgeAPIProvider } from "@cartridge/ui/utils/api/cartridge";
2727
import { ErrorBoundary } from "../ErrorBoundary";
2828
import { MarketplaceClientProvider } from "@cartridge/arcade/marketplace/react";
29+
import { SpinnerIcon } from "@cartridge/ui";
2930

3031
export function Provider({ children }: PropsWithChildren) {
3132
const connection = useConnectionValue();
@@ -59,6 +60,16 @@ export function Provider({ children }: PropsWithChildren) {
5960
[connection.controller, connection.project],
6061
);
6162

63+
// Wait for preset config to load before rendering UI to prevent
64+
// flash of default theme and unverified domain warning
65+
if (connection.isConfigLoading) {
66+
return (
67+
<div className="flex items-center justify-center h-screen w-screen bg-background">
68+
<SpinnerIcon className="animate-spin text-muted-foreground" size="lg" />
69+
</div>
70+
);
71+
}
72+
6273
return (
6374
<FeatureProvider>
6475
<CartridgeAPIProvider url={ENDPOINT}>

packages/keychain/src/hooks/connection.ts

Lines changed: 81 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,54 @@ export function resolvePolicies({
215215
return { policies: urlPolicies, isPoliciesResolved: true };
216216
}
217217

218+
/**
219+
* Computes the verified state from config data and origin.
220+
* Extracted as a pure function so it can be called both synchronously
221+
* (in the loadConfig callback) and in the verified useEffect.
222+
*/
223+
function computeVerifiedState(
224+
configData: Record<string, unknown>,
225+
currentOrigin: string | undefined,
226+
): boolean {
227+
const allowedOrigins = toArray(configData.origin as string | string[]);
228+
229+
if (!isIframe()) {
230+
const searchParams = new URLSearchParams(window.location.search);
231+
const redirectUrl =
232+
searchParams.get("redirect_url") || searchParams.get("redirect_uri");
233+
234+
if (redirectUrl) {
235+
try {
236+
const redirectUrlObj = new URL(redirectUrl);
237+
const redirectOrigin = redirectUrlObj.origin;
238+
const isLocalhost =
239+
redirectOrigin.includes("localhost") ||
240+
redirectOrigin === "capacitor://localhost";
241+
const isOriginAllowed = isOriginVerified(redirectUrl, allowedOrigins);
242+
return isLocalhost || isOriginAllowed;
243+
} catch (error) {
244+
console.error("Failed to parse redirect_url:", error);
245+
}
246+
}
247+
248+
return false;
249+
}
250+
251+
if (!configData.origin) {
252+
return false;
253+
}
254+
255+
if (currentOrigin) {
256+
const isLocalhost =
257+
currentOrigin.includes("localhost") ||
258+
currentOrigin === "capacitor://localhost";
259+
const isOriginAllowed = isOriginVerified(currentOrigin, allowedOrigins);
260+
return isLocalhost || isOriginAllowed;
261+
}
262+
263+
return false;
264+
}
265+
218266
export function useConnectionValue() {
219267
const { navigate } = useNavigation();
220268
const [parent, setParent] = useState<ParentMethods>();
@@ -254,6 +302,10 @@ export function useConnectionValue() {
254302
isPoliciesResolved,
255303
isConfigLoading,
256304
});
305+
// Ref to track origin synchronously for use in async callbacks (e.g. loadConfig)
306+
// where the state value may not yet be available via closure.
307+
const originRef = useRef<string | undefined>(undefined);
308+
257309
const [onModalClose, setOnModalCloseInternal] = useState<
258310
(() => void) | undefined
259311
>();
@@ -504,7 +556,26 @@ export function useConnectionValue() {
504556
setIsConfigLoading(true);
505557
loadConfig(urlParams.preset)
506558
.then((config) => {
507-
setConfigData((config as Record<string, unknown>) || null);
559+
const configObj = (config as Record<string, unknown>) || null;
560+
setConfigData(configObj);
561+
562+
if (configObj) {
563+
// Compute verified and theme immediately so React 18 batches
564+
// all state updates into a single render, preventing a flash
565+
// of default theme / unverified domain warning.
566+
const computedVerified = computeVerifiedState(
567+
configObj,
568+
originRef.current,
569+
);
570+
setVerified(computedVerified);
571+
572+
if ("theme" in configObj) {
573+
setTheme({
574+
verified: computedVerified,
575+
...(configObj.theme as ControllerTheme),
576+
});
577+
}
578+
}
508579
})
509580
.catch((error: Error) => {
510581
console.error("Failed to load config:", error);
@@ -515,60 +586,16 @@ export function useConnectionValue() {
515586
});
516587
}, [urlParams.preset]);
517588

518-
// Compute verified state separately once config is loaded and origin or redirect_url are available
589+
// Compute verified state when config is loaded and origin or redirect_url change.
590+
// Initial computation also happens in the loadConfig callback above to avoid
591+
// a flash, but this effect handles subsequent changes (e.g. origin arriving
592+
// later in iframe mode).
519593
useEffect(() => {
520594
if (!configData || isConfigLoading) {
521595
return;
522596
}
523597

524-
const allowedOrigins = toArray(configData.origin as string | string[]);
525-
526-
// In standalone mode (not iframe), verify preset if redirect_url matches preset whitelist
527-
if (!isIframe()) {
528-
const searchParams = new URLSearchParams(window.location.search);
529-
const redirectUrl =
530-
searchParams.get("redirect_url") || searchParams.get("redirect_uri");
531-
532-
if (redirectUrl) {
533-
try {
534-
const redirectUrlObj = new URL(redirectUrl);
535-
const redirectOrigin = redirectUrlObj.origin;
536-
537-
// Always consider localhost and default capacitor as verified for development
538-
const isLocalhost =
539-
redirectOrigin.includes("localhost") ||
540-
redirectOrigin === "capacitor://localhost";
541-
const isOriginAllowed = isOriginVerified(redirectUrl, allowedOrigins);
542-
const finalVerified = isLocalhost || isOriginAllowed;
543-
544-
setVerified(finalVerified);
545-
return;
546-
} catch (error) {
547-
console.error("Failed to parse redirect_url:", error);
548-
}
549-
}
550-
551-
// No redirect_url or invalid redirect_url - don't verify preset in standalone mode
552-
setVerified(false);
553-
return;
554-
}
555-
556-
if (!configData.origin) {
557-
setVerified(false);
558-
return;
559-
}
560-
561-
// Embedded mode: verify against parent origin
562-
// Always consider localhost and default capacitor as verified for development (not 127.0.0.1)
563-
if (origin) {
564-
const isLocalhost =
565-
origin.includes("localhost") || origin === "capacitor://localhost";
566-
const isOriginAllowed = isOriginVerified(origin, allowedOrigins);
567-
const finalVerified = isLocalhost || isOriginAllowed;
568-
setVerified(finalVerified);
569-
} else {
570-
setVerified(false);
571-
}
598+
setVerified(computeVerifiedState(configData, origin));
572599
}, [origin, configData, isConfigLoading]);
573600

574601
// Store referral data when URL params are available
@@ -708,7 +735,9 @@ export function useConnectionValue() {
708735

709736
connection.promise
710737
.then((parentConnection) => {
711-
setOrigin(normalizeOrigin(parentConnection.origin));
738+
const normalizedOrigin = normalizeOrigin(parentConnection.origin);
739+
originRef.current = normalizedOrigin;
740+
setOrigin(normalizedOrigin);
712741
// Extract origin and spread the rest to match ParentMethods type
713742
// eslint-disable-next-line @typescript-eslint/no-unused-vars
714743
const { origin: _, ...methods } = parentConnection;
@@ -741,6 +770,7 @@ export function useConnectionValue() {
741770
}
742771
}
743772

773+
originRef.current = appOrigin;
744774
setOrigin(appOrigin);
745775

746776
setParent({

0 commit comments

Comments
 (0)