Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 7 additions & 6 deletions e2e/app-locale-routes.spec.ts
Original file line number Diff line number Diff line change
@@ -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).
*
Expand Down Expand Up @@ -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 }) => {
Expand Down
11 changes: 11 additions & 0 deletions e2e/helpers/playgroundRoute.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
11 changes: 8 additions & 3 deletions e2e/locale-migration-l3b.spec.ts
Original file line number Diff line number Diff line change
@@ -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.
*/
Expand Down Expand Up @@ -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 ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand All @@ -90,7 +90,7 @@ describe("usePlaygroundPanelsReady", () => {
});

await vi.waitFor(() => {
expect(result.current).toBe(true);
expect(result.current).toBe(false);
});
});
});
38 changes: 0 additions & 38 deletions src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts

This file was deleted.

Original file line number Diff line number Diff line change
Expand Up @@ -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, <Link>).
Expand All @@ -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:
Expand All @@ -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 ||
Expand Down
6 changes: 1 addition & 5 deletions src/features/playground/hooks/usePlaygroundPanelsReady.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
};
2 changes: 0 additions & 2 deletions src/features/playground/ui/PlaygroundLayoutClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -23,7 +22,6 @@ const PlaygroundRouteEffects: React.FC = () => {
usePlaygroundPyodideWarmup();
useClientCanonicalPlaygroundRedirect();
usePlaygroundSlugLoadingSync();
useBarePlaygroundBrowseLanding();

useEffect(() => {
void prefetchSplitPanelsLayout();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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();
});
Expand Down
7 changes: 3 additions & 4 deletions src/server/playground/resolveCanonicalPlaygroundRedirect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,13 +102,12 @@ export async function resolveCanonicalPlaygroundRedirect({
ssrDeviceType,
viewParam,
}: ResolveCanonicalPlaygroundRedirectInput): Promise<string | null> {
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,
Expand Down
Loading