Skip to content

Commit 6290fd6

Browse files
committed
fix: stabilize hydration layout slots
1 parent d49bdcc commit 6290fd6

6 files changed

Lines changed: 316 additions & 40 deletions

File tree

‎src/browser/components/AppLoader/AppLoader.auth.test.tsx‎

Lines changed: 44 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import React from "react";
44
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
55
import { cleanup, render } from "@testing-library/react";
66
import { useTheme } from "../../contexts/ThemeContext";
7+
import type { APIClient, UseAPIResult } from "../../contexts/API";
78
import { installDom } from "../../../../tests/ui/dom";
89

910
let cleanupDom: (() => void) | null = null;
@@ -27,37 +28,56 @@ void mock.module("lottie-react", () => ({
2728
default: () => <div data-testid="LottieMock" />,
2829
}));
2930

30-
void mock.module("@/browser/contexts/API", () => ({
31-
APIProvider: (props: { children: React.ReactNode }) => props.children,
32-
useAPI: () => {
33-
if (apiStatus === "auth_required") {
34-
return {
35-
api: null,
36-
status: "auth_required" as const,
37-
error: apiError,
38-
authenticate: () => undefined,
39-
retry: () => undefined,
40-
};
41-
}
42-
43-
if (apiStatus === "error") {
44-
return {
45-
api: null,
46-
status: "error" as const,
47-
error: apiError ?? "Connection error",
48-
authenticate: () => undefined,
49-
retry: () => undefined,
50-
};
51-
}
31+
function getMockAPIContextValue(): UseAPIResult {
32+
if (apiStatus === "auth_required") {
33+
return {
34+
api: null,
35+
status: "auth_required" as const,
36+
error: apiError,
37+
authenticate: () => undefined,
38+
retry: () => undefined,
39+
};
40+
}
5241

42+
if (apiStatus === "error") {
5343
return {
5444
api: null,
55-
status: "connecting" as const,
56-
error: null,
45+
status: "error" as const,
46+
error: apiError ?? "Connection error",
5747
authenticate: () => undefined,
5848
retry: () => undefined,
5949
};
50+
}
51+
52+
return {
53+
api: null,
54+
status: "connecting" as const,
55+
error: null,
56+
authenticate: () => undefined,
57+
retry: () => undefined,
58+
};
59+
}
60+
61+
const MockAPIContext = React.createContext<UseAPIResult | null>(null);
62+
let injectedAPIValue: UseAPIResult | null = null;
63+
64+
void mock.module("@/browser/contexts/API", () => ({
65+
APIContext: MockAPIContext,
66+
APIProvider: (props: { children: React.ReactNode; client?: APIClient }) => {
67+
const value = props.client
68+
? {
69+
api: props.client,
70+
status: "connected" as const,
71+
error: null,
72+
authenticate: () => undefined,
73+
retry: () => undefined,
74+
}
75+
: getMockAPIContextValue();
76+
injectedAPIValue = value;
77+
return <MockAPIContext.Provider value={value}>{props.children}</MockAPIContext.Provider>;
6078
},
79+
useAPI: () => injectedAPIValue ?? getMockAPIContextValue(),
80+
useOptionalAPI: () => injectedAPIValue ?? getMockAPIContextValue(),
6181
}));
6282

6383
void mock.module("@/browser/components/LoadingScreen/LoadingScreen", () => ({

‎src/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar.tsx‎

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,28 @@
11
import { Check, Settings } from "lucide-react";
22
import type { ProvidersConfigMap } from "@/common/orpc/types";
3+
import { Skeleton } from "../Skeleton/Skeleton";
34
import { formatProviderDisplayName } from "@/common/utils/providers/customProviders";
45
import { usePolicy } from "@/browser/contexts/PolicyContext";
56
import { getAllowedProvidersForUi } from "@/browser/utils/policyUi";
67
import { hasProviderIcon, ProviderIcon } from "../ProviderIcon/ProviderIcon";
78
import { useSettings } from "@/browser/contexts/SettingsContext";
89
import { Tooltip, TooltipContent, TooltipTrigger } from "../Tooltip/Tooltip";
910

11+
const CONFIGURED_PROVIDERS_BAR_FRAME_CLASS = "flex h-9 items-center justify-center gap-2 text-sm";
12+
13+
export function ConfiguredProvidersBarSkeleton() {
14+
return (
15+
<div
16+
// User-reported hydration flash: keep this loading slot height tied to
17+
// ConfiguredProvidersBar so ProjectPage cannot jump when provider config resolves.
18+
className={CONFIGURED_PROVIDERS_BAR_FRAME_CLASS}
19+
data-component="ConfiguredProvidersBarSkeleton"
20+
>
21+
<Skeleton className="h-6 w-32" />
22+
</div>
23+
);
24+
}
25+
1026
interface ConfiguredProvidersBarProps {
1127
providersConfig: ProvidersConfigMap;
1228
}
@@ -35,7 +51,10 @@ export function ConfiguredProvidersBar(props: ConfiguredProvidersBarProps) {
3551
.join(", ");
3652

3753
return (
38-
<div className="text-muted-foreground flex items-center justify-center gap-2 py-1.5 text-sm">
54+
<div
55+
className={`text-muted-foreground ${CONFIGURED_PROVIDERS_BAR_FRAME_CLASS}`}
56+
data-component="ConfiguredProvidersBar"
57+
>
3958
<Tooltip>
4059
<TooltipTrigger asChild>
4160
<span className="border-border/50 hover:border-border inline-flex items-center gap-1.5 rounded border px-2 py-1 text-sm transition-colors">

‎src/browser/components/ProjectPage/ProjectPage.autofocus.test.tsx‎

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useEffect } from "react";
1+
import { createContext, useEffect, type ReactNode } from "react";
22
import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test";
33
import { requireTestModule } from "@/browser/testUtils";
44
import { RouterProvider } from "@/browser/contexts/RouterContext";
@@ -23,14 +23,18 @@ function registerProjectPageMocks() {
2323
default: () => <div data-testid="LottieMock" />,
2424
}));
2525

26+
const apiContextValue = {
27+
api: null,
28+
status: "connecting" as const,
29+
error: null,
30+
authenticate: () => undefined,
31+
retry: () => undefined,
32+
};
2633
void mock.module("@/browser/contexts/API", () => ({
27-
useAPI: () => ({
28-
api: null,
29-
status: "connecting" as const,
30-
error: null,
31-
authenticate: () => undefined,
32-
retry: () => undefined,
33-
}),
34+
APIContext: createContext(apiContextValue),
35+
APIProvider: (props: { children: ReactNode }) => props.children,
36+
useAPI: () => apiContextValue,
37+
useOptionalAPI: () => apiContextValue,
3438
}));
3539

3640
// Mock useProvidersConfig to return a configured provider so ChatInput renders
@@ -43,13 +47,21 @@ function registerProjectPageMocks() {
4347
}));
4448

4549
// Mock ConfiguredProvidersBar to avoid tooltip/context dependencies
50+
const providerBarFrameClass = "flex h-9 items-center justify-center gap-2 text-sm";
4651
void mock.module("@/browser/components/ConfiguredProvidersBar/ConfiguredProvidersBar", () => ({
47-
ConfiguredProvidersBar: () => <div data-testid="ConfiguredProvidersBarMock" />,
52+
CONFIGURED_PROVIDERS_BAR_FRAME_CLASS: providerBarFrameClass,
53+
ConfiguredProvidersBar: () => (
54+
<div className={providerBarFrameClass} data-component="ConfiguredProvidersBar" />
55+
),
56+
ConfiguredProvidersBarSkeleton: () => (
57+
<div className={providerBarFrameClass} data-component="ConfiguredProvidersBarSkeleton" />
58+
),
4859
}));
4960

5061
// Mock ProjectContext to provide the minimal routing/project surface used by the
5162
// real WorkspaceProvider/AgentProvider stack without wiring the full app shell.
5263
void mock.module("@/browser/contexts/ProjectContext", () => ({
64+
ProjectProvider: (props: { children: ReactNode }) => props.children,
5365
useProjectContext: () => ({
5466
userProjects: new Map(),
5567
systemProjectPath: null,

‎src/browser/components/ProjectPage/ProjectPage.tsx‎

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,10 @@ import { ArchivedWorkspaces } from "../ArchivedWorkspaces/ArchivedWorkspaces";
1111
import { useAPI } from "@/browser/contexts/API";
1212
import { isWorkspaceArchived } from "@/common/utils/archive";
1313
import { GitInitBanner } from "../GitInitBanner/GitInitBanner";
14-
import { ConfiguredProvidersBar } from "../ConfiguredProvidersBar/ConfiguredProvidersBar";
14+
import {
15+
ConfiguredProvidersBar,
16+
ConfiguredProvidersBarSkeleton,
17+
} from "../ConfiguredProvidersBar/ConfiguredProvidersBar";
1518
import { ConfigureProvidersPrompt } from "../ConfigureProvidersPrompt/ConfigureProvidersPrompt";
1619
import { useProvidersConfig } from "@/browser/hooks/useProvidersConfig";
1720
import type { ProvidersConfigMap } from "@/common/orpc/types";
@@ -31,7 +34,6 @@ import {
3134
getProjectScopeId,
3235
} from "@/common/constants/storage";
3336
import { Button } from "@/browser/components/Button/Button";
34-
import { Skeleton } from "@/browser/components/Skeleton/Skeleton";
3537
import { isDesktopMode } from "@/browser/hooks/useDesktopTitlebar";
3638

3739
interface ProjectPageProps {
@@ -309,10 +311,7 @@ export const ProjectPage: React.FC<ProjectPageProps> = ({
309311
)}
310312
{/* Configured providers bar - compact icon carousel */}
311313
{providersLoading ? (
312-
// Skeleton placeholder matching ConfiguredProvidersBar height
313-
<div className="flex items-center justify-center gap-2 py-1.5">
314-
<Skeleton className="h-7 w-32" />
315-
</div>
314+
<ConfiguredProvidersBarSkeleton />
316315
) : (
317316
hasProviders &&
318317
providersConfig && (

‎src/browser/features/ChatInput/index.tsx‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2969,6 +2969,7 @@ const ChatInputInner: React.FC<ChatInputProps> = (props) => {
29692969
pb-[max(8px,min(env(safe-area-inset-bottom,0px),40px))]
29702970
mb-[calc(-1*min(env(safe-area-inset-bottom,0px),40px))]`
29712971
)}
2972+
data-chat-input-variant={variant}
29722973
data-component="ChatInputSection"
29732974
data-autofocus-state="done"
29742975
>

0 commit comments

Comments
 (0)