fix: remove the footer layout shift, and hide the Studio chrome from signed-out visitors
CLS was 0.192 on every public route, and one element accounted for all of it: the footer moved at t≈482ms, right when the lazily loaded route chunk arrived. The site frame laid the footer out in normal flow, so before the content existed it sat at the bottom edge of the viewport — visible — and then dropped out of view when the page grew to 2400px. The frame is a flex column now with the footer pinned by `margin-top: auto`, and the content slot holds a viewport of height so the footer starts below the fold and only ever moves further out of sight. The slot is `main` once the route renders and `section.ui-page` while Suspense is pending; covering only the first left the shift in place, which is what the intermediate measurements showed. / /explore /projects /releases /search /profile 0.1924 → 0.0001 360 / 768 / 1440 across four routes: no horizontal overflow Separately, the route gate stopped the Studio page but not the Studio shell, so a signed-out visitor who typed /studio still got the whole workspace navigation — 작업본, 게시 기록, 새 문서, by name. No data crosses an href, but "비로그인 사용자가 Studio 화면을 볼 수 없다" is not satisfied by hiding the contents of a screen while showing the screen. The header is drawn only for an authenticated session; `children` is already the router's sign-in surface, which is the whole of what such a visitor gets. signed out h1 "세션이 필요합니다." 0 nav, 0 studio links signed in h1 "작업 흐름" 2 nav, 8 links, sign-out present Three test updates follow from behaviour that changed rather than broke: the frozen route inventory now derives `access` from each route's own layoutGroup instead of asserting "public" for all 28 — so a Studio route added without a gate fails that table too — and the router and shell harnesses supply the session the Studio surface now reads. The router suite also gains a test that a signed-out visitor gets neither the Studio heading nor its navigation, which is the regression the gate exists for. test:all is 1811 passed; the eight remaining failures are the three load-dependent flake families (ci-artifact-contract, provider-guardian-transaction, security-followup), all of which pass in isolation and reference none of the changed files. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f1498feee5
commit
5e2b1a5586
@@ -6,6 +6,7 @@ import { TECH_LOG_FEATURE_ID } from "../../application/tech-log-feature-input.ts
|
||||
import { StudioHeader } from "./components/studio-header.tsx";
|
||||
import { StudioProvider } from "./studio-provider.tsx";
|
||||
import { StudioRuntimeBoundary } from "./studio-runtime-boundary.tsx";
|
||||
import { useSession } from "../../../../presentation/providers/session-provider.tsx";
|
||||
import { useStudio } from "./use-studio.ts";
|
||||
|
||||
type StudioShellProps = Readonly<{ children: ReactNode }>;
|
||||
@@ -13,12 +14,20 @@ type StudioShellProps = Readonly<{ children: ReactNode }>;
|
||||
function StudioFrame({ children }: StudioShellProps) {
|
||||
const location = useLocation();
|
||||
const { requestAnnouncement } = useStudio();
|
||||
const { sessionState } = useSession();
|
||||
// The router gates the page, not the chrome, so a signed-out visitor who
|
||||
// typed /studio still got the Studio navigation — every workspace link, by
|
||||
// name. No data leaks through an href, but the checklist item is that a
|
||||
// signed-out visitor does not see the Studio screen, and the navigation is
|
||||
// the Studio screen. `children` here is the router's sign-in surface, which
|
||||
// is the whole of what such a visitor should get.
|
||||
const signedIn = sessionState === "authenticated";
|
||||
return (
|
||||
<>
|
||||
<a className="studio-skip-link" href="#main-content">
|
||||
본문으로 건너뛰기
|
||||
</a>
|
||||
<StudioHeader currentPath={location.pathname} />
|
||||
{signedIn ? <StudioHeader currentPath={location.pathname} /> : null}
|
||||
<main id="main-content" className="studio-main" tabIndex={-1}>
|
||||
{children}
|
||||
</main>
|
||||
|
||||
@@ -79,7 +79,39 @@ a {
|
||||
}
|
||||
|
||||
.site-frame {
|
||||
min-height: 100vh;
|
||||
/* Flex column with the footer pushed down by `margin-top: auto` instead of
|
||||
sitting wherever the flow leaves it. The route chunk loads after first
|
||||
paint, so in flow the footer rendered just under the header and then jumped
|
||||
when the content arrived — a single 0.192 layout shift, which is most of
|
||||
what the page scored. Pinned to the bottom of the frame it starts where it
|
||||
ends up. */
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
/* The route chunk loads after first paint, and while it does the shell renders
|
||||
the Suspense fallback — a `section.ui-page`, not a `main`. Both slots need the
|
||||
same reserved height: with only `main` covered the footer still sat at the
|
||||
viewport bottom during loading and then dropped out of view when the content
|
||||
arrived, which is the whole 0.192 the page scored. */
|
||||
.site-frame > main,
|
||||
.site-frame > .ui-page {
|
||||
/* Takes the slack so the footer stays put whether the route rendered a long
|
||||
document or nothing yet.
|
||||
|
||||
`min-height` is what actually removes the shift. The route chunk loads
|
||||
after first paint; with only `flex` the footer sat at the bottom edge of
|
||||
the viewport — visible — and then dropped to y≈2500 when the content
|
||||
arrived, which is a visible element moving and so counts in full. Holding
|
||||
the content area to a viewport tall puts the footer below the fold from the
|
||||
first frame, and later growth only pushes it further out of sight. */
|
||||
flex: 1 0 auto;
|
||||
min-height: 100dvh;
|
||||
}
|
||||
|
||||
.site-footer {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.shell {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
RouterProvider,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { createAnonymousSessionAdapter } from "../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { createTechLogFeatureInstalledInput } from "../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import { MOCK_STUDIO_INSTALL_CONTEXT } from "../helpers/studio-install-context.ts";
|
||||
@@ -102,12 +103,29 @@ function compileTimeGroupedRouteContract() {
|
||||
}
|
||||
void compileTimeGroupedRouteContract;
|
||||
|
||||
function renderRouter() {
|
||||
/**
|
||||
* Studio routes are `session-required`, so a Studio assertion needs a session
|
||||
* that says so — with the anonymous adapter the router correctly renders the
|
||||
* sign-in surface instead of the page, which is what the gate is for.
|
||||
*/
|
||||
function createSignedInSessionAdapter() {
|
||||
return createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated" as const,
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async () => ({ headers: {} }),
|
||||
recoverSession: async () => "restored" as const,
|
||||
notifyUnauthenticated: () => {},
|
||||
});
|
||||
}
|
||||
|
||||
function renderRouter(session = createAnonymousSessionAdapter()) {
|
||||
const techLog = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT);
|
||||
return render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
session: createAnonymousSessionAdapter(),
|
||||
session,
|
||||
featureInputs: { [TECH_LOG_FEATURE_ID]: techLog.input },
|
||||
})}
|
||||
>
|
||||
@@ -355,10 +373,27 @@ describe("generic application router", () => {
|
||||
expect(screen.queryByText("Not Found", { exact: true })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||
it("keeps a signed-out visitor out of the Studio layout entirely", async () => {
|
||||
window.history.pushState({}, "", "/studio");
|
||||
renderRouter();
|
||||
|
||||
// Not merely "no data": the Studio surface itself must not mount. Every
|
||||
// TechLog route used to register as `access: "public"`, so a signed-out
|
||||
// visitor who typed /studio got the shell, the navigation, and the page —
|
||||
// and the page then issued Studio API calls.
|
||||
await screen.findByRole("heading", { name: /세션|로그인/ });
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "작업 흐름" }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("navigation", { name: "Studio 주 탐색" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps Studio routes inside the persistent Studio layout", async () => {
|
||||
window.history.pushState({}, "", "/studio");
|
||||
renderRouter(createSignedInSessionAdapter());
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", { name: "작업 흐름" }),
|
||||
).toBeVisible();
|
||||
@@ -371,7 +406,7 @@ describe("generic application router", () => {
|
||||
|
||||
it("gives the Studio wildcard precedence over the Public not-found route", async () => {
|
||||
window.history.pushState({}, "", "/studio/missing");
|
||||
renderRouter();
|
||||
renderRouter(createSignedInSessionAdapter());
|
||||
|
||||
expect(
|
||||
await screen.findByRole("heading", {
|
||||
|
||||
@@ -92,9 +92,14 @@ describe("TechLog route boundary contract", () => {
|
||||
definition.chunkId,
|
||||
]),
|
||||
).toEqual(
|
||||
expectedRoutes.map(([routeId]) => [
|
||||
// Studio is the authenticated surface, Public is not. Derived here from
|
||||
// the frozen inventory's own layoutGroup column rather than restated per
|
||||
// route, so a Studio route added without a gate fails this table too —
|
||||
// every TechLog route used to be registered "public", which made the
|
||||
// router's access decision a no-op for Studio.
|
||||
expectedRoutes.map(([routeId, , layoutGroup]) => [
|
||||
routeId,
|
||||
"public",
|
||||
layoutGroup === "STUDIO" ? "session-required" : "public",
|
||||
routeId === "NOT_FOUND"
|
||||
? "route-not-found"
|
||||
: `route-${routeId.toLowerCase().replaceAll("_", "-")}`,
|
||||
|
||||
@@ -11,7 +11,9 @@ import type { StudioGateway } from "../../../src/features/tech-log/application/p
|
||||
import { StudioHomePage } from "../../../src/features/tech-log/presentation/studio/pages/studio-home-page.tsx";
|
||||
import { StudioNotFoundPage } from "../../../src/features/tech-log/presentation/studio/pages/studio-not-found-page.tsx";
|
||||
import { StudioShell } from "../../../src/features/tech-log/presentation/studio/studio-shell.tsx";
|
||||
import { createExternalAuthSessionAdapter } from "../../../src/adapters/auth/external-session-adapter.ts";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import { SessionProvider } from "../../../src/presentation/providers/session-provider.tsx";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
@@ -42,13 +44,28 @@ function renderStudio(
|
||||
);
|
||||
const installed = createTechLogFeatureInstalledInput(MOCK_STUDIO_INSTALL_CONTEXT).input;
|
||||
const application = createTestApplication({
|
||||
// The Studio shell draws its chrome only for an authenticated session — a
|
||||
// signed-out visitor gets the sign-in surface and nothing else, which is
|
||||
// what these tests are not about. In the real app AppRouter supplies both
|
||||
// the session state and the provider; here the harness does.
|
||||
session: createExternalAuthSessionAdapter({
|
||||
readState: () => "authenticated" as const,
|
||||
subscribe: () => () => {},
|
||||
beginSignIn: async () => {},
|
||||
signOut: async () => {},
|
||||
attachCredential: async () => ({ headers: {} }),
|
||||
recoverSession: async () => "restored" as const,
|
||||
notifyUnauthenticated: () => {},
|
||||
}),
|
||||
featureInputs: {
|
||||
"tech-log": { ...installed, createStudioGateway },
|
||||
},
|
||||
});
|
||||
const view = render(
|
||||
<ApplicationProvider application={application}>
|
||||
<SessionProvider>
|
||||
<RouterProvider router={router} />
|
||||
</SessionProvider>
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
return { ...view, router };
|
||||
|
||||
Reference in New Issue
Block a user