diff --git a/e2e/app-locale-routes.spec.ts b/e2e/app-locale-routes.spec.ts index d0db25d5..94ef2298 100644 --- a/e2e/app-locale-routes.spec.ts +++ b/e2e/app-locale-routes.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from "@playwright/test"; +import { hasCanonicalPlaygroundSlugPath } from "./helpers/playgroundRoute"; + /** * Smoke tests for public `app/[lang]/*` routes (locale migration L1). * @@ -43,16 +45,15 @@ test.describe("app/[lang] public routes (non-default locales)", () => { ); }); - test("de playground landing is indexable with locale canonical", async ({ + test("de playground redirects to a canonical project path", async ({ page, }) => { await page.goto("/de/playground"); - await expect(page).toHaveTitle(/Playground/i); - await expect(page.locator('meta[name="robots"]')).toHaveCount(0); - await expect(page.locator('link[rel="canonical"]')).toHaveAttribute( - "href", - "https://dstruct.pro/de/playground", + await page.waitForURL( + (url) => hasCanonicalPlaygroundSlugPath(url.pathname), + { timeout: 30_000 }, ); + await expect(page).toHaveTitle(/\| dStruct$/); }); test("de profile is noindex with locale canonical", async ({ page }) => { diff --git a/e2e/helpers/playgroundRoute.ts b/e2e/helpers/playgroundRoute.ts index 3c5507c8..0c15e381 100644 --- a/e2e/helpers/playgroundRoute.ts +++ b/e2e/helpers/playgroundRoute.ts @@ -2,6 +2,17 @@ export const INVERT_BINARY_TREE_CANONICAL_PATH = "/playground/invert-binary-tree/case-1/solution-1"; +/** True when pathname includes playground + project + case + solution segments. */ +export function hasCanonicalPlaygroundSlugPath(pathname: string): boolean { + const segments = pathname.split("/").filter(Boolean); + const playgroundIndex = segments.indexOf("playground"); + if (playgroundIndex === -1) { + return false; + } + + return segments.length - playgroundIndex - 1 >= 3; +} + export function isCanonicalPlaygroundProjectPath( pathname: string, projectSlug: string, diff --git a/e2e/locale-migration-l3b.spec.ts b/e2e/locale-migration-l3b.spec.ts index 2b015daf..9fb39113 100644 --- a/e2e/locale-migration-l3b.spec.ts +++ b/e2e/locale-migration-l3b.spec.ts @@ -1,5 +1,7 @@ import { expect, test } from "@playwright/test"; +import { hasCanonicalPlaygroundSlugPath } from "./helpers/playgroundRoute"; + /** * L3b: legacy `/internal-marketing/*` and duplicate `/en/*` URLs 308 to public App routes. */ @@ -43,12 +45,15 @@ test.describe("locale migration L3b legacy redirects", () => { ); }); - test("internal-marketing en playground redirects to /playground", async ({ + test("internal-marketing en playground redirects to canonical playground project", async ({ page, }) => { await page.goto("/internal-marketing/en/playground"); - await expect(page).toHaveURL(/\/playground$/); - await expect(page).toHaveTitle(/Playground/i); + await page.waitForURL( + (url) => hasCanonicalPlaygroundSlugPath(url.pathname), + { timeout: 30_000 }, + ); + await expect(page).toHaveTitle(/\| dStruct$/); }); test("internal-marketing en profile redirects to /profile/:userId", async ({ diff --git a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx index 35d32600..8163004b 100644 --- a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx +++ b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx @@ -75,7 +75,7 @@ describe("usePlaygroundPanelsReady", () => { expect(result.current).toBe(true); }); - it("returns true on desktop bare /playground after split layout loads", async () => { + it("returns false on desktop bare /playground until a project slug is present", async () => { mockUsePlaygroundRoute.mockReturnValue({ basePath: "/playground", slug: [], @@ -90,7 +90,7 @@ describe("usePlaygroundPanelsReady", () => { }); await vi.waitFor(() => { - expect(result.current).toBe(true); + expect(result.current).toBe(false); }); }); }); diff --git a/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts b/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts deleted file mode 100644 index b8d95d86..00000000 --- a/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts +++ /dev/null @@ -1,38 +0,0 @@ -"use client"; - -import { useEffect, useRef } from "react"; - -import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; -import { useOptionalProjectBrowserContext } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; -import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; - -/** - * Desktop bare `/playground` is an indexable landing — open the project browser - * so users are not stuck on an empty split layout. - */ -export const useBarePlaygroundBrowseLanding = (): void => { - const isMobile = usePlaygroundMobileLayout(); - const route = usePlaygroundRoute(); - const projectBrowser = useOptionalProjectBrowserContext(); - const openedForPathRef = useRef(null); - - const routePath = route?.pathname ?? ""; - const projectSlug = route?.slug[0] ?? ""; - - useEffect(() => { - openedForPathRef.current = null; - }, [routePath]); - - useEffect(() => { - if (!route || isMobile || projectSlug || !projectBrowser) { - return; - } - - if (openedForPathRef.current === routePath) { - return; - } - - openedForPathRef.current = routePath; - projectBrowser.openBrowser(); - }, [isMobile, projectBrowser, projectSlug, route, routePath]); -}; diff --git a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts index 7a8d3f7e..621113f5 100644 --- a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts +++ b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts @@ -6,13 +6,16 @@ import { useEffect, useRef } from "react"; import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; import { api } from "#/shared/api"; +import { useHasMounted } from "#/shared/hooks"; import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; import { appendPlaygroundMobileViewQuery } from "#/shared/lib/appendPlaygroundMobileViewQuery"; import { buildCanonicalPlaygroundSlug, playgroundSlugKey, } from "#/shared/lib/buildCanonicalPlaygroundSlug"; +import { getRestorablePlaygroundPath } from "#/shared/lib/playgroundLastPath"; import { buildPlaygroundPath } from "#/shared/lib/playgroundRoute"; +import { getLastPlaygroundPath } from "#/shared/local-storage/playgroundPath"; /** * Mirrors server canonical redirects for client navigations (instant nav, ). @@ -23,12 +26,18 @@ export const useClientCanonicalPlaygroundRedirect = (): void => { const searchParams = useSearchParams(); const isMobile = usePlaygroundMobileLayout(); const serverInitialData = usePlaygroundInitialData(); + const hasMounted = useHasMounted(); const redirectingRef = useRef(false); const routeProjectSlug = route?.slug[0] ?? ""; const viewParam = searchParams?.get("view") ?? null; const routePath = route?.pathname ?? ""; + const allBriefQuery = api.project.allBrief.useQuery(undefined, { + initialData: serverInitialData?.allBrief, + enabled: Boolean(route && !routeProjectSlug && viewParam !== "browse"), + }); + const projectQuery = api.project.getBySlug.useQuery(routeProjectSlug, { enabled: Boolean(route && routeProjectSlug), initialData: @@ -41,6 +50,58 @@ export const useClientCanonicalPlaygroundRedirect = (): void => { redirectingRef.current = false; }, [routePath]); + // Bare `/playground`: restore last visit or first public project (mirror server redirect). + useEffect(() => { + if ( + !route || + routeProjectSlug || + viewParam === "browse" || + redirectingRef.current || + !hasMounted + ) { + return; + } + + const restoredPath = getRestorablePlaygroundPath( + getLastPlaygroundPath(), + route.basePath, + ); + if (restoredPath) { + const restoredTarget = appendPlaygroundMobileViewQuery(restoredPath, { + isMobile, + hasViewParam: Boolean(viewParam), + hasCanonicalSlug: true, + }); + if (restoredTarget !== routePath) { + redirectingRef.current = true; + route.navigateTo(restoredTarget, { replace: true }); + } + return; + } + + const firstProjectSlug = allBriefQuery.data?.[0]?.slug; + if (!firstProjectSlug) { + return; + } + + redirectingRef.current = true; + const targetPath = buildPlaygroundPath(route.basePath, [firstProjectSlug]); + const pathWithMobileView = appendPlaygroundMobileViewQuery(targetPath, { + isMobile, + hasViewParam: Boolean(viewParam), + hasCanonicalSlug: true, + }); + route.navigateTo(pathWithMobileView, { replace: true }); + }, [ + allBriefQuery.data, + hasMounted, + isMobile, + route, + routePath, + routeProjectSlug, + viewParam, + ]); + useEffect(() => { if ( !route || diff --git a/src/features/playground/hooks/usePlaygroundPanelsReady.ts b/src/features/playground/hooks/usePlaygroundPanelsReady.ts index 3044a555..c3ed3bbf 100644 --- a/src/features/playground/hooks/usePlaygroundPanelsReady.ts +++ b/src/features/playground/hooks/usePlaygroundPanelsReady.ts @@ -51,14 +51,10 @@ export const usePlaygroundPanelsReady = (): boolean => { return isInitialized; } - if (!splitLayoutReady) { + if (!splitLayoutReady || !projectSlug) { return false; } - if (!projectSlug) { - return true; - } - return isInitialized; }, [isInitialized, isMobile, projectSlug, route, splitLayoutReady]); }; diff --git a/src/features/playground/ui/PlaygroundLayoutClient.tsx b/src/features/playground/ui/PlaygroundLayoutClient.tsx index 6b1307a8..1abfce03 100644 --- a/src/features/playground/ui/PlaygroundLayoutClient.tsx +++ b/src/features/playground/ui/PlaygroundLayoutClient.tsx @@ -2,7 +2,6 @@ import React, { type ReactNode, useEffect } from "react"; -import { useBarePlaygroundBrowseLanding } from "#/features/playground/hooks/useBarePlaygroundBrowseLanding"; import { useClientCanonicalPlaygroundRedirect } from "#/features/playground/hooks/useClientCanonicalPlaygroundRedirect"; import { usePlaygroundPyodideWarmup } from "#/features/playground/hooks/usePlaygroundPyodideWarmup"; import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; @@ -23,7 +22,6 @@ const PlaygroundRouteEffects: React.FC = () => { usePlaygroundPyodideWarmup(); useClientCanonicalPlaygroundRedirect(); usePlaygroundSlugLoadingSync(); - useBarePlaygroundBrowseLanding(); useEffect(() => { void prefetchSplitPanelsLayout(); diff --git a/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts index 438e23c7..3b6db2ba 100644 --- a/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts +++ b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts @@ -46,7 +46,7 @@ describe("resolveCanonicalPlaygroundRedirect", () => { }); }); - it("keeps bare /playground indexable when there is no last-visit cookie", async () => { + it("redirects bare /playground to the first public project when there is no last-visit cookie", async () => { const { resolveCanonicalPlaygroundRedirect } = await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); @@ -56,6 +56,21 @@ describe("resolveCanonicalPlaygroundRedirect", () => { lastPathCookie: null, }); + expect(redirectPath).toBe("/playground/two-sum/case-1/solution-1"); + expect(mockLoadCachedPublicProjectsBrief).toHaveBeenCalled(); + }); + + it("keeps bare /playground when view=browse is set", async () => { + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: [], + lastPathCookie: null, + viewParam: "browse", + }); + expect(redirectPath).toBeNull(); expect(mockLoadCachedPublicProjectsBrief).not.toHaveBeenCalled(); }); diff --git a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts index d860ba71..b67950f9 100644 --- a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts +++ b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts @@ -102,13 +102,12 @@ export async function resolveCanonicalPlaygroundRedirect({ ssrDeviceType, viewParam, }: ResolveCanonicalPlaygroundRedirectInput): Promise { - const restoredPath = getRestorablePlaygroundPath(lastPathCookie, basePath); - - // Bare `/playground` stays indexable for SEO; only restore when a valid cookie exists. - if (slug.length === 0 && !restoredPath) { + if (viewParam === "browse") { return null; } + const restoredPath = getRestorablePlaygroundPath(lastPathCookie, basePath); + const caller = await createPlaygroundCaller(); const project = await resolveProjectSlug( caller,