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
@@ -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