From 218049fd62f41e14e236c73da6a282879fa11673 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 9 Aug 2026 21:20:30 +0000 Subject: [PATCH 1/2] feat(app): add playground App Router pilot shell (Phase 3) - Extract PlaygroundPageView shared by Pages and App pilot routes - Add /internal-marketing/[locale]/playground/[[...slug]] with pilot metadata - Share resolvePlaygroundPageSeo between gSSP and generateMetadata - Add usePlaygroundRoute bridge for slug navigation under App Router - Extend useSearchParam and usePlaygroundSlugs for next/navigation - Add playgroundRoute path helpers and vitest next/navigation mock --- .../[locale]/playground/[[...slug]]/page.tsx | 46 ++++++ .../useMobilePlaygroundView.test.tsx | 10 ++ .../hooks/useMobilePlaygroundView.ts | 29 ++-- .../lib/resolvePlaygroundPageSeo.ts | 36 +++++ .../playground/ui/PlaygroundPageView.tsx | 86 +++++++++++ src/pages/playground/[[...slug]].tsx | 131 +++-------------- .../hooks/__tests__/useSearchParam.test.tsx | 10 ++ src/shared/hooks/usePlaygroundRoute.ts | 139 ++++++++++++++++++ src/shared/hooks/usePlaygroundSlugs.ts | 109 +++++--------- src/shared/hooks/useSearchParam.ts | 101 +++++++++---- .../lib/__tests__/playgroundRoute.test.ts | 58 ++++++++ src/shared/lib/playgroundRoute.ts | 60 ++++++++ src/shared/local-storage/playgroundPath.ts | 20 ++- vibe-docs/Instant-Navigations-TODO.md | 4 +- vitest.setup.ts | 15 ++ 15 files changed, 614 insertions(+), 240 deletions(-) create mode 100644 src/app/internal-marketing/[locale]/playground/[[...slug]]/page.tsx create mode 100644 src/features/playground/lib/resolvePlaygroundPageSeo.ts create mode 100644 src/features/playground/ui/PlaygroundPageView.tsx create mode 100644 src/shared/hooks/usePlaygroundRoute.ts create mode 100644 src/shared/lib/__tests__/playgroundRoute.test.ts create mode 100644 src/shared/lib/playgroundRoute.ts diff --git a/src/app/internal-marketing/[locale]/playground/[[...slug]]/page.tsx b/src/app/internal-marketing/[locale]/playground/[[...slug]]/page.tsx new file mode 100644 index 00000000..d70b34f2 --- /dev/null +++ b/src/app/internal-marketing/[locale]/playground/[[...slug]]/page.tsx @@ -0,0 +1,46 @@ +import type { Metadata } from "next"; +import React, { Suspense } from "react"; + +import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlaygroundPageSeo"; +import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView"; +import type { Locales } from "#/i18n/i18n-types"; +import { locales } from "#/i18n/i18n-util"; +import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; + +import { internalMarketingPilotMetadata } from "#/app/internal-marketing/internalMarketingPilotMetadata"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ locale: string; slug?: string[] }>; +}): Promise { + const { locale: localeParam, slug } = await params; + if (!locales.includes(localeParam as Locales)) { + return { robots: { index: false, follow: false } }; + } + const locale = localeParam as Locales; + const slugStr = slug?.[0]; + const pagePath = slugStr ? `/playground/${slugStr}` : "/playground"; + const { pageTitle, pageDescription } = await resolvePlaygroundPageSeo( + locale, + slugStr, + ); + + return internalMarketingPilotMetadata({ + locale, + pagePath, + title: pageTitle, + description: pageDescription, + }); +} + +const PlaygroundPilotFallback: React.FC = () => ; + +/** Instant Nav pilot: playground shell (noindex; public `/playground` remains canonical). */ +export default function InternalMarketingPlaygroundPage() { + return ( + }> + + + ); +} diff --git a/src/features/playground/hooks/__tests__/useMobilePlaygroundView.test.tsx b/src/features/playground/hooks/__tests__/useMobilePlaygroundView.test.tsx index 0674aa27..d97dd56c 100644 --- a/src/features/playground/hooks/__tests__/useMobilePlaygroundView.test.tsx +++ b/src/features/playground/hooks/__tests__/useMobilePlaygroundView.test.tsx @@ -13,6 +13,16 @@ vi.mock("next/compat/router", () => ({ useRouter: vi.fn(), })); +vi.mock("next/navigation", () => ({ + usePathname: () => "/playground", + useParams: () => ({}), + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + }), + useSearchParams: () => new URLSearchParams(), +})); + vi.mock("#/shared/hooks/useHasMounted", () => ({ useHasMounted: () => true, })); diff --git a/src/features/playground/hooks/useMobilePlaygroundView.ts b/src/features/playground/hooks/useMobilePlaygroundView.ts index af8529b6..625b5e35 100644 --- a/src/features/playground/hooks/useMobilePlaygroundView.ts +++ b/src/features/playground/hooks/useMobilePlaygroundView.ts @@ -5,7 +5,8 @@ import { useCallback, useEffect, useMemo } from "react"; import { callstackSlice } from "#/features/callstack/model/callstackSlice"; import { projectSlice } from "#/features/project/model/projectSlice"; import { useHasMounted, usePrevious } from "#/shared/hooks"; -import { usePagesRouterCompat } from "#/shared/hooks/usePagesRouterCompat"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { buildPlaygroundPath } from "#/shared/lib/playgroundRoute"; import { getLastPlaygroundPath, isValidLastPlaygroundPath, @@ -25,22 +26,19 @@ export type { PlaygroundView } from "./usePlaygroundViewParam"; * * - **Implicit code with a slug**: if `view` is absent, shallow `replace` adds `?view=code`. * - **Tab changes**: shallow `router.replace` (no extra history entries per tab tap). - * - Under App Router (no Pages router): returns browse defaults; no URL sync. + * - Under App Router pilot: uses {@link usePlaygroundRoute} for slug + `?view=` sync. */ export const useMobilePlaygroundView = () => { - const router = usePagesRouterCompat(); + const route = usePlaygroundRoute(); const dispatch = useAppDispatch(); const hasMounted = useHasMounted(); const { view, setView } = usePlaygroundViewParam(); - const hasProjectSlug = useMemo(() => { - if (!router) { - return false; - } - const slugs = router.query.slug; - return Array.isArray(slugs) && slugs.length > 0; - }, [router]); + const hasProjectSlug = useMemo( + () => route !== null && route.slug.length > 0, + [route], + ); const currentView: PlaygroundView = useMemo(() => { if (view && isPlaygroundView(view)) { @@ -66,12 +64,12 @@ export const useMobilePlaygroundView = () => { // With a project slug, default UI is Code; mirror that in the URL when `view` is omitted // (e.g. "Try it out", shared links, or `/playground/foo` without query). useEffect(() => { - if (!router?.isReady) return; + if (route?.basePath === undefined) return; if (!hasProjectSlug) return; if (view !== "") return; setView("code", { replace: true }); - }, [router?.isReady, hasProjectSlug, view, setView, router]); + }, [hasProjectSlug, view, setView, route?.basePath]); const navigateTo = useCallback( (targetView: PlaygroundView, pathName?: string) => { @@ -83,14 +81,17 @@ export const useMobilePlaygroundView = () => { const goToBrowse = useCallback(() => navigateTo("browse"), [navigateTo]); const goToCode = useCallback( (projectSlug?: string) => { - const pathName = projectSlug ? `/playground/${projectSlug}` : undefined; + const pathName = + projectSlug && route + ? buildPlaygroundPath(route.basePath, [projectSlug]) + : undefined; if (projectSlug) { dispatch(projectSlice.actions.loadStart()); } navigateTo("code", pathName); }, - [dispatch, navigateTo], + [dispatch, navigateTo, route], ); const goToResults = useCallback(() => navigateTo("results"), [navigateTo]); diff --git a/src/features/playground/lib/resolvePlaygroundPageSeo.ts b/src/features/playground/lib/resolvePlaygroundPageSeo.ts new file mode 100644 index 00000000..34ed4da6 --- /dev/null +++ b/src/features/playground/lib/resolvePlaygroundPageSeo.ts @@ -0,0 +1,36 @@ +import { createTranslationFunctions } from "#/i18n/createTranslationFunctions"; +import type { Locales } from "#/i18n/i18n-types"; +import { importLocaleAsync } from "#/i18n/i18n-util.async"; +import { db } from "#/server/db/client"; + +export type PlaygroundPageSeo = { + pageTitle: string; + pageDescription: string; +}; + +/** Shared playground `` / meta description for Pages gSSP and App metadata. */ +export async function resolvePlaygroundPageSeo( + locale: Locales, + slugStr?: string, +): Promise<PlaygroundPageSeo> { + const translation = await importLocaleAsync(locale); + const LL = createTranslationFunctions(locale, translation); + + let pageTitle: string = LL.PLAYGROUND_SEO_TITLE(); + let pageDescription: string = LL.SITE_SEO_DESCRIPTION(); + + if (slugStr) { + const project = await db.playgroundProject.findUnique({ + where: { slug: slugStr }, + select: { title: true, description: true }, + }); + if (project) { + pageTitle = `${project.title} | dStruct`; + pageDescription = project.description?.trim() + ? `${project.title}: ${project.description.trim()}` + : LL.PLAYGROUND_PROJECT_SEO_DESCRIPTION({ title: project.title }); + } + } + + return { pageTitle, pageDescription }; +} diff --git a/src/features/playground/ui/PlaygroundPageView.tsx b/src/features/playground/ui/PlaygroundPageView.tsx new file mode 100644 index 00000000..c230b475 --- /dev/null +++ b/src/features/playground/ui/PlaygroundPageView.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { darken, useTheme } from "@mui/material"; +import React from "react"; + +import { ConfigContext } from "#/context"; +import { MainAppBar } from "#/features/appBar/ui/MainAppBar"; +import { CodePanel } from "#/features/codeRunner/ui/CodePanel"; +import { OutputPanel } from "#/features/output/ui/OutputPanel"; +import { PlaygroundViewProvider } from "#/features/playground/context/PlaygroundViewContext"; +import { MobilePlayground } from "#/features/playground/ui/MobilePlayground"; +import { ProjectPanel } from "#/features/project/ui/ProjectPanel"; +import { TreeViewPanel } from "#/features/treeViewer/ui/TreeViewPanel"; +import { useAppConfig, useHasMounted } from "#/shared/hooks"; +import { useMobileLayout } from "#/shared/hooks/useMobileLayout"; +import { PageScrollContainer } from "#/shared/ui/templates/PageScrollContainer"; +import type { SplitPanelsLayoutProps } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; +import { SplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; +import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; + +type DesktopWrapperProps = SplitPanelsLayoutProps; + +const DesktopWrapper: React.FC<DesktopWrapperProps> = ({ + TopLeft, + BottomLeft, + TopRight, + BottomRight, +}) => { + const hasMounted = useHasMounted(); + + // Defer split layout until after mount to avoid Emotion hydration mismatch + // (server and client can render the four panels in different order). + if (!hasMounted) return <SplitPanelsLayoutSkeleton />; + + return ( + <SplitPanelsLayout + component="main" + TopLeft={TopLeft} + BottomLeft={BottomLeft} + TopRight={TopRight} + BottomRight={BottomRight} + /> + ); +}; + +/** Playground shell shared by Pages `/playground` and App pilot routes. */ +export const PlaygroundPageView: React.FC = () => { + const theme = useTheme(); + const isMobile = useMobileLayout(); + + const { data = {} } = useAppConfig(); + + return ( + <ConfigContext.Provider value={data}> + <PageScrollContainer + isPage={true} + options={ + isMobile + ? { overflow: { x: "hidden", y: "hidden" } } + : { scrollbars: { autoHide: "scroll" }, overflow: { x: "hidden" } } + } + style={{ + height: "100vh", + background: darken(theme.palette.background.default, 0.1), + }} + > + {isMobile ? ( + <PlaygroundViewProvider> + <MainAppBar toolbarVariant="dense" /> + <MobilePlayground /> + </PlaygroundViewProvider> + ) : ( + <> + <MainAppBar toolbarVariant="dense" /> + <DesktopWrapper + TopLeft={ProjectPanel} + BottomLeft={CodePanel} + TopRight={TreeViewPanel} + BottomRight={OutputPanel} + /> + </> + )} + </PageScrollContainer> + </ConfigContext.Provider> + ); +}; diff --git a/src/pages/playground/[[...slug]].tsx b/src/pages/playground/[[...slug]].tsx index 85475ed2..3ceea7b0 100644 --- a/src/pages/playground/[[...slug]].tsx +++ b/src/pages/playground/[[...slug]].tsx @@ -1,20 +1,8 @@ -import { darken, useTheme } from "@mui/material"; import type { GetServerSideProps, NextPage } from "next"; -import React from "react"; -import { ConfigContext } from "#/context"; -import { MainAppBar } from "#/features/appBar/ui/MainAppBar"; -import { CodePanel } from "#/features/codeRunner/ui/CodePanel"; -import { OutputPanel } from "#/features/output/ui/OutputPanel"; -import { PlaygroundViewProvider } from "#/features/playground/context/PlaygroundViewContext"; -import { MobilePlayground } from "#/features/playground/ui/MobilePlayground"; -import { ProjectPanel } from "#/features/project/ui/ProjectPanel"; -import { TreeViewPanel } from "#/features/treeViewer/ui/TreeViewPanel"; -import { createTranslationFunctions } from "#/i18n/createTranslationFunctions"; +import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlaygroundPageSeo"; +import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView"; import { loadI18nServerProps, localeFromContext } from "#/i18n/getI18nProps"; -import { db } from "#/server/db/client"; -import { useAppConfig, useHasMounted } from "#/shared/hooks"; -import { useMobileLayout } from "#/shared/hooks/useMobileLayout"; import { absoluteUrlFromPathname, pathnameFromResolvedUrl, @@ -24,37 +12,8 @@ import { setDeviceHintResponseHeaders, } from "#/shared/lib/ssrDevice"; import { SiteSeoHead } from "#/shared/ui/seo/SiteSeoHead"; -import { PageScrollContainer } from "#/shared/ui/templates/PageScrollContainer"; -import type { SplitPanelsLayoutProps } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; -import { SplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; -import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; import type { SsrDeviceType } from "#/themes"; -type DesktopWrapperProps = SplitPanelsLayoutProps; - -const DesktopWrapper: React.FC<DesktopWrapperProps> = ({ - TopLeft, - BottomLeft, - TopRight, - BottomRight, -}) => { - const hasMounted = useHasMounted(); - - // Defer split layout until after mount to avoid Emotion hydration mismatch - // (server and client can render the four panels in different order). - if (!hasMounted) return <SplitPanelsLayoutSkeleton />; - - return ( - <SplitPanelsLayout - component="main" - TopLeft={TopLeft} - BottomLeft={BottomLeft} - TopRight={TopRight} - BottomRight={BottomRight} - /> - ); -}; - type PlaygroundPageProps = { ssrDeviceType: SsrDeviceType; canonicalUrl: string; @@ -66,51 +25,16 @@ const PlaygroundPage: NextPage<PlaygroundPageProps> = ({ canonicalUrl, pageTitle, pageDescription, -}) => { - const theme = useTheme(); - const isMobile = useMobileLayout(); - - const { data = {} } = useAppConfig(); - - return ( - <ConfigContext.Provider value={data}> - <SiteSeoHead - title={pageTitle} - description={pageDescription} - canonicalUrl={canonicalUrl} - /> - <PageScrollContainer - isPage={true} - options={ - isMobile - ? { overflow: { x: "hidden", y: "hidden" } } - : { scrollbars: { autoHide: "scroll" }, overflow: { x: "hidden" } } - } - style={{ - height: "100vh", - background: darken(theme.palette.background.default, 0.1), - }} - > - {isMobile ? ( - <PlaygroundViewProvider> - <MainAppBar toolbarVariant="dense" /> - <MobilePlayground /> - </PlaygroundViewProvider> - ) : ( - <> - <MainAppBar toolbarVariant="dense" /> - <DesktopWrapper - TopLeft={ProjectPanel} - BottomLeft={CodePanel} - TopRight={TreeViewPanel} - BottomRight={OutputPanel} - /> - </> - )} - </PageScrollContainer> - </ConfigContext.Provider> - ); -}; +}) => ( + <> + <SiteSeoHead + title={pageTitle} + description={pageDescription} + canonicalUrl={canonicalUrl} + /> + <PlaygroundPageView /> + </> +); export const getServerSideProps: GetServerSideProps< PlaygroundPageProps @@ -124,33 +48,12 @@ export const getServerSideProps: GetServerSideProps< const pathOnly = pathnameFromResolvedUrl(resolvedUrl) || "/playground"; const canonicalUrl = absoluteUrlFromPathname(pathOnly); - const { i18n } = await loadI18nServerProps(context); const locale = localeFromContext(context); - const translation = i18n.translations[locale]; - const LL = translation - ? createTranslationFunctions(locale, translation) - : undefined; - - let pageTitle = LL?.PLAYGROUND_SEO_TITLE() ?? "Playground | dStruct"; - let pageDescription = - LL?.SITE_SEO_DESCRIPTION() ?? - "dStruct is a web app that helps you understand LeetCode problems. It allows you to visualize your solutions that you write in a built-in code editor."; - - if (slugStr) { - const project = await db.playgroundProject.findUnique({ - where: { slug: slugStr }, - select: { title: true, description: true }, - }); - if (project) { - pageTitle = `${project.title} | dStruct`; - pageDescription = project.description?.trim() - ? `${project.title}: ${project.description.trim()}` - : (LL?.PLAYGROUND_PROJECT_SEO_DESCRIPTION({ - title: project.title, - }) ?? - `Practice ${project.title} in dStruct — visualize solutions and run code in the browser.`); - } - } + const { pageTitle, pageDescription } = await resolvePlaygroundPageSeo( + locale, + slugStr, + ); + const { i18n } = await loadI18nServerProps(context); return { props: { diff --git a/src/shared/hooks/__tests__/useSearchParam.test.tsx b/src/shared/hooks/__tests__/useSearchParam.test.tsx index dea6f9ca..25f249c1 100644 --- a/src/shared/hooks/__tests__/useSearchParam.test.tsx +++ b/src/shared/hooks/__tests__/useSearchParam.test.tsx @@ -17,6 +17,16 @@ vi.mock("next/compat/router", () => ({ }), })); +vi.mock("next/navigation", () => ({ + usePathname: () => "/playground", + useParams: () => ({}), + useRouter: () => ({ + push: mockPush, + replace: mockReplace, + }), + useSearchParams: () => new URLSearchParams(), +})); + vi.mock("next/router", () => ({ useRouter: () => ({ asPath: "/playground", diff --git a/src/shared/hooks/usePlaygroundRoute.ts b/src/shared/hooks/usePlaygroundRoute.ts new file mode 100644 index 00000000..d1ff6c87 --- /dev/null +++ b/src/shared/hooks/usePlaygroundRoute.ts @@ -0,0 +1,139 @@ +"use client"; + +import { + useRouter as useAppRouter, + useParams, + usePathname, + useSearchParams, +} from "next/navigation"; +import { useCallback, useMemo } from "react"; + +import { usePagesRouterCompat } from "#/shared/hooks/usePagesRouterCompat"; +import { + parsePlaygroundPathname, + PLAYGROUND_PUBLIC_BASE_PATH, +} from "#/shared/lib/playgroundRoute"; + +export type PlaygroundNavigateOptions = { + /** @default false */ + replace?: boolean; + /** Drop `view` from the next URL. */ + omitView?: boolean; +}; + +export type PlaygroundRouteContext = { + basePath: string; + slug: string[]; + pathname: string; + navigateTo: (path: string, options?: PlaygroundNavigateOptions) => void; +}; + +/** + * Unified playground route state for Pages (`/playground`) and App pilot + * (`/internal-marketing/[locale]/playground`). + */ +export const usePlaygroundRoute = (): PlaygroundRouteContext | null => { + const pagesRouter = usePagesRouterCompat(); + const pathname = usePathname(); + const params = useParams(); + const appRouter = useAppRouter(); + const searchParams = useSearchParams(); + + const getPagesQuery = useCallback( + (omitView?: boolean) => { + if (!pagesRouter) { + return {}; + } + const query = { ...pagesRouter.query }; + delete query.slug; + if (omitView) { + delete query.view; + } + return query; + }, + [pagesRouter], + ); + + const buildAppQuerySuffix = useCallback( + (omitView?: boolean) => { + const nextParams = new URLSearchParams(searchParams?.toString()); + if (omitView) { + nextParams.delete("view"); + } + const queryString = nextParams.toString(); + return queryString ? `?${queryString}` : ""; + }, + [searchParams], + ); + + return useMemo(() => { + if (pagesRouter) { + const slug = Array.isArray(pagesRouter.query.slug) + ? pagesRouter.query.slug + : typeof pagesRouter.query.slug === "string" + ? [pagesRouter.query.slug] + : []; + + const navigateTo = ( + targetPath: string, + options?: PlaygroundNavigateOptions, + ) => { + const replace = options?.replace ?? false; + pagesRouter[replace ? "replace" : "push"]( + { + pathname: targetPath, + query: getPagesQuery(options?.omitView), + }, + undefined, + { shallow: true }, + ); + }; + + return { + basePath: PLAYGROUND_PUBLIC_BASE_PATH, + slug, + pathname: + pagesRouter.asPath.split("?")[0] ?? PLAYGROUND_PUBLIC_BASE_PATH, + navigateTo, + }; + } + + const parsed = pathname ? parsePlaygroundPathname(pathname) : null; + if (!parsed) { + return null; + } + + const slugParam = params?.slug; + const slug = Array.isArray(slugParam) + ? slugParam + : typeof slugParam === "string" + ? [slugParam] + : parsed.slug; + + const navigateTo = ( + targetPath: string, + options?: PlaygroundNavigateOptions, + ) => { + const href = `${targetPath}${buildAppQuerySuffix(options?.omitView)}`; + if (options?.replace) { + void appRouter.replace(href, { scroll: false }); + return; + } + void appRouter.push(href, { scroll: false }); + }; + + return { + basePath: parsed.basePath, + slug, + pathname: pathname ?? parsed.basePath, + navigateTo, + }; + }, [ + appRouter, + buildAppQuerySuffix, + getPagesQuery, + pagesRouter, + params?.slug, + pathname, + ]); +}; diff --git a/src/shared/hooks/usePlaygroundSlugs.ts b/src/shared/hooks/usePlaygroundSlugs.ts index ea61ee40..cca3071b 100644 --- a/src/shared/hooks/usePlaygroundSlugs.ts +++ b/src/shared/hooks/usePlaygroundSlugs.ts @@ -1,98 +1,70 @@ "use client"; -import { useCallback, useEffect, useMemo } from "react"; +import { useEffect, useMemo } from "react"; import { projectSlice } from "#/features/project/model/projectSlice"; -import { usePagesRouterCompat } from "#/shared/hooks/usePagesRouterCompat"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { + buildPlaygroundPath, + parsePlaygroundPathname, +} from "#/shared/lib/playgroundRoute"; import { getLastPlaygroundPath, getRestorablePlaygroundPath, - PLAYGROUND_BASE_PATH, removeLastPlaygroundPath, setLastPlaygroundPath, } from "#/shared/local-storage/playgroundPath"; import { useAppDispatch } from "#/store/hooks"; -type PlaygroundSlugNavigateOptions = { - /** @default false */ - replace?: boolean; - /** - * Remove `view` from the next URL. Use when changing project or landing on - * `/playground` so `?view=browse` from the picker is not carried over; omit - * for case/solution path changes so `?view=code` / `?view=results` stay put. - */ - omitView?: boolean; -}; - export const usePlaygroundSlugs = () => { const dispatch = useAppDispatch(); - const router = usePagesRouterCompat(); - - const getCurrentQuery = useCallback( - (omitView?: boolean) => { - if (!router) { - return {}; - } - const query = { ...router.query }; - delete query.slug; - if (omitView) { - delete query.view; - } - - return query; - }, - [router], - ); - - const navigateTo = useCallback( - (pathname: string, options?: PlaygroundSlugNavigateOptions) => { - if (!router) { - return; - } - const replace = options?.replace ?? false; - router[replace ? "replace" : "push"]( - { - pathname, - query: getCurrentQuery(options?.omitView), - }, - undefined, - { shallow: true }, - ); - }, - [getCurrentQuery, router], - ); + const route = usePlaygroundRoute(); useEffect(() => { - if (!router) { + if (!route) { + return; + } + const parsed = parsePlaygroundPathname(route.pathname); + if (!parsed) { return; } - const currentPath = router.asPath.split("?")[0]; - if (!currentPath?.startsWith(PLAYGROUND_BASE_PATH)) return; - const projectSlug = currentPath.split("/")[2]; - if (!projectSlug) return; + const projectSlug = parsed.slug[0]; + if (!projectSlug) { + return; + } - setLastPlaygroundPath(currentPath); - }, [router, router?.asPath]); + setLastPlaygroundPath(route.pathname); + }, [route]); return useMemo(() => { - const [projectSlug, caseSlug, solutionSlug] = Array.isArray( - router?.query.slug, - ) - ? router.query.slug - : []; + if (!route) { + return { + projectSlug: undefined, + caseSlug: undefined, + solutionSlug: undefined, + setProject: () => undefined, + setCase: () => undefined, + setSolution: () => undefined, + clearSlugs: () => undefined, + } as const; + } + + const [projectSlug, caseSlug, solutionSlug] = route.slug; + const { basePath, navigateTo } = route; const setProject = (slug?: string, isInitial?: boolean) => { dispatch(projectSlice.actions.loadStart()); if (!slug) { - return navigateTo(PLAYGROUND_BASE_PATH, { + return navigateTo(basePath, { replace: true, omitView: true, }); } const lastPath = getLastPlaygroundPath(); - if (lastPath && !lastPath.startsWith(PLAYGROUND_BASE_PATH)) { + const lastParsed = lastPath ? parsePlaygroundPathname(lastPath) : null; + if (lastPath && !lastParsed) { removeLastPlaygroundPath(); } const pathToRestore = isInitial @@ -103,7 +75,7 @@ export const usePlaygroundSlugs = () => { return navigateTo(pathToRestore, { replace: true, omitView: true }); } - return navigateTo(`${PLAYGROUND_BASE_PATH}/${slug}`, { + return navigateTo(buildPlaygroundPath(basePath, [slug]), { replace: true, omitView: true, }); @@ -117,7 +89,7 @@ export const usePlaygroundSlugs = () => { if (slug === caseSlug) return; return navigateTo( - `${PLAYGROUND_BASE_PATH}/${projectSlug}/${slug}/${solutionSlug ?? ""}`, + buildPlaygroundPath(basePath, [projectSlug, slug, solutionSlug ?? ""]), { replace: !caseSlug }, ); }; @@ -129,14 +101,14 @@ export const usePlaygroundSlugs = () => { if (slug === solutionSlug) return; return navigateTo( - `${PLAYGROUND_BASE_PATH}/${projectSlug}/${caseSlug}/${slug}`, + buildPlaygroundPath(basePath, [projectSlug, caseSlug, slug]), { replace: !solutionSlug }, ); }; const clearSlugs = () => { removeLastPlaygroundPath(); - return navigateTo(PLAYGROUND_BASE_PATH, { omitView: true }); + return navigateTo(basePath, { omitView: true }); }; return { @@ -148,6 +120,5 @@ export const usePlaygroundSlugs = () => { setSolution, clearSlugs, } as const; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router?.query.slug]); + }, [dispatch, route]); }; diff --git a/src/shared/hooks/useSearchParam.ts b/src/shared/hooks/useSearchParam.ts index b591d012..d66659f0 100644 --- a/src/shared/hooks/useSearchParam.ts +++ b/src/shared/hooks/useSearchParam.ts @@ -1,5 +1,10 @@ "use client"; +import { + useRouter as useAppRouter, + usePathname, + useSearchParams, +} from "next/navigation"; import { startTransition, useCallback, useEffect, useState } from "react"; import { usePagesRouterCompat } from "#/shared/hooks/usePagesRouterCompat"; @@ -28,7 +33,7 @@ const getParamFromRouter = ( /** * React hook to read and update a single search param (Pages Router). - * Under App Router (no Pages router), keeps React state only — no URL sync. + * Under App Router (no Pages router), syncs via `next/navigation` search params. * * @param param The search param to read and update. * @param options Options to customize the behavior of the hook. @@ -43,11 +48,19 @@ export const useSearchParam = <T extends string = string>( ) => { const { defaultValue, validate } = options; const router = usePagesRouterCompat(); + const pathname = usePathname(); + const appRouter = useAppRouter(); + const searchParams = useSearchParams(); const [state, setState] = useState<T | "">(() => { - if (!router) { + if (router) { + const initialValue = getParamFromRouter(param, router.query); + if (validate(initialValue)) { + return initialValue; + } return defaultValue; } - const initialValue = getParamFromRouter(param, router.query); + + const initialValue = searchParams?.get(param) ?? undefined; if (validate(initialValue)) { return initialValue; } @@ -56,55 +69,81 @@ export const useSearchParam = <T extends string = string>( }); useEffect(() => { - if (!router) { - return; - } - const { query } = router; - const paramValue = query[param]; - if (Array.isArray(paramValue)) { - console.error( - `useSearchParam: param ${param} is an array. This is not supported.`, - ); + if (router) { + const { query } = router; + const paramValue = query[param]; + if (Array.isArray(paramValue)) { + console.error( + `useSearchParam: param ${param} is an array. This is not supported.`, + ); + return; + } + + if (validate(paramValue)) { + startTransition(() => { + setState(paramValue); + }); + } return; } + const paramValue = searchParams?.get(param) ?? undefined; if (validate(paramValue)) { startTransition(() => { setState(paramValue); }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [router, param]); + }, [router, param, searchParams]); const updateParam = useCallback( (value: string, options: SearchParamUpdateOptions = {}) => { if (value !== "" && !validate(value)) return; setState(value); - if (!router) { + if (router) { + const { + asPath, + query: { slug: _slugQuery, ...newQuery }, + } = router; + if (value === "") { + delete newQuery[param]; + } else { + newQuery[param] = value; + } + + const currentPathname = asPath.split("?")[0]; + + void router[options.replace ? "replace" : "push"]( + { pathname: options.pathName || currentPathname, query: newQuery }, + undefined, + { + shallow: true, + }, + ); return; } - const { - asPath, - query: { slug: _slugQuery, ...newQuery }, - } = router; + + if (!pathname) { + return; + } + + const nextParams = new URLSearchParams(searchParams?.toString()); if (value === "") { - delete newQuery[param]; + nextParams.delete(param); } else { - newQuery[param] = value; + nextParams.set(param, value); + } + const queryString = nextParams.toString(); + const targetPath = options.pathName ?? pathname; + const href = queryString ? `${targetPath}?${queryString}` : targetPath; + if (options.replace) { + void appRouter.replace(href, { scroll: false }); + } else { + void appRouter.push(href, { scroll: false }); } - - const currentPathname = asPath.split("?")[0]; - - void router[options.replace ? "replace" : "push"]( - { pathname: options.pathName || currentPathname, query: newQuery }, - undefined, - { - shallow: true, - }, - ); }, - [validate, router, param], + [appRouter, param, pathname, router, searchParams, validate], ); return [state, updateParam] as const; diff --git a/src/shared/lib/__tests__/playgroundRoute.test.ts b/src/shared/lib/__tests__/playgroundRoute.test.ts new file mode 100644 index 00000000..98ac4629 --- /dev/null +++ b/src/shared/lib/__tests__/playgroundRoute.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it } from "vitest"; + +import { + buildPlaygroundPath, + internalMarketingPlaygroundBasePath, + parsePlaygroundPathname, + PLAYGROUND_PUBLIC_BASE_PATH, +} from "#/shared/lib/playgroundRoute"; + +describe("playgroundRoute", () => { + it("parses public playground root and slugs", () => { + expect(parsePlaygroundPathname("/playground")).toEqual({ + basePath: PLAYGROUND_PUBLIC_BASE_PATH, + slug: [], + }); + expect(parsePlaygroundPathname("/playground/invert-binary-tree")).toEqual({ + basePath: PLAYGROUND_PUBLIC_BASE_PATH, + slug: ["invert-binary-tree"], + }); + expect(parsePlaygroundPathname("/playground/foo/bar/baz")).toEqual({ + basePath: PLAYGROUND_PUBLIC_BASE_PATH, + slug: ["foo", "bar", "baz"], + }); + }); + + it("parses internal-marketing pilot playground paths", () => { + const basePath = internalMarketingPlaygroundBasePath("de"); + expect( + parsePlaygroundPathname("/internal-marketing/de/playground"), + ).toEqual({ + basePath, + slug: [], + }); + expect( + parsePlaygroundPathname( + "/internal-marketing/de/playground/invert-binary-tree", + ), + ).toEqual({ + basePath, + slug: ["invert-binary-tree"], + }); + }); + + it("returns null for non-playground paths", () => { + expect(parsePlaygroundPathname("/")).toBeNull(); + expect(parsePlaygroundPathname("/privacy")).toBeNull(); + expect(parsePlaygroundPathname("/internal-marketing/en")).toBeNull(); + }); + + it("builds paths from base + slug segments", () => { + expect(buildPlaygroundPath(PLAYGROUND_PUBLIC_BASE_PATH, [])).toBe( + "/playground", + ); + expect( + buildPlaygroundPath(PLAYGROUND_PUBLIC_BASE_PATH, ["foo", "bar"]), + ).toBe("/playground/foo/bar"); + }); +}); diff --git a/src/shared/lib/playgroundRoute.ts b/src/shared/lib/playgroundRoute.ts new file mode 100644 index 00000000..07b75584 --- /dev/null +++ b/src/shared/lib/playgroundRoute.ts @@ -0,0 +1,60 @@ +/** Public Pages Router playground prefix (canonical URLs). */ +export const PLAYGROUND_PUBLIC_BASE_PATH = "/playground"; + +const INTERNAL_MARKETING_PREFIX = "/internal-marketing"; + +export type ParsedPlaygroundRoute = { + basePath: string; + slug: string[]; +}; + +/** App Router pilot base for a locale segment. */ +export function internalMarketingPlaygroundBasePath(locale: string): string { + return `${INTERNAL_MARKETING_PREFIX}/${locale}/playground`; +} + +/** + * Parses `/playground/...` or `/internal-marketing/{locale}/playground/...`. + */ +export function parsePlaygroundPathname( + pathname: string, +): ParsedPlaygroundRoute | null { + const pathOnly = pathname.split("?")[0] ?? pathname; + + if ( + pathOnly === PLAYGROUND_PUBLIC_BASE_PATH || + pathOnly.startsWith(`${PLAYGROUND_PUBLIC_BASE_PATH}/`) + ) { + const remainder = pathOnly.slice(PLAYGROUND_PUBLIC_BASE_PATH.length); + const slug = remainder + .replace(/^\//, "") + .split("/") + .filter((segment) => segment.length > 0); + return { basePath: PLAYGROUND_PUBLIC_BASE_PATH, slug }; + } + + const pilotMatch = pathOnly.match( + /^\/internal-marketing\/([^/]+)\/playground(?:\/(.*))?$/, + ); + if (pilotMatch) { + const locale = pilotMatch[1] ?? ""; + const slugPart = pilotMatch[2]; + const slug = slugPart + ? slugPart.split("/").filter((segment) => segment.length > 0) + : []; + return { + basePath: internalMarketingPlaygroundBasePath(locale), + slug, + }; + } + + return null; +} + +/** Builds a playground path under the given base (public or pilot). */ +export function buildPlaygroundPath(basePath: string, slug: string[]): string { + if (slug.length === 0) { + return basePath; + } + return `${basePath}/${slug.join("/")}`; +} diff --git a/src/shared/local-storage/playgroundPath.ts b/src/shared/local-storage/playgroundPath.ts index 5dfcc89a..ac23356e 100644 --- a/src/shared/local-storage/playgroundPath.ts +++ b/src/shared/local-storage/playgroundPath.ts @@ -1,13 +1,15 @@ import { createStringStorage } from "#/shared/browser-storage"; +import { + parsePlaygroundPathname, + PLAYGROUND_PUBLIC_BASE_PATH, +} from "#/shared/lib/playgroundRoute"; -export const PLAYGROUND_BASE_PATH = "/playground"; +export const PLAYGROUND_BASE_PATH = PLAYGROUND_PUBLIC_BASE_PATH; const lastPlaygroundPathStorage = createStringStorage({ key: "lastPlaygroundPath", }); -const getProjectSlug = (path: string): string | undefined => path.split("/")[2]; - /** * Returns the last playground path from localStorage, or null on SSR / when not set. */ @@ -27,13 +29,8 @@ export const removeLastPlaygroundPath = (): void => { * Used to decide if we have a "last project" to show (e.g. default view). */ export const isValidLastPlaygroundPath = (path: string | null): boolean => { - if (!path?.startsWith(PLAYGROUND_BASE_PATH)) { - return false; - } - - const projectSlug = getProjectSlug(path); - - return Boolean(projectSlug); + const parsed = path ? parsePlaygroundPathname(path) : null; + return Boolean(parsed?.slug[0]); }; /** @@ -44,6 +41,7 @@ export const getRestorablePlaygroundPath = ( path: string | null, ): string | null => { if (!isValidLastPlaygroundPath(path)) return null; - const projectSlug = getProjectSlug(path!); + const parsed = parsePlaygroundPathname(path!); + const projectSlug = parsed?.slug[0]; return projectSlug?.startsWith("[[") ? null : path; }; diff --git a/vibe-docs/Instant-Navigations-TODO.md b/vibe-docs/Instant-Navigations-TODO.md index b4a35cdf..a7facdde 100644 --- a/vibe-docs/Instant-Navigations-TODO.md +++ b/vibe-docs/Instant-Navigations-TODO.md @@ -36,7 +36,9 @@ ## Phase 3+ — Playground / full migration -- [ ] Playground App route shell +- [x] Playground App route shell (`/internal-marketing/[locale]/playground/[[...slug]]`) +- [x] `PlaygroundPageView` shared by Pages + App pilot +- [x] `usePlaygroundRoute` bridge for slug navigation under App Router - [ ] Profile migration - [ ] Remove `i18n` from `next.config.mjs` - [ ] `@next/playwright` `instant()` tests diff --git a/vitest.setup.ts b/vitest.setup.ts index c1e35647..752e1bc3 100644 --- a/vitest.setup.ts +++ b/vitest.setup.ts @@ -2,11 +2,26 @@ import "@testing-library/jest-dom/vitest"; import { defineWebWorkers } from "@vitest/web-worker/pure"; import ResizeObserver from "resize-observer-polyfill"; import { TextDecoder, TextEncoder } from "util"; +import { vi } from "vitest"; import "vitest-canvas-mock"; // Set SKIP_ENV_VALIDATION before any imports that might trigger env validation process.env.SKIP_ENV_VALIDATION = "1"; +vi.mock("next/navigation", () => ({ + usePathname: () => "/", + useParams: () => ({}), + useRouter: () => ({ + push: vi.fn(), + replace: vi.fn(), + refresh: vi.fn(), + back: vi.fn(), + forward: vi.fn(), + prefetch: vi.fn(), + }), + useSearchParams: () => new URLSearchParams(), +})); + defineWebWorkers({ clone: "none" }); Object.assign(global, { TextDecoder, TextEncoder, ResizeObserver }); From db7264d948556932d1bac5aa2b7e31c464566fc1 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Mon, 10 Aug 2026 07:20:05 +0000 Subject: [PATCH 2/2] fix(playground): address pilot route review follow-ups - Remap lastPlaygroundPath restore onto current basePath (pilot vs public) - Omit empty slug segments in buildPlaygroundPath and setCase navigation - Add remapPlaygroundPathToBase helper with unit tests --- src/shared/hooks/usePlaygroundSlugs.ts | 25 +++++++---------- .../lib/__tests__/playgroundPath.test.ts | 7 +++++ .../lib/__tests__/playgroundRoute.test.ts | 27 +++++++++++++++++++ src/shared/lib/playgroundRoute.ts | 22 ++++++++++++--- src/shared/local-storage/playgroundPath.ts | 9 +++++-- 5 files changed, 69 insertions(+), 21 deletions(-) diff --git a/src/shared/hooks/usePlaygroundSlugs.ts b/src/shared/hooks/usePlaygroundSlugs.ts index cca3071b..b5a4f011 100644 --- a/src/shared/hooks/usePlaygroundSlugs.ts +++ b/src/shared/hooks/usePlaygroundSlugs.ts @@ -21,19 +21,9 @@ export const usePlaygroundSlugs = () => { const route = usePlaygroundRoute(); useEffect(() => { - if (!route) { - return; - } - const parsed = parsePlaygroundPathname(route.pathname); - if (!parsed) { - return; - } - - const projectSlug = parsed.slug[0]; - if (!projectSlug) { + if (!route?.slug[0]) { return; } - setLastPlaygroundPath(route.pathname); }, [route]); @@ -68,7 +58,7 @@ export const usePlaygroundSlugs = () => { removeLastPlaygroundPath(); } const pathToRestore = isInitial - ? getRestorablePlaygroundPath(lastPath) + ? getRestorablePlaygroundPath(lastPath, basePath) : null; if (pathToRestore) { @@ -88,10 +78,13 @@ export const usePlaygroundSlugs = () => { if (slug === caseSlug) return; - return navigateTo( - buildPlaygroundPath(basePath, [projectSlug, slug, solutionSlug ?? ""]), - { replace: !caseSlug }, - ); + const caseSegments = solutionSlug + ? [projectSlug, slug, solutionSlug] + : [projectSlug, slug]; + + return navigateTo(buildPlaygroundPath(basePath, caseSegments), { + replace: !caseSlug, + }); }; const setSolution = (slug: string) => { diff --git a/src/shared/lib/__tests__/playgroundPath.test.ts b/src/shared/lib/__tests__/playgroundPath.test.ts index 37fa327a..75ce86fa 100644 --- a/src/shared/lib/__tests__/playgroundPath.test.ts +++ b/src/shared/lib/__tests__/playgroundPath.test.ts @@ -54,6 +54,13 @@ describe("playgroundPath", () => { expect(getRestorablePlaygroundPath(path)).toBe(path); }); + it("remaps slug segments when targetBasePath is provided", () => { + const path = "/playground/some-project"; + expect( + getRestorablePlaygroundPath(path, "/internal-marketing/de/playground"), + ).toBe("/internal-marketing/de/playground/some-project"); + }); + it("returns null when path is invalid", () => { expect(getRestorablePlaygroundPath(null)).toBe(null); expect(getRestorablePlaygroundPath("")).toBe(null); diff --git a/src/shared/lib/__tests__/playgroundRoute.test.ts b/src/shared/lib/__tests__/playgroundRoute.test.ts index 98ac4629..e1869a55 100644 --- a/src/shared/lib/__tests__/playgroundRoute.test.ts +++ b/src/shared/lib/__tests__/playgroundRoute.test.ts @@ -5,6 +5,7 @@ import { internalMarketingPlaygroundBasePath, parsePlaygroundPathname, PLAYGROUND_PUBLIC_BASE_PATH, + remapPlaygroundPathToBase, } from "#/shared/lib/playgroundRoute"; describe("playgroundRoute", () => { @@ -55,4 +56,30 @@ describe("playgroundRoute", () => { buildPlaygroundPath(PLAYGROUND_PUBLIC_BASE_PATH, ["foo", "bar"]), ).toBe("/playground/foo/bar"); }); + + it("omits empty slug segments", () => { + expect( + buildPlaygroundPath(PLAYGROUND_PUBLIC_BASE_PATH, ["foo", "", "bar"]), + ).toBe("/playground/foo/bar"); + expect( + buildPlaygroundPath(PLAYGROUND_PUBLIC_BASE_PATH, ["foo", "case", ""]), + ).toBe("/playground/foo/case"); + }); + + it("remaps stored paths onto a different base (pilot vs public)", () => { + const pilotBase = internalMarketingPlaygroundBasePath("de"); + expect( + remapPlaygroundPathToBase("/playground/invert-binary-tree", pilotBase), + ).toBe("/internal-marketing/de/playground/invert-binary-tree"); + expect( + remapPlaygroundPathToBase( + "/internal-marketing/en/playground/foo/bar", + PLAYGROUND_PUBLIC_BASE_PATH, + ), + ).toBe("/playground/foo/bar"); + expect(remapPlaygroundPathToBase("/playground", pilotBase)).toBeNull(); + expect( + remapPlaygroundPathToBase("/playground/[[...slug]]", pilotBase), + ).toBeNull(); + }); }); diff --git a/src/shared/lib/playgroundRoute.ts b/src/shared/lib/playgroundRoute.ts index 07b75584..04bf210c 100644 --- a/src/shared/lib/playgroundRoute.ts +++ b/src/shared/lib/playgroundRoute.ts @@ -51,10 +51,26 @@ export function parsePlaygroundPathname( return null; } -/** Builds a playground path under the given base (public or pilot). */ +/** Builds a playground path under the given base (public or pilot). Empty segments are omitted. */ export function buildPlaygroundPath(basePath: string, slug: string[]): string { - if (slug.length === 0) { + const segments = slug.filter((segment) => segment.length > 0); + if (segments.length === 0) { return basePath; } - return `${basePath}/${slug.join("/")}`; + return `${basePath}/${segments.join("/")}`; +} + +/** + * Remaps slug segments from a stored playground path onto `targetBasePath`. + * Used when restoring last project on pilot vs public routes. + */ +export function remapPlaygroundPathToBase( + path: string, + targetBasePath: string, +): string | null { + const parsed = parsePlaygroundPathname(path); + if (!parsed?.slug[0] || parsed.slug[0].startsWith("[[")) { + return null; + } + return buildPlaygroundPath(targetBasePath, parsed.slug); } diff --git a/src/shared/local-storage/playgroundPath.ts b/src/shared/local-storage/playgroundPath.ts index ac23356e..a439aab4 100644 --- a/src/shared/local-storage/playgroundPath.ts +++ b/src/shared/local-storage/playgroundPath.ts @@ -2,6 +2,7 @@ import { createStringStorage } from "#/shared/browser-storage"; import { parsePlaygroundPathname, PLAYGROUND_PUBLIC_BASE_PATH, + remapPlaygroundPathToBase, } from "#/shared/lib/playgroundRoute"; export const PLAYGROUND_BASE_PATH = PLAYGROUND_PUBLIC_BASE_PATH; @@ -34,13 +35,17 @@ export const isValidLastPlaygroundPath = (path: string | null): boolean => { }; /** - * Returns the path if it can be restored (valid + project slug is not a Next.js catch-all). - * Returns null otherwise. + * Returns a restorable path for the current playground base (public or pilot). + * Slug segments are preserved; only the prefix is remapped when `targetBasePath` is set. */ export const getRestorablePlaygroundPath = ( path: string | null, + targetBasePath?: string, ): string | null => { if (!isValidLastPlaygroundPath(path)) return null; + if (targetBasePath) { + return remapPlaygroundPathToBase(path!, targetBasePath); + } const parsed = parsePlaygroundPathname(path!); const projectSlug = parsed?.slug[0]; return projectSlug?.startsWith("[[") ? null : path;