From 2df7d4f23d266cead864a3ca4a4c37d6048bc80b Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 22:57:06 +0000 Subject: [PATCH 01/19] Adopt RSC-first patterns: provider split, privacy SSR, playground prefetch - Route groups: (marketing) vs (app) with Apollo-only InteractiveDataProviders - Privacy page: server-rendered PrivacyPageContent inside client PrivacyPageShell - Playground: server-prefetch via getPlaygroundInitialData + tRPC initialData hydration - LocaleAppPageShell is a Server Component; ProjectBrowser loads via dynamic import - Add loading.tsx skeletons for playground and profile instant routes - Fix stale layout comments about session streaming Co-authored-by: maxim.kayander1 --- .../{ => (app)}/daily/page.tsx | 0 src/app/(default-locale)/(app)/layout.tsx | 10 ++ .../(app)/playground/[[...slug]]/loading.tsx | 5 + .../playground/[[...slug]]/page.tsx | 0 .../(app)/profile/[userId]/loading.tsx | 5 + .../{ => (app)}/profile/[userId]/page.tsx | 0 .../{ => (marketing)}/page.tsx | 0 .../{ => (marketing)}/privacy/page.tsx | 0 src/app/(default-locale)/layout.tsx | 2 +- src/app/[lang]/{ => (app)}/daily/page.tsx | 0 src/app/[lang]/(app)/layout.tsx | 10 ++ .../(app)/playground/[[...slug]]/loading.tsx | 5 + .../playground/[[...slug]]/page.tsx | 0 .../[lang]/(app)/profile/[userId]/loading.tsx | 5 + .../{ => (app)}/profile/[userId]/page.tsx | 0 src/app/[lang]/{ => (marketing)}/page.tsx | 0 .../[lang]/{ => (marketing)}/privacy/page.tsx | 0 src/app/[lang]/layout.tsx | 2 +- .../locale-app/InteractiveDataProviders.tsx | 14 ++ src/app/locale-app/LocaleAppPageShell.tsx | 12 +- src/app/locale-app/ProjectBrowserOverlay.tsx | 15 ++ src/app/locale-app/pages/playgroundPage.tsx | 20 ++- src/app/locale-app/pages/privacyPage.tsx | 44 +++++- .../context/PlaygroundInitialDataContext.tsx | 24 ++++ .../privacy/ui/PrivacyPageContent.tsx | 120 ++++++++++++++++ src/features/privacy/ui/PrivacyPageShell.tsx | 10 ++ src/features/privacy/ui/PrivacyPageView.tsx | 133 ------------------ .../project/hooks/useProjectPanelData.ts | 20 ++- .../getPlaygroundInitialData.test.ts | 69 +++++++++ .../playground/getPlaygroundInitialData.ts | 55 ++++++++ src/shared/ui/providers/AppShellProviders.tsx | 42 +++--- 31 files changed, 446 insertions(+), 176 deletions(-) rename src/app/(default-locale)/{ => (app)}/daily/page.tsx (100%) create mode 100644 src/app/(default-locale)/(app)/layout.tsx create mode 100644 src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx rename src/app/(default-locale)/{ => (app)}/playground/[[...slug]]/page.tsx (100%) create mode 100644 src/app/(default-locale)/(app)/profile/[userId]/loading.tsx rename src/app/(default-locale)/{ => (app)}/profile/[userId]/page.tsx (100%) rename src/app/(default-locale)/{ => (marketing)}/page.tsx (100%) rename src/app/(default-locale)/{ => (marketing)}/privacy/page.tsx (100%) rename src/app/[lang]/{ => (app)}/daily/page.tsx (100%) create mode 100644 src/app/[lang]/(app)/layout.tsx create mode 100644 src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx rename src/app/[lang]/{ => (app)}/playground/[[...slug]]/page.tsx (100%) create mode 100644 src/app/[lang]/(app)/profile/[userId]/loading.tsx rename src/app/[lang]/{ => (app)}/profile/[userId]/page.tsx (100%) rename src/app/[lang]/{ => (marketing)}/page.tsx (100%) rename src/app/[lang]/{ => (marketing)}/privacy/page.tsx (100%) create mode 100644 src/app/locale-app/InteractiveDataProviders.tsx create mode 100644 src/app/locale-app/ProjectBrowserOverlay.tsx create mode 100644 src/features/playground/context/PlaygroundInitialDataContext.tsx create mode 100644 src/features/privacy/ui/PrivacyPageContent.tsx create mode 100644 src/features/privacy/ui/PrivacyPageShell.tsx delete mode 100644 src/features/privacy/ui/PrivacyPageView.tsx create mode 100644 src/server/playground/__tests__/getPlaygroundInitialData.test.ts create mode 100644 src/server/playground/getPlaygroundInitialData.ts diff --git a/src/app/(default-locale)/daily/page.tsx b/src/app/(default-locale)/(app)/daily/page.tsx similarity index 100% rename from src/app/(default-locale)/daily/page.tsx rename to src/app/(default-locale)/(app)/daily/page.tsx diff --git a/src/app/(default-locale)/(app)/layout.tsx b/src/app/(default-locale)/(app)/layout.tsx new file mode 100644 index 00000000..2c2dc364 --- /dev/null +++ b/src/app/(default-locale)/(app)/layout.tsx @@ -0,0 +1,10 @@ +import { InteractiveDataProviders } from "#/app/locale-app/InteractiveDataProviders"; + +/** Apollo GraphQL for daily, playground, and profile (not marketing routes). */ +export default function DefaultLocaleInteractiveLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx b/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx new file mode 100644 index 00000000..e6c664a7 --- /dev/null +++ b/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx @@ -0,0 +1,5 @@ +import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; + +export default function PlaygroundLoading() { + return ; +} diff --git a/src/app/(default-locale)/playground/[[...slug]]/page.tsx b/src/app/(default-locale)/(app)/playground/[[...slug]]/page.tsx similarity index 100% rename from src/app/(default-locale)/playground/[[...slug]]/page.tsx rename to src/app/(default-locale)/(app)/playground/[[...slug]]/page.tsx diff --git a/src/app/(default-locale)/(app)/profile/[userId]/loading.tsx b/src/app/(default-locale)/(app)/profile/[userId]/loading.tsx new file mode 100644 index 00000000..8f46fdcc --- /dev/null +++ b/src/app/(default-locale)/(app)/profile/[userId]/loading.tsx @@ -0,0 +1,5 @@ +import { ProfilePageSkeleton } from "#/features/profile/ui/ProfilePageSkeleton"; + +export default function ProfileLoading() { + return ; +} diff --git a/src/app/(default-locale)/profile/[userId]/page.tsx b/src/app/(default-locale)/(app)/profile/[userId]/page.tsx similarity index 100% rename from src/app/(default-locale)/profile/[userId]/page.tsx rename to src/app/(default-locale)/(app)/profile/[userId]/page.tsx diff --git a/src/app/(default-locale)/page.tsx b/src/app/(default-locale)/(marketing)/page.tsx similarity index 100% rename from src/app/(default-locale)/page.tsx rename to src/app/(default-locale)/(marketing)/page.tsx diff --git a/src/app/(default-locale)/privacy/page.tsx b/src/app/(default-locale)/(marketing)/privacy/page.tsx similarity index 100% rename from src/app/(default-locale)/privacy/page.tsx rename to src/app/(default-locale)/(marketing)/privacy/page.tsx diff --git a/src/app/(default-locale)/layout.tsx b/src/app/(default-locale)/layout.tsx index 5ba2fc69..ac4badda 100644 --- a/src/app/(default-locale)/layout.tsx +++ b/src/app/(default-locale)/layout.tsx @@ -2,7 +2,7 @@ import { baseLocale } from "#/i18n/i18n-util"; import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale shell — cached i18n + synchronous SSR device hint; session streams via Suspense (P10). */ +/** Locale shell — cached i18n + SSR device hint; session resolved in layout (P10). */ export const instant = false; /** Default-locale (`en`) public App shell at unprefixed URLs (L2). */ diff --git a/src/app/[lang]/daily/page.tsx b/src/app/[lang]/(app)/daily/page.tsx similarity index 100% rename from src/app/[lang]/daily/page.tsx rename to src/app/[lang]/(app)/daily/page.tsx diff --git a/src/app/[lang]/(app)/layout.tsx b/src/app/[lang]/(app)/layout.tsx new file mode 100644 index 00000000..fe987106 --- /dev/null +++ b/src/app/[lang]/(app)/layout.tsx @@ -0,0 +1,10 @@ +import { InteractiveDataProviders } from "#/app/locale-app/InteractiveDataProviders"; + +/** Apollo GraphQL for daily, playground, and profile (not marketing routes). */ +export default function LangInteractiveLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx b/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx new file mode 100644 index 00000000..e6c664a7 --- /dev/null +++ b/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx @@ -0,0 +1,5 @@ +import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; + +export default function PlaygroundLoading() { + return ; +} diff --git a/src/app/[lang]/playground/[[...slug]]/page.tsx b/src/app/[lang]/(app)/playground/[[...slug]]/page.tsx similarity index 100% rename from src/app/[lang]/playground/[[...slug]]/page.tsx rename to src/app/[lang]/(app)/playground/[[...slug]]/page.tsx diff --git a/src/app/[lang]/(app)/profile/[userId]/loading.tsx b/src/app/[lang]/(app)/profile/[userId]/loading.tsx new file mode 100644 index 00000000..8f46fdcc --- /dev/null +++ b/src/app/[lang]/(app)/profile/[userId]/loading.tsx @@ -0,0 +1,5 @@ +import { ProfilePageSkeleton } from "#/features/profile/ui/ProfilePageSkeleton"; + +export default function ProfileLoading() { + return ; +} diff --git a/src/app/[lang]/profile/[userId]/page.tsx b/src/app/[lang]/(app)/profile/[userId]/page.tsx similarity index 100% rename from src/app/[lang]/profile/[userId]/page.tsx rename to src/app/[lang]/(app)/profile/[userId]/page.tsx diff --git a/src/app/[lang]/page.tsx b/src/app/[lang]/(marketing)/page.tsx similarity index 100% rename from src/app/[lang]/page.tsx rename to src/app/[lang]/(marketing)/page.tsx diff --git a/src/app/[lang]/privacy/page.tsx b/src/app/[lang]/(marketing)/privacy/page.tsx similarity index 100% rename from src/app/[lang]/privacy/page.tsx rename to src/app/[lang]/(marketing)/privacy/page.tsx diff --git a/src/app/[lang]/layout.tsx b/src/app/[lang]/layout.tsx index 1e411eb3..e61040dd 100644 --- a/src/app/[lang]/layout.tsx +++ b/src/app/[lang]/layout.tsx @@ -1,7 +1,7 @@ import { generateLangStaticParams } from "#/app/locale-app/generateLangStaticParams"; import { LocaleAppLayout } from "#/app/locale-app/LocaleAppLayout"; -/** Locale shell — cached i18n + synchronous SSR device hint; session streams via Suspense (P10). */ +/** Locale shell — cached i18n + SSR device hint; session resolved in layout (P10). */ export const instant = false; export function generateStaticParams() { diff --git a/src/app/locale-app/InteractiveDataProviders.tsx b/src/app/locale-app/InteractiveDataProviders.tsx new file mode 100644 index 00000000..588ab30a --- /dev/null +++ b/src/app/locale-app/InteractiveDataProviders.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { ApolloProvider } from "@apollo/client"; +import React, { type ReactNode } from "react"; + +import { apolloClient } from "#/graphql/apolloClient"; + +/** + * Apollo GraphQL for routes that use generated hooks (daily, profile). + * tRPC stays in {@link AppShellProviders} because MainAppBar uses it globally. + */ +export const InteractiveDataProviders: React.FC<{ children: ReactNode }> = ({ + children, +}) => {children}; diff --git a/src/app/locale-app/LocaleAppPageShell.tsx b/src/app/locale-app/LocaleAppPageShell.tsx index b90d678f..a30a88cd 100644 --- a/src/app/locale-app/LocaleAppPageShell.tsx +++ b/src/app/locale-app/LocaleAppPageShell.tsx @@ -1,15 +1,11 @@ -"use client"; +import type { ReactNode } from "react"; -import React, { type ReactNode } from "react"; - -import { ProjectBrowser } from "#/features/project/ui/ProjectBrowser/ProjectBrowser"; +import { ProjectBrowserOverlay } from "#/app/locale-app/ProjectBrowserOverlay"; /** Page tree + global overlays that require SessionProvider (inside SessionGate). */ -export const LocaleAppPageShell: React.FC<{ children: ReactNode }> = ({ - children, -}) => ( +export const LocaleAppPageShell = ({ children }: { children: ReactNode }) => ( <> {children} - + ); diff --git a/src/app/locale-app/ProjectBrowserOverlay.tsx b/src/app/locale-app/ProjectBrowserOverlay.tsx new file mode 100644 index 00000000..1fcbd61e --- /dev/null +++ b/src/app/locale-app/ProjectBrowserOverlay.tsx @@ -0,0 +1,15 @@ +"use client"; + +import dynamic from "next/dynamic"; +import React from "react"; + +const ProjectBrowser = dynamic( + () => + import("#/features/project/ui/ProjectBrowser/ProjectBrowser").then( + (module) => ({ default: module.ProjectBrowser }), + ), + { ssr: false }, +); + +/** Global project browser modal — loaded client-only so the page shell stays a Server Component. */ +export const ProjectBrowserOverlay: React.FC = () => ; diff --git a/src/app/locale-app/pages/playgroundPage.tsx b/src/app/locale-app/pages/playgroundPage.tsx index 9c520843..658fe754 100644 --- a/src/app/locale-app/pages/playgroundPage.tsx +++ b/src/app/locale-app/pages/playgroundPage.tsx @@ -1,15 +1,18 @@ import type { Metadata } from "next"; +import { connection } from "next/server"; import React, { Suspense } from "react"; +import { PlaygroundInitialDataProvider } from "#/features/playground/context/PlaygroundInitialDataContext"; import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlaygroundPageSeo"; import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView"; import { baseLocale } from "#/i18n/i18n-util"; +import { getPlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; -/** Playground shell — instant with Suspense fallback skeleton (L5). */ +/** Playground shell — instant with Suspense fallback; public data prefetched on server. */ export const instant = true; const PlaygroundFallback: React.FC = () => ; @@ -60,10 +63,21 @@ export async function generateLangPlaygroundMetadata({ }); } -export function PlaygroundPage() { +type PlaygroundPageProps = { + params: Promise<{ slug?: string[]; lang?: string }>; +}; + +export async function PlaygroundPage({ params }: PlaygroundPageProps) { + await connection(); + const { slug } = await params; + const [projectSlug, caseSlug] = slug ?? []; + const initialData = await getPlaygroundInitialData(projectSlug, caseSlug); + return ( }> - + + + ); } diff --git a/src/app/locale-app/pages/privacyPage.tsx b/src/app/locale-app/pages/privacyPage.tsx index e487a4cf..0bf3d696 100644 --- a/src/app/locale-app/pages/privacyPage.tsx +++ b/src/app/locale-app/pages/privacyPage.tsx @@ -1,12 +1,17 @@ -import { PrivacyPageView } from "#/features/privacy/ui/PrivacyPageView"; -import type { Translation } from "#/i18n/i18n-types"; +import { PrivacyPageContent } from "#/features/privacy/ui/PrivacyPageContent"; +import { PrivacyPageShell } from "#/features/privacy/ui/PrivacyPageShell"; +import { createTranslationFunctions } from "#/i18n/createTranslationFunctions"; +import type { Locales, Translation } from "#/i18n/i18n-types"; +import { baseLocale } from "#/i18n/i18n-util"; +import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; import { createDefaultLocaleRouteMetadata, createLangRouteMetadata, } from "#/app/locale-app/createLocaleRouteMetadata"; +import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; -/** Marketing privacy — instant client navigations to sibling routes (L5). */ +/** Marketing privacy — instant client navigations; body is server-rendered (RSC). */ export const instant = true; const pickPrivacyCopy = (translation: Translation) => ({ @@ -22,6 +27,35 @@ export const generateLangPrivacyMetadata = createLangRouteMetadata( pickPrivacyCopy, ); -export function PrivacyPage() { - return ; +type PrivacyPageProps = { + params?: Promise<{ lang?: string }>; +}; + +async function resolvePrivacyLocale( + params?: Promise<{ lang?: string }>, +): Promise { + if (!params) { + return baseLocale; + } + const { lang: langParam } = await params; + if (!langParam) { + return baseLocale; + } + return resolveLangParamSync(langParam) ?? baseLocale; +} + +export async function PrivacyPage({ params }: PrivacyPageProps = {}) { + const locale = await resolvePrivacyLocale(params); + const { translations } = await loadI18nForLocale(locale); + const translation = translations[locale]; + if (!translation) { + throw new Error(`Missing translations for locale: ${locale}`); + } + const LL = createTranslationFunctions(locale, translation); + + return ( + + + + ); } diff --git a/src/features/playground/context/PlaygroundInitialDataContext.tsx b/src/features/playground/context/PlaygroundInitialDataContext.tsx new file mode 100644 index 00000000..a8efeadd --- /dev/null +++ b/src/features/playground/context/PlaygroundInitialDataContext.tsx @@ -0,0 +1,24 @@ +"use client"; + +import React, { createContext, type ReactNode, useContext } from "react"; + +import type { PlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; + +const PlaygroundInitialDataContext = + createContext(null); + +type PlaygroundInitialDataProviderProps = { + initialData: PlaygroundInitialData; + children: ReactNode; +}; + +export const PlaygroundInitialDataProvider: React.FC< + PlaygroundInitialDataProviderProps +> = ({ initialData, children }) => ( + + {children} + +); + +export const usePlaygroundInitialData = (): PlaygroundInitialData | null => + useContext(PlaygroundInitialDataContext); diff --git a/src/features/privacy/ui/PrivacyPageContent.tsx b/src/features/privacy/ui/PrivacyPageContent.tsx new file mode 100644 index 00000000..49ea42e8 --- /dev/null +++ b/src/features/privacy/ui/PrivacyPageContent.tsx @@ -0,0 +1,120 @@ +import { Box, Container, Link as MuiLink, Typography } from "@mui/material"; +import Link from "next/link"; +import React from "react"; + +import { PrivacyCookieInventoryTable } from "#/features/privacy/ui/PrivacyCookieInventoryTable"; +import type { TranslationFunctions } from "#/i18n/i18n-types"; + +const PrivacySection: React.FC<{ + title: string; + children: React.ReactNode; +}> = ({ title, children }) => ( + + + {title} + + {children} + +); + +const PrivacyParagraph: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => ( + + {children} + +); + +type PrivacyPageContentProps = { + LL: TranslationFunctions; +}; + +/** Server-rendered privacy policy body (passed into {@link PrivacyPageShell}). */ +export const PrivacyPageContent: React.FC = ({ + LL, +}) => ( + + + {LL.PRIVACY_PAGE_TITLE()} + + {LL.PRIVACY_INTRO()} + {LL.PRIVACY_LAST_UPDATED()} + + + {LL.PRIVACY_CONTROLLER_BODY()} + + {LL.PRIVACY_CONTACT_INTRO()}{" "} + + {LL.PRIVACY_CONTACT_EMAIL()} + + . + + + + + {LL.PRIVACY_DATA_COLLECTED_BODY()} + + + + {LL.PRIVACY_LEGAL_BASES_BODY()} + + + + {LL.PRIVACY_RETENTION_BODY()} + + + + {LL.PRIVACY_RIGHTS_BODY()} + {LL.PRIVACY_WITHDRAW_CONSENT_BODY()} + + + + {LL.PRIVACY_SUBPROCESSORS_BODY()} + + + + {LL.PRIVACY_TRANSFERS_BODY()} + + + + {LL.PRIVACY_COOKIES_OVERVIEW_BODY()} + + + {LL.PRIVACY_COOKIES_ESSENTIAL_TITLE()} + + {LL.PRIVACY_COOKIES_ESSENTIAL_BODY()} + + + {LL.PRIVACY_COOKIES_PREFERENCES_TITLE()} + + + {LL.PRIVACY_COOKIES_PREFERENCES_BODY()} + + + + {LL.PRIVACY_COOKIES_ANALYTICS_TITLE()} + + {LL.PRIVACY_COOKIES_ANALYTICS_BODY()} + + + {LL.PRIVACY_COOKIE_TABLE_TITLE()} + + {LL.PRIVACY_COOKIE_TABLE_INTRO()} + + + + + {LL.PRIVACY_EXECUTION_BODY()} + + + + {LL.PRIVACY_CCPA_BODY()} + + + + + {LL.DASHBOARD()} + + + +); diff --git a/src/features/privacy/ui/PrivacyPageShell.tsx b/src/features/privacy/ui/PrivacyPageShell.tsx new file mode 100644 index 00000000..5d8f271b --- /dev/null +++ b/src/features/privacy/ui/PrivacyPageShell.tsx @@ -0,0 +1,10 @@ +"use client"; + +import React, { type ReactNode } from "react"; + +import { MainLayout } from "#/shared/ui/templates/MainLayout"; + +/** Client chrome for privacy — server-rendered content is passed as `children`. */ +export const PrivacyPageShell: React.FC<{ children: ReactNode }> = ({ + children, +}) => {children}; diff --git a/src/features/privacy/ui/PrivacyPageView.tsx b/src/features/privacy/ui/PrivacyPageView.tsx deleted file mode 100644 index 993d5137..00000000 --- a/src/features/privacy/ui/PrivacyPageView.tsx +++ /dev/null @@ -1,133 +0,0 @@ -"use client"; - -import { Box, Container, Link as MuiLink, Typography } from "@mui/material"; -import Link from "next/link"; -import React from "react"; - -import { PrivacyCookieInventoryTable } from "#/features/privacy/ui/PrivacyCookieInventoryTable"; -import { useI18nContext } from "#/shared/hooks"; -import { MainLayout } from "#/shared/ui/templates/MainLayout"; - -const PrivacySection: React.FC<{ - title: string; - children: React.ReactNode; -}> = ({ title, children }) => ( - - - {title} - - {children} - -); - -const PrivacyParagraph: React.FC<{ children: React.ReactNode }> = ({ - children, -}) => ( - - {children} - -); - -/** Privacy policy content. Shared by Pages `/privacy` and App Router pilot. */ -export const PrivacyPageView: React.FC = () => { - const { LL } = useI18nContext(); - - return ( - - - - {LL.PRIVACY_PAGE_TITLE()} - - {LL.PRIVACY_INTRO()} - {LL.PRIVACY_LAST_UPDATED()} - - - {LL.PRIVACY_CONTROLLER_BODY()} - - {LL.PRIVACY_CONTACT_INTRO()}{" "} - - {LL.PRIVACY_CONTACT_EMAIL()} - - . - - - - - - {LL.PRIVACY_DATA_COLLECTED_BODY()} - - - - - {LL.PRIVACY_LEGAL_BASES_BODY()} - - - - {LL.PRIVACY_RETENTION_BODY()} - - - - {LL.PRIVACY_RIGHTS_BODY()} - - {LL.PRIVACY_WITHDRAW_CONSENT_BODY()} - - - - - {LL.PRIVACY_SUBPROCESSORS_BODY()} - - - - {LL.PRIVACY_TRANSFERS_BODY()} - - - - - {LL.PRIVACY_COOKIES_OVERVIEW_BODY()} - - - - {LL.PRIVACY_COOKIES_ESSENTIAL_TITLE()} - - - {LL.PRIVACY_COOKIES_ESSENTIAL_BODY()} - - - - {LL.PRIVACY_COOKIES_PREFERENCES_TITLE()} - - - {LL.PRIVACY_COOKIES_PREFERENCES_BODY()} - - - - {LL.PRIVACY_COOKIES_ANALYTICS_TITLE()} - - - {LL.PRIVACY_COOKIES_ANALYTICS_BODY()} - - - - {LL.PRIVACY_COOKIE_TABLE_TITLE()} - - {LL.PRIVACY_COOKIE_TABLE_INTRO()} - - - - - {LL.PRIVACY_EXECUTION_BODY()} - - - - {LL.PRIVACY_CCPA_BODY()} - - - - - {LL.DASHBOARD()} - - - - - ); -}; diff --git a/src/features/project/hooks/useProjectPanelData.ts b/src/features/project/hooks/useProjectPanelData.ts index 12e7ddee..eda9faf4 100644 --- a/src/features/project/hooks/useProjectPanelData.ts +++ b/src/features/project/hooks/useProjectPanelData.ts @@ -4,6 +4,7 @@ import { TRPCClientError } from "@trpc/client"; import { useSession } from "next-auth/react"; import { useEffect } from "react"; +import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; import { projectSlice, selectIsEditable, @@ -31,11 +32,19 @@ export const useProjectPanelData = () => { clearSlugs, } = usePlaygroundSlugs(); - const allBrief = api.project.allBrief.useQuery(); + const serverInitialData = usePlaygroundInitialData(); + + const allBrief = api.project.allBrief.useQuery(undefined, { + initialData: serverInitialData?.allBrief, + }); const isEditable = useAppSelector(selectIsEditable); const selectedProject = api.project.getBySlug.useQuery(projectSlug, { enabled: Boolean(projectSlug), + initialData: + serverInitialData?.projectBySlug?.slug === projectSlug + ? serverInitialData.projectBySlug + : undefined, retry(failureCount, error) { if (error instanceof TRPCClientError && error.data.code === "NOT_FOUND") { return false; @@ -58,7 +67,14 @@ export const useProjectPanelData = () => { const selectedCase = api.project.getCaseBySlug.useQuery( { projectId: selectedProject.data?.id || "", slug: caseSlug }, - { enabled: Boolean(selectedProject.data?.id && caseSlug) }, + { + enabled: Boolean(selectedProject.data?.id && caseSlug), + initialData: + serverInitialData?.caseBySlug?.slug === caseSlug && + serverInitialData.projectBySlug?.id === selectedProject.data?.id + ? serverInitialData.caseBySlug + : undefined, + }, ); useEffect(() => { diff --git a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts new file mode 100644 index 00000000..a3f203da --- /dev/null +++ b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts @@ -0,0 +1,69 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockAllBrief = vi.fn(); +const mockGetBySlug = vi.fn(); +const mockGetCaseBySlug = vi.fn(); + +vi.mock("#/server/auth/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("#/server/api/root", () => ({ + createCaller: () => ({ + project: { + allBrief: mockAllBrief, + getBySlug: mockGetBySlug, + getCaseBySlug: mockGetCaseBySlug, + }, + }), +})); + +vi.mock("#/server/api/context", () => ({ + createInnerTRPCContext: async (opts: unknown) => opts, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn().mockResolvedValue(null), +})); + +describe("getPlaygroundInitialData", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAllBrief.mockResolvedValue([{ id: "1", slug: "demo", title: "Demo" }]); + mockGetBySlug.mockResolvedValue({ + id: "proj-1", + slug: "two-sum", + title: "Two Sum", + }); + mockGetCaseBySlug.mockResolvedValue({ + id: "case-1", + slug: "case-a", + projectId: "proj-1", + }); + }); + + it("returns allBrief only when no slug is provided", async () => { + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData(); + + expect(result.allBrief).toHaveLength(1); + expect(result.projectBySlug).toBeNull(); + expect(result.caseBySlug).toBeNull(); + expect(mockGetBySlug).not.toHaveBeenCalled(); + }); + + it("prefetches project and case when slugs are provided", async () => { + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData("two-sum", "case-a"); + + expect(mockGetBySlug).toHaveBeenCalledWith("two-sum"); + expect(mockGetCaseBySlug).toHaveBeenCalledWith({ + projectId: "proj-1", + slug: "case-a", + }); + expect(result.projectBySlug?.slug).toBe("two-sum"); + expect(result.caseBySlug?.slug).toBe("case-a"); + }); +}); diff --git a/src/server/playground/getPlaygroundInitialData.ts b/src/server/playground/getPlaygroundInitialData.ts new file mode 100644 index 00000000..a93cf55f --- /dev/null +++ b/src/server/playground/getPlaygroundInitialData.ts @@ -0,0 +1,55 @@ +import { TRPCError } from "@trpc/server"; +import { getServerSession } from "next-auth"; + +import { createInnerTRPCContext } from "#/server/api/context"; +import { createCaller } from "#/server/api/root"; +import { authOptions } from "#/server/auth/authOptions"; +import type { RouterOutputs } from "#/shared/api"; + +export type PlaygroundInitialData = { + allBrief: RouterOutputs["project"]["allBrief"]; + projectBySlug: RouterOutputs["project"]["getBySlug"] | null; + caseBySlug: RouterOutputs["project"]["getCaseBySlug"] | null; +}; + +/** + * Server-prefetch public playground lists and the active project/case for RSC pages. + * Hydrates client tRPC queries via {@link PlaygroundInitialDataProvider}. + */ +export async function getPlaygroundInitialData( + projectSlug?: string, + caseSlug?: string, +): Promise { + const session = await getServerSession(authOptions); + const caller = createCaller( + await createInnerTRPCContext({ + session, + }), + ); + + const allBrief = await caller.project.allBrief(); + + if (!projectSlug) { + return { allBrief, projectBySlug: null, caseBySlug: null }; + } + + try { + const projectBySlug = await caller.project.getBySlug(projectSlug); + + if (!caseSlug) { + return { allBrief, projectBySlug, caseBySlug: null }; + } + + const caseBySlug = await caller.project.getCaseBySlug({ + projectId: projectBySlug.id, + slug: caseSlug, + }); + + return { allBrief, projectBySlug, caseBySlug }; + } catch (error) { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return { allBrief, projectBySlug: null, caseBySlug: null }; + } + throw error; + } +} diff --git a/src/shared/ui/providers/AppShellProviders.tsx b/src/shared/ui/providers/AppShellProviders.tsx index 7daa2a6c..c70d86d8 100644 --- a/src/shared/ui/providers/AppShellProviders.tsx +++ b/src/shared/ui/providers/AppShellProviders.tsx @@ -1,10 +1,8 @@ "use client"; -import { ApolloProvider } from "@apollo/client"; import { SnackbarProvider } from "notistack"; import React, { type ReactNode } from "react"; -import { apolloClient } from "#/graphql/apolloClient"; import { TrpcProvider } from "#/shared/trpc/TrpcProvider"; import { SnackbarCloseButton } from "#/shared/ui/atoms/SnackbarCloseButton"; import { StateThemeProvider } from "#/shared/ui/providers/StateThemeProvider"; @@ -18,7 +16,8 @@ type AppShellProvidersProps = { }; /** - * Shared client provider stack for App Router layouts. + * Base client providers for all App Router layouts. + * Apollo mounts in {@link InteractiveDataProviders} on data routes only. * SessionProvider is mounted in SessionGate (inside LocaleAppLayout). */ export const AppShellProviders: React.FC = ({ @@ -27,26 +26,23 @@ export const AppShellProviders: React.FC = ({ }) => ( - - - - isSnackbarClosable(snackbarKey) ? ( - - ) : null - } - classes={{ - containerAnchorOriginBottomLeft: "snackbar-mobile-bottom-margin", - containerAnchorOriginBottomCenter: - "snackbar-mobile-bottom-margin", - containerAnchorOriginBottomRight: "snackbar-mobile-bottom-margin", - }} - > - {children} - - - + + + isSnackbarClosable(snackbarKey) ? ( + + ) : null + } + classes={{ + containerAnchorOriginBottomLeft: "snackbar-mobile-bottom-margin", + containerAnchorOriginBottomCenter: "snackbar-mobile-bottom-margin", + containerAnchorOriginBottomRight: "snackbar-mobile-bottom-margin", + }} + > + {children} + + ); From 4fe379e7f6221c5b7ddc60734cc7963454416459 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 5 Sep 2026 22:58:11 +0000 Subject: [PATCH 02/19] Phase 2: RSC refactor plan, cached playground SEO, transition navigations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add vibe-docs/RSC-First-Refactor-Plan.md with phases 2–5 - Cache public project SEO fields with use cache + cacheTag per slug - Use loadI18nForLocale in resolvePlaygroundPageSeo (reuse cached i18n) - Wrap playground navigateTo in startTransition for Instant Nav UX Co-authored-by: maxim.kayander1 --- .../lib/loadPublicProjectSeoFields.ts | 25 +++++ .../lib/resolvePlaygroundPageSeo.ts | 15 +-- src/shared/hooks/usePlaygroundRoute.ts | 14 +-- vibe-docs/RSC-First-Refactor-Plan.md | 95 +++++++++++++++++++ 4 files changed, 136 insertions(+), 13 deletions(-) create mode 100644 src/features/playground/lib/loadPublicProjectSeoFields.ts create mode 100644 vibe-docs/RSC-First-Refactor-Plan.md diff --git a/src/features/playground/lib/loadPublicProjectSeoFields.ts b/src/features/playground/lib/loadPublicProjectSeoFields.ts new file mode 100644 index 00000000..3e324384 --- /dev/null +++ b/src/features/playground/lib/loadPublicProjectSeoFields.ts @@ -0,0 +1,25 @@ +import { cacheLife, cacheTag } from "next/cache"; + +import { db } from "#/server/db/client"; + +export type PublicProjectSeoFields = { + title: string; + description: string | null; +}; + +/** + * Cached public project fields for playground `` / meta description. + * Invalidated when admin edits ship `revalidateTag('playground-project-seo:*')`. + */ +export async function loadPublicProjectSeoFields( + slug: string, +): Promise<PublicProjectSeoFields | null> { + "use cache"; + cacheLife("hours"); + cacheTag(`playground-project-seo:${slug}`); + + return db.playgroundProject.findUnique({ + where: { slug, isPublic: true }, + select: { title: true, description: true }, + }); +} diff --git a/src/features/playground/lib/resolvePlaygroundPageSeo.ts b/src/features/playground/lib/resolvePlaygroundPageSeo.ts index 34ed4da6..b02dad56 100644 --- a/src/features/playground/lib/resolvePlaygroundPageSeo.ts +++ b/src/features/playground/lib/resolvePlaygroundPageSeo.ts @@ -1,7 +1,7 @@ +import { loadPublicProjectSeoFields } from "#/features/playground/lib/loadPublicProjectSeoFields"; 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"; +import { loadI18nForLocale } from "#/i18n/loadI18nForLocale"; export type PlaygroundPageSeo = { pageTitle: string; @@ -13,17 +13,18 @@ export async function resolvePlaygroundPageSeo( locale: Locales, slugStr?: string, ): Promise<PlaygroundPageSeo> { - const translation = await importLocaleAsync(locale); + const { translations } = await loadI18nForLocale(locale); + const translation = translations[locale]; + if (!translation) { + throw new Error(`Missing translations for locale: ${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 }, - }); + const project = await loadPublicProjectSeoFields(slugStr); if (project) { pageTitle = `${project.title} | dStruct`; pageDescription = project.description?.trim() diff --git a/src/shared/hooks/usePlaygroundRoute.ts b/src/shared/hooks/usePlaygroundRoute.ts index 91fc9df8..23da13cc 100644 --- a/src/shared/hooks/usePlaygroundRoute.ts +++ b/src/shared/hooks/usePlaygroundRoute.ts @@ -6,7 +6,7 @@ import { usePathname, useSearchParams, } from "next/navigation"; -import { useCallback, useMemo } from "react"; +import { startTransition, useCallback, useMemo } from "react"; import { parsePlaygroundPathname } from "#/shared/lib/playgroundRoute"; @@ -64,11 +64,13 @@ export const usePlaygroundRoute = (): PlaygroundRouteContext | null => { options?: PlaygroundNavigateOptions, ) => { const href = `${targetPath}${buildAppQuerySuffix(options?.omitView)}`; - if (options?.replace) { - void appRouter.replace(href, { scroll: false }); - return; - } - void appRouter.push(href, { scroll: false }); + startTransition(() => { + if (options?.replace) { + void appRouter.replace(href, { scroll: false }); + return; + } + void appRouter.push(href, { scroll: false }); + }); }; return { diff --git a/vibe-docs/RSC-First-Refactor-Plan.md b/vibe-docs/RSC-First-Refactor-Plan.md new file mode 100644 index 00000000..43c29ff8 --- /dev/null +++ b/vibe-docs/RSC-First-Refactor-Plan.md @@ -0,0 +1,95 @@ +# RSC-first App Router refactor — phased plan + +## Status (2026-09) + +**Phase 1 (done)** — PR #187 on `cursor/rsc-improvements-b999`: + +- Route groups `(marketing)` / `(app)`; Apollo only on data routes +- Privacy policy server-rendered (`PrivacyPageContent` RSC + `PrivacyPageShell` client chrome) +- Playground server prefetch (`getPlaygroundInitialData` + tRPC `initialData`) +- `LocaleAppPageShell` as Server Component; `ProjectBrowser` via `dynamic(..., { ssr: false })` +- `loading.tsx` for playground + profile instant routes +- Layout comment fix (session is resolved in layout, not Suspense-streamed) + +--- + +## Phase 2 — Cache + navigation polish (in progress) + +| Item | Effort | Notes | +|------|--------|-------| +| `'use cache'` on public playground SEO DB reads | Small | `resolvePlaygroundPageSeo` — cache per slug | +| `startTransition` on playground slug navigations | Small | `usePlaygroundRoute.navigateTo` | +| `loading.tsx` for `/daily` | Small | Reuse daily skeleton or simple pulse | +| Cached anonymous `allBrief` in server prefetch | Medium | `'use cache'` when `session === null` only | +| `cacheTag` + `revalidateTag` on project admin mutations | Medium | Wire tRPC `update` / `delete` to invalidate | + +**Success criteria:** Playground metadata and anonymous project list hit build/request cache; slug changes feel non-blocking under Instant Nav. + +--- + +## Phase 3 — Marketing RSC islands + +| Item | Effort | Notes | +|------|--------|-------| +| Split `MarketingHomeView` into RSC sections + client islands | Medium | Hero copy, FAQ, sections as server; 3D preview + scroll hooks client | +| `DailyPageView` shell as RSC | Medium | Server-fetch daily question; client island for interactive bits | +| Remove duplicate locale loads in page modules | Small | Prefer `loadI18nForLocale` / layout-passed `LL` everywhere | + +**Success criteria:** `/` and `/daily` ship meaningful HTML without waiting for client hydration; WebGL/Monaco remain client-only. + +--- + +## Phase 4 — Provider + data stack slimming + +| Item | Effort | Notes | +|------|--------|-------| +| Extract `MainAppBarProfileImageSync` (lazy, session-gated) | Small | Isolates tRPC mutation; does **not** remove `TrpcProvider` while signed-in users browse marketing | +| Server Action for profile image upload | Medium | Would allow dropping tRPC from marketing for signed-out users | +| tRPC RSC / `createCaller` prefetch helpers | Medium | Replace ad-hoc `getPlaygroundInitialData` with shared `prefetchProjectQueries` | +| Consolidate profile on tRPC **or** GraphQL (not both) | Large | Profile uses GraphQL; playground uses tRPC | + +**Success criteria:** Marketing routes mount fewer client providers when signed out; one server-fetch path per feature. + +--- + +## Phase 5 — Edge + Server Actions (optional) + +| Item | Effort | Notes | +|------|--------|-------| +| Edge Route Handlers for read-only public config | Medium | Only where latency wins are measurable | +| Server Actions for cookie consent / simple settings forms | Small | Progressive enhancement, `revalidateTag` | +| Broader `useTransition` on filters, project browser search | Small | Pair with Instant Nav | + +--- + +## Architecture target + +```mermaid +flowchart TB + subgraph marketing [Marketing routes] + MRSC[RSC page content] + MIsland[Client islands: 3D, session widget] + end + + subgraph app [Data routes] + Prefetch[Server prefetch + use cache] + Client[Playground / daily / profile clients] + end + + Base[Base providers: theme, Redux, tRPC, session] + Apollo[Apollo — app routes only] + + Base --> marketing + Base --> Apollo + Apollo --> app + Prefetch --> Client + MRSC --> MIsland +``` + +--- + +## References + +- `vibe-docs/Instant-Navigations-Design.md` — PPR / `instant = true` patterns +- `vibe-docs/App-Router-Migration-Plan.md` — migration complete (P6–P10) +- Review thread that spawned Phase 1 (2026-09) From e92270b9d635d1b9835f0fcb9ed40fd202612903 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 13:05:31 +0000 Subject: [PATCH 03/19] Fix playground 500 when case slug is invalid - getCaseBySlug: map Prisma P2025 to TRPC NOT_FOUND (parity with getBySlug) - getPlaygroundInitialData: keep project prefetch when case lookup fails - Add regression test for invalid case slug Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- src/server/api/routers/project.ts | 23 +++++++++++++++---- .../getPlaygroundInitialData.test.ts | 14 +++++++++++ .../playground/getPlaygroundInitialData.ts | 19 ++++++++++----- 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index 6eeaba83..e1a414e6 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -628,11 +628,24 @@ export const projectRouter = createTRPCRouter({ }), ) .query(async ({ input, ctx }) => - ctx.db.playgroundTestCase.findUniqueOrThrow({ - where: { - projectId_slug: input, - }, - }), + ctx.db.playgroundTestCase + .findUniqueOrThrow({ + where: { + projectId_slug: input, + }, + }) + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Case "${input.slug}" not found.`, + }); + } + throw error; + }), ), addCase: projectOwnerProcedure diff --git a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts index a3f203da..3b7a51d2 100644 --- a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts +++ b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts @@ -1,3 +1,4 @@ +import { TRPCError } from "@trpc/server"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mockAllBrief = vi.fn(); @@ -66,4 +67,17 @@ describe("getPlaygroundInitialData", () => { expect(result.projectBySlug?.slug).toBe("two-sum"); expect(result.caseBySlug?.slug).toBe("case-a"); }); + + it("keeps project prefetch when case slug is invalid", async () => { + mockGetCaseBySlug.mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Case not found." }), + ); + + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData("two-sum", "missing-case"); + + expect(result.projectBySlug?.slug).toBe("two-sum"); + expect(result.caseBySlug).toBeNull(); + }); }); diff --git a/src/server/playground/getPlaygroundInitialData.ts b/src/server/playground/getPlaygroundInitialData.ts index a93cf55f..9be3ecb8 100644 --- a/src/server/playground/getPlaygroundInitialData.ts +++ b/src/server/playground/getPlaygroundInitialData.ts @@ -40,12 +40,19 @@ export async function getPlaygroundInitialData( return { allBrief, projectBySlug, caseBySlug: null }; } - const caseBySlug = await caller.project.getCaseBySlug({ - projectId: projectBySlug.id, - slug: caseSlug, - }); - - return { allBrief, projectBySlug, caseBySlug }; + try { + const caseBySlug = await caller.project.getCaseBySlug({ + projectId: projectBySlug.id, + slug: caseSlug, + }); + + return { allBrief, projectBySlug, caseBySlug }; + } catch (caseError) { + if (caseError instanceof TRPCError && caseError.code === "NOT_FOUND") { + return { allBrief, projectBySlug, caseBySlug: null }; + } + throw caseError; + } } catch (error) { if (error instanceof TRPCError && error.code === "NOT_FOUND") { return { allBrief, projectBySlug: null, caseBySlug: null }; From 466a70a01ac549e79943c4eab0547631d6ef41a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 13:08:53 +0000 Subject: [PATCH 04/19] Address review follow-ups: SEO cache invalidation, private metadata, tests - revalidatePlaygroundProjectSeo on project create/update/delete/deleteAll - Session-aware loadProjectSeoFieldsForSession for owner/admin private SEO - Remove redundant playground Suspense; rely on loading.tsx - Add daily loading.tsx skeletons - Split queryPublicProjectSeoFields for testability; fix revalidateTag profile arg - Add unit tests for SEO helpers and invalid project slug prefetch Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- next-env.d.ts | 4 +- .../(default-locale)/(app)/daily/loading.tsx | 24 ++++++ src/app/[lang]/(app)/daily/loading.tsx | 24 ++++++ src/app/locale-app/pages/playgroundPage.tsx | 14 +--- .../loadProjectSeoFieldsForSession.test.ts | 73 ++++++++++++++++ .../__tests__/playgroundProjectSeo.test.ts | 43 ++++++++++ .../lib/loadProjectSeoFieldsForSession.ts | 45 ++++++++++ .../lib/loadPublicProjectSeoFields.ts | 20 ++--- .../lib/playgroundProjectSeoCache.ts | 9 ++ .../lib/queryPublicProjectSeoFields.ts | 16 ++++ .../lib/resolvePlaygroundPageSeo.ts | 5 +- src/server/api/routers/project.ts | 84 +++++++++++++++---- .../getPlaygroundInitialData.test.ts | 14 ++++ vibe-docs/RSC-First-Refactor-Plan.md | 22 ++--- 14 files changed, 346 insertions(+), 51 deletions(-) create mode 100644 src/app/(default-locale)/(app)/daily/loading.tsx create mode 100644 src/app/[lang]/(app)/daily/loading.tsx create mode 100644 src/features/playground/lib/__tests__/loadProjectSeoFieldsForSession.test.ts create mode 100644 src/features/playground/lib/__tests__/playgroundProjectSeo.test.ts create mode 100644 src/features/playground/lib/loadProjectSeoFieldsForSession.ts create mode 100644 src/features/playground/lib/playgroundProjectSeoCache.ts create mode 100644 src/features/playground/lib/queryPublicProjectSeoFields.ts diff --git a/next-env.d.ts b/next-env.d.ts index ce4e94a6..a419cbe4 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> -import "./.next/types/routes.d.ts"; -import "./.next/types/root-params.d.ts"; +import "./.next/dev/types/routes.d.ts"; +import "./.next/dev/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/src/app/(default-locale)/(app)/daily/loading.tsx b/src/app/(default-locale)/(app)/daily/loading.tsx new file mode 100644 index 00000000..bb3b63b9 --- /dev/null +++ b/src/app/(default-locale)/(app)/daily/loading.tsx @@ -0,0 +1,24 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for `/daily` while the client view hydrates. */ +export default function DailyLoading() { + return ( + <Box component="main" sx={{ minHeight: "85vh", py: 8 }}> + <Container maxWidth="lg"> + <Skeleton + variant="text" + width="60%" + height={48} + animation="wave" + sx={{ mx: "auto", mb: 4 }} + /> + <Skeleton + variant="rounded" + height={320} + animation="wave" + sx={{ borderRadius: 2 }} + /> + </Container> + </Box> + ); +} diff --git a/src/app/[lang]/(app)/daily/loading.tsx b/src/app/[lang]/(app)/daily/loading.tsx new file mode 100644 index 00000000..bb3b63b9 --- /dev/null +++ b/src/app/[lang]/(app)/daily/loading.tsx @@ -0,0 +1,24 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for `/daily` while the client view hydrates. */ +export default function DailyLoading() { + return ( + <Box component="main" sx={{ minHeight: "85vh", py: 8 }}> + <Container maxWidth="lg"> + <Skeleton + variant="text" + width="60%" + height={48} + animation="wave" + sx={{ mx: "auto", mb: 4 }} + /> + <Skeleton + variant="rounded" + height={320} + animation="wave" + sx={{ borderRadius: 2 }} + /> + </Container> + </Box> + ); +} diff --git a/src/app/locale-app/pages/playgroundPage.tsx b/src/app/locale-app/pages/playgroundPage.tsx index 658fe754..afb4055b 100644 --- a/src/app/locale-app/pages/playgroundPage.tsx +++ b/src/app/locale-app/pages/playgroundPage.tsx @@ -1,22 +1,18 @@ import type { Metadata } from "next"; import { connection } from "next/server"; -import React, { Suspense } from "react"; import { PlaygroundInitialDataProvider } from "#/features/playground/context/PlaygroundInitialDataContext"; import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlaygroundPageSeo"; import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView"; import { baseLocale } from "#/i18n/i18n-util"; import { getPlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; -import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; -/** Playground shell — instant with Suspense fallback; public data prefetched on server. */ +/** Playground — instant shell; public data prefetched on server; fallback via loading.tsx. */ export const instant = true; -const PlaygroundFallback: React.FC = () => <SplitPanelsLayoutSkeleton />; - export async function generateDefaultLocalePlaygroundMetadata({ params, }: { @@ -74,10 +70,8 @@ export async function PlaygroundPage({ params }: PlaygroundPageProps) { const initialData = await getPlaygroundInitialData(projectSlug, caseSlug); return ( - <Suspense fallback={<PlaygroundFallback />}> - <PlaygroundInitialDataProvider initialData={initialData}> - <PlaygroundPageView /> - </PlaygroundInitialDataProvider> - </Suspense> + <PlaygroundInitialDataProvider initialData={initialData}> + <PlaygroundPageView /> + </PlaygroundInitialDataProvider> ); } diff --git a/src/features/playground/lib/__tests__/loadProjectSeoFieldsForSession.test.ts b/src/features/playground/lib/__tests__/loadProjectSeoFieldsForSession.test.ts new file mode 100644 index 00000000..3d740446 --- /dev/null +++ b/src/features/playground/lib/__tests__/loadProjectSeoFieldsForSession.test.ts @@ -0,0 +1,73 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFindUnique = vi.fn(); +const mockGetServerSession = vi.fn(); + +vi.mock("#/server/db/client", () => ({ + db: { + playgroundProject: { + findUnique: mockFindUnique, + }, + }, +})); + +vi.mock("#/server/auth/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("next-auth", () => ({ + getServerSession: (...args: unknown[]) => mockGetServerSession(...args), +})); + +describe("loadProjectSeoFieldsForSession", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockGetServerSession.mockResolvedValue({ + user: { id: "user-1", isAdmin: false }, + }); + }); + + it("returns null when there is no session", async () => { + mockGetServerSession.mockResolvedValue(null); + + const { loadProjectSeoFieldsForSession } = + await import("#/features/playground/lib/loadProjectSeoFieldsForSession"); + const result = await loadProjectSeoFieldsForSession("private-project"); + + expect(result).toBeNull(); + expect(mockFindUnique).not.toHaveBeenCalled(); + }); + + it("returns private project SEO for the owner", async () => { + mockFindUnique.mockResolvedValue({ + title: "Secret Project", + description: "Owner only", + isPublic: false, + userId: "user-1", + }); + + const { loadProjectSeoFieldsForSession } = + await import("#/features/playground/lib/loadProjectSeoFieldsForSession"); + const result = await loadProjectSeoFieldsForSession("secret-project"); + + expect(result).toEqual({ + title: "Secret Project", + description: "Owner only", + }); + }); + + it("returns null for private projects when viewer is not owner", async () => { + mockFindUnique.mockResolvedValue({ + title: "Secret Project", + description: "Owner only", + isPublic: false, + userId: "other-user", + }); + + const { loadProjectSeoFieldsForSession } = + await import("#/features/playground/lib/loadProjectSeoFieldsForSession"); + const result = await loadProjectSeoFieldsForSession("secret-project"); + + expect(result).toBeNull(); + }); +}); diff --git a/src/features/playground/lib/__tests__/playgroundProjectSeo.test.ts b/src/features/playground/lib/__tests__/playgroundProjectSeo.test.ts new file mode 100644 index 00000000..7aff65c8 --- /dev/null +++ b/src/features/playground/lib/__tests__/playgroundProjectSeo.test.ts @@ -0,0 +1,43 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockFindUnique = vi.fn(); + +vi.mock("#/server/db/client", () => ({ + db: { + playgroundProject: { + findUnique: mockFindUnique, + }, + }, +})); + +describe("queryPublicProjectSeoFields", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockFindUnique.mockResolvedValue({ + title: "Two Sum", + description: "Find two numbers", + }); + }); + + it("queries only public projects by slug", async () => { + const { queryPublicProjectSeoFields } = + await import("#/features/playground/lib/queryPublicProjectSeoFields"); + const result = await queryPublicProjectSeoFields("two-sum"); + + expect(mockFindUnique).toHaveBeenCalledWith({ + where: { slug: "two-sum", isPublic: true }, + select: { title: true, description: true }, + }); + expect(result?.title).toBe("Two Sum"); + }); +}); + +describe("playgroundProjectSeoCacheTag", () => { + it("builds a stable tag per slug", async () => { + const { playgroundProjectSeoCacheTag } = + await import("#/features/playground/lib/playgroundProjectSeoCache"); + expect(playgroundProjectSeoCacheTag("two-sum")).toBe( + "playground-project-seo:two-sum", + ); + }); +}); diff --git a/src/features/playground/lib/loadProjectSeoFieldsForSession.ts b/src/features/playground/lib/loadProjectSeoFieldsForSession.ts new file mode 100644 index 00000000..971bab40 --- /dev/null +++ b/src/features/playground/lib/loadProjectSeoFieldsForSession.ts @@ -0,0 +1,45 @@ +import { getServerSession } from "next-auth"; + +import type { PublicProjectSeoFields } from "#/features/playground/lib/loadPublicProjectSeoFields"; +import { authOptions } from "#/server/auth/authOptions"; +import { db } from "#/server/db/client"; + +/** + * Uncached SEO lookup for private projects when the viewer is owner or admin. + * Returns null for anonymous users and non-owners (no title leakage in `<meta>`). + */ +export async function loadProjectSeoFieldsForSession( + slug: string, +): Promise<PublicProjectSeoFields | null> { + const session = await getServerSession(authOptions); + const userId = session?.user?.id; + if (!userId) { + return null; + } + + const project = await db.playgroundProject.findUnique({ + where: { slug }, + select: { + title: true, + description: true, + isPublic: true, + userId: true, + }, + }); + + if (!project) { + return null; + } + + if (project.isPublic) { + return { title: project.title, description: project.description }; + } + + const isOwner = project.userId === userId; + const isAdmin = Boolean(session.user.isAdmin); + if (!isOwner && !isAdmin) { + return null; + } + + return { title: project.title, description: project.description }; +} diff --git a/src/features/playground/lib/loadPublicProjectSeoFields.ts b/src/features/playground/lib/loadPublicProjectSeoFields.ts index 3e324384..861b4bfa 100644 --- a/src/features/playground/lib/loadPublicProjectSeoFields.ts +++ b/src/features/playground/lib/loadPublicProjectSeoFields.ts @@ -1,25 +1,23 @@ import { cacheLife, cacheTag } from "next/cache"; -import { db } from "#/server/db/client"; +import { playgroundProjectSeoCacheTag } from "#/features/playground/lib/playgroundProjectSeoCache"; +import { + type PublicProjectSeoFields, + queryPublicProjectSeoFields, +} from "#/features/playground/lib/queryPublicProjectSeoFields"; -export type PublicProjectSeoFields = { - title: string; - description: string | null; -}; +export type { PublicProjectSeoFields }; /** * Cached public project fields for playground `<title>` / meta description. - * Invalidated when admin edits ship `revalidateTag('playground-project-seo:*')`. + * Invalidated via {@link revalidatePlaygroundProjectSeo} on project mutations. */ export async function loadPublicProjectSeoFields( slug: string, ): Promise<PublicProjectSeoFields | null> { "use cache"; cacheLife("hours"); - cacheTag(`playground-project-seo:${slug}`); + cacheTag(playgroundProjectSeoCacheTag(slug)); - return db.playgroundProject.findUnique({ - where: { slug, isPublic: true }, - select: { title: true, description: true }, - }); + return queryPublicProjectSeoFields(slug); } diff --git a/src/features/playground/lib/playgroundProjectSeoCache.ts b/src/features/playground/lib/playgroundProjectSeoCache.ts new file mode 100644 index 00000000..58d971a5 --- /dev/null +++ b/src/features/playground/lib/playgroundProjectSeoCache.ts @@ -0,0 +1,9 @@ +import { revalidateTag } from "next/cache"; + +export const playgroundProjectSeoCacheTag = (slug: string) => + `playground-project-seo:${slug}`; + +/** Bust cached public SEO fields after project create/update/delete. */ +export function revalidatePlaygroundProjectSeo(slug: string): void { + revalidateTag(playgroundProjectSeoCacheTag(slug), "hours"); +} diff --git a/src/features/playground/lib/queryPublicProjectSeoFields.ts b/src/features/playground/lib/queryPublicProjectSeoFields.ts new file mode 100644 index 00000000..9e3c7453 --- /dev/null +++ b/src/features/playground/lib/queryPublicProjectSeoFields.ts @@ -0,0 +1,16 @@ +import { db } from "#/server/db/client"; + +export type PublicProjectSeoFields = { + title: string; + description: string | null; +}; + +/** Uncached DB read for public project SEO fields (wrapped by `loadPublicProjectSeoFields`). */ +export async function queryPublicProjectSeoFields( + slug: string, +): Promise<PublicProjectSeoFields | null> { + return db.playgroundProject.findUnique({ + where: { slug, isPublic: true }, + select: { title: true, description: true }, + }); +} diff --git a/src/features/playground/lib/resolvePlaygroundPageSeo.ts b/src/features/playground/lib/resolvePlaygroundPageSeo.ts index b02dad56..539f8923 100644 --- a/src/features/playground/lib/resolvePlaygroundPageSeo.ts +++ b/src/features/playground/lib/resolvePlaygroundPageSeo.ts @@ -1,3 +1,4 @@ +import { loadProjectSeoFieldsForSession } from "#/features/playground/lib/loadProjectSeoFieldsForSession"; import { loadPublicProjectSeoFields } from "#/features/playground/lib/loadPublicProjectSeoFields"; import { createTranslationFunctions } from "#/i18n/createTranslationFunctions"; import type { Locales } from "#/i18n/i18n-types"; @@ -24,7 +25,9 @@ export async function resolvePlaygroundPageSeo( let pageDescription: string = LL.SITE_SEO_DESCRIPTION(); if (slugStr) { - const project = await loadPublicProjectSeoFields(slugStr); + const project = + (await loadPublicProjectSeoFields(slugStr)) ?? + (await loadProjectSeoFieldsForSession(slugStr)); if (project) { pageTitle = `${project.title} | dStruct`; pageDescription = project.description?.trim() diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index e1a414e6..be7e9df1 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -17,6 +17,7 @@ import { getDefaultCodeSnippets, getMergedCodeContent, } from "#/features/codeRunner/lib/getDefaultCodeSnippets"; +import { revalidatePlaygroundProjectSeo } from "#/features/playground/lib/playgroundProjectSeoCache"; import { createTRPCRouter, protectedProcedure, @@ -525,8 +526,8 @@ export const projectRouter = createTRPCRouter({ isExample: z.boolean().optional(), }), ) - .mutation(async ({ input: data, ctx }) => - ctx.db.playgroundProject + .mutation(async ({ input: data, ctx }) => { + const created = await ctx.db.playgroundProject .create({ data: { ...data, @@ -561,8 +562,14 @@ export const projectRouter = createTRPCRouter({ }); } throw error; - }), - ), + }); + + if (created.isPublic) { + revalidatePlaygroundProjectSeo(created.slug); + } + + return created; + }), update: projectOwnerProcedure .input( @@ -578,23 +585,46 @@ export const projectRouter = createTRPCRouter({ isExample: z.boolean().optional(), }), ) - .mutation(async ({ input: { projectId: id, ...data }, ctx }) => - ctx.db.playgroundProject + .mutation(async ({ input: { projectId: id, ...data }, ctx }) => { + const existing = await ctx.db.playgroundProject.findUnique({ + where: { id }, + select: { slug: true, isPublic: true }, + }); + + const updated = await ctx.db.playgroundProject .update({ where: { id, }, data, }) - .catch((error: any) => { - if (error.code === "P2002") { + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { throw new TRPCError({ code: "BAD_REQUEST", message: "You already have a project with this name.", }); - } else throw error; - }), - ), + } + throw error; + }); + + if (existing?.isPublic || updated.isPublic) { + revalidatePlaygroundProjectSeo(existing?.slug ?? updated.slug); + } + if ( + data.slug && + existing && + data.slug !== existing.slug && + updated.isPublic + ) { + revalidatePlaygroundProjectSeo(data.slug); + } + + return updated; + }), delete: projectOwnerProcedure .input( @@ -603,22 +633,44 @@ export const projectRouter = createTRPCRouter({ }), ) .mutation(async ({ input: { projectId }, ctx }) => { + const existing = await ctx.db.playgroundProject.findUnique({ + where: { id: projectId }, + select: { slug: true, isPublic: true }, + }); + void clearProjectEntities(projectId); - return ctx.db.playgroundProject.delete({ + const deleted = await ctx.db.playgroundProject.delete({ where: { id: projectId, }, }); + + if (existing?.isPublic) { + revalidatePlaygroundProjectSeo(existing.slug); + } + + return deleted; }), // Delete all personal projects - deleteAll: protectedProcedure.mutation(async ({ ctx }) => - ctx.db.playgroundProject.deleteMany({ + deleteAll: protectedProcedure.mutation(async ({ ctx }) => { + const projects = await ctx.db.playgroundProject.findMany({ + where: { userId: ctx.session.user.id, isPublic: true }, + select: { slug: true }, + }); + + const result = await ctx.db.playgroundProject.deleteMany({ where: { userId: ctx.session.user.id, }, - }), - ), + }); + + for (const project of projects) { + revalidatePlaygroundProjectSeo(project.slug); + } + + return result; + }), getCaseBySlug: publicProcedure .input( diff --git a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts index 3b7a51d2..0db59920 100644 --- a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts +++ b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts @@ -80,4 +80,18 @@ describe("getPlaygroundInitialData", () => { expect(result.projectBySlug?.slug).toBe("two-sum"); expect(result.caseBySlug).toBeNull(); }); + + it("returns null project when project slug is invalid", async () => { + mockGetBySlug.mockRejectedValue( + new TRPCError({ code: "NOT_FOUND", message: "Project not found." }), + ); + + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData("missing-project"); + + expect(result.allBrief).toHaveLength(1); + expect(result.projectBySlug).toBeNull(); + expect(result.caseBySlug).toBeNull(); + }); }); diff --git a/vibe-docs/RSC-First-Refactor-Plan.md b/vibe-docs/RSC-First-Refactor-Plan.md index 43c29ff8..3b945cdb 100644 --- a/vibe-docs/RSC-First-Refactor-Plan.md +++ b/vibe-docs/RSC-First-Refactor-Plan.md @@ -13,17 +13,17 @@ --- -## Phase 2 — Cache + navigation polish (in progress) - -| Item | Effort | Notes | -|------|--------|-------| -| `'use cache'` on public playground SEO DB reads | Small | `resolvePlaygroundPageSeo` — cache per slug | -| `startTransition` on playground slug navigations | Small | `usePlaygroundRoute.navigateTo` | -| `loading.tsx` for `/daily` | Small | Reuse daily skeleton or simple pulse | -| Cached anonymous `allBrief` in server prefetch | Medium | `'use cache'` when `session === null` only | -| `cacheTag` + `revalidateTag` on project admin mutations | Medium | Wire tRPC `update` / `delete` to invalidate | - -**Success criteria:** Playground metadata and anonymous project list hit build/request cache; slug changes feel non-blocking under Instant Nav. +## Phase 2 — Cache + navigation polish (mostly done) + +| Item | Effort | Status | +|------|--------|--------| +| `'use cache'` on public playground SEO DB reads | Small | Done | +| `revalidateTag` on project admin mutations | Medium | Done | +| Session-aware private project SEO metadata | Medium | Done | +| `startTransition` on playground slug navigations | Small | Done | +| `loading.tsx` for `/daily` | Small | Done | +| Remove redundant playground `<Suspense>` (use `loading.tsx`) | Small | Done | +| Cached anonymous `allBrief` in server prefetch | Medium | Planned | --- From 5cc443e2376f80110ec66bd02b68db37c77e0826 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 13:09:04 +0000 Subject: [PATCH 05/19] Revert dev-only next-env.d.ts path change Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- next-env.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/next-env.d.ts b/next-env.d.ts index a419cbe4..ce4e94a6 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,7 +1,7 @@ /// <reference types="next" /> /// <reference types="next/image-types/global" /> -import "./.next/dev/types/routes.d.ts"; -import "./.next/dev/types/root-params.d.ts"; +import "./.next/types/routes.d.ts"; +import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information. From 87a72a257c842d90dfdc62ca42b3b7f37efd8a24 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 13:50:50 +0000 Subject: [PATCH 06/19] Fix playground loading shell: include app bar and reduce skeleton flicker - PlaygroundPageLoading/PlaygroundPageShell share chrome with live page (header + bg) - Route loading.tsx uses full shell instead of bare SplitPanelsLayoutSkeleton - Replace useHasMounted gate with SplitPanelsLayoutClient (dynamic, ssr: false) - usePlaygroundMobileLayout uses SSR device hint to avoid mobile/desktop flash - Align split layout height with PLAYGROUND_DESKTOP_APP_BAR_HEIGHT (56px) Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- .../(app)/playground/[[...slug]]/loading.tsx | 6 +- .../(app)/playground/[[...slug]]/loading.tsx | 6 +- src/features/appBar/constants.ts | 3 + .../hooks/usePlaygroundMobileLayout.ts | 22 ++++++ .../playground/ui/PlaygroundPageLoading.tsx | 13 ++++ .../playground/ui/PlaygroundPageShell.tsx | 41 ++++++++++ .../playground/ui/PlaygroundPageView.tsx | 74 ++++--------------- .../ui/PlaygroundPanelsSkeleton.tsx | 37 ++++++++++ .../SplitPanelsLayout/SplitPanelsLayout.tsx | 3 +- .../SplitPanelsLayoutClient.tsx | 23 ++++++ .../SplitPanelsLayoutSkeleton.tsx | 4 +- 11 files changed, 163 insertions(+), 69 deletions(-) create mode 100644 src/features/playground/hooks/usePlaygroundMobileLayout.ts create mode 100644 src/features/playground/ui/PlaygroundPageLoading.tsx create mode 100644 src/features/playground/ui/PlaygroundPageShell.tsx create mode 100644 src/features/playground/ui/PlaygroundPanelsSkeleton.tsx create mode 100644 src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx diff --git a/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx b/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx index e6c664a7..8151c695 100644 --- a/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx +++ b/src/app/(default-locale)/(app)/playground/[[...slug]]/loading.tsx @@ -1,5 +1,3 @@ -import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; +import { PlaygroundPageLoading } from "#/features/playground/ui/PlaygroundPageLoading"; -export default function PlaygroundLoading() { - return <SplitPanelsLayoutSkeleton />; -} +export default PlaygroundPageLoading; diff --git a/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx b/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx index e6c664a7..8151c695 100644 --- a/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx +++ b/src/app/[lang]/(app)/playground/[[...slug]]/loading.tsx @@ -1,5 +1,3 @@ -import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; +import { PlaygroundPageLoading } from "#/features/playground/ui/PlaygroundPageLoading"; -export default function PlaygroundLoading() { - return <SplitPanelsLayoutSkeleton />; -} +export default PlaygroundPageLoading; diff --git a/src/features/appBar/constants.ts b/src/features/appBar/constants.ts index 308e1286..1d56c59a 100644 --- a/src/features/appBar/constants.ts +++ b/src/features/appBar/constants.ts @@ -1,2 +1,5 @@ /** Height of the app bar when in mobile playground layout. */ export const MOBILE_APPBAR_HEIGHT = 48; + +/** Dense desktop toolbar height on playground (`MainAppBar` + split layout). */ +export const PLAYGROUND_DESKTOP_APP_BAR_HEIGHT = 56; diff --git a/src/features/playground/hooks/usePlaygroundMobileLayout.ts b/src/features/playground/hooks/usePlaygroundMobileLayout.ts new file mode 100644 index 00000000..10ccb645 --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundMobileLayout.ts @@ -0,0 +1,22 @@ +"use client"; + +import { type Theme, useMediaQuery } from "@mui/material"; + +import { useHasMounted } from "#/shared/hooks/useHasMounted"; + +import { useRuntimeDeviceHint } from "#/app/locale-app/RuntimeDeviceHintContext"; + +/** Playground layout mode — SSR device hint until mount, then live breakpoint. */ +export const usePlaygroundMobileLayout = (): boolean => { + const { ssrDeviceType } = useRuntimeDeviceHint(); + const hasMounted = useHasMounted(); + const matchesMobile = useMediaQuery((theme: Theme) => + theme.breakpoints.down("sm"), + ); + + if (!hasMounted) { + return ssrDeviceType === "mobile"; + } + + return matchesMobile; +}; diff --git a/src/features/playground/ui/PlaygroundPageLoading.tsx b/src/features/playground/ui/PlaygroundPageLoading.tsx new file mode 100644 index 00000000..07e2d812 --- /dev/null +++ b/src/features/playground/ui/PlaygroundPageLoading.tsx @@ -0,0 +1,13 @@ +"use client"; + +import React from "react"; + +import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; +import { PlaygroundPanelsSkeleton } from "#/features/playground/ui/PlaygroundPanelsSkeleton"; + +/** Route-level instant-nav fallback — matches {@link PlaygroundPageView} chrome. */ +export const PlaygroundPageLoading: React.FC = () => ( + <PlaygroundPageShell> + <PlaygroundPanelsSkeleton /> + </PlaygroundPageShell> +); diff --git a/src/features/playground/ui/PlaygroundPageShell.tsx b/src/features/playground/ui/PlaygroundPageShell.tsx new file mode 100644 index 00000000..8f874edc --- /dev/null +++ b/src/features/playground/ui/PlaygroundPageShell.tsx @@ -0,0 +1,41 @@ +"use client"; + +import { darken, useTheme } from "@mui/material"; +import React, { type ReactNode } from "react"; + +import { MainAppBar } from "#/features/appBar/ui/MainAppBar"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { PageScrollContainer } from "#/shared/ui/templates/PageScrollContainer"; + +type PlaygroundPageShellProps = { + children: ReactNode; +}; + +/** + * Shared chrome for playground routes: scroll container, background, and app bar. + * Used by the live page and route `loading.tsx` so instant navigations keep the header. + */ +export const PlaygroundPageShell: React.FC<PlaygroundPageShellProps> = ({ + children, +}) => { + const theme = useTheme(); + const isMobile = usePlaygroundMobileLayout(); + + return ( + <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), + }} + > + <MainAppBar toolbarVariant="dense" /> + {children} + </PageScrollContainer> + ); +}; diff --git a/src/features/playground/ui/PlaygroundPageView.tsx b/src/features/playground/ui/PlaygroundPageView.tsx index 9d36f553..a17b480f 100644 --- a/src/features/playground/ui/PlaygroundPageView.tsx +++ b/src/features/playground/ui/PlaygroundPageView.tsx @@ -1,53 +1,23 @@ "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 { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; import { MobilePlayground } from "#/features/playground/ui/MobilePlayground"; +import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; 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"; +import { useAppConfig } from "#/shared/hooks"; +import { SplitPanelsLayoutClient } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient"; -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. */ +/** Playground shell — desktop split layout or mobile phased UI inside shared chrome. */ export const PlaygroundPageView: React.FC = () => { - const theme = useTheme(); - const isMobile = useMobileLayout(); + const isMobile = usePlaygroundMobileLayout(); usePlaygroundRuntimeRelease(); @@ -55,35 +25,21 @@ export const PlaygroundPageView: React.FC = () => { 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), - }} - > + <PlaygroundPageShell> {isMobile ? ( <PlaygroundViewProvider> - <MainAppBar toolbarVariant="dense" /> <MobilePlayground /> </PlaygroundViewProvider> ) : ( - <> - <MainAppBar toolbarVariant="dense" /> - <DesktopWrapper - TopLeft={ProjectPanel} - BottomLeft={CodePanel} - TopRight={TreeViewPanel} - BottomRight={OutputPanel} - /> - </> + <SplitPanelsLayoutClient + component="main" + TopLeft={ProjectPanel} + BottomLeft={CodePanel} + TopRight={TreeViewPanel} + BottomRight={OutputPanel} + /> )} - </PageScrollContainer> + </PlaygroundPageShell> </ConfigContext.Provider> ); }; diff --git a/src/features/playground/ui/PlaygroundPanelsSkeleton.tsx b/src/features/playground/ui/PlaygroundPanelsSkeleton.tsx new file mode 100644 index 00000000..4c868355 --- /dev/null +++ b/src/features/playground/ui/PlaygroundPanelsSkeleton.tsx @@ -0,0 +1,37 @@ +"use client"; + +import { Box, Skeleton } from "@mui/material"; +import React from "react"; + +import { MOBILE_APPBAR_HEIGHT } from "#/features/appBar/constants"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; + +const MobilePlaygroundPanelsSkeleton: React.FC = () => ( + <Box + component="main" + sx={{ + height: `calc(100vh - ${MOBILE_APPBAR_HEIGHT}px - env(safe-area-inset-top, 0px))`, + px: 1, + pb: 1, + overflow: "hidden", + }} + > + <Skeleton + variant="rounded" + animation="wave" + sx={{ height: "100%", borderRadius: 2, cursor: "wait" }} + /> + </Box> +); + +/** Playground panel-area skeleton (desktop split layout or mobile full-bleed). */ +export const PlaygroundPanelsSkeleton: React.FC = () => { + const isMobile = usePlaygroundMobileLayout(); + + if (isMobile) { + return <MobilePlaygroundPanelsSkeleton />; + } + + return <SplitPanelsLayoutSkeleton />; +}; diff --git a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout.tsx b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout.tsx index 728ebf10..3d6ea47c 100644 --- a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout.tsx +++ b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout.tsx @@ -4,6 +4,7 @@ import { Box } from "@mui/material"; import React, { useState } from "react"; import { Group, Panel, type PanelProps } from "react-resizable-panels"; +import { PLAYGROUND_DESKTOP_APP_BAR_HEIGHT } from "#/features/appBar/constants"; import { ResizeHandle } from "#/shared/ui/atoms/ResizeHandle"; export type SplitPanelsLayoutProps = { @@ -52,7 +53,7 @@ export const SplitPanelsLayout: React.FC<SplitPanelsLayoutProps> = ({ <Box component={component} sx={{ - height: "calc(100vh - 57px)", + height: `calc(100vh - ${PLAYGROUND_DESKTOP_APP_BAR_HEIGHT}px)`, width: "100vw", px: 1, pb: 1, diff --git a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx new file mode 100644 index 00000000..4455de03 --- /dev/null +++ b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx @@ -0,0 +1,23 @@ +"use client"; + +import dynamic from "next/dynamic"; +import React from "react"; + +import type { SplitPanelsLayoutProps } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; +import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; + +const SplitPanelsLayout = dynamic( + () => + import("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout").then( + (module) => ({ default: module.SplitPanelsLayout }), + ), + { + ssr: false, + loading: () => <SplitPanelsLayoutSkeleton />, + }, +); + +/** Client-only split layout — avoids Emotion hydration mismatch without a mount gate. */ +export const SplitPanelsLayoutClient: React.FC<SplitPanelsLayoutProps> = ( + props, +) => <SplitPanelsLayout {...props} />; diff --git a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton.tsx b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton.tsx index c584448a..d0828326 100644 --- a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton.tsx +++ b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton.tsx @@ -1,6 +1,8 @@ import { Box, Skeleton } from "@mui/material"; import React from "react"; +import { PLAYGROUND_DESKTOP_APP_BAR_HEIGHT } from "#/features/appBar/constants"; + function PanelSkeleton({ flex }: { flex: string }) { return ( <Skeleton @@ -44,7 +46,7 @@ export const SplitPanelsLayoutSkeleton: React.FC = () => { <Box component="main" sx={{ - height: "calc(100vh - 57px)", + height: `calc(100vh - ${PLAYGROUND_DESKTOP_APP_BAR_HEIGHT}px)`, width: "100vw", px: 1, pb: 1, From 5a4f4bd80d243eae24e9f47163028acd7f3ea6a9 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 14:24:07 +0000 Subject: [PATCH 07/19] Fix playground double flicker on instant navigation Move PlaygroundPageShell to a persistent playground layout so loading.tsx only swaps the panel skeleton while chrome stays mounted. Prefetch the split layout chunk and drop the dynamic loading fallback to avoid a second skeleton pass. Warm Pyodide from the layout segment instead of CodePanel mount so init and the progress snackbar are not restarted when the route loading UI hands off to the page. Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- .../(app)/playground/layout.tsx | 9 +++ src/app/[lang]/(app)/playground/layout.tsx | 9 +++ .../codeRunner/hooks/usePythonCodeRunner.tsx | 55 +---------------- .../hooks/usePlaygroundPyodideWarmup.ts | 59 +++++++++++++++++++ .../playground/ui/PlaygroundLayoutClient.tsx | 29 +++++++++ .../playground/ui/PlaygroundPageLoading.tsx | 7 +-- .../playground/ui/PlaygroundPageShell.tsx | 2 +- .../playground/ui/PlaygroundPageView.tsx | 32 ++++------ .../SplitPanelsLayoutClient.tsx | 6 +- 9 files changed, 124 insertions(+), 84 deletions(-) create mode 100644 src/app/(default-locale)/(app)/playground/layout.tsx create mode 100644 src/app/[lang]/(app)/playground/layout.tsx create mode 100644 src/features/playground/hooks/usePlaygroundPyodideWarmup.ts create mode 100644 src/features/playground/ui/PlaygroundLayoutClient.tsx diff --git a/src/app/(default-locale)/(app)/playground/layout.tsx b/src/app/(default-locale)/(app)/playground/layout.tsx new file mode 100644 index 00000000..6a4fe629 --- /dev/null +++ b/src/app/(default-locale)/(app)/playground/layout.tsx @@ -0,0 +1,9 @@ +import { PlaygroundLayoutClient } from "#/features/playground/ui/PlaygroundLayoutClient"; + +export default function DefaultLocalePlaygroundLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <PlaygroundLayoutClient>{children}</PlaygroundLayoutClient>; +} diff --git a/src/app/[lang]/(app)/playground/layout.tsx b/src/app/[lang]/(app)/playground/layout.tsx new file mode 100644 index 00000000..a4c592df --- /dev/null +++ b/src/app/[lang]/(app)/playground/layout.tsx @@ -0,0 +1,9 @@ +import { PlaygroundLayoutClient } from "#/features/playground/ui/PlaygroundLayoutClient"; + +export default function LangPlaygroundLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <PlaygroundLayoutClient>{children}</PlaygroundLayoutClient>; +} diff --git a/src/features/codeRunner/hooks/usePythonCodeRunner.tsx b/src/features/codeRunner/hooks/usePythonCodeRunner.tsx index bf3a9953..a5af1972 100644 --- a/src/features/codeRunner/hooks/usePythonCodeRunner.tsx +++ b/src/features/codeRunner/hooks/usePythonCodeRunner.tsx @@ -1,64 +1,11 @@ import { useMutation } from "@tanstack/react-query"; -import { useCallback, useEffect } from "react"; - -import { useAppDispatch } from "#/store/hooks"; +import { useCallback } from "react"; import type { SerializedPythonArg } from "../lib/createPythonRuntimeArgs"; import { pythonRunner } from "../lib/pythonRunner"; -import { pyodideSlice } from "../model/pyodideSlice"; import type { ExecutionResult } from "./useCodeExecution"; -import { usePyodideProgressSnackbar } from "./usePyodideProgressSnackbar"; - -/** Delay after 100% so the progress bar animation completes before snackbar closes. */ -const PROGRESS_COMPLETE_DELAY_MS = 400; export const usePythonCodeRunner = () => { - const dispatch = useAppDispatch(); - - usePyodideProgressSnackbar(); - - // Preload Pyodide worker when the hook mounts (user entered Python page). - // Progress is dispatched to Redux; usePyodideProgressSnackbar shows the snackbar. - useEffect(() => { - if (pythonRunner.isReady) return; - - let cancelled = false; - - dispatch( - pyodideSlice.actions.setProgress({ value: 0, stage: "Starting…" }), - ); - - let completeTimeoutId: ReturnType<typeof setTimeout> | null = null; - - pythonRunner - .init({ - onProgress: (value, stage) => { - if (!cancelled) { - dispatch(pyodideSlice.actions.setProgress({ value, stage })); - } - }, - }) - .catch(() => undefined) - .finally(() => { - if (cancelled) return; - - completeTimeoutId = setTimeout(() => { - completeTimeoutId = null; - if (!cancelled) { - dispatch(pyodideSlice.actions.clearProgress()); - } - }, PROGRESS_COMPLETE_DELAY_MS); - }); - - return () => { - cancelled = true; - if (completeTimeoutId !== null) { - clearTimeout(completeTimeoutId); - } - dispatch(pyodideSlice.actions.clearProgress()); - }; - }, [dispatch]); - const { mutateAsync: executePythonCode, isPending } = useMutation({ mutationFn: async ({ codeInput, diff --git a/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts b/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts new file mode 100644 index 00000000..c8b1cf99 --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts @@ -0,0 +1,59 @@ +import { useEffect } from "react"; + +import { usePyodideProgressSnackbar } from "#/features/codeRunner/hooks/usePyodideProgressSnackbar"; +import { pythonRunner } from "#/features/codeRunner/lib/pythonRunner"; +import { pyodideSlice } from "#/features/codeRunner/model/pyodideSlice"; +import { useAppDispatch } from "#/store/hooks"; + +/** Delay after 100% so the progress bar animation completes before snackbar closes. */ +const PROGRESS_COMPLETE_DELAY_MS = 400; + +/** + * Warm Pyodide once per playground segment visit. + * Lives in playground layout so loading → page transitions do not restart init. + */ +export const usePlaygroundPyodideWarmup = (): void => { + const dispatch = useAppDispatch(); + + usePyodideProgressSnackbar(); + + useEffect(() => { + if (pythonRunner.isReady) return; + + let cancelled = false; + + dispatch( + pyodideSlice.actions.setProgress({ value: 0, stage: "Starting…" }), + ); + + let completeTimeoutId: ReturnType<typeof setTimeout> | null = null; + + pythonRunner + .init({ + onProgress: (value, stage) => { + if (!cancelled) { + dispatch(pyodideSlice.actions.setProgress({ value, stage })); + } + }, + }) + .catch(() => undefined) + .finally(() => { + if (cancelled) return; + + completeTimeoutId = setTimeout(() => { + completeTimeoutId = null; + if (!cancelled) { + dispatch(pyodideSlice.actions.clearProgress()); + } + }, PROGRESS_COMPLETE_DELAY_MS); + }); + + return () => { + cancelled = true; + if (completeTimeoutId !== null) { + clearTimeout(completeTimeoutId); + } + dispatch(pyodideSlice.actions.clearProgress()); + }; + }, [dispatch]); +}; diff --git a/src/features/playground/ui/PlaygroundLayoutClient.tsx b/src/features/playground/ui/PlaygroundLayoutClient.tsx new file mode 100644 index 00000000..d49a1c85 --- /dev/null +++ b/src/features/playground/ui/PlaygroundLayoutClient.tsx @@ -0,0 +1,29 @@ +"use client"; + +import React, { type ReactNode, useEffect } from "react"; + +import { usePlaygroundPyodideWarmup } from "#/features/playground/hooks/usePlaygroundPyodideWarmup"; +import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; +import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; + +type PlaygroundLayoutClientProps = { + children: ReactNode; +}; + +/** + * Persistent playground segment chrome — survives loading.tsx → page swaps + * so instant navigations do not remount the header or restart Pyodide. + */ +export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ + children, +}) => { + usePlaygroundRuntimeRelease(); + usePlaygroundPyodideWarmup(); + + // Prefetch split layout chunk while route loading skeleton is visible. + useEffect(() => { + void import("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"); + }, []); + + return <PlaygroundPageShell>{children}</PlaygroundPageShell>; +}; diff --git a/src/features/playground/ui/PlaygroundPageLoading.tsx b/src/features/playground/ui/PlaygroundPageLoading.tsx index 07e2d812..91dd3daa 100644 --- a/src/features/playground/ui/PlaygroundPageLoading.tsx +++ b/src/features/playground/ui/PlaygroundPageLoading.tsx @@ -2,12 +2,9 @@ import React from "react"; -import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; import { PlaygroundPanelsSkeleton } from "#/features/playground/ui/PlaygroundPanelsSkeleton"; -/** Route-level instant-nav fallback — matches {@link PlaygroundPageView} chrome. */ +/** Route-level instant-nav fallback — panel area only; shell lives in playground layout. */ export const PlaygroundPageLoading: React.FC = () => ( - <PlaygroundPageShell> - <PlaygroundPanelsSkeleton /> - </PlaygroundPageShell> + <PlaygroundPanelsSkeleton /> ); diff --git a/src/features/playground/ui/PlaygroundPageShell.tsx b/src/features/playground/ui/PlaygroundPageShell.tsx index 8f874edc..566401eb 100644 --- a/src/features/playground/ui/PlaygroundPageShell.tsx +++ b/src/features/playground/ui/PlaygroundPageShell.tsx @@ -13,7 +13,7 @@ type PlaygroundPageShellProps = { /** * Shared chrome for playground routes: scroll container, background, and app bar. - * Used by the live page and route `loading.tsx` so instant navigations keep the header. + * Rendered from playground `layout.tsx` so it persists across loading → page swaps. */ export const PlaygroundPageShell: React.FC<PlaygroundPageShellProps> = ({ children, diff --git a/src/features/playground/ui/PlaygroundPageView.tsx b/src/features/playground/ui/PlaygroundPageView.tsx index a17b480f..0398af56 100644 --- a/src/features/playground/ui/PlaygroundPageView.tsx +++ b/src/features/playground/ui/PlaygroundPageView.tsx @@ -7,9 +7,7 @@ import { CodePanel } from "#/features/codeRunner/ui/CodePanel"; import { OutputPanel } from "#/features/output/ui/OutputPanel"; import { PlaygroundViewProvider } from "#/features/playground/context/PlaygroundViewContext"; import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; -import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; import { MobilePlayground } from "#/features/playground/ui/MobilePlayground"; -import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; import { ProjectPanel } from "#/features/project/ui/ProjectPanel"; import { TreeViewPanel } from "#/features/treeViewer/ui/TreeViewPanel"; import { useAppConfig } from "#/shared/hooks"; @@ -19,27 +17,23 @@ import { SplitPanelsLayoutClient } from "#/shared/ui/templates/SplitPanelsLayout export const PlaygroundPageView: React.FC = () => { const isMobile = usePlaygroundMobileLayout(); - usePlaygroundRuntimeRelease(); - const { data = {} } = useAppConfig(); return ( <ConfigContext.Provider value={data}> - <PlaygroundPageShell> - {isMobile ? ( - <PlaygroundViewProvider> - <MobilePlayground /> - </PlaygroundViewProvider> - ) : ( - <SplitPanelsLayoutClient - component="main" - TopLeft={ProjectPanel} - BottomLeft={CodePanel} - TopRight={TreeViewPanel} - BottomRight={OutputPanel} - /> - )} - </PlaygroundPageShell> + {isMobile ? ( + <PlaygroundViewProvider> + <MobilePlayground /> + </PlaygroundViewProvider> + ) : ( + <SplitPanelsLayoutClient + component="main" + TopLeft={ProjectPanel} + BottomLeft={CodePanel} + TopRight={TreeViewPanel} + BottomRight={OutputPanel} + /> + )} </ConfigContext.Provider> ); }; diff --git a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx index 4455de03..eb09e12a 100644 --- a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx +++ b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx @@ -4,17 +4,13 @@ import dynamic from "next/dynamic"; import React from "react"; import type { SplitPanelsLayoutProps } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; -import { SplitPanelsLayoutSkeleton } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutSkeleton"; const SplitPanelsLayout = dynamic( () => import("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout").then( (module) => ({ default: module.SplitPanelsLayout }), ), - { - ssr: false, - loading: () => <SplitPanelsLayoutSkeleton />, - }, + { ssr: false }, ); /** Client-only split layout — avoids Emotion hydration mismatch without a mount gate. */ From 8edc8985d379ec53607fe4ebf3bc29ea76e1af03 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 14:47:27 +0000 Subject: [PATCH 08/19] Stabilize playground initial loading without layout jumps Add PlaygroundPanelsGate to keep one continuous skeleton from route loading through split-layout hydration and editor init. Reset project loading state on playground layout entry, and skip loadStart on the initial auto-project redirect so panel overlays do not flash back on. Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- .../usePlaygroundPanelsReady.test.tsx | 81 +++++++++++++++++++ .../hooks/usePlaygroundPanelsReady.ts | 60 ++++++++++++++ .../playground/ui/PlaygroundLayoutClient.tsx | 9 +++ .../playground/ui/PlaygroundPageView.tsx | 29 ++++--- .../playground/ui/PlaygroundPanelsGate.tsx | 52 ++++++++++++ src/shared/hooks/usePlaygroundSlugs.ts | 4 +- 6 files changed, 221 insertions(+), 14 deletions(-) create mode 100644 src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx create mode 100644 src/features/playground/hooks/usePlaygroundPanelsReady.ts create mode 100644 src/features/playground/ui/PlaygroundPanelsGate.tsx diff --git a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx new file mode 100644 index 00000000..a95b131a --- /dev/null +++ b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx @@ -0,0 +1,81 @@ +import { renderHook } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { usePlaygroundPanelsReady } from "#/features/playground/hooks/usePlaygroundPanelsReady"; +import { projectSlice } from "#/features/project/model/projectSlice"; +import { makeStore } from "#/store/makeStore"; + +const mockUsePlaygroundMobileLayout = vi.fn(() => false); +const mockUsePlaygroundRoute = vi.fn(() => ({ + basePath: "/playground", + slug: ["two-sum"], + pathname: "/playground/two-sum", + navigateTo: vi.fn(), +})); + +vi.mock("#/features/playground/hooks/usePlaygroundMobileLayout", () => ({ + usePlaygroundMobileLayout: () => mockUsePlaygroundMobileLayout(), +})); + +vi.mock("#/shared/hooks/usePlaygroundRoute", () => ({ + usePlaygroundRoute: () => mockUsePlaygroundRoute(), +})); + +vi.mock( + "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout", + () => ({ + SplitPanelsLayout: () => null, + }), +); + +describe("usePlaygroundPanelsReady", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUsePlaygroundMobileLayout.mockReturnValue(false); + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: ["two-sum"], + pathname: "/playground/two-sum", + navigateTo: vi.fn(), + }); + }); + + it("returns false on desktop until split layout loads and project is initialized", async () => { + const store = makeStore(); + + const { result } = renderHook(() => usePlaygroundPanelsReady(), { + wrapper: ({ children }) => ( + <Provider store={store}>{children}</Provider> + ), + }); + + expect(result.current).toBe(false); + + store.dispatch(projectSlice.actions.loadFinish()); + + await vi.waitFor(() => { + expect(result.current).toBe(true); + }); + }); + + it("returns true on mobile browse view without a project slug", () => { + mockUsePlaygroundMobileLayout.mockReturnValue(true); + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: [], + pathname: "/playground", + navigateTo: vi.fn(), + }); + + const store = makeStore(); + + const { result } = renderHook(() => usePlaygroundPanelsReady(), { + wrapper: ({ children }) => ( + <Provider store={store}>{children}</Provider> + ), + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/features/playground/hooks/usePlaygroundPanelsReady.ts b/src/features/playground/hooks/usePlaygroundPanelsReady.ts new file mode 100644 index 00000000..9a66ca0f --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundPanelsReady.ts @@ -0,0 +1,60 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; + +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { selectIsInitialized } from "#/features/project/model/projectSlice"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { useAppSelector } from "#/store/hooks"; + +/** + * True when playground panel content can replace the route/panel skeleton + * without a visible layout jump (split layout chunk loaded + project initialized). + */ +export const usePlaygroundPanelsReady = (): boolean => { + const isMobile = usePlaygroundMobileLayout(); + const isInitialized = useAppSelector(selectIsInitialized); + const route = usePlaygroundRoute(); + const projectSlug = route?.slug[0] ?? ""; + const [splitLayoutReady, setSplitLayoutReady] = useState(false); + + useEffect(() => { + if (isMobile) { + return; + } + + let cancelled = false; + + void import("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout").then( + () => { + if (!cancelled) { + setSplitLayoutReady(true); + } + }, + ); + + return () => { + cancelled = true; + }; + }, [isMobile]); + + return useMemo(() => { + if (!route) { + return false; + } + + if (isMobile) { + if (!projectSlug) { + return true; + } + + return isInitialized; + } + + if (!splitLayoutReady || !projectSlug) { + return false; + } + + return isInitialized; + }, [isInitialized, isMobile, projectSlug, route, splitLayoutReady]); +}; diff --git a/src/features/playground/ui/PlaygroundLayoutClient.tsx b/src/features/playground/ui/PlaygroundLayoutClient.tsx index d49a1c85..eed9dea2 100644 --- a/src/features/playground/ui/PlaygroundLayoutClient.tsx +++ b/src/features/playground/ui/PlaygroundLayoutClient.tsx @@ -5,6 +5,8 @@ import React, { type ReactNode, useEffect } from "react"; import { usePlaygroundPyodideWarmup } from "#/features/playground/hooks/usePlaygroundPyodideWarmup"; import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; +import { projectSlice } from "#/features/project/model/projectSlice"; +import { useAppDispatch } from "#/store/hooks"; type PlaygroundLayoutClientProps = { children: ReactNode; @@ -17,9 +19,16 @@ type PlaygroundLayoutClientProps = { export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ children, }) => { + const dispatch = useAppDispatch(); + usePlaygroundRuntimeRelease(); usePlaygroundPyodideWarmup(); + // Reset panel loading state on segment entry so the skeleton gate stays up until ready. + useEffect(() => { + dispatch(projectSlice.actions.loadStart()); + }, [dispatch]); + // Prefetch split layout chunk while route loading skeleton is visible. useEffect(() => { void import("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"); diff --git a/src/features/playground/ui/PlaygroundPageView.tsx b/src/features/playground/ui/PlaygroundPageView.tsx index 0398af56..7ea9b9f5 100644 --- a/src/features/playground/ui/PlaygroundPageView.tsx +++ b/src/features/playground/ui/PlaygroundPageView.tsx @@ -8,6 +8,7 @@ import { OutputPanel } from "#/features/output/ui/OutputPanel"; import { PlaygroundViewProvider } from "#/features/playground/context/PlaygroundViewContext"; import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; import { MobilePlayground } from "#/features/playground/ui/MobilePlayground"; +import { PlaygroundPanelsGate } from "#/features/playground/ui/PlaygroundPanelsGate"; import { ProjectPanel } from "#/features/project/ui/ProjectPanel"; import { TreeViewPanel } from "#/features/treeViewer/ui/TreeViewPanel"; import { useAppConfig } from "#/shared/hooks"; @@ -21,19 +22,21 @@ export const PlaygroundPageView: React.FC = () => { return ( <ConfigContext.Provider value={data}> - {isMobile ? ( - <PlaygroundViewProvider> - <MobilePlayground /> - </PlaygroundViewProvider> - ) : ( - <SplitPanelsLayoutClient - component="main" - TopLeft={ProjectPanel} - BottomLeft={CodePanel} - TopRight={TreeViewPanel} - BottomRight={OutputPanel} - /> - )} + <PlaygroundPanelsGate> + {isMobile ? ( + <PlaygroundViewProvider> + <MobilePlayground /> + </PlaygroundViewProvider> + ) : ( + <SplitPanelsLayoutClient + component="main" + TopLeft={ProjectPanel} + BottomLeft={CodePanel} + TopRight={TreeViewPanel} + BottomRight={OutputPanel} + /> + )} + </PlaygroundPanelsGate> </ConfigContext.Provider> ); }; diff --git a/src/features/playground/ui/PlaygroundPanelsGate.tsx b/src/features/playground/ui/PlaygroundPanelsGate.tsx new file mode 100644 index 00000000..52bdb3d0 --- /dev/null +++ b/src/features/playground/ui/PlaygroundPanelsGate.tsx @@ -0,0 +1,52 @@ +"use client"; + +import { Box } from "@mui/material"; +import React, { type ReactNode } from "react"; + +import { + MOBILE_APPBAR_HEIGHT, + PLAYGROUND_DESKTOP_APP_BAR_HEIGHT, +} from "#/features/appBar/constants"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { usePlaygroundPanelsReady } from "#/features/playground/hooks/usePlaygroundPanelsReady"; +import { PlaygroundPanelsSkeleton } from "#/features/playground/ui/PlaygroundPanelsSkeleton"; + +type PlaygroundPanelsGateProps = { + children: ReactNode; +}; + +/** + * Keeps one continuous panel skeleton from route loading through client hydration. + * Panels mount underneath (opacity 0) so editors can finish init before reveal. + */ +export const PlaygroundPanelsGate: React.FC<PlaygroundPanelsGateProps> = ({ + children, +}) => { + const isMobile = usePlaygroundMobileLayout(); + const panelsReady = usePlaygroundPanelsReady(); + + const panelAreaMinHeight = isMobile + ? `calc(100vh - ${MOBILE_APPBAR_HEIGHT}px - env(safe-area-inset-top, 0px))` + : `calc(100vh - ${PLAYGROUND_DESKTOP_APP_BAR_HEIGHT}px)`; + + return ( + <Box sx={{ position: "relative" }}> + {!panelsReady ? ( + <Box sx={{ position: "absolute", inset: 0, zIndex: 2 }}> + <PlaygroundPanelsSkeleton /> + </Box> + ) : null} + <Box + aria-busy={!panelsReady} + aria-hidden={!panelsReady} + sx={{ + minHeight: panelAreaMinHeight, + opacity: panelsReady ? 1 : 0, + pointerEvents: panelsReady ? "auto" : "none", + }} + > + {children} + </Box> + </Box> + ); +}; diff --git a/src/shared/hooks/usePlaygroundSlugs.ts b/src/shared/hooks/usePlaygroundSlugs.ts index b5a4f011..1f1a7a3d 100644 --- a/src/shared/hooks/usePlaygroundSlugs.ts +++ b/src/shared/hooks/usePlaygroundSlugs.ts @@ -44,7 +44,9 @@ export const usePlaygroundSlugs = () => { const { basePath, navigateTo } = route; const setProject = (slug?: string, isInitial?: boolean) => { - dispatch(projectSlice.actions.loadStart()); + if (!isInitial) { + dispatch(projectSlice.actions.loadStart()); + } if (!slug) { return navigateTo(basePath, { replace: true, From b31dcbdef2f942be1da31a9a214d984ca08760b7 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 14:48:05 +0000 Subject: [PATCH 09/19] Fix Prettier formatting in usePlaygroundPanelsReady test Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- .../__tests__/usePlaygroundPanelsReady.test.tsx | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx index a95b131a..03bc1386 100644 --- a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx +++ b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx @@ -22,12 +22,9 @@ vi.mock("#/shared/hooks/usePlaygroundRoute", () => ({ usePlaygroundRoute: () => mockUsePlaygroundRoute(), })); -vi.mock( - "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout", - () => ({ - SplitPanelsLayout: () => null, - }), -); +vi.mock("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout", () => ({ + SplitPanelsLayout: () => null, +})); describe("usePlaygroundPanelsReady", () => { beforeEach(() => { @@ -45,9 +42,7 @@ describe("usePlaygroundPanelsReady", () => { const store = makeStore(); const { result } = renderHook(() => usePlaygroundPanelsReady(), { - wrapper: ({ children }) => ( - <Provider store={store}>{children}</Provider> - ), + wrapper: ({ children }) => <Provider store={store}>{children}</Provider>, }); expect(result.current).toBe(false); @@ -71,9 +66,7 @@ describe("usePlaygroundPanelsReady", () => { const store = makeStore(); const { result } = renderHook(() => usePlaygroundPanelsReady(), { - wrapper: ({ children }) => ( - <Provider store={store}>{children}</Provider> - ), + wrapper: ({ children }) => <Provider store={store}>{children}</Provider>, }); expect(result.current).toBe(true); From e7d12e569ea640a45f0933d0ac0738604e72e668 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 14:59:12 +0000 Subject: [PATCH 10/19] Address deep review: slug loading sync, prefetch dedup, prefetch parallelization - Reset panel loading gate when playground URL segments change (back/forward, Link navigations) via usePlaygroundSlugLoadingSync - Consolidate split-layout prefetch into prefetchSplitPanelsLayout helper - Parallelize allBrief + getBySlug in getPlaygroundInitialData - Unblock loading gate when project has no cases or no solutions - Add use client directives to playground runtime hooks - Fix locale-aware home link on privacy page - Add tests for slug loading sync Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- src/app/locale-app/pages/privacyPage.tsx | 3 +- .../usePlaygroundPanelsReady.test.tsx | 9 ++- .../usePlaygroundSlugLoadingSync.test.tsx | 55 +++++++++++++++++ .../hooks/usePlaygroundPanelsReady.ts | 14 ++--- .../hooks/usePlaygroundPyodideWarmup.ts | 3 + .../hooks/usePlaygroundRuntimeRelease.ts | 2 + .../hooks/usePlaygroundSlugLoadingSync.ts | 31 ++++++++++ .../playground/ui/PlaygroundLayoutClient.tsx | 14 ++--- .../privacy/ui/PrivacyPageContent.tsx | 4 +- .../project/hooks/useProjectPanelData.ts | 22 +++++++ .../playground/getPlaygroundInitialData.ts | 61 ++++++++++++------- .../SplitPanelsLayoutClient.tsx | 7 ++- .../prefetchSplitPanelsLayout.ts | 15 +++++ 13 files changed, 192 insertions(+), 48 deletions(-) create mode 100644 src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx create mode 100644 src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts create mode 100644 src/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout.ts diff --git a/src/app/locale-app/pages/privacyPage.tsx b/src/app/locale-app/pages/privacyPage.tsx index 0bf3d696..479b0ae3 100644 --- a/src/app/locale-app/pages/privacyPage.tsx +++ b/src/app/locale-app/pages/privacyPage.tsx @@ -52,10 +52,11 @@ export async function PrivacyPage({ params }: PrivacyPageProps = {}) { throw new Error(`Missing translations for locale: ${locale}`); } const LL = createTranslationFunctions(locale, translation); + const homePath = locale === baseLocale ? "/" : `/${locale}`; return ( <PrivacyPageShell> - <PrivacyPageContent LL={LL} /> + <PrivacyPageContent LL={LL} homePath={homePath} /> </PrivacyPageShell> ); } diff --git a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx index 03bc1386..953fde63 100644 --- a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx +++ b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx @@ -22,9 +22,12 @@ vi.mock("#/shared/hooks/usePlaygroundRoute", () => ({ usePlaygroundRoute: () => mockUsePlaygroundRoute(), })); -vi.mock("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout", () => ({ - SplitPanelsLayout: () => null, -})); +vi.mock( + "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout", + () => ({ + prefetchSplitPanelsLayout: vi.fn(() => Promise.resolve({})), + }), +); describe("usePlaygroundPanelsReady", () => { beforeEach(() => { diff --git a/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx new file mode 100644 index 00000000..dacd6fcf --- /dev/null +++ b/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx @@ -0,0 +1,55 @@ +import { renderHook } from "@testing-library/react"; +import { Provider } from "react-redux"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { usePlaygroundSlugLoadingSync } from "#/features/playground/hooks/usePlaygroundSlugLoadingSync"; +import { projectSlice } from "#/features/project/model/projectSlice"; +import { makeStore } from "#/store/makeStore"; + +const mockUsePlaygroundRoute = vi.fn(() => ({ + basePath: "/playground", + slug: ["two-sum"], + pathname: "/playground/two-sum", + navigateTo: vi.fn(), +})); + +vi.mock("#/shared/hooks/usePlaygroundRoute", () => ({ + usePlaygroundRoute: () => mockUsePlaygroundRoute(), +})); + +describe("usePlaygroundSlugLoadingSync", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: ["two-sum"], + pathname: "/playground/two-sum", + navigateTo: vi.fn(), + }); + }); + + it("dispatches loadStart when slug segments change", () => { + const store = makeStore(); + store.dispatch(projectSlice.actions.loadFinish()); + + const { rerender } = renderHook(() => usePlaygroundSlugLoadingSync(), { + wrapper: ({ children }) => <Provider store={store}>{children}</Provider>, + }); + + expect(store.getState().project.isInitialized).toBe(false); + + store.dispatch(projectSlice.actions.loadFinish()); + expect(store.getState().project.isInitialized).toBe(true); + + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: ["three-sum"], + pathname: "/playground/three-sum", + navigateTo: vi.fn(), + }); + + rerender(); + + expect(store.getState().project.isInitialized).toBe(false); + }); +}); diff --git a/src/features/playground/hooks/usePlaygroundPanelsReady.ts b/src/features/playground/hooks/usePlaygroundPanelsReady.ts index 9a66ca0f..c3ed3bbf 100644 --- a/src/features/playground/hooks/usePlaygroundPanelsReady.ts +++ b/src/features/playground/hooks/usePlaygroundPanelsReady.ts @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from "react"; import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; import { selectIsInitialized } from "#/features/project/model/projectSlice"; import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { prefetchSplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout"; import { useAppSelector } from "#/store/hooks"; /** @@ -18,6 +19,7 @@ export const usePlaygroundPanelsReady = (): boolean => { const projectSlug = route?.slug[0] ?? ""; const [splitLayoutReady, setSplitLayoutReady] = useState(false); + // Desktop: wait for the shared split-layout prefetch started in playground layout. useEffect(() => { if (isMobile) { return; @@ -25,13 +27,11 @@ export const usePlaygroundPanelsReady = (): boolean => { let cancelled = false; - void import("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout").then( - () => { - if (!cancelled) { - setSplitLayoutReady(true); - } - }, - ); + void prefetchSplitPanelsLayout().then(() => { + if (!cancelled) { + setSplitLayoutReady(true); + } + }); return () => { cancelled = true; diff --git a/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts b/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts index c8b1cf99..3a482d9f 100644 --- a/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts +++ b/src/features/playground/hooks/usePlaygroundPyodideWarmup.ts @@ -1,3 +1,5 @@ +"use client"; + import { useEffect } from "react"; import { usePyodideProgressSnackbar } from "#/features/codeRunner/hooks/usePyodideProgressSnackbar"; @@ -17,6 +19,7 @@ export const usePlaygroundPyodideWarmup = (): void => { usePyodideProgressSnackbar(); + // Preload Pyodide when entering the playground segment; progress drives the snackbar. useEffect(() => { if (pythonRunner.isReady) return; diff --git a/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts index b6fccdc3..14083a8a 100644 --- a/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts +++ b/src/features/playground/hooks/usePlaygroundRuntimeRelease.ts @@ -1,3 +1,5 @@ +"use client"; + import { useLayoutEffect } from "react"; import { pythonRunner } from "#/features/codeRunner/lib/pythonRunner"; diff --git a/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts b/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts new file mode 100644 index 00000000..5470dd09 --- /dev/null +++ b/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts @@ -0,0 +1,31 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { projectSlice } from "#/features/project/model/projectSlice"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { useAppDispatch } from "#/store/hooks"; + +/** + * Reset the panel loading gate when playground URL segments change + * (back/forward, <Link>, or programmatic navigations — not only setProject). + */ +export const usePlaygroundSlugLoadingSync = (): void => { + const dispatch = useAppDispatch(); + const route = usePlaygroundRoute(); + const slugKey = route?.slug.join("/") ?? ""; + const previousSlugKeyRef = useRef<string | null>(null); + + useEffect(() => { + if (!route) { + return; + } + + if (previousSlugKeyRef.current === slugKey) { + return; + } + + previousSlugKeyRef.current = slugKey; + dispatch(projectSlice.actions.loadStart()); + }, [dispatch, route, slugKey]); +}; diff --git a/src/features/playground/ui/PlaygroundLayoutClient.tsx b/src/features/playground/ui/PlaygroundLayoutClient.tsx index eed9dea2..a170a83c 100644 --- a/src/features/playground/ui/PlaygroundLayoutClient.tsx +++ b/src/features/playground/ui/PlaygroundLayoutClient.tsx @@ -4,9 +4,9 @@ import React, { type ReactNode, useEffect } from "react"; import { usePlaygroundPyodideWarmup } from "#/features/playground/hooks/usePlaygroundPyodideWarmup"; import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; +import { usePlaygroundSlugLoadingSync } from "#/features/playground/hooks/usePlaygroundSlugLoadingSync"; import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; -import { projectSlice } from "#/features/project/model/projectSlice"; -import { useAppDispatch } from "#/store/hooks"; +import { prefetchSplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout"; type PlaygroundLayoutClientProps = { children: ReactNode; @@ -19,19 +19,13 @@ type PlaygroundLayoutClientProps = { export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ children, }) => { - const dispatch = useAppDispatch(); - usePlaygroundRuntimeRelease(); usePlaygroundPyodideWarmup(); - - // Reset panel loading state on segment entry so the skeleton gate stays up until ready. - useEffect(() => { - dispatch(projectSlice.actions.loadStart()); - }, [dispatch]); + usePlaygroundSlugLoadingSync(); // Prefetch split layout chunk while route loading skeleton is visible. useEffect(() => { - void import("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"); + void prefetchSplitPanelsLayout(); }, []); return <PlaygroundPageShell>{children}</PlaygroundPageShell>; diff --git a/src/features/privacy/ui/PrivacyPageContent.tsx b/src/features/privacy/ui/PrivacyPageContent.tsx index 49ea42e8..8bf0d832 100644 --- a/src/features/privacy/ui/PrivacyPageContent.tsx +++ b/src/features/privacy/ui/PrivacyPageContent.tsx @@ -27,11 +27,13 @@ const PrivacyParagraph: React.FC<{ children: React.ReactNode }> = ({ type PrivacyPageContentProps = { LL: TranslationFunctions; + homePath: string; }; /** Server-rendered privacy policy body (passed into {@link PrivacyPageShell}). */ export const PrivacyPageContent: React.FC<PrivacyPageContentProps> = ({ LL, + homePath, }) => ( <Container maxWidth="md" sx={{ py: 4 }}> <Typography variant="h4" component="h1" gutterBottom> @@ -112,7 +114,7 @@ export const PrivacyPageContent: React.FC<PrivacyPageContentProps> = ({ </PrivacySection> <Typography variant="body2" color="text.secondary" sx={{ mt: 4 }}> - <Link href="/" style={{ color: "inherit" }}> + <Link href={homePath} style={{ color: "inherit" }}> {LL.DASHBOARD()} </Link> </Typography> diff --git a/src/features/project/hooks/useProjectPanelData.ts b/src/features/project/hooks/useProjectPanelData.ts index eda9faf4..e1657591 100644 --- a/src/features/project/hooks/useProjectPanelData.ts +++ b/src/features/project/hooks/useProjectPanelData.ts @@ -114,6 +114,28 @@ export const useProjectPanelData = () => { } }, [allBrief.data, isRouteReady, projectSlug, setProject]); + // Unblock the loading gate when case/solution auto-selection cannot proceed. + useEffect(() => { + if (!selectedProject.data || selectedProject.isLoading) { + return; + } + + const { cases, solutions } = selectedProject.data; + + if (cases.length === 0) { + dispatch(projectSlice.actions.loadFinish()); + return; + } + + if (!caseSlug) { + return; + } + + if (solutions.length === 0) { + dispatch(projectSlice.actions.loadFinish()); + } + }, [caseSlug, dispatch, selectedProject.data, selectedProject.isLoading]); + return { session, isEditable, diff --git a/src/server/playground/getPlaygroundInitialData.ts b/src/server/playground/getPlaygroundInitialData.ts index 9be3ecb8..44a38d27 100644 --- a/src/server/playground/getPlaygroundInitialData.ts +++ b/src/server/playground/getPlaygroundInitialData.ts @@ -12,6 +12,22 @@ export type PlaygroundInitialData = { caseBySlug: RouterOutputs["project"]["getCaseBySlug"] | null; }; +type ProjectBySlug = RouterOutputs["project"]["getBySlug"]; + +async function loadProjectBySlug( + caller: ReturnType<typeof createCaller>, + projectSlug: string, +): Promise<ProjectBySlug | null> { + try { + return await caller.project.getBySlug(projectSlug); + } catch (error) { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return null; + } + throw error; + } +} + /** * Server-prefetch public playground lists and the active project/case for RSC pages. * Hydrates client tRPC queries via {@link PlaygroundInitialDataProvider}. @@ -27,36 +43,35 @@ export async function getPlaygroundInitialData( }), ); - const allBrief = await caller.project.allBrief(); - if (!projectSlug) { + const allBrief = await caller.project.allBrief(); + return { allBrief, projectBySlug: null, caseBySlug: null }; + } + + const [allBrief, projectBySlug] = await Promise.all([ + caller.project.allBrief(), + loadProjectBySlug(caller, projectSlug), + ]); + + if (!projectBySlug) { return { allBrief, projectBySlug: null, caseBySlug: null }; } + if (!caseSlug) { + return { allBrief, projectBySlug, caseBySlug: null }; + } + try { - const projectBySlug = await caller.project.getBySlug(projectSlug); + const caseBySlug = await caller.project.getCaseBySlug({ + projectId: projectBySlug.id, + slug: caseSlug, + }); - if (!caseSlug) { + return { allBrief, projectBySlug, caseBySlug }; + } catch (caseError) { + if (caseError instanceof TRPCError && caseError.code === "NOT_FOUND") { return { allBrief, projectBySlug, caseBySlug: null }; } - - try { - const caseBySlug = await caller.project.getCaseBySlug({ - projectId: projectBySlug.id, - slug: caseSlug, - }); - - return { allBrief, projectBySlug, caseBySlug }; - } catch (caseError) { - if (caseError instanceof TRPCError && caseError.code === "NOT_FOUND") { - return { allBrief, projectBySlug, caseBySlug: null }; - } - throw caseError; - } - } catch (error) { - if (error instanceof TRPCError && error.code === "NOT_FOUND") { - return { allBrief, projectBySlug: null, caseBySlug: null }; - } - throw error; + throw caseError; } } diff --git a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx index eb09e12a..50de7c51 100644 --- a/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx +++ b/src/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayoutClient.tsx @@ -3,13 +3,14 @@ import dynamic from "next/dynamic"; import React from "react"; +import { prefetchSplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout"; import type { SplitPanelsLayoutProps } from "#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout"; const SplitPanelsLayout = dynamic( () => - import("#/shared/ui/templates/SplitPanelsLayout/SplitPanelsLayout").then( - (module) => ({ default: module.SplitPanelsLayout }), - ), + prefetchSplitPanelsLayout().then((module) => ({ + default: module.SplitPanelsLayout, + })), { ssr: false }, ); diff --git a/src/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout.ts b/src/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout.ts new file mode 100644 index 00000000..c2e5d0f1 --- /dev/null +++ b/src/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout.ts @@ -0,0 +1,15 @@ +function loadSplitPanelsLayoutModule() { + return import("./SplitPanelsLayout"); +} + +let splitPanelsLayoutPromise: ReturnType< + typeof loadSplitPanelsLayoutModule +> | null = null; + +/** Warm the client-only split layout chunk (idempotent). */ +export const prefetchSplitPanelsLayout = (): ReturnType< + typeof loadSplitPanelsLayoutModule +> => { + splitPanelsLayoutPromise ??= loadSplitPanelsLayoutModule(); + return splitPanelsLayoutPromise; +}; From b51909957f56eed584ab3212deb6cc0b192c0392 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 15:26:31 +0000 Subject: [PATCH 11/19] Collapse playground bootstrap into one server canonical redirect Resolve incomplete playground URLs on the server (project/case/solution defaults plus last-visit cookie restore) before prefetching, instead of chaining three client-side navigations from TestCaseSelectBar and SolutionSelectBar. - Add buildCanonicalPlaygroundSlug and resolveCanonicalPlaygroundRedirect - Mirror lastPlaygroundPath to a cookie for server-side restore - Extend getPlaygroundInitialData with solution prefetch - Remove client auto-select effects for project/case/solution defaults - Map getSolutionBySlug NOT_FOUND like getCaseBySlug Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- src/app/locale-app/pages/playgroundPage.tsx | 36 +++++- src/features/codeRunner/ui/CodePanel.tsx | 7 + .../codeRunner/ui/SolutionSelectBar.tsx | 17 +-- .../project/hooks/useProjectPanelData.ts | 26 +--- src/features/project/ui/TestCaseSelectBar.tsx | 15 +-- src/server/api/routers/project.ts | 29 +++-- .../getPlaygroundInitialData.test.ts | 28 ++++ ...resolveCanonicalPlaygroundRedirect.test.ts | 86 +++++++++++++ .../playground/getPlaygroundInitialData.ts | 70 ++++++++-- .../resolveCanonicalPlaygroundRedirect.ts | 121 ++++++++++++++++++ src/shared/hooks/usePlaygroundSlugs.ts | 26 +--- .../buildCanonicalPlaygroundSlug.test.ts | 37 ++++++ .../lib/buildCanonicalPlaygroundSlug.ts | 68 ++++++++++ src/shared/lib/playgroundLastPath.ts | 29 +++++ src/shared/lib/playgroundLastPathCookie.ts | 23 ++++ src/shared/local-storage/playgroundPath.ts | 42 ++---- 16 files changed, 533 insertions(+), 127 deletions(-) create mode 100644 src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts create mode 100644 src/server/playground/resolveCanonicalPlaygroundRedirect.ts create mode 100644 src/shared/lib/__tests__/buildCanonicalPlaygroundSlug.test.ts create mode 100644 src/shared/lib/buildCanonicalPlaygroundSlug.ts create mode 100644 src/shared/lib/playgroundLastPath.ts create mode 100644 src/shared/lib/playgroundLastPathCookie.ts diff --git a/src/app/locale-app/pages/playgroundPage.tsx b/src/app/locale-app/pages/playgroundPage.tsx index afb4055b..d8a5f3d4 100644 --- a/src/app/locale-app/pages/playgroundPage.tsx +++ b/src/app/locale-app/pages/playgroundPage.tsx @@ -1,4 +1,6 @@ import type { Metadata } from "next"; +import { cookies } from "next/headers"; +import { redirect } from "next/navigation"; import { connection } from "next/server"; import { PlaygroundInitialDataProvider } from "#/features/playground/context/PlaygroundInitialDataContext"; @@ -6,6 +8,9 @@ import { resolvePlaygroundPageSeo } from "#/features/playground/lib/resolvePlayg import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView"; import { baseLocale } from "#/i18n/i18n-util"; import { getPlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; +import { resolveCanonicalPlaygroundRedirect } from "#/server/playground/resolveCanonicalPlaygroundRedirect"; +import { LAST_PLAYGROUND_PATH_COOKIE } from "#/shared/lib/playgroundLastPathCookie"; +import { playgroundBasePathForLocale } from "#/shared/lib/playgroundRoute"; import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; @@ -65,9 +70,34 @@ type PlaygroundPageProps = { export async function PlaygroundPage({ params }: PlaygroundPageProps) { await connection(); - const { slug } = await params; - const [projectSlug, caseSlug] = slug ?? []; - const initialData = await getPlaygroundInitialData(projectSlug, caseSlug); + const { slug, lang: langParam } = await params; + const locale = langParam + ? (resolveLangParamSync(langParam) ?? baseLocale) + : baseLocale; + const basePath = playgroundBasePathForLocale(locale); + const cookieStore = await cookies(); + const rawLastPathCookie = + cookieStore.get(LAST_PLAYGROUND_PATH_COOKIE)?.value ?? null; + const lastPathCookie = rawLastPathCookie + ? decodeURIComponent(rawLastPathCookie) + : null; + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath, + slug: slug ?? [], + lastPathCookie, + }); + + if (redirectPath) { + redirect(redirectPath); + } + + const [projectSlug, caseSlug, solutionSlug] = slug ?? []; + const initialData = await getPlaygroundInitialData( + projectSlug, + caseSlug, + solutionSlug, + ); return ( <PlaygroundInitialDataProvider initialData={initialData}> diff --git a/src/features/codeRunner/ui/CodePanel.tsx b/src/features/codeRunner/ui/CodePanel.tsx index 137ade26..807c4faa 100644 --- a/src/features/codeRunner/ui/CodePanel.tsx +++ b/src/features/codeRunner/ui/CodePanel.tsx @@ -49,6 +49,7 @@ import { EditorStateIcon, } from "#/features/codeRunner/ui/EditorStateIcon"; import { SolutionSelectBar } from "#/features/codeRunner/ui/SolutionSelectBar"; +import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; import { projectSlice, selectIsEditable, @@ -121,6 +122,7 @@ export const CodePanel: React.FC<CodePanelProps> = ({ const saveTimeoutControllerRef = useRef(createLatestOnlyTimeoutController()); const { projectSlug = "", solutionSlug = "" } = usePlaygroundSlugs(); + const serverInitialData = usePlaygroundInitialData(); const isEditable = useAppSelector(selectIsEditable); const isEditingNodes = useAppSelector(selectIsEditingNodes); const error = useAppSelector(selectCallstackError); @@ -145,6 +147,11 @@ export const CodePanel: React.FC<CodePanelProps> = ({ }, { enabled: Boolean(selectedProject.data?.id && solutionSlug), + initialData: + serverInitialData?.solutionBySlug?.slug === solutionSlug && + serverInitialData.projectBySlug?.id === selectedProject.data?.id + ? serverInitialData.solutionBySlug + : undefined, }, ); diff --git a/src/features/codeRunner/ui/SolutionSelectBar.tsx b/src/features/codeRunner/ui/SolutionSelectBar.tsx index fa1bc932..4a9bc4e0 100644 --- a/src/features/codeRunner/ui/SolutionSelectBar.tsx +++ b/src/features/codeRunner/ui/SolutionSelectBar.tsx @@ -2,7 +2,7 @@ import type { OnDragEndResponder } from "@hello-pangea/dnd"; import type { StackProps } from "@mui/material"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { SolutionModal } from "#/features/codeRunner/ui/SolutionModal"; import { selectIsEditable } from "#/features/project/model/projectSlice"; @@ -14,7 +14,7 @@ import { api } from "#/shared/api"; import type { RouterOutputs } from "#/shared/api"; import { usePlaygroundSlugs } from "#/shared/hooks"; import { useI18nContext } from "#/shared/hooks"; -import { useAppDispatch, useAppSelector } from "#/store/hooks"; +import { useAppSelector } from "#/store/hooks"; type SolutionBrief = Pick< PlaygroundSolution, @@ -35,14 +35,11 @@ export const SolutionSelectBar: React.FC<SolutionSelectBarProps> = ({ const { projectSlug = "", - caseSlug = "", solutionSlug = "", setSolution, } = usePlaygroundSlugs(); const solutions = selectedProject.data?.solutions; - const dispatch = useAppDispatch(); - const trpcUtils = api.useUtils(); const updateSolutionsCache = (solutions: SolutionBrief[]) => { @@ -94,16 +91,6 @@ export const SolutionSelectBar: React.FC<SolutionSelectBarProps> = ({ const isEditable = useAppSelector(selectIsEditable); - useEffect(() => { - if (solutionSlug || !selectedProject.data || !caseSlug) return; - - const firstSolutionSlug = selectedProject.data.solutions[0]?.slug; - - if (firstSolutionSlug) { - setSolution(firstSolutionSlug); - } - }, [solutionSlug, selectedProject.data, dispatch, setSolution, caseSlug]); - const handleSolutionClick = (solution: SolutionBrief) => { void setSolution(solution.slug); }; diff --git a/src/features/project/hooks/useProjectPanelData.ts b/src/features/project/hooks/useProjectPanelData.ts index e1657591..00dbc9a1 100644 --- a/src/features/project/hooks/useProjectPanelData.ts +++ b/src/features/project/hooks/useProjectPanelData.ts @@ -10,7 +10,6 @@ import { selectIsEditable, } from "#/features/project/model/projectSlice"; import { usePlaygroundSlugs } from "#/shared/hooks"; -import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; import { api } from "#/shared/lib"; import { useAppDispatch, useAppSelector } from "#/store/hooks"; @@ -21,22 +20,11 @@ import { useAppDispatch, useAppSelector } from "#/store/hooks"; export const useProjectPanelData = () => { const session = useSession(); const dispatch = useAppDispatch(); - const playgroundRoute = usePlaygroundRoute(); - const isRouteReady = playgroundRoute !== null; - - const { - projectSlug = "", - caseSlug = "", - setProject, - clearSlugs, - } = usePlaygroundSlugs(); + const { projectSlug = "", caseSlug = "", clearSlugs } = usePlaygroundSlugs(); const serverInitialData = usePlaygroundInitialData(); - const allBrief = api.project.allBrief.useQuery(undefined, { - initialData: serverInitialData?.allBrief, - }); const isEditable = useAppSelector(selectIsEditable); const selectedProject = api.project.getBySlug.useQuery(projectSlug, { @@ -104,17 +92,7 @@ export const useProjectPanelData = () => { session.data, ]); - // On landing with no slug, open the first public project once route + brief list are ready. - useEffect(() => { - if (allBrief.data?.length && isRouteReady && !projectSlug) { - const firstProject = allBrief.data[0]; - if (firstProject) { - setProject(firstProject.slug, true); - } - } - }, [allBrief.data, isRouteReady, projectSlug, setProject]); - - // Unblock the loading gate when case/solution auto-selection cannot proceed. + // Unblock the loading gate when case/solution selection cannot proceed. useEffect(() => { if (!selectedProject.data || selectedProject.isLoading) { return; diff --git a/src/features/project/ui/TestCaseSelectBar.tsx b/src/features/project/ui/TestCaseSelectBar.tsx index 10ef74d6..f253dce1 100644 --- a/src/features/project/ui/TestCaseSelectBar.tsx +++ b/src/features/project/ui/TestCaseSelectBar.tsx @@ -3,7 +3,7 @@ import type { OnDragEndResponder } from "@hello-pangea/dnd"; import type { StackProps } from "@mui/material"; import { useSnackbar } from "notistack"; -import React, { useEffect, useState } from "react"; +import React, { useState } from "react"; import { selectIsEditable } from "#/features/project/model/projectSlice"; import { DraggableSelectBarList } from "#/features/selectBar/ui/DraggableSelectBarList"; @@ -14,7 +14,7 @@ import { api } from "#/shared/api"; import type { RouterOutputs } from "#/shared/api"; import { usePlaygroundSlugs } from "#/shared/hooks"; import { useI18nContext } from "#/shared/hooks"; -import { useAppDispatch, useAppSelector } from "#/store/hooks"; +import { useAppSelector } from "#/store/hooks"; import { CaseModal } from "./CaseModal"; @@ -32,7 +32,6 @@ export const TestCaseSelectBar: React.FC<TestCaseSelectBarProps> = ({ ...restProps }) => { const { LL } = useI18nContext(); - const dispatch = useAppDispatch(); const { enqueueSnackbar } = useSnackbar(); @@ -95,16 +94,6 @@ export const TestCaseSelectBar: React.FC<TestCaseSelectBarProps> = ({ const isEditable = useAppSelector(selectIsEditable); - useEffect(() => { - if (caseSlug || !selectedProject.data) return; - - const firstCaseSlug = selectedProject.data.cases[0]?.slug; - - if (firstCaseSlug) { - setCase(firstCaseSlug); - } - }, [caseSlug, selectedProject.data, dispatch, setCase]); - const handleCaseClick = (testCase: TestCaseBrief) => { void setCase(testCase.slug); }; diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index be7e9df1..27d606ed 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -818,14 +818,27 @@ export const projectRouter = createTRPCRouter({ }), ) .query(async ({ input, ctx }) => { - const solution = await ctx.db.playgroundSolution.findUniqueOrThrow({ - where: { - projectId_slug: input, - }, - include: { - project: { select: { category: true } }, - }, - }); + const solution = await ctx.db.playgroundSolution + .findUniqueOrThrow({ + where: { + projectId_slug: input, + }, + include: { + project: { select: { category: true } }, + }, + }) + .catch((error: unknown) => { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2025" + ) { + throw new TRPCError({ + code: "NOT_FOUND", + message: `Solution "${input.slug}" not found.`, + }); + } + throw error; + }); const { project, ...rest } = solution; const mergedCode = getMergedCodeContent(project.category, { code: rest.code, diff --git a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts index 0db59920..9b1bd928 100644 --- a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts +++ b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockAllBrief = vi.fn(); const mockGetBySlug = vi.fn(); const mockGetCaseBySlug = vi.fn(); +const mockGetSolutionBySlug = vi.fn(); vi.mock("#/server/auth/authOptions", () => ({ authOptions: {}, @@ -15,6 +16,7 @@ vi.mock("#/server/api/root", () => ({ allBrief: mockAllBrief, getBySlug: mockGetBySlug, getCaseBySlug: mockGetCaseBySlug, + getSolutionBySlug: mockGetSolutionBySlug, }, }), })); @@ -41,6 +43,13 @@ describe("getPlaygroundInitialData", () => { slug: "case-a", projectId: "proj-1", }); + mockGetSolutionBySlug.mockResolvedValue({ + id: "solution-1", + slug: "solution-a", + projectId: "proj-1", + code: "", + pythonCode: "", + }); }); it("returns allBrief only when no slug is provided", async () => { @@ -51,6 +60,7 @@ describe("getPlaygroundInitialData", () => { expect(result.allBrief).toHaveLength(1); expect(result.projectBySlug).toBeNull(); expect(result.caseBySlug).toBeNull(); + expect(result.solutionBySlug).toBeNull(); expect(mockGetBySlug).not.toHaveBeenCalled(); }); @@ -66,6 +76,23 @@ describe("getPlaygroundInitialData", () => { }); expect(result.projectBySlug?.slug).toBe("two-sum"); expect(result.caseBySlug?.slug).toBe("case-a"); + expect(result.solutionBySlug).toBeNull(); + }); + + it("prefetches solution when solution slug is provided", async () => { + const { getPlaygroundInitialData } = + await import("#/server/playground/getPlaygroundInitialData"); + const result = await getPlaygroundInitialData( + "two-sum", + "case-a", + "solution-a", + ); + + expect(mockGetSolutionBySlug).toHaveBeenCalledWith({ + projectId: "proj-1", + slug: "solution-a", + }); + expect(result.solutionBySlug?.slug).toBe("solution-a"); }); it("keeps project prefetch when case slug is invalid", async () => { @@ -93,5 +120,6 @@ describe("getPlaygroundInitialData", () => { expect(result.allBrief).toHaveLength(1); expect(result.projectBySlug).toBeNull(); expect(result.caseBySlug).toBeNull(); + expect(result.solutionBySlug).toBeNull(); }); }); diff --git a/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts new file mode 100644 index 00000000..68e80823 --- /dev/null +++ b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts @@ -0,0 +1,86 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const mockAllBrief = vi.fn(); +const mockGetBySlug = vi.fn(); + +vi.mock("#/server/auth/authOptions", () => ({ + authOptions: {}, +})); + +vi.mock("#/server/api/root", () => ({ + createCaller: () => ({ + project: { + allBrief: mockAllBrief, + getBySlug: mockGetBySlug, + }, + }), +})); + +vi.mock("#/server/api/context", () => ({ + createInnerTRPCContext: async (opts: unknown) => opts, +})); + +vi.mock("next-auth", () => ({ + getServerSession: vi.fn().mockResolvedValue(null), +})); + +describe("resolveCanonicalPlaygroundRedirect", () => { + beforeEach(() => { + vi.clearAllMocks(); + mockAllBrief.mockResolvedValue([ + { id: "1", slug: "two-sum", title: "Two Sum" }, + ]); + mockGetBySlug.mockResolvedValue({ + id: "proj-1", + slug: "two-sum", + cases: [{ slug: "case-1" }], + solutions: [{ slug: "solution-1" }], + }); + }); + + it("redirects bare /playground to the first public project with defaults", async () => { + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: [], + lastPathCookie: null, + }); + + expect(redirectPath).toBe("/playground/two-sum/case-1/solution-1"); + }); + + it("restores the last path from cookie when landing on bare /playground", async () => { + mockGetBySlug.mockResolvedValue({ + id: "proj-2", + slug: "three-sum", + cases: [{ slug: "case-a" }], + solutions: [{ slug: "solution-a" }], + }); + + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: [], + lastPathCookie: "/playground/three-sum/case-a/solution-a", + }); + + expect(redirectPath).toBe("/playground/three-sum/case-a/solution-a"); + }); + + it("returns null when the URL is already canonical", async () => { + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: ["two-sum", "case-1", "solution-1"], + lastPathCookie: null, + }); + + expect(redirectPath).toBeNull(); + }); +}); diff --git a/src/server/playground/getPlaygroundInitialData.ts b/src/server/playground/getPlaygroundInitialData.ts index 44a38d27..a7058722 100644 --- a/src/server/playground/getPlaygroundInitialData.ts +++ b/src/server/playground/getPlaygroundInitialData.ts @@ -10,11 +10,12 @@ export type PlaygroundInitialData = { allBrief: RouterOutputs["project"]["allBrief"]; projectBySlug: RouterOutputs["project"]["getBySlug"] | null; caseBySlug: RouterOutputs["project"]["getCaseBySlug"] | null; + solutionBySlug: RouterOutputs["project"]["getSolutionBySlug"] | null; }; -type ProjectBySlug = RouterOutputs["project"]["getBySlug"]; +export type ProjectBySlug = RouterOutputs["project"]["getBySlug"]; -async function loadProjectBySlug( +export async function loadProjectBySlug( caller: ReturnType<typeof createCaller>, projectSlug: string, ): Promise<ProjectBySlug | null> { @@ -28,13 +29,32 @@ async function loadProjectBySlug( } } +async function loadSolutionBySlug( + caller: ReturnType<typeof createCaller>, + projectId: string, + solutionSlug: string, +): Promise<RouterOutputs["project"]["getSolutionBySlug"] | null> { + try { + return await caller.project.getSolutionBySlug({ + projectId, + slug: solutionSlug, + }); + } catch (error) { + if (error instanceof TRPCError && error.code === "NOT_FOUND") { + return null; + } + throw error; + } +} + /** - * Server-prefetch public playground lists and the active project/case for RSC pages. + * Server-prefetch public playground lists and the active project/case/solution for RSC pages. * Hydrates client tRPC queries via {@link PlaygroundInitialDataProvider}. */ export async function getPlaygroundInitialData( projectSlug?: string, caseSlug?: string, + solutionSlug?: string, ): Promise<PlaygroundInitialData> { const session = await getServerSession(authOptions); const caller = createCaller( @@ -45,7 +65,12 @@ export async function getPlaygroundInitialData( if (!projectSlug) { const allBrief = await caller.project.allBrief(); - return { allBrief, projectBySlug: null, caseBySlug: null }; + return { + allBrief, + projectBySlug: null, + caseBySlug: null, + solutionBySlug: null, + }; } const [allBrief, projectBySlug] = await Promise.all([ @@ -54,24 +79,47 @@ export async function getPlaygroundInitialData( ]); if (!projectBySlug) { - return { allBrief, projectBySlug: null, caseBySlug: null }; + return { + allBrief, + projectBySlug: null, + caseBySlug: null, + solutionBySlug: null, + }; } if (!caseSlug) { - return { allBrief, projectBySlug, caseBySlug: null }; + return { + allBrief, + projectBySlug, + caseBySlug: null, + solutionBySlug: null, + }; } + let caseBySlug: RouterOutputs["project"]["getCaseBySlug"] | null = null; + try { - const caseBySlug = await caller.project.getCaseBySlug({ + caseBySlug = await caller.project.getCaseBySlug({ projectId: projectBySlug.id, slug: caseSlug, }); - - return { allBrief, projectBySlug, caseBySlug }; } catch (caseError) { if (caseError instanceof TRPCError && caseError.code === "NOT_FOUND") { - return { allBrief, projectBySlug, caseBySlug: null }; + caseBySlug = null; + } else { + throw caseError; } - throw caseError; } + + if (!solutionSlug) { + return { allBrief, projectBySlug, caseBySlug, solutionBySlug: null }; + } + + const solutionBySlug = await loadSolutionBySlug( + caller, + projectBySlug.id, + solutionSlug, + ); + + return { allBrief, projectBySlug, caseBySlug, solutionBySlug }; } diff --git a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts new file mode 100644 index 00000000..51995669 --- /dev/null +++ b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts @@ -0,0 +1,121 @@ +import { getServerSession } from "next-auth"; + +import { createInnerTRPCContext } from "#/server/api/context"; +import { createCaller } from "#/server/api/root"; +import { authOptions } from "#/server/auth/authOptions"; +import type { RouterOutputs } from "#/shared/api"; +import { + buildCanonicalPlaygroundSlug, + playgroundSlugKey, +} from "#/shared/lib/buildCanonicalPlaygroundSlug"; +import { getRestorablePlaygroundPath } from "#/shared/lib/playgroundLastPath"; +import { + buildPlaygroundPath, + parsePlaygroundPathname, +} from "#/shared/lib/playgroundRoute"; + +import { loadProjectBySlug } from "./getPlaygroundInitialData"; + +type ProjectBySlug = RouterOutputs["project"]["getBySlug"]; + +async function createPlaygroundCaller() { + const session = await getServerSession(authOptions); + return createCaller( + await createInnerTRPCContext({ + session, + }), + ); +} + +async function resolveProjectSlug( + caller: ReturnType<typeof createCaller>, + slug: string[], + lastPathCookie: string | null, + basePath: string, +): Promise<ProjectBySlug | null> { + const [projectSlug] = slug; + + if (projectSlug) { + const project = await loadProjectBySlug(caller, projectSlug); + if (project) { + return project; + } + } + + const restoredPath = getRestorablePlaygroundPath(lastPathCookie, basePath); + const restoredParsed = restoredPath + ? parsePlaygroundPathname(restoredPath) + : null; + const restoredProjectSlug = restoredParsed?.slug[0]; + + if (restoredProjectSlug) { + const restoredProject = await loadProjectBySlug( + caller, + restoredProjectSlug, + ); + if (restoredProject) { + return restoredProject; + } + } + + const allBrief = await caller.project.allBrief(); + const firstProjectSlug = allBrief[0]?.slug; + if (!firstProjectSlug) { + return null; + } + + return loadProjectBySlug(caller, firstProjectSlug); +} + +export type ResolveCanonicalPlaygroundRedirectInput = { + basePath: string; + slug: string[]; + lastPathCookie: string | null; +}; + +/** + * Returns a canonical playground pathname when URL segments are incomplete, + * or null when the current path already matches the canonical slug. + */ +export async function resolveCanonicalPlaygroundRedirect({ + basePath, + slug, + lastPathCookie, +}: ResolveCanonicalPlaygroundRedirectInput): Promise<string | null> { + const caller = await createPlaygroundCaller(); + const project = await resolveProjectSlug( + caller, + slug, + lastPathCookie, + basePath, + ); + + if (!project) { + return null; + } + + const restoredPath = getRestorablePlaygroundPath(lastPathCookie, basePath); + const restoredParsed = restoredPath + ? parsePlaygroundPathname(restoredPath) + : null; + const useRestoredSegments = + !slug[0] && restoredParsed?.slug[0] === project.slug; + + const [, caseSlug = "", solutionSlug = ""] = slug; + const canonicalSlug = buildCanonicalPlaygroundSlug( + project, + useRestoredSegments ? (restoredParsed?.slug[1] ?? caseSlug) : caseSlug, + useRestoredSegments + ? (restoredParsed?.slug[2] ?? solutionSlug) + : solutionSlug, + ); + + const currentSlugKey = playgroundSlugKey(slug); + const canonicalSlugKey = playgroundSlugKey(canonicalSlug); + + if (currentSlugKey === canonicalSlugKey) { + return null; + } + + return buildPlaygroundPath(basePath, canonicalSlug); +} diff --git a/src/shared/hooks/usePlaygroundSlugs.ts b/src/shared/hooks/usePlaygroundSlugs.ts index 1f1a7a3d..7ec8ee9e 100644 --- a/src/shared/hooks/usePlaygroundSlugs.ts +++ b/src/shared/hooks/usePlaygroundSlugs.ts @@ -4,13 +4,8 @@ import { useEffect, useMemo } from "react"; import { projectSlice } from "#/features/project/model/projectSlice"; import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { buildPlaygroundPath } from "#/shared/lib/playgroundRoute"; import { - buildPlaygroundPath, - parsePlaygroundPathname, -} from "#/shared/lib/playgroundRoute"; -import { - getLastPlaygroundPath, - getRestorablePlaygroundPath, removeLastPlaygroundPath, setLastPlaygroundPath, } from "#/shared/local-storage/playgroundPath"; @@ -43,10 +38,8 @@ export const usePlaygroundSlugs = () => { const [projectSlug, caseSlug, solutionSlug] = route.slug; const { basePath, navigateTo } = route; - const setProject = (slug?: string, isInitial?: boolean) => { - if (!isInitial) { - dispatch(projectSlice.actions.loadStart()); - } + const setProject = (slug?: string) => { + dispatch(projectSlice.actions.loadStart()); if (!slug) { return navigateTo(basePath, { replace: true, @@ -54,19 +47,6 @@ export const usePlaygroundSlugs = () => { }); } - const lastPath = getLastPlaygroundPath(); - const lastParsed = lastPath ? parsePlaygroundPathname(lastPath) : null; - if (lastPath && !lastParsed) { - removeLastPlaygroundPath(); - } - const pathToRestore = isInitial - ? getRestorablePlaygroundPath(lastPath, basePath) - : null; - - if (pathToRestore) { - return navigateTo(pathToRestore, { replace: true, omitView: true }); - } - return navigateTo(buildPlaygroundPath(basePath, [slug]), { replace: true, omitView: true, diff --git a/src/shared/lib/__tests__/buildCanonicalPlaygroundSlug.test.ts b/src/shared/lib/__tests__/buildCanonicalPlaygroundSlug.test.ts new file mode 100644 index 00000000..35ebd42d --- /dev/null +++ b/src/shared/lib/__tests__/buildCanonicalPlaygroundSlug.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; + +import { buildCanonicalPlaygroundSlug } from "#/shared/lib/buildCanonicalPlaygroundSlug"; + +describe("buildCanonicalPlaygroundSlug", () => { + const project = { + slug: "two-sum", + cases: [{ slug: "case-1" }, { slug: "case-2" }], + solutions: [{ slug: "solution-1" }, { slug: "solution-2" }], + }; + + it("fills missing case and solution with first defaults", () => { + expect(buildCanonicalPlaygroundSlug(project)).toEqual([ + "two-sum", + "case-1", + "solution-1", + ]); + }); + + it("keeps a valid case slug and fills solution", () => { + expect(buildCanonicalPlaygroundSlug(project, "case-2")).toEqual([ + "two-sum", + "case-2", + "solution-1", + ]); + }); + + it("returns only project slug when there are no cases or solutions", () => { + expect( + buildCanonicalPlaygroundSlug({ + slug: "empty", + cases: [], + solutions: [], + }), + ).toEqual(["empty"]); + }); +}); diff --git a/src/shared/lib/buildCanonicalPlaygroundSlug.ts b/src/shared/lib/buildCanonicalPlaygroundSlug.ts new file mode 100644 index 00000000..91ab8760 --- /dev/null +++ b/src/shared/lib/buildCanonicalPlaygroundSlug.ts @@ -0,0 +1,68 @@ +type PlaygroundCaseBrief = { + slug: string; +}; + +type PlaygroundSolutionBrief = { + slug: string; +}; + +type PlaygroundProjectSlugSource = { + slug: string; + cases: PlaygroundCaseBrief[]; + solutions: PlaygroundSolutionBrief[]; +}; + +const pickCaseSlug = ( + project: PlaygroundProjectSlugSource, + caseSlug?: string, +): string | undefined => { + if ( + caseSlug && + project.cases.some((testCase) => testCase.slug === caseSlug) + ) { + return caseSlug; + } + + return project.cases[0]?.slug; +}; + +const pickSolutionSlug = ( + project: PlaygroundProjectSlugSource, + solutionSlug?: string, +): string | undefined => { + if ( + solutionSlug && + project.solutions.some((solution) => solution.slug === solutionSlug) + ) { + return solutionSlug; + } + + return project.solutions[0]?.slug; +}; + +/** + * Builds the canonical playground slug segments (project / case / solution) + * by filling missing segments with the first available defaults. + */ +export const buildCanonicalPlaygroundSlug = ( + project: PlaygroundProjectSlugSource, + caseSlug?: string, + solutionSlug?: string, +): string[] => { + const segments = [project.slug]; + + const resolvedCaseSlug = pickCaseSlug(project, caseSlug); + if (resolvedCaseSlug) { + segments.push(resolvedCaseSlug); + } + + const resolvedSolutionSlug = pickSolutionSlug(project, solutionSlug); + if (resolvedSolutionSlug) { + segments.push(resolvedSolutionSlug); + } + + return segments; +}; + +export const playgroundSlugKey = (slug: string[]): string => + slug.filter((segment) => segment.length > 0).join("/"); diff --git a/src/shared/lib/playgroundLastPath.ts b/src/shared/lib/playgroundLastPath.ts new file mode 100644 index 00000000..8abe67ed --- /dev/null +++ b/src/shared/lib/playgroundLastPath.ts @@ -0,0 +1,29 @@ +import { + parsePlaygroundPathname, + remapPlaygroundPathToBase, +} from "#/shared/lib/playgroundRoute"; + +/** Returns true when the path is a playground URL with a project slug. */ +export const isValidLastPlaygroundPath = (path: string | null): boolean => { + const parsed = path ? parsePlaygroundPathname(path) : null; + return Boolean(parsed?.slug[0]); +}; + +/** + * Returns a restorable path for the current playground base (public or locale-prefixed). + * 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; +}; diff --git a/src/shared/lib/playgroundLastPathCookie.ts b/src/shared/lib/playgroundLastPathCookie.ts new file mode 100644 index 00000000..157b7432 --- /dev/null +++ b/src/shared/lib/playgroundLastPathCookie.ts @@ -0,0 +1,23 @@ +/** Cookie mirror of `lastPlaygroundPath` localStorage for server-side restore. */ +export const LAST_PLAYGROUND_PATH_COOKIE = "dstruct-last-playground-path"; + +const ONE_YEAR_SECONDS = 60 * 60 * 24 * 365; + +/** Client-only: persist the last playground pathname for server restore on cold load. */ +export const setLastPlaygroundPathCookie = (pathname: string): void => { + if (typeof document === "undefined") { + return; + } + + const encoded = encodeURIComponent(pathname); + document.cookie = `${LAST_PLAYGROUND_PATH_COOKIE}=${encoded}; path=/; max-age=${ONE_YEAR_SECONDS}; samesite=lax`; +}; + +/** Client-only: clear the last playground pathname cookie. */ +export const clearLastPlaygroundPathCookie = (): void => { + if (typeof document === "undefined") { + return; + } + + document.cookie = `${LAST_PLAYGROUND_PATH_COOKIE}=; path=/; max-age=0; samesite=lax`; +}; diff --git a/src/shared/local-storage/playgroundPath.ts b/src/shared/local-storage/playgroundPath.ts index a439aab4..b81433ca 100644 --- a/src/shared/local-storage/playgroundPath.ts +++ b/src/shared/local-storage/playgroundPath.ts @@ -1,9 +1,15 @@ import { createStringStorage } from "#/shared/browser-storage"; import { - parsePlaygroundPathname, - PLAYGROUND_PUBLIC_BASE_PATH, - remapPlaygroundPathToBase, -} from "#/shared/lib/playgroundRoute"; + getRestorablePlaygroundPath, + isValidLastPlaygroundPath, +} from "#/shared/lib/playgroundLastPath"; +import { + clearLastPlaygroundPathCookie, + setLastPlaygroundPathCookie, +} from "#/shared/lib/playgroundLastPathCookie"; +import { PLAYGROUND_PUBLIC_BASE_PATH } from "#/shared/lib/playgroundRoute"; + +export { getRestorablePlaygroundPath, isValidLastPlaygroundPath }; export const PLAYGROUND_BASE_PATH = PLAYGROUND_PUBLIC_BASE_PATH; @@ -19,34 +25,10 @@ export const getLastPlaygroundPath = (): string | null => export const setLastPlaygroundPath = (path: string): void => { lastPlaygroundPathStorage.set(path); + setLastPlaygroundPathCookie(path); }; export const removeLastPlaygroundPath = (): void => { lastPlaygroundPathStorage.remove(); -}; - -/** - * Returns true if the path is a valid playground path with a project slug. - * Used to decide if we have a "last project" to show (e.g. default view). - */ -export const isValidLastPlaygroundPath = (path: string | null): boolean => { - const parsed = path ? parsePlaygroundPathname(path) : null; - return Boolean(parsed?.slug[0]); -}; - -/** - * 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; + clearLastPlaygroundPathCookie(); }; From 663f2ffa38a998d45d40c42c13993462dba4a0a0 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 15:48:44 +0000 Subject: [PATCH 12/19] Scope Apollo to daily/profile with server GraphQL prefetch Extract createApolloClient factory and ApolloHydrationProvider for route-scoped cache hydration. Daily and profile pages prefetch LeetCode GraphQL on the server; playground no longer mounts Apollo globally. Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- .../(default-locale)/(app)/daily/layout.tsx | 8 +++ src/app/(default-locale)/(app)/layout.tsx | 8 +-- .../(default-locale)/(app)/profile/layout.tsx | 8 +++ src/app/[lang]/(app)/daily/layout.tsx | 8 +++ src/app/[lang]/(app)/layout.tsx | 8 +-- src/app/[lang]/(app)/profile/layout.tsx | 8 +++ .../locale-app/ApolloHydrationProvider.tsx | 26 ++++++++ .../locale-app/InteractiveDataProviders.tsx | 14 ----- src/app/locale-app/pages/dailyPage.tsx | 12 +++- src/app/locale-app/pages/profilePage.tsx | 41 ++++++++++++- src/features/profile/ui/ProfilePageGate.tsx | 17 ------ src/graphql/apolloClient.ts | 59 +------------------ src/graphql/apolloInMemoryCache.ts | 29 +++++++++ src/graphql/createApolloClient.ts | 52 ++++++++++++++++ src/server/daily/getDailyInitialData.ts | 41 +++++++++++++ src/server/profile/getProfileInitialData.ts | 39 ++++++++++++ src/shared/ui/providers/AppShellProviders.tsx | 2 +- 17 files changed, 277 insertions(+), 103 deletions(-) create mode 100644 src/app/(default-locale)/(app)/daily/layout.tsx create mode 100644 src/app/(default-locale)/(app)/profile/layout.tsx create mode 100644 src/app/[lang]/(app)/daily/layout.tsx create mode 100644 src/app/[lang]/(app)/profile/layout.tsx create mode 100644 src/app/locale-app/ApolloHydrationProvider.tsx delete mode 100644 src/app/locale-app/InteractiveDataProviders.tsx delete mode 100644 src/features/profile/ui/ProfilePageGate.tsx create mode 100644 src/graphql/apolloInMemoryCache.ts create mode 100644 src/graphql/createApolloClient.ts create mode 100644 src/server/daily/getDailyInitialData.ts create mode 100644 src/server/profile/getProfileInitialData.ts diff --git a/src/app/(default-locale)/(app)/daily/layout.tsx b/src/app/(default-locale)/(app)/daily/layout.tsx new file mode 100644 index 00000000..cf388857 --- /dev/null +++ b/src/app/(default-locale)/(app)/daily/layout.tsx @@ -0,0 +1,8 @@ +/** Daily route — Apollo client is created in {@link DailyPage} with server prefetch. */ +export default function DefaultLocaleDailyLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/(default-locale)/(app)/layout.tsx b/src/app/(default-locale)/(app)/layout.tsx index 2c2dc364..ff479c73 100644 --- a/src/app/(default-locale)/(app)/layout.tsx +++ b/src/app/(default-locale)/(app)/layout.tsx @@ -1,10 +1,8 @@ -import { InteractiveDataProviders } from "#/app/locale-app/InteractiveDataProviders"; - -/** Apollo GraphQL for daily, playground, and profile (not marketing routes). */ -export default function DefaultLocaleInteractiveLayout({ +/** App routes under `(app)` — Apollo mounts on daily/profile segment layouts only. */ +export default function DefaultLocaleAppLayout({ children, }: { children: React.ReactNode; }) { - return <InteractiveDataProviders>{children}</InteractiveDataProviders>; + return children; } diff --git a/src/app/(default-locale)/(app)/profile/layout.tsx b/src/app/(default-locale)/(app)/profile/layout.tsx new file mode 100644 index 00000000..cdff2994 --- /dev/null +++ b/src/app/(default-locale)/(app)/profile/layout.tsx @@ -0,0 +1,8 @@ +/** Profile route — Apollo client is created in profile page with server prefetch. */ +export default function DefaultLocaleProfileLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/[lang]/(app)/daily/layout.tsx b/src/app/[lang]/(app)/daily/layout.tsx new file mode 100644 index 00000000..305a2fd0 --- /dev/null +++ b/src/app/[lang]/(app)/daily/layout.tsx @@ -0,0 +1,8 @@ +/** Daily route — Apollo client is created in {@link DailyPage} with server prefetch. */ +export default function LangDailyLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/[lang]/(app)/layout.tsx b/src/app/[lang]/(app)/layout.tsx index fe987106..3c7bcbb9 100644 --- a/src/app/[lang]/(app)/layout.tsx +++ b/src/app/[lang]/(app)/layout.tsx @@ -1,10 +1,8 @@ -import { InteractiveDataProviders } from "#/app/locale-app/InteractiveDataProviders"; - -/** Apollo GraphQL for daily, playground, and profile (not marketing routes). */ -export default function LangInteractiveLayout({ +/** App routes under `(app)` — Apollo mounts on daily/profile segment layouts only. */ +export default function LangAppLayout({ children, }: { children: React.ReactNode; }) { - return <InteractiveDataProviders>{children}</InteractiveDataProviders>; + return children; } diff --git a/src/app/[lang]/(app)/profile/layout.tsx b/src/app/[lang]/(app)/profile/layout.tsx new file mode 100644 index 00000000..6d0e3938 --- /dev/null +++ b/src/app/[lang]/(app)/profile/layout.tsx @@ -0,0 +1,8 @@ +/** Profile route — Apollo client is created in profile page with server prefetch. */ +export default function LangProfileLayout({ + children, +}: { + children: React.ReactNode; +}) { + return children; +} diff --git a/src/app/locale-app/ApolloHydrationProvider.tsx b/src/app/locale-app/ApolloHydrationProvider.tsx new file mode 100644 index 00000000..da131919 --- /dev/null +++ b/src/app/locale-app/ApolloHydrationProvider.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { ApolloProvider, type NormalizedCacheObject } from "@apollo/client"; +import React, { type ReactNode, useMemo } from "react"; + +import { createApolloClient } from "#/graphql/createApolloClient"; + +type ApolloHydrationProviderProps = { + initialCache: NormalizedCacheObject | null; + children: ReactNode; +}; + +/** + * Route-scoped Apollo client with server-extracted cache for GraphQL routes + * (daily, profile). Playground uses tRPC only. + */ +export const ApolloHydrationProvider: React.FC< + ApolloHydrationProviderProps +> = ({ initialCache, children }) => { + const client = useMemo( + () => createApolloClient({ initialState: initialCache ?? undefined }), + [initialCache], + ); + + return <ApolloProvider client={client}>{children}</ApolloProvider>; +}; diff --git a/src/app/locale-app/InteractiveDataProviders.tsx b/src/app/locale-app/InteractiveDataProviders.tsx deleted file mode 100644 index 588ab30a..00000000 --- a/src/app/locale-app/InteractiveDataProviders.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import { ApolloProvider } from "@apollo/client"; -import React, { type ReactNode } from "react"; - -import { apolloClient } from "#/graphql/apolloClient"; - -/** - * Apollo GraphQL for routes that use generated hooks (daily, profile). - * tRPC stays in {@link AppShellProviders} because MainAppBar uses it globally. - */ -export const InteractiveDataProviders: React.FC<{ children: ReactNode }> = ({ - children, -}) => <ApolloProvider client={apolloClient}>{children}</ApolloProvider>; diff --git a/src/app/locale-app/pages/dailyPage.tsx b/src/app/locale-app/pages/dailyPage.tsx index e1ed140b..fee0d8c6 100644 --- a/src/app/locale-app/pages/dailyPage.tsx +++ b/src/app/locale-app/pages/dailyPage.tsx @@ -1,6 +1,8 @@ import { DailyPageView } from "#/features/homePage/ui/DailyPageView"; import type { Translation } from "#/i18n/i18n-types"; +import { getDailyInitialData } from "#/server/daily/getDailyInitialData"; +import { ApolloHydrationProvider } from "#/app/locale-app/ApolloHydrationProvider"; import { createDefaultLocaleRouteMetadata, createLangRouteMetadata, @@ -22,6 +24,12 @@ export const generateLangDailyMetadata = createLangRouteMetadata( pickDailyCopy, ); -export function DailyPage() { - return <DailyPageView />; +export async function DailyPage() { + const initialCache = await getDailyInitialData(); + + return ( + <ApolloHydrationProvider initialCache={initialCache}> + <DailyPageView /> + </ApolloHydrationProvider> + ); } diff --git a/src/app/locale-app/pages/profilePage.tsx b/src/app/locale-app/pages/profilePage.tsx index 9b96a761..fa9b5f87 100644 --- a/src/app/locale-app/pages/profilePage.tsx +++ b/src/app/locale-app/pages/profilePage.tsx @@ -1,15 +1,21 @@ import type { Metadata } from "next"; +import { getServerSession } from "next-auth"; +import { cookies } from "next/headers"; +import { notFound } from "next/navigation"; import { Suspense } from "react"; -import { ProfilePageGate } from "#/features/profile/ui/ProfilePageGate"; import { ProfilePageSkeleton } from "#/features/profile/ui/ProfilePageSkeleton"; +import { ProfilePageView } from "#/features/profile/ui/ProfilePageView"; import type { Locales, Translation } from "#/i18n/i18n-types"; import { baseLocale } from "#/i18n/i18n-util"; +import { authOptions } from "#/server/auth/authOptions"; +import { getProfileInitialData } from "#/server/profile/getProfileInitialData"; +import { ApolloHydrationProvider } from "#/app/locale-app/ApolloHydrationProvider"; import { publicRouteMetadataForLocale } from "#/app/locale-app/createLocaleRouteMetadata"; import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; -/** Profile — instant shell with Suspense fallback; user data client-fetched (P10). */ +/** Profile — instant shell with Suspense fallback; user data prefetched when possible. */ export const instant = true; const pickProfileCopy = (translation: Translation) => ({ @@ -60,10 +66,39 @@ type ProfilePageProps = { params: Promise<{ userId: string; lang?: string }>; }; +/** Server gate: validate `userId` and prefetch LeetCode profile when session allows. */ +async function ProfilePageContent({ + params, +}: { + params: Promise<{ userId: string }>; +}) { + const { userId } = await params; + if (!userId.trim()) { + notFound(); + } + + const [session, cookieStore] = await Promise.all([ + getServerSession(authOptions), + cookies(), + ]); + const leetCodeUsername = session?.user?.leetCodeUsername; + const leetCodeSession = cookieStore.get("LEETCODE_SESSION")?.value ?? null; + const initialCache = await getProfileInitialData( + leetCodeUsername, + leetCodeSession, + ); + + return ( + <ApolloHydrationProvider initialCache={initialCache}> + <ProfilePageView /> + </ApolloHydrationProvider> + ); +} + export function ProfilePage({ params }: ProfilePageProps) { return ( <Suspense fallback={<ProfilePageSkeleton />}> - <ProfilePageGate params={params} /> + <ProfilePageContent params={params} /> </Suspense> ); } diff --git a/src/features/profile/ui/ProfilePageGate.tsx b/src/features/profile/ui/ProfilePageGate.tsx deleted file mode 100644 index 1a440a29..00000000 --- a/src/features/profile/ui/ProfilePageGate.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { notFound } from "next/navigation"; - -import { ProfilePageView } from "#/features/profile/ui/ProfilePageView"; - -type ProfilePageGateProps = { - params: Promise<{ userId: string }>; -}; - -/** Server gate: validate `userId` before rendering client profile view (inside Suspense). */ -export async function ProfilePageGate({ params }: ProfilePageGateProps) { - const { userId } = await params; - if (!userId.trim()) { - notFound(); - } - - return <ProfilePageView />; -} diff --git a/src/graphql/apolloClient.ts b/src/graphql/apolloClient.ts index bc5f9303..725b2350 100644 --- a/src/graphql/apolloClient.ts +++ b/src/graphql/apolloClient.ts @@ -1,57 +1,4 @@ -import { ApolloClient, createHttpLink, InMemoryCache } from "@apollo/client"; -import { setContext } from "@apollo/client/link/context"; -import { getCookie } from "cookies-next"; +import { createApolloClient } from "#/graphql/createApolloClient"; -const httpLink = createHttpLink({ - uri: "/api/graphql", -}); - -// const testCookie = ''; - -// setCookie('LEETCODE_SESSION', testCookie); - -const authLink = setContext((_, { headers }) => { - const token = getCookie("LEETCODE_SESSION"); - // return the headers to the context so httpLink can read them - return { - headers: { - ...headers, - extToken: token, - }, - }; -}); - -const jsonParseRead = (field: string) => JSON.parse(field); - -const cache = new InMemoryCache({ - typePolicies: { - QuestionNode: { - fields: { - stats: { - read: (stats: string) => { - const result = jsonParseRead(stats); - result.acRate = parseFloat(result.acRate); - return result; - }, - }, - similarQuestions: { - read: jsonParseRead, - }, - envInfo: { - read: jsonParseRead, - }, - metaData: { - read: jsonParseRead, - }, - }, - }, - }, -}); - -const isApolloDevtoolsEnabled = process.env.NODE_ENV !== "production"; - -export const apolloClient = new ApolloClient({ - link: authLink.concat(httpLink), - cache, - devtools: { enabled: isApolloDevtoolsEnabled }, -}); +/** Browser singleton — daily/profile routes hydrate via {@link ApolloHydrationProvider}. */ +export const apolloClient = createApolloClient(); diff --git a/src/graphql/apolloInMemoryCache.ts b/src/graphql/apolloInMemoryCache.ts new file mode 100644 index 00000000..eb4b31ac --- /dev/null +++ b/src/graphql/apolloInMemoryCache.ts @@ -0,0 +1,29 @@ +import { InMemoryCache } from "@apollo/client"; + +const jsonParseRead = (field: string) => JSON.parse(field); + +export const createApolloInMemoryCache = (): InMemoryCache => + new InMemoryCache({ + typePolicies: { + QuestionNode: { + fields: { + stats: { + read: (stats: string) => { + const result = jsonParseRead(stats); + result.acRate = parseFloat(result.acRate); + return result; + }, + }, + similarQuestions: { + read: jsonParseRead, + }, + envInfo: { + read: jsonParseRead, + }, + metaData: { + read: jsonParseRead, + }, + }, + }, + }, + }); diff --git a/src/graphql/createApolloClient.ts b/src/graphql/createApolloClient.ts new file mode 100644 index 00000000..23dcfda6 --- /dev/null +++ b/src/graphql/createApolloClient.ts @@ -0,0 +1,52 @@ +import { + ApolloClient, + createHttpLink, + type NormalizedCacheObject, +} from "@apollo/client"; +import { setContext } from "@apollo/client/link/context"; +import { getCookie } from "cookies-next"; + +import { createApolloInMemoryCache } from "#/graphql/apolloInMemoryCache"; + +export type CreateApolloClientOptions = { + initialState?: NormalizedCacheObject; + /** Server-side fetch against LeetCode directly (no browser cookies). */ + ssr?: boolean; + leetCodeSession?: string | null; +}; + +export function createApolloClient( + options?: CreateApolloClientOptions, +): ApolloClient<NormalizedCacheObject> { + const httpLink = createHttpLink({ + uri: options?.ssr ? "https://leetcode.com/graphql/" : "/api/graphql", + }); + + const authLink = setContext((_, { headers }) => { + const token = options?.ssr + ? options.leetCodeSession + : getCookie("LEETCODE_SESSION"); + + return { + headers: { + ...headers, + ...(options?.ssr && token + ? { cookie: `LEETCODE_SESSION=${token}` } + : {}), + ...(!options?.ssr && token ? { extToken: token } : {}), + }, + }; + }); + + const client = new ApolloClient({ + link: authLink.concat(httpLink), + cache: createApolloInMemoryCache(), + devtools: { enabled: process.env.NODE_ENV !== "production" }, + }); + + if (options?.initialState) { + client.cache.restore(options.initialState); + } + + return client; +} diff --git a/src/server/daily/getDailyInitialData.ts b/src/server/daily/getDailyInitialData.ts new file mode 100644 index 00000000..81d9bcee --- /dev/null +++ b/src/server/daily/getDailyInitialData.ts @@ -0,0 +1,41 @@ +import type { NormalizedCacheObject } from "@apollo/client"; + +import { createApolloClient } from "#/graphql/createApolloClient"; +import { + QuestionDataDocument, + QuestionOfTodayDocument, +} from "#/graphql/generated"; + +/** + * Prefetch today's LeetCode daily question for RSC + Apollo cache hydration. + * Returns null when upstream GraphQL is unavailable (client hooks refetch). + */ +export async function getDailyInitialData(): Promise<NormalizedCacheObject | null> { + const client = createApolloClient({ ssr: true }); + + try { + const todayResult = await client.query({ + query: QuestionOfTodayDocument, + fetchPolicy: "no-cache", + }); + + const titleSlug = + todayResult.data.activeDailyCodingChallengeQuestion?.question?.titleSlug; + + if (titleSlug) { + await client.query({ + query: QuestionDataDocument, + variables: { titleSlug }, + fetchPolicy: "no-cache", + }); + } + + return client.cache.extract(); + } catch (error) { + console.warn( + "getDailyInitialData: LeetCode GraphQL prefetch failed", + error, + ); + return null; + } +} diff --git a/src/server/profile/getProfileInitialData.ts b/src/server/profile/getProfileInitialData.ts new file mode 100644 index 00000000..6c3aa697 --- /dev/null +++ b/src/server/profile/getProfileInitialData.ts @@ -0,0 +1,39 @@ +import type { NormalizedCacheObject } from "@apollo/client"; + +import { createApolloClient } from "#/graphql/createApolloClient"; +import { GetUserProfileDocument } from "#/graphql/generated"; + +/** + * Prefetch LeetCode profile stats when the user has linked a LeetCode account. + * Returns null when username or session cookie is missing. + */ +export async function getProfileInitialData( + leetCodeUsername: string | null | undefined, + leetCodeSession: string | null | undefined, +): Promise<NormalizedCacheObject | null> { + const username = leetCodeUsername?.trim(); + if (!username) { + return null; + } + + const client = createApolloClient({ + ssr: true, + leetCodeSession: leetCodeSession ?? null, + }); + + try { + await client.query({ + query: GetUserProfileDocument, + variables: { username }, + fetchPolicy: "no-cache", + }); + + return client.cache.extract(); + } catch (error) { + console.warn( + "getProfileInitialData: LeetCode GraphQL prefetch failed", + error, + ); + return null; + } +} diff --git a/src/shared/ui/providers/AppShellProviders.tsx b/src/shared/ui/providers/AppShellProviders.tsx index c70d86d8..02bec578 100644 --- a/src/shared/ui/providers/AppShellProviders.tsx +++ b/src/shared/ui/providers/AppShellProviders.tsx @@ -17,7 +17,7 @@ type AppShellProvidersProps = { /** * Base client providers for all App Router layouts. - * Apollo mounts in {@link InteractiveDataProviders} on data routes only. + * Apollo mounts per-route via {@link ApolloHydrationProvider} on daily/profile pages. * SessionProvider is mounted in SessionGate (inside LocaleAppLayout). */ export const AppShellProviders: React.FC<AppShellProvidersProps> = ({ From 1fc5271d041a2d026fc450fc5b6050cd2ec57397 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 15:48:46 +0000 Subject: [PATCH 13/19] Tighten playground architecture: scoped browser, cached brief, gate sync Move ProjectBrowserProvider to playground layout only; add optional context for MainAppBar on other routes. Cache anonymous allBrief for server prefetch and canonical redirects. Skip redundant loadStart when server prefetch matches URL; append mobile ?view=code in canonical redirect. Update e2e for full slug paths. Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- e2e/instant-playground-nav.spec.ts | 4 +- src/app/AppRootLayoutClient.tsx | 9 ++-- src/app/locale-app/LocaleAppPageShell.tsx | 9 +--- src/app/locale-app/pages/playgroundPage.tsx | 19 ++++++- src/features/appBar/ui/MainAppBar.tsx | 23 +++++--- .../appBar/ui/__tests__/MainAppBar.test.tsx | 6 ++- .../usePlaygroundSlugLoadingSync.test.tsx | 36 +++++++++++-- .../hooks/usePlaygroundSlugLoadingSync.ts | 17 +++++- .../serverPrefetchMatchesRoute.test.ts | 35 ++++++++++++ .../lib/serverPrefetchMatchesRoute.ts | 31 +++++++++++ .../playground/ui/PlaygroundLayoutClient.tsx | 10 +++- .../project/hooks/useProjectPanelData.ts | 9 +++- .../ProjectBrowser/ProjectBrowserContext.tsx | 6 ++- .../getPlaygroundInitialData.test.ts | 8 +++ ...resolveCanonicalPlaygroundRedirect.test.ts | 25 +++++++++ .../playground/getPlaygroundInitialData.ts | 18 ++++++- .../loadCachedPublicProjectsBrief.ts | 54 +++++++++++++++++++ .../resolveCanonicalPlaygroundRedirect.ts | 28 +++++++++- 18 files changed, 311 insertions(+), 36 deletions(-) create mode 100644 src/features/playground/lib/__tests__/serverPrefetchMatchesRoute.test.ts create mode 100644 src/features/playground/lib/serverPrefetchMatchesRoute.ts create mode 100644 src/server/playground/loadCachedPublicProjectsBrief.ts diff --git a/e2e/instant-playground-nav.spec.ts b/e2e/instant-playground-nav.spec.ts index 6b59048b..6bfdaffd 100644 --- a/e2e/instant-playground-nav.spec.ts +++ b/e2e/instant-playground-nav.spec.ts @@ -25,7 +25,9 @@ test.describe("instant playground navigation (L5)", () => { await instant(page, async () => { await page.getByTestId("cta-to-playground").click(); await page.waitForURL( - (url) => url.pathname === "/playground/invert-binary-tree", + (url) => + url.pathname.startsWith("/playground/invert-binary-tree/") && + url.pathname.split("/").length >= 5, { timeout: 30_000 }, ); }); diff --git a/src/app/AppRootLayoutClient.tsx b/src/app/AppRootLayoutClient.tsx index ea6df9ce..9ef68413 100644 --- a/src/app/AppRootLayoutClient.tsx +++ b/src/app/AppRootLayoutClient.tsx @@ -7,7 +7,6 @@ import React, { type ReactNode } from "react"; import "symbol-observable"; import { CookieConsentRoot } from "#/features/cookieConsent/ui/CookieConsentRoot"; -import { ProjectBrowserProvider } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; import { type I18nProps } from "#/i18n/getI18nProps"; import type { Locales } from "#/i18n/i18n-types"; import { AppShellProviders } from "#/shared/ui/providers/AppShellProviders"; @@ -38,11 +37,9 @@ export const AppRootLayoutClient: React.FC<AppRootLayoutClientProps> = ({ <AppShellProviders ssrDeviceType={ssrDeviceType}> <I18nProvider locale={locale} i18n={i18n}> <CookieConsentRoot> - <ProjectBrowserProvider> - {children} - <Analytics /> - <SpeedInsights /> - </ProjectBrowserProvider> + {children} + <Analytics /> + <SpeedInsights /> </CookieConsentRoot> </I18nProvider> </AppShellProviders> diff --git a/src/app/locale-app/LocaleAppPageShell.tsx b/src/app/locale-app/LocaleAppPageShell.tsx index a30a88cd..ad5bf983 100644 --- a/src/app/locale-app/LocaleAppPageShell.tsx +++ b/src/app/locale-app/LocaleAppPageShell.tsx @@ -1,11 +1,6 @@ import type { ReactNode } from "react"; -import { ProjectBrowserOverlay } from "#/app/locale-app/ProjectBrowserOverlay"; - -/** Page tree + global overlays that require SessionProvider (inside SessionGate). */ +/** Page tree inside SessionGate (playground overlays live in playground layout). */ export const LocaleAppPageShell = ({ children }: { children: ReactNode }) => ( - <> - {children} - <ProjectBrowserOverlay /> - </> + <>{children}</> ); diff --git a/src/app/locale-app/pages/playgroundPage.tsx b/src/app/locale-app/pages/playgroundPage.tsx index d8a5f3d4..0be1d80b 100644 --- a/src/app/locale-app/pages/playgroundPage.tsx +++ b/src/app/locale-app/pages/playgroundPage.tsx @@ -1,5 +1,5 @@ import type { Metadata } from "next"; -import { cookies } from "next/headers"; +import { cookies, headers } from "next/headers"; import { redirect } from "next/navigation"; import { connection } from "next/server"; @@ -9,8 +9,10 @@ import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView" import { baseLocale } from "#/i18n/i18n-util"; import { getPlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; import { resolveCanonicalPlaygroundRedirect } from "#/server/playground/resolveCanonicalPlaygroundRedirect"; +import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; import { LAST_PLAYGROUND_PATH_COOKIE } from "#/shared/lib/playgroundLastPathCookie"; import { playgroundBasePathForLocale } from "#/shared/lib/playgroundRoute"; +import { parseSsrDeviceTypeHeader } from "#/shared/lib/ssrDevice"; import { publicAppMetadata } from "#/app/locale-app/publicAppMetadata"; import { resolveLangParamSync } from "#/app/locale-app/resolveLangParam"; @@ -66,11 +68,16 @@ export async function generateLangPlaygroundMetadata({ type PlaygroundPageProps = { params: Promise<{ slug?: string[]; lang?: string }>; + searchParams: Promise<{ view?: string }>; }; -export async function PlaygroundPage({ params }: PlaygroundPageProps) { +export async function PlaygroundPage({ + params, + searchParams, +}: PlaygroundPageProps) { await connection(); const { slug, lang: langParam } = await params; + const { view: viewParam } = await searchParams; const locale = langParam ? (resolveLangParamSync(langParam) ?? baseLocale) : baseLocale; @@ -82,10 +89,18 @@ export async function PlaygroundPage({ params }: PlaygroundPageProps) { ? decodeURIComponent(rawLastPathCookie) : null; + const headerList = await headers(); + const ssrDeviceType = + parseSsrDeviceTypeHeader( + headerList.get(APP_ROUTER_SSR_DEVICE_TYPE_HEADER), + ) ?? "desktop"; + const redirectPath = await resolveCanonicalPlaygroundRedirect({ basePath, slug: slug ?? [], lastPathCookie, + ssrDeviceType, + viewParam, }); if (redirectPath) { diff --git a/src/features/appBar/ui/MainAppBar.tsx b/src/features/appBar/ui/MainAppBar.tsx index 1266399e..a5b4968b 100644 --- a/src/features/appBar/ui/MainAppBar.tsx +++ b/src/features/appBar/ui/MainAppBar.tsx @@ -27,6 +27,7 @@ import { import { signIn, useSession } from "next-auth/react"; import Image from "next/image"; import Link from "next/link"; +import { useRouter } from "next/navigation"; import { useSnackbar } from "notistack"; import React, { type MouseEvent, useState } from "react"; @@ -34,12 +35,12 @@ import { MOBILE_APPBAR_HEIGHT } from "#/features/appBar/constants"; import { selectIsAppBarScrolled } from "#/features/appBar/model/appBarSlice"; import { SidePanel } from "#/features/menuSidePanel/ui/SidePanel"; import { useMobilePlaygroundView } from "#/features/playground/hooks/useMobilePlaygroundView"; -import { useProjectBrowserContext } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; +import { usePlaygroundMobileLayout } from "#/features/playground/hooks/usePlaygroundMobileLayout"; +import { useOptionalProjectBrowserContext } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; import { appFontStackDisplay } from "#/shared/fonts/fontVariables"; import { useI18nContext, useRoutePathname } from "#/shared/hooks"; import { useProfileImageUploader } from "#/shared/hooks"; import { useHasMounted } from "#/shared/hooks/useHasMounted"; -import { useMobileLayout } from "#/shared/hooks/useMobileLayout"; import { getImageUrl } from "#/shared/lib"; import { useAppSelector } from "#/store/hooks"; @@ -99,22 +100,23 @@ export const MainAppBar: React.FC<MainAppBarProps> = ({ }) => { const currentPath = useRoutePathname(); const theme = useTheme(); - const isMobileLayout = useMobileLayout(); + const isPlaygroundMobileLayout = usePlaygroundMobileLayout(); const useCompactNav = useMediaQuery(theme.breakpoints.down("lg")); const { LL } = useI18nContext(); const { enqueueSnackbar } = useSnackbar(); const hasMounted = useHasMounted(); + const router = useRouter(); const [isSidePanelOpen, setIsSidePanelOpen] = useState(false); const [anchorElNav, setAnchorElNav] = useState<null | HTMLElement>(null); - const { openBrowser } = useProjectBrowserContext(); + const projectBrowser = useOptionalProjectBrowserContext(); const { currentView } = useMobilePlaygroundView(); const isScrolled = useAppSelector(selectIsAppBarScrolled); const session = useSession(); const isPlayground = currentPath.startsWith("/playground"); - const useMobilePlayground = isMobileLayout && isPlayground; + const useMobilePlayground = isPlayground && isPlaygroundMobileLayout; const pages = [ { @@ -153,6 +155,15 @@ export const MainAppBar: React.FC<MainAppBarProps> = ({ setIsSidePanelOpen(true); }; + const handleOpenProjectBrowser = () => { + if (projectBrowser) { + projectBrowser.openBrowser(); + return; + } + + router.push("/playground?view=browse"); + }; + const toolbarHeight = useMobilePlayground ? MOBILE_APPBAR_HEIGHT : 56; const isCompact = useCompactNav; @@ -298,7 +309,7 @@ export const MainAppBar: React.FC<MainAppBarProps> = ({ <Tooltip title={LL.PROJECT_BROWSER()} arrow> <IconButton size={isCompact ? "small" : "medium"} - onClick={openBrowser} + onClick={handleOpenProjectBrowser} color="inherit" aria-label={LL.PROJECT_BROWSER()} > diff --git a/src/features/appBar/ui/__tests__/MainAppBar.test.tsx b/src/features/appBar/ui/__tests__/MainAppBar.test.tsx index 9355e474..7a14748b 100644 --- a/src/features/appBar/ui/__tests__/MainAppBar.test.tsx +++ b/src/features/appBar/ui/__tests__/MainAppBar.test.tsx @@ -46,6 +46,10 @@ vi.mock("#/shared/hooks/useMobileLayout", () => ({ useMobileLayout: () => true, })); +vi.mock("#/features/playground/hooks/usePlaygroundMobileLayout", () => ({ + usePlaygroundMobileLayout: () => true, +})); + vi.mock("#/features/playground/hooks/useMobilePlaygroundView", () => ({ useMobilePlaygroundView: () => ({ currentView: "code" as const, @@ -54,7 +58,7 @@ vi.mock("#/features/playground/hooks/useMobilePlaygroundView", () => ({ const mockOpenBrowser = vi.fn(); vi.mock("#/features/project/ui/ProjectBrowser/ProjectBrowserContext", () => ({ - useProjectBrowserContext: () => ({ + useOptionalProjectBrowserContext: () => ({ openBrowser: mockOpenBrowser, }), })); diff --git a/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx index dacd6fcf..a8e16b64 100644 --- a/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx +++ b/src/features/playground/hooks/__tests__/usePlaygroundSlugLoadingSync.test.tsx @@ -8,30 +8,56 @@ import { makeStore } from "#/store/makeStore"; const mockUsePlaygroundRoute = vi.fn(() => ({ basePath: "/playground", - slug: ["two-sum"], - pathname: "/playground/two-sum", + slug: ["two-sum", "case-1", "solution-1"], + pathname: "/playground/two-sum/case-1/solution-1", navigateTo: vi.fn(), })); +const mockServerPrefetchMatchesRoute = vi.fn( + (_initialData: unknown, _slug: unknown) => false, +); + vi.mock("#/shared/hooks/usePlaygroundRoute", () => ({ usePlaygroundRoute: () => mockUsePlaygroundRoute(), })); +vi.mock("#/features/playground/lib/serverPrefetchMatchesRoute", () => ({ + serverPrefetchMatchesRoute: (initialData: unknown, slug: unknown) => + mockServerPrefetchMatchesRoute(initialData, slug), +})); + +vi.mock("#/features/playground/context/PlaygroundInitialDataContext", () => ({ + usePlaygroundInitialData: () => null, +})); + describe("usePlaygroundSlugLoadingSync", () => { beforeEach(() => { vi.clearAllMocks(); mockUsePlaygroundRoute.mockReturnValue({ basePath: "/playground", - slug: ["two-sum"], - pathname: "/playground/two-sum", + slug: ["two-sum", "case-1", "solution-1"], + pathname: "/playground/two-sum/case-1/solution-1", navigateTo: vi.fn(), }); + mockServerPrefetchMatchesRoute.mockReturnValue(false); }); - it("dispatches loadStart when slug segments change", () => { + it("skips loadStart on first render when server prefetch matches the URL", () => { + mockServerPrefetchMatchesRoute.mockReturnValue(true); + const store = makeStore(); store.dispatch(projectSlice.actions.loadFinish()); + renderHook(() => usePlaygroundSlugLoadingSync(), { + wrapper: ({ children }) => <Provider store={store}>{children}</Provider>, + }); + + expect(store.getState().project.isInitialized).toBe(true); + }); + + it("dispatches loadStart when slug segments change", () => { + const store = makeStore(); + const { rerender } = renderHook(() => usePlaygroundSlugLoadingSync(), { wrapper: ({ children }) => <Provider store={store}>{children}</Provider>, }); diff --git a/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts b/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts index 5470dd09..c5adbc25 100644 --- a/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts +++ b/src/features/playground/hooks/usePlaygroundSlugLoadingSync.ts @@ -2,6 +2,8 @@ import { useEffect, useRef } from "react"; +import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; +import { serverPrefetchMatchesRoute } from "#/features/playground/lib/serverPrefetchMatchesRoute"; import { projectSlice } from "#/features/project/model/projectSlice"; import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; import { useAppDispatch } from "#/store/hooks"; @@ -9,12 +11,15 @@ import { useAppDispatch } from "#/store/hooks"; /** * Reset the panel loading gate when playground URL segments change * (back/forward, <Link>, or programmatic navigations — not only setProject). + * Skips the initial loadStart when the server already prefetched matching data. */ export const usePlaygroundSlugLoadingSync = (): void => { const dispatch = useAppDispatch(); const route = usePlaygroundRoute(); + const serverInitialData = usePlaygroundInitialData(); const slugKey = route?.slug.join("/") ?? ""; const previousSlugKeyRef = useRef<string | null>(null); + const isFirstSlugEffectRef = useRef(true); useEffect(() => { if (!route) { @@ -25,7 +30,17 @@ export const usePlaygroundSlugLoadingSync = (): void => { return; } + const skipInitialLoadStart = + isFirstSlugEffectRef.current && + serverPrefetchMatchesRoute(serverInitialData, route.slug); + previousSlugKeyRef.current = slugKey; + isFirstSlugEffectRef.current = false; + + if (skipInitialLoadStart) { + return; + } + dispatch(projectSlice.actions.loadStart()); - }, [dispatch, route, slugKey]); + }, [dispatch, route, serverInitialData, slugKey]); }; diff --git a/src/features/playground/lib/__tests__/serverPrefetchMatchesRoute.test.ts b/src/features/playground/lib/__tests__/serverPrefetchMatchesRoute.test.ts new file mode 100644 index 00000000..91a2a46c --- /dev/null +++ b/src/features/playground/lib/__tests__/serverPrefetchMatchesRoute.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { serverPrefetchMatchesRoute } from "#/features/playground/lib/serverPrefetchMatchesRoute"; + +describe("serverPrefetchMatchesRoute", () => { + it("returns false when project prefetch is missing", () => { + expect(serverPrefetchMatchesRoute(null, ["two-sum"])).toBe(false); + }); + + it("returns true when all prefetched slugs match the URL", () => { + expect( + serverPrefetchMatchesRoute( + { + projectBySlug: { slug: "two-sum" }, + caseBySlug: { slug: "case-1" }, + solutionBySlug: { slug: "solution-1" }, + }, + ["two-sum", "case-1", "solution-1"], + ), + ).toBe(true); + }); + + it("returns false when a slug segment differs", () => { + expect( + serverPrefetchMatchesRoute( + { + projectBySlug: { slug: "two-sum" }, + caseBySlug: { slug: "case-1" }, + solutionBySlug: null, + }, + ["two-sum", "case-2"], + ), + ).toBe(false); + }); +}); diff --git a/src/features/playground/lib/serverPrefetchMatchesRoute.ts b/src/features/playground/lib/serverPrefetchMatchesRoute.ts new file mode 100644 index 00000000..e1e7273e --- /dev/null +++ b/src/features/playground/lib/serverPrefetchMatchesRoute.ts @@ -0,0 +1,31 @@ +type ServerPrefetchSlugData = { + projectBySlug?: { slug: string } | null; + caseBySlug?: { slug: string } | null; + solutionBySlug?: { slug: string } | null; +}; + +/** True when server-prefetched playground data matches the active URL slug segments. */ +export function serverPrefetchMatchesRoute( + serverInitialData: ServerPrefetchSlugData | null, + slug: string[], +): boolean { + if (!serverInitialData?.projectBySlug) { + return false; + } + + const [projectSlug, caseSlug, solutionSlug] = slug; + + if (serverInitialData.projectBySlug.slug !== projectSlug) { + return false; + } + + if (caseSlug && serverInitialData.caseBySlug?.slug !== caseSlug) { + return false; + } + + if (solutionSlug && serverInitialData.solutionBySlug?.slug !== solutionSlug) { + return false; + } + + return true; +} diff --git a/src/features/playground/ui/PlaygroundLayoutClient.tsx b/src/features/playground/ui/PlaygroundLayoutClient.tsx index a170a83c..c3b8cf95 100644 --- a/src/features/playground/ui/PlaygroundLayoutClient.tsx +++ b/src/features/playground/ui/PlaygroundLayoutClient.tsx @@ -6,8 +6,11 @@ import { usePlaygroundPyodideWarmup } from "#/features/playground/hooks/usePlayg import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; import { usePlaygroundSlugLoadingSync } from "#/features/playground/hooks/usePlaygroundSlugLoadingSync"; import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShell"; +import { ProjectBrowserProvider } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; import { prefetchSplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout"; +import { ProjectBrowserOverlay } from "#/app/locale-app/ProjectBrowserOverlay"; + type PlaygroundLayoutClientProps = { children: ReactNode; }; @@ -28,5 +31,10 @@ export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ void prefetchSplitPanelsLayout(); }, []); - return <PlaygroundPageShell>{children}</PlaygroundPageShell>; + return ( + <ProjectBrowserProvider> + <PlaygroundPageShell>{children}</PlaygroundPageShell> + <ProjectBrowserOverlay /> + </ProjectBrowserProvider> + ); }; diff --git a/src/features/project/hooks/useProjectPanelData.ts b/src/features/project/hooks/useProjectPanelData.ts index 00dbc9a1..0fcad28a 100644 --- a/src/features/project/hooks/useProjectPanelData.ts +++ b/src/features/project/hooks/useProjectPanelData.ts @@ -10,6 +10,7 @@ import { selectIsEditable, } from "#/features/project/model/projectSlice"; import { usePlaygroundSlugs } from "#/shared/hooks"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; import { api } from "#/shared/lib"; import { useAppDispatch, useAppSelector } from "#/store/hooks"; @@ -22,6 +23,7 @@ export const useProjectPanelData = () => { const dispatch = useAppDispatch(); const { projectSlug = "", caseSlug = "", clearSlugs } = usePlaygroundSlugs(); + const route = usePlaygroundRoute(); const serverInitialData = usePlaygroundInitialData(); @@ -68,7 +70,11 @@ export const useProjectPanelData = () => { useEffect(() => { if (selectedProject.error) { console.error("selectedProject.error: ", selectedProject.error); - clearSlugs(); + if (route) { + route.navigateTo(route.basePath, { omitView: true }); + } else { + clearSlugs(); + } return; } if (!selectedProject.data || !session.data) { @@ -87,6 +93,7 @@ export const useProjectPanelData = () => { clearSlugs, dispatch, isEditable, + route, selectedProject.data, selectedProject.error, session.data, diff --git a/src/features/project/ui/ProjectBrowser/ProjectBrowserContext.tsx b/src/features/project/ui/ProjectBrowser/ProjectBrowserContext.tsx index e9df4d18..216c3aa0 100644 --- a/src/features/project/ui/ProjectBrowser/ProjectBrowserContext.tsx +++ b/src/features/project/ui/ProjectBrowser/ProjectBrowserContext.tsx @@ -266,7 +266,7 @@ const ProjectBrowserContext = createContext<ProjectBrowserContextValue | null>( * const { isOpen, openBrowser, searchQuery, setSearchQuery } = useProjectBrowserContext(); */ export const useProjectBrowserContext = (): ProjectBrowserContextValue => { - const context = useContext(ProjectBrowserContext); + const context = useOptionalProjectBrowserContext(); if (!context) { throw new Error( "useProjectBrowserContext must be used within ProjectBrowserProvider", @@ -276,6 +276,10 @@ export const useProjectBrowserContext = (): ProjectBrowserContextValue => { return context; }; +/** Returns null outside playground {@link ProjectBrowserProvider}. */ +export const useOptionalProjectBrowserContext = + (): ProjectBrowserContextValue | null => useContext(ProjectBrowserContext); + type ProjectBrowserProviderProps = { children: ReactNode; }; diff --git a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts index 9b1bd928..03780199 100644 --- a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts +++ b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts @@ -5,6 +5,11 @@ const mockAllBrief = vi.fn(); const mockGetBySlug = vi.fn(); const mockGetCaseBySlug = vi.fn(); const mockGetSolutionBySlug = vi.fn(); +const mockLoadCachedPublicProjectsBrief = vi.fn(); + +vi.mock("#/server/playground/loadCachedPublicProjectsBrief", () => ({ + loadCachedPublicProjectsBrief: () => mockLoadCachedPublicProjectsBrief(), +})); vi.mock("#/server/auth/authOptions", () => ({ authOptions: {}, @@ -32,6 +37,9 @@ vi.mock("next-auth", () => ({ describe("getPlaygroundInitialData", () => { beforeEach(() => { vi.clearAllMocks(); + mockLoadCachedPublicProjectsBrief.mockResolvedValue([ + { id: "1", slug: "demo", title: "Demo" }, + ]); mockAllBrief.mockResolvedValue([{ id: "1", slug: "demo", title: "Demo" }]); mockGetBySlug.mockResolvedValue({ id: "proj-1", diff --git a/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts index 68e80823..2730f355 100644 --- a/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts +++ b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; const mockAllBrief = vi.fn(); const mockGetBySlug = vi.fn(); +const mockLoadCachedPublicProjectsBrief = vi.fn(); vi.mock("#/server/auth/authOptions", () => ({ authOptions: {}, @@ -20,6 +21,10 @@ vi.mock("#/server/api/context", () => ({ createInnerTRPCContext: async (opts: unknown) => opts, })); +vi.mock("#/server/playground/loadCachedPublicProjectsBrief", () => ({ + loadCachedPublicProjectsBrief: () => mockLoadCachedPublicProjectsBrief(), +})); + vi.mock("next-auth", () => ({ getServerSession: vi.fn().mockResolvedValue(null), })); @@ -27,6 +32,9 @@ vi.mock("next-auth", () => ({ describe("resolveCanonicalPlaygroundRedirect", () => { beforeEach(() => { vi.clearAllMocks(); + mockLoadCachedPublicProjectsBrief.mockResolvedValue([ + { id: "1", slug: "two-sum", title: "Two Sum" }, + ]); mockAllBrief.mockResolvedValue([ { id: "1", slug: "two-sum", title: "Two Sum" }, ]); @@ -49,6 +57,7 @@ describe("resolveCanonicalPlaygroundRedirect", () => { }); expect(redirectPath).toBe("/playground/two-sum/case-1/solution-1"); + expect(mockLoadCachedPublicProjectsBrief).toHaveBeenCalled(); }); it("restores the last path from cookie when landing on bare /playground", async () => { @@ -83,4 +92,20 @@ describe("resolveCanonicalPlaygroundRedirect", () => { expect(redirectPath).toBeNull(); }); + + it("appends ?view=code on mobile when canonicalizing a project path", async () => { + const { resolveCanonicalPlaygroundRedirect } = + await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); + + const redirectPath = await resolveCanonicalPlaygroundRedirect({ + basePath: "/playground", + slug: ["two-sum"], + lastPathCookie: null, + ssrDeviceType: "mobile", + }); + + expect(redirectPath).toBe( + "/playground/two-sum/case-1/solution-1?view=code", + ); + }); }); diff --git a/src/server/playground/getPlaygroundInitialData.ts b/src/server/playground/getPlaygroundInitialData.ts index a7058722..005dd0c5 100644 --- a/src/server/playground/getPlaygroundInitialData.ts +++ b/src/server/playground/getPlaygroundInitialData.ts @@ -6,6 +6,8 @@ import { createCaller } from "#/server/api/root"; import { authOptions } from "#/server/auth/authOptions"; import type { RouterOutputs } from "#/shared/api"; +import { loadCachedPublicProjectsBrief } from "./loadCachedPublicProjectsBrief"; + export type PlaygroundInitialData = { allBrief: RouterOutputs["project"]["allBrief"]; projectBySlug: RouterOutputs["project"]["getBySlug"] | null; @@ -47,6 +49,17 @@ async function loadSolutionBySlug( } } +async function loadProjectsBrief( + caller: ReturnType<typeof createCaller>, + userId?: string, +): Promise<RouterOutputs["project"]["allBrief"]> { + if (userId) { + return caller.project.allBrief(); + } + + return loadCachedPublicProjectsBrief(); +} + /** * Server-prefetch public playground lists and the active project/case/solution for RSC pages. * Hydrates client tRPC queries via {@link PlaygroundInitialDataProvider}. @@ -62,9 +75,10 @@ export async function getPlaygroundInitialData( session, }), ); + const userId = session?.user?.id; if (!projectSlug) { - const allBrief = await caller.project.allBrief(); + const allBrief = await loadProjectsBrief(caller, userId); return { allBrief, projectBySlug: null, @@ -74,7 +88,7 @@ export async function getPlaygroundInitialData( } const [allBrief, projectBySlug] = await Promise.all([ - caller.project.allBrief(), + loadProjectsBrief(caller, userId), loadProjectBySlug(caller, projectSlug), ]); diff --git a/src/server/playground/loadCachedPublicProjectsBrief.ts b/src/server/playground/loadCachedPublicProjectsBrief.ts new file mode 100644 index 00000000..8abdb798 --- /dev/null +++ b/src/server/playground/loadCachedPublicProjectsBrief.ts @@ -0,0 +1,54 @@ +import { cacheLife, cacheTag } from "next/cache"; + +import { + calculateIsNew, + getNewProjectMarginMs, +} from "#/entities/projectEntity/lib/calculateIsNew"; +import { db } from "#/server/db/client"; +import type { RouterOutputs } from "#/shared/api"; + +export const PUBLIC_PROJECTS_BRIEF_CACHE_TAG = + "playground-public-projects-brief"; + +type PublicProjectsBrief = RouterOutputs["project"]["allBrief"]; + +async function queryPublicProjectsBrief(): Promise<PublicProjectsBrief> { + const projects = await db.playgroundProject.findMany({ + where: { isPublic: true }, + select: { + id: true, + createdAt: true, + slug: true, + title: true, + category: true, + difficulty: true, + author: { + select: { + id: true, + name: true, + bucketImage: true, + }, + }, + }, + orderBy: [{ category: "asc" }, { title: "asc" }], + }); + + const newProjectMarginMs = await getNewProjectMarginMs(); + + return projects.map((project) => ({ + ...project, + isNew: calculateIsNew(project.createdAt, newProjectMarginMs), + })); +} + +/** + * Cached anonymous project list for server redirects and prefetch. + * Authenticated users still get personal projects via live tRPC `allBrief`. + */ +export async function loadCachedPublicProjectsBrief(): Promise<PublicProjectsBrief> { + "use cache"; + cacheLife("hours"); + cacheTag(PUBLIC_PROJECTS_BRIEF_CACHE_TAG); + + return queryPublicProjectsBrief(); +} diff --git a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts index 51995669..f6b2a558 100644 --- a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts +++ b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts @@ -13,8 +13,10 @@ import { buildPlaygroundPath, parsePlaygroundPathname, } from "#/shared/lib/playgroundRoute"; +import type { SsrDeviceType } from "#/themes"; import { loadProjectBySlug } from "./getPlaygroundInitialData"; +import { loadCachedPublicProjectsBrief } from "./loadCachedPublicProjectsBrief"; type ProjectBySlug = RouterOutputs["project"]["getBySlug"]; @@ -58,7 +60,7 @@ async function resolveProjectSlug( } } - const allBrief = await caller.project.allBrief(); + const allBrief = await loadCachedPublicProjectsBrief(); const firstProjectSlug = allBrief[0]?.slug; if (!firstProjectSlug) { return null; @@ -71,16 +73,37 @@ export type ResolveCanonicalPlaygroundRedirectInput = { basePath: string; slug: string[]; lastPathCookie: string | null; + ssrDeviceType?: SsrDeviceType; + viewParam?: string | null; }; /** * Returns a canonical playground pathname when URL segments are incomplete, * or null when the current path already matches the canonical slug. */ +function appendMobileViewQuery( + path: string, + canonicalSlug: string[], + ssrDeviceType: SsrDeviceType | undefined, + viewParam: string | null | undefined, +): string { + if (ssrDeviceType !== "mobile" || canonicalSlug.length === 0) { + return path; + } + + if (viewParam) { + return path; + } + + return `${path}?view=code`; +} + export async function resolveCanonicalPlaygroundRedirect({ basePath, slug, lastPathCookie, + ssrDeviceType, + viewParam, }: ResolveCanonicalPlaygroundRedirectInput): Promise<string | null> { const caller = await createPlaygroundCaller(); const project = await resolveProjectSlug( @@ -117,5 +140,6 @@ export async function resolveCanonicalPlaygroundRedirect({ return null; } - return buildPlaygroundPath(basePath, canonicalSlug); + const path = buildPlaygroundPath(basePath, canonicalSlug); + return appendMobileViewQuery(path, canonicalSlug, ssrDeviceType, viewParam); } From 99e88691cd0e62baf1c8b7ddfe5ea462f089740b Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 15:48:48 +0000 Subject: [PATCH 14/19] Add marketing loading.tsx fallbacks and update RSC refactor plan Instant-nav skeletons for home and privacy routes. Document completed Phase 2 architecture items in vibe-docs. Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- src/__tests__/index.test.tsx | 15 +++---- .../(default-locale)/(marketing)/loading.tsx | 41 +++++++++++++++++++ .../(marketing)/privacy/loading.tsx | 15 +++++++ src/app/[lang]/(marketing)/loading.tsx | 41 +++++++++++++++++++ .../[lang]/(marketing)/privacy/loading.tsx | 15 +++++++ vibe-docs/RSC-First-Refactor-Plan.md | 12 ++++-- 6 files changed, 129 insertions(+), 10 deletions(-) create mode 100644 src/app/(default-locale)/(marketing)/loading.tsx create mode 100644 src/app/(default-locale)/(marketing)/privacy/loading.tsx create mode 100644 src/app/[lang]/(marketing)/loading.tsx create mode 100644 src/app/[lang]/(marketing)/privacy/loading.tsx diff --git a/src/__tests__/index.test.tsx b/src/__tests__/index.test.tsx index 4d798b1d..6ddbcd32 100644 --- a/src/__tests__/index.test.tsx +++ b/src/__tests__/index.test.tsx @@ -6,7 +6,6 @@ import { vi } from "vitest"; import { LANDING_PRIMARY_PLAYGROUND_HREF } from "#/features/homePage/lib/landingPlaygroundDemos"; import { MarketingHomeView } from "#/features/homePage/ui/MarketingHomeView"; import { mockUseSearchParam } from "#/features/project/ui/ProjectBrowser/__tests__/testUtils"; -import { ProjectBrowserProvider } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; import { QuestionOfTodayDocument } from "#/graphql/generated"; import en from "#/i18n/en/index"; import type { Translation } from "#/i18n/i18n-types"; @@ -15,6 +14,8 @@ import { I18nProvider } from "#/shared/ui/providers/I18nProvider"; import { StateThemeProvider } from "#/shared/ui/providers/StateThemeProvider"; import { makeStore } from "#/store/makeStore"; +import { RuntimeDeviceHintProvider } from "#/app/locale-app/RuntimeDeviceHintContext"; + const store = makeStore(); vi.mock("next-auth/react", () => { @@ -68,13 +69,13 @@ describe("MarketingHomeView", () => { render( <ReduxProvider store={store}> <MockedProvider mocks={mocks} addTypename={false}> - <StateThemeProvider> - <I18nProvider locale="en" i18n={i18n}> - <ProjectBrowserProvider> + <RuntimeDeviceHintProvider initialSsrDeviceType="desktop"> + <StateThemeProvider> + <I18nProvider locale="en" i18n={i18n}> <MarketingHomeView /> - </ProjectBrowserProvider> - </I18nProvider> - </StateThemeProvider> + </I18nProvider> + </StateThemeProvider> + </RuntimeDeviceHintProvider> </MockedProvider> </ReduxProvider>, { wrapper: withNextTRPC }, diff --git a/src/app/(default-locale)/(marketing)/loading.tsx b/src/app/(default-locale)/(marketing)/loading.tsx new file mode 100644 index 00000000..fd7743d2 --- /dev/null +++ b/src/app/(default-locale)/(marketing)/loading.tsx @@ -0,0 +1,41 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for marketing home while client islands hydrate. */ +export default function MarketingHomeLoading() { + return ( + <Box component="main" sx={{ minHeight: "100vh" }}> + <Box + sx={{ + minHeight: { xs: 520, md: 640 }, + display: "flex", + alignItems: "center", + justifyContent: "center", + px: 2, + }} + > + <Container maxWidth="lg"> + <Skeleton + variant="text" + width="70%" + height={56} + animation="wave" + sx={{ mx: "auto", mb: 2 }} + /> + <Skeleton + variant="text" + width="50%" + height={32} + animation="wave" + sx={{ mx: "auto", mb: 4 }} + /> + <Skeleton + variant="rounded" + height={280} + animation="wave" + sx={{ borderRadius: 3 }} + /> + </Container> + </Box> + </Box> + ); +} diff --git a/src/app/(default-locale)/(marketing)/privacy/loading.tsx b/src/app/(default-locale)/(marketing)/privacy/loading.tsx new file mode 100644 index 00000000..49dd39a4 --- /dev/null +++ b/src/app/(default-locale)/(marketing)/privacy/loading.tsx @@ -0,0 +1,15 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for privacy policy while client chrome hydrates. */ +export default function PrivacyLoading() { + return ( + <Box component="main" sx={{ minHeight: "70vh", py: 8 }}> + <Container maxWidth="md"> + <Skeleton variant="text" width="40%" height={48} animation="wave" /> + <Skeleton variant="text" width="100%" animation="wave" sx={{ mt: 3 }} /> + <Skeleton variant="text" width="95%" animation="wave" /> + <Skeleton variant="text" width="90%" animation="wave" /> + </Container> + </Box> + ); +} diff --git a/src/app/[lang]/(marketing)/loading.tsx b/src/app/[lang]/(marketing)/loading.tsx new file mode 100644 index 00000000..ad72c1ed --- /dev/null +++ b/src/app/[lang]/(marketing)/loading.tsx @@ -0,0 +1,41 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for marketing home while client islands hydrate. */ +export default function LangMarketingHomeLoading() { + return ( + <Box component="main" sx={{ minHeight: "100vh" }}> + <Box + sx={{ + minHeight: { xs: 520, md: 640 }, + display: "flex", + alignItems: "center", + justifyContent: "center", + px: 2, + }} + > + <Container maxWidth="lg"> + <Skeleton + variant="text" + width="70%" + height={56} + animation="wave" + sx={{ mx: "auto", mb: 2 }} + /> + <Skeleton + variant="text" + width="50%" + height={32} + animation="wave" + sx={{ mx: "auto", mb: 4 }} + /> + <Skeleton + variant="rounded" + height={280} + animation="wave" + sx={{ borderRadius: 3 }} + /> + </Container> + </Box> + </Box> + ); +} diff --git a/src/app/[lang]/(marketing)/privacy/loading.tsx b/src/app/[lang]/(marketing)/privacy/loading.tsx new file mode 100644 index 00000000..3e2fab4b --- /dev/null +++ b/src/app/[lang]/(marketing)/privacy/loading.tsx @@ -0,0 +1,15 @@ +import { Box, Container, Skeleton } from "@mui/material"; + +/** Instant-nav fallback for privacy policy while client chrome hydrates. */ +export default function LangPrivacyLoading() { + return ( + <Box component="main" sx={{ minHeight: "70vh", py: 8 }}> + <Container maxWidth="md"> + <Skeleton variant="text" width="40%" height={48} animation="wave" /> + <Skeleton variant="text" width="100%" animation="wave" sx={{ mt: 3 }} /> + <Skeleton variant="text" width="95%" animation="wave" /> + <Skeleton variant="text" width="90%" animation="wave" /> + </Container> + </Box> + ); +} diff --git a/vibe-docs/RSC-First-Refactor-Plan.md b/vibe-docs/RSC-First-Refactor-Plan.md index 3b945cdb..85f25e74 100644 --- a/vibe-docs/RSC-First-Refactor-Plan.md +++ b/vibe-docs/RSC-First-Refactor-Plan.md @@ -23,7 +23,13 @@ | `startTransition` on playground slug navigations | Small | Done | | `loading.tsx` for `/daily` | Small | Done | | Remove redundant playground `<Suspense>` (use `loading.tsx`) | Small | Done | -| Cached anonymous `allBrief` in server prefetch | Medium | Planned | +| Cached anonymous `allBrief` in server prefetch | Medium | Done | +| Server canonical playground redirect (project/case/solution) | Medium | Done | +| Apollo scoped to daily/profile only (not playground) | Medium | Done | +| Daily + profile GraphQL server prefetch | Medium | Done | +| `loading.tsx` for marketing `/` and `/privacy` | Small | Done | +| ProjectBrowser scoped to playground layout | Medium | Done | +| Mobile `?view=code` in server canonical redirect | Small | Done | --- @@ -32,7 +38,7 @@ | Item | Effort | Notes | |------|--------|-------| | Split `MarketingHomeView` into RSC sections + client islands | Medium | Hero copy, FAQ, sections as server; 3D preview + scroll hooks client | -| `DailyPageView` shell as RSC | Medium | Server-fetch daily question; client island for interactive bits | +| `DailyPageView` shell as RSC | Medium | Server prefetch done; interactive bits remain client | | Remove duplicate locale loads in page modules | Small | Prefer `loadI18nForLocale` / layout-passed `LL` everywhere | **Success criteria:** `/` and `/daily` ship meaningful HTML without waiting for client hydration; WebGL/Monaco remain client-only. @@ -77,7 +83,7 @@ flowchart TB end Base[Base providers: theme, Redux, tRPC, session] - Apollo[Apollo — app routes only] + Apollo[Apollo — daily/profile routes only] Base --> marketing Base --> Apollo From 76d93345a008d6f16b77f66a0b6a411e86d71610 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 20:43:11 +0000 Subject: [PATCH 15/19] Fix playground CI: serialize RSC props, client canonical redirect, Apollo for ProjectModal - Revert getPlaygroundInitialData to live tRPC allBrief (not use cache) for client hydration - Add serializePlaygroundInitialData to strip cache metadata before client boundary - JSON-serialize loadCachedPublicProjectsBrief for server-only redirect cache - Add useClientCanonicalPlaygroundRedirect for soft-nav canonical URL updates - Mount ApolloHydrationProvider on playground for ProjectModal LeetCode import - Update E2E specs to wait for canonical invert-binary-tree path Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- e2e/helpers/playgroundRoute.ts | 24 ++++++++ e2e/instant-playground-nav.spec.ts | 12 ++-- e2e/playground-monaco-nav.spec.ts | 11 ++-- .../locale-app/ApolloHydrationProvider.tsx | 4 +- src/app/locale-app/pages/playgroundPage.tsx | 7 +-- .../useClientCanonicalPlaygroundRedirect.ts | 56 +++++++++++++++++++ .../playground/ui/PlaygroundLayoutClient.tsx | 13 +++-- .../getPlaygroundInitialData.test.ts | 8 --- .../playground/getPlaygroundInitialData.ts | 20 ++----- .../loadCachedPublicProjectsBrief.ts | 21 +++++-- .../serializePlaygroundInitialData.ts | 11 ++++ 11 files changed, 139 insertions(+), 48 deletions(-) create mode 100644 e2e/helpers/playgroundRoute.ts create mode 100644 src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts create mode 100644 src/server/playground/serializePlaygroundInitialData.ts diff --git a/e2e/helpers/playgroundRoute.ts b/e2e/helpers/playgroundRoute.ts new file mode 100644 index 00000000..3c5507c8 --- /dev/null +++ b/e2e/helpers/playgroundRoute.ts @@ -0,0 +1,24 @@ +/** Canonical invert-binary-tree path segments (project / case / solution). */ +export const INVERT_BINARY_TREE_CANONICAL_PATH = + "/playground/invert-binary-tree/case-1/solution-1"; + +export function isCanonicalPlaygroundProjectPath( + pathname: string, + projectSlug: string, +): boolean { + const segments = pathname.split("/").filter(Boolean); + const playgroundIndex = segments.indexOf("playground"); + if (playgroundIndex === -1) { + return false; + } + + const projectIndex = playgroundIndex + 1; + return ( + segments[projectIndex] === projectSlug && + segments.length >= projectIndex + 3 + ); +} + +export function isInvertBinaryTreeCanonicalPath(pathname: string): boolean { + return isCanonicalPlaygroundProjectPath(pathname, "invert-binary-tree"); +} diff --git a/e2e/instant-playground-nav.spec.ts b/e2e/instant-playground-nav.spec.ts index 6bfdaffd..c2a371b0 100644 --- a/e2e/instant-playground-nav.spec.ts +++ b/e2e/instant-playground-nav.spec.ts @@ -6,6 +6,7 @@ import { visiblePlaygroundMonacoEditor, waitForPlaygroundMonacoEditor, } from "./helpers/playgroundMonacoEditor"; +import { isInvertBinaryTreeCanonicalPath } from "./helpers/playgroundRoute"; /** * L5: playground opts into `instant = true` with Suspense skeleton fallback. @@ -24,14 +25,13 @@ test.describe("instant playground navigation (L5)", () => { await instant(page, async () => { await page.getByTestId("cta-to-playground").click(); - await page.waitForURL( - (url) => - url.pathname.startsWith("/playground/invert-binary-tree/") && - url.pathname.split("/").length >= 5, - { timeout: 30_000 }, - ); }); + await page.waitForURL( + (url) => isInvertBinaryTreeCanonicalPath(url.pathname), + { timeout: 30_000 }, + ); + await waitForPlaygroundMonacoEditor(page); await expect(visiblePlaygroundMonacoEditor(page)).toBeVisible(); }); diff --git a/e2e/playground-monaco-nav.spec.ts b/e2e/playground-monaco-nav.spec.ts index 714286eb..1afb7664 100644 --- a/e2e/playground-monaco-nav.spec.ts +++ b/e2e/playground-monaco-nav.spec.ts @@ -8,9 +8,12 @@ import { visiblePlaygroundMonacoEditor, waitForPlaygroundMonacoEditor, } from "./helpers/playgroundMonacoEditor"; +import { + INVERT_BINARY_TREE_CANONICAL_PATH, + isInvertBinaryTreeCanonicalPath, +} from "./helpers/playgroundRoute"; -const PLAYGROUND_PYTHON_URL = - "/playground/invert-binary-tree?view=code&language=python"; +const PLAYGROUND_PYTHON_URL = `${INVERT_BINARY_TREE_CANONICAL_PATH}?view=code&language=python`; test.describe("playground runtime navigation", () => { test.describe.configure({ mode: "serial" }); @@ -31,7 +34,7 @@ test.describe("playground runtime navigation", () => { for (let roundIndex = 0; roundIndex < 3; roundIndex += 1) { await page.getByTestId("cta-to-playground").click(); await page.waitForURL( - (url) => url.pathname === "/playground/invert-binary-tree", + (url) => isInvertBinaryTreeCanonicalPath(url.pathname), { timeout: 30_000 }, ); await waitForPlaygroundMonacoEditor(page); @@ -43,7 +46,7 @@ test.describe("playground runtime navigation", () => { await dismissCookieBannerIfVisible(page); } - await page.goto("/playground/invert-binary-tree?view=code"); + await page.goto(`${INVERT_BINARY_TREE_CANONICAL_PATH}?view=code`); await dismissCookieBannerIfVisible(page); await waitForPlaygroundMonacoEditor(page); diff --git a/src/app/locale-app/ApolloHydrationProvider.tsx b/src/app/locale-app/ApolloHydrationProvider.tsx index da131919..1c78141e 100644 --- a/src/app/locale-app/ApolloHydrationProvider.tsx +++ b/src/app/locale-app/ApolloHydrationProvider.tsx @@ -11,8 +11,8 @@ type ApolloHydrationProviderProps = { }; /** - * Route-scoped Apollo client with server-extracted cache for GraphQL routes - * (daily, profile). Playground uses tRPC only. + * Route-scoped Apollo client with optional server-extracted cache (daily, profile). + * Playground mounts with `initialCache={null}` for {@link ProjectModal} LeetCode import only. */ export const ApolloHydrationProvider: React.FC< ApolloHydrationProviderProps diff --git a/src/app/locale-app/pages/playgroundPage.tsx b/src/app/locale-app/pages/playgroundPage.tsx index 0be1d80b..6b279ead 100644 --- a/src/app/locale-app/pages/playgroundPage.tsx +++ b/src/app/locale-app/pages/playgroundPage.tsx @@ -9,6 +9,7 @@ import { PlaygroundPageView } from "#/features/playground/ui/PlaygroundPageView" import { baseLocale } from "#/i18n/i18n-util"; import { getPlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; import { resolveCanonicalPlaygroundRedirect } from "#/server/playground/resolveCanonicalPlaygroundRedirect"; +import { serializePlaygroundInitialData } from "#/server/playground/serializePlaygroundInitialData"; import { APP_ROUTER_SSR_DEVICE_TYPE_HEADER } from "#/shared/lib/appRouterLocaleHeader"; import { LAST_PLAYGROUND_PATH_COOKIE } from "#/shared/lib/playgroundLastPathCookie"; import { playgroundBasePathForLocale } from "#/shared/lib/playgroundRoute"; @@ -108,10 +109,8 @@ export async function PlaygroundPage({ } const [projectSlug, caseSlug, solutionSlug] = slug ?? []; - const initialData = await getPlaygroundInitialData( - projectSlug, - caseSlug, - solutionSlug, + const initialData = serializePlaygroundInitialData( + await getPlaygroundInitialData(projectSlug, caseSlug, solutionSlug), ); return ( diff --git a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts new file mode 100644 index 00000000..9f204dc1 --- /dev/null +++ b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +import { usePlaygroundInitialData } from "#/features/playground/context/PlaygroundInitialDataContext"; +import { api } from "#/shared/api"; +import { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { + buildCanonicalPlaygroundSlug, + playgroundSlugKey, +} from "#/shared/lib/buildCanonicalPlaygroundSlug"; +import { buildPlaygroundPath } from "#/shared/lib/playgroundRoute"; + +/** + * Mirrors server canonical redirects for client navigations (instant nav, <Link>). + * Server `redirect()` does not always update the browser URL during soft navigations. + */ +export const useClientCanonicalPlaygroundRedirect = (): void => { + const route = usePlaygroundRoute(); + const serverInitialData = usePlaygroundInitialData(); + const redirectingRef = useRef(false); + + const routeProjectSlug = route?.slug[0] ?? ""; + const fallbackProjectSlug = serverInitialData?.allBrief[0]?.slug ?? ""; + const queryProjectSlug = routeProjectSlug || fallbackProjectSlug; + + const projectQuery = api.project.getBySlug.useQuery(queryProjectSlug, { + enabled: Boolean(route && queryProjectSlug), + initialData: + serverInitialData?.projectBySlug?.slug === queryProjectSlug + ? serverInitialData.projectBySlug + : undefined, + }); + + useEffect(() => { + if (!route || !projectQuery.data || redirectingRef.current) { + return; + } + + const canonicalSlug = buildCanonicalPlaygroundSlug( + projectQuery.data, + route.slug[1], + route.slug[2], + ); + + if (playgroundSlugKey(route.slug) === playgroundSlugKey(canonicalSlug)) { + redirectingRef.current = false; + return; + } + + redirectingRef.current = true; + route.navigateTo(buildPlaygroundPath(route.basePath, canonicalSlug), { + replace: true, + }); + }, [projectQuery.data, route]); +}; diff --git a/src/features/playground/ui/PlaygroundLayoutClient.tsx b/src/features/playground/ui/PlaygroundLayoutClient.tsx index c3b8cf95..c5c5b7db 100644 --- a/src/features/playground/ui/PlaygroundLayoutClient.tsx +++ b/src/features/playground/ui/PlaygroundLayoutClient.tsx @@ -2,6 +2,7 @@ import React, { type ReactNode, useEffect } from "react"; +import { useClientCanonicalPlaygroundRedirect } from "#/features/playground/hooks/useClientCanonicalPlaygroundRedirect"; import { usePlaygroundPyodideWarmup } from "#/features/playground/hooks/usePlaygroundPyodideWarmup"; import { usePlaygroundRuntimeRelease } from "#/features/playground/hooks/usePlaygroundRuntimeRelease"; import { usePlaygroundSlugLoadingSync } from "#/features/playground/hooks/usePlaygroundSlugLoadingSync"; @@ -9,6 +10,7 @@ import { PlaygroundPageShell } from "#/features/playground/ui/PlaygroundPageShel import { ProjectBrowserProvider } from "#/features/project/ui/ProjectBrowser/ProjectBrowserContext"; import { prefetchSplitPanelsLayout } from "#/shared/ui/templates/SplitPanelsLayout/prefetchSplitPanelsLayout"; +import { ApolloHydrationProvider } from "#/app/locale-app/ApolloHydrationProvider"; import { ProjectBrowserOverlay } from "#/app/locale-app/ProjectBrowserOverlay"; type PlaygroundLayoutClientProps = { @@ -24,6 +26,7 @@ export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ }) => { usePlaygroundRuntimeRelease(); usePlaygroundPyodideWarmup(); + useClientCanonicalPlaygroundRedirect(); usePlaygroundSlugLoadingSync(); // Prefetch split layout chunk while route loading skeleton is visible. @@ -32,9 +35,11 @@ export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ }, []); return ( - <ProjectBrowserProvider> - <PlaygroundPageShell>{children}</PlaygroundPageShell> - <ProjectBrowserOverlay /> - </ProjectBrowserProvider> + <ApolloHydrationProvider initialCache={null}> + <ProjectBrowserProvider> + <PlaygroundPageShell>{children}</PlaygroundPageShell> + <ProjectBrowserOverlay /> + </ProjectBrowserProvider> + </ApolloHydrationProvider> ); }; diff --git a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts index 03780199..9b1bd928 100644 --- a/src/server/playground/__tests__/getPlaygroundInitialData.test.ts +++ b/src/server/playground/__tests__/getPlaygroundInitialData.test.ts @@ -5,11 +5,6 @@ const mockAllBrief = vi.fn(); const mockGetBySlug = vi.fn(); const mockGetCaseBySlug = vi.fn(); const mockGetSolutionBySlug = vi.fn(); -const mockLoadCachedPublicProjectsBrief = vi.fn(); - -vi.mock("#/server/playground/loadCachedPublicProjectsBrief", () => ({ - loadCachedPublicProjectsBrief: () => mockLoadCachedPublicProjectsBrief(), -})); vi.mock("#/server/auth/authOptions", () => ({ authOptions: {}, @@ -37,9 +32,6 @@ vi.mock("next-auth", () => ({ describe("getPlaygroundInitialData", () => { beforeEach(() => { vi.clearAllMocks(); - mockLoadCachedPublicProjectsBrief.mockResolvedValue([ - { id: "1", slug: "demo", title: "Demo" }, - ]); mockAllBrief.mockResolvedValue([{ id: "1", slug: "demo", title: "Demo" }]); mockGetBySlug.mockResolvedValue({ id: "proj-1", diff --git a/src/server/playground/getPlaygroundInitialData.ts b/src/server/playground/getPlaygroundInitialData.ts index 005dd0c5..d7bd15a7 100644 --- a/src/server/playground/getPlaygroundInitialData.ts +++ b/src/server/playground/getPlaygroundInitialData.ts @@ -6,8 +6,6 @@ import { createCaller } from "#/server/api/root"; import { authOptions } from "#/server/auth/authOptions"; import type { RouterOutputs } from "#/shared/api"; -import { loadCachedPublicProjectsBrief } from "./loadCachedPublicProjectsBrief"; - export type PlaygroundInitialData = { allBrief: RouterOutputs["project"]["allBrief"]; projectBySlug: RouterOutputs["project"]["getBySlug"] | null; @@ -49,20 +47,11 @@ async function loadSolutionBySlug( } } -async function loadProjectsBrief( - caller: ReturnType<typeof createCaller>, - userId?: string, -): Promise<RouterOutputs["project"]["allBrief"]> { - if (userId) { - return caller.project.allBrief(); - } - - return loadCachedPublicProjectsBrief(); -} - /** * Server-prefetch public playground lists and the active project/case/solution for RSC pages. * Hydrates client tRPC queries via {@link PlaygroundInitialDataProvider}. + * + * Uses live tRPC `allBrief` (not `'use cache'`) so props stay plain objects for the client. */ export async function getPlaygroundInitialData( projectSlug?: string, @@ -75,10 +64,9 @@ export async function getPlaygroundInitialData( session, }), ); - const userId = session?.user?.id; if (!projectSlug) { - const allBrief = await loadProjectsBrief(caller, userId); + const allBrief = await caller.project.allBrief(); return { allBrief, projectBySlug: null, @@ -88,7 +76,7 @@ export async function getPlaygroundInitialData( } const [allBrief, projectBySlug] = await Promise.all([ - loadProjectsBrief(caller, userId), + caller.project.allBrief(), loadProjectBySlug(caller, projectSlug), ]); diff --git a/src/server/playground/loadCachedPublicProjectsBrief.ts b/src/server/playground/loadCachedPublicProjectsBrief.ts index 8abdb798..50e4f551 100644 --- a/src/server/playground/loadCachedPublicProjectsBrief.ts +++ b/src/server/playground/loadCachedPublicProjectsBrief.ts @@ -36,19 +36,32 @@ async function queryPublicProjectsBrief(): Promise<PublicProjectsBrief> { const newProjectMarginMs = await getNewProjectMarginMs(); return projects.map((project) => ({ - ...project, + id: project.id, + createdAt: project.createdAt, + slug: project.slug, + title: project.title, + category: project.category, + difficulty: project.difficulty, + author: project.author + ? { + id: project.author.id, + name: project.author.name, + bucketImage: project.author.bucketImage, + } + : null, isNew: calculateIsNew(project.createdAt, newProjectMarginMs), })); } /** - * Cached anonymous project list for server redirects and prefetch. - * Authenticated users still get personal projects via live tRPC `allBrief`. + * Cached anonymous project list for server-only redirects (not client props). + * `'use cache'` results must be plain objects — strip cache metadata before return. */ export async function loadCachedPublicProjectsBrief(): Promise<PublicProjectsBrief> { "use cache"; cacheLife("hours"); cacheTag(PUBLIC_PROJECTS_BRIEF_CACHE_TAG); - return queryPublicProjectsBrief(); + const brief = await queryPublicProjectsBrief(); + return JSON.parse(JSON.stringify(brief)) as PublicProjectsBrief; } diff --git a/src/server/playground/serializePlaygroundInitialData.ts b/src/server/playground/serializePlaygroundInitialData.ts new file mode 100644 index 00000000..325df844 --- /dev/null +++ b/src/server/playground/serializePlaygroundInitialData.ts @@ -0,0 +1,11 @@ +import type { PlaygroundInitialData } from "#/server/playground/getPlaygroundInitialData"; + +/** + * Strip non-serializable values (Dates, cache metadata symbols) before passing + * playground prefetch props to a Client Component boundary. + */ +export function serializePlaygroundInitialData( + initialData: PlaygroundInitialData, +): PlaygroundInitialData { + return JSON.parse(JSON.stringify(initialData)) as PlaygroundInitialData; +} From 6984a2e8449a6ea9dd0bf278b67813c0ea643b0e Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 20:53:50 +0000 Subject: [PATCH 16/19] Fix E2E: preserve bare playground landing URL, harden home nav waits - Skip server/client canonical redirect on bare /playground without last-visit cookie - Keep /de/playground indexable with Playground SEO title and canonical URL - Wait for visible app bar home link before clicking in E2E helpers - Only assert WebGL context-lost console messages after returning to home Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- e2e/helpers/playgroundMonacoEditor.ts | 2 ++ e2e/home-webgl-nav.spec.ts | 11 ++++++++--- .../hooks/useClientCanonicalPlaygroundRedirect.ts | 10 ++++------ .../resolveCanonicalPlaygroundRedirect.test.ts | 6 +++--- .../playground/resolveCanonicalPlaygroundRedirect.ts | 8 +++++++- 5 files changed, 24 insertions(+), 13 deletions(-) diff --git a/e2e/helpers/playgroundMonacoEditor.ts b/e2e/helpers/playgroundMonacoEditor.ts index e8ff49fe..1988a1da 100644 --- a/e2e/helpers/playgroundMonacoEditor.ts +++ b/e2e/helpers/playgroundMonacoEditor.ts @@ -89,6 +89,8 @@ export async function clickAppBarHomeLink(page: Page): Promise<void> { .getByTestId("app-bar-home-link") .locator("visible=true"); + await homeLinks.first().waitFor({ state: "visible", timeout: 30_000 }); + const linkCount = await homeLinks.count(); if (linkCount === 0) { throw new Error("No visible app bar home link found"); diff --git a/e2e/home-webgl-nav.spec.ts b/e2e/home-webgl-nav.spec.ts index de691bbc..467df5fb 100644 --- a/e2e/home-webgl-nav.spec.ts +++ b/e2e/home-webgl-nav.spec.ts @@ -17,21 +17,26 @@ test.describe("home landing WebGL canvases", () => { test("keep active WebGL contexts after client navigation away and back", async ({ page, }) => { - const contextLostMessages = collectWebGlContextLostMessages(page); - await page.goto("/"); await dismissCookieBannerIfVisible(page); await waitForActiveLandingWebGLCanvases(page); await clickFooterPrivacyPolicyLink(page); await page.waitForURL((url) => url.pathname === "/privacy"); + await page + .getByTestId("app-bar-home-link") + .locator("visible=true") + .first() + .waitFor({ state: "visible", timeout: 30_000 }); + + const contextLostAfterReturn = collectWebGlContextLostMessages(page); await clickAppBarHomeLink(page); await page.waitForURL((url) => url.pathname === "/"); const activeCanvases = await waitForActiveLandingWebGLCanvases(page); expect(activeCanvases.length).toBeGreaterThanOrEqual(2); - expect(contextLostMessages).toEqual([]); + expect(contextLostAfterReturn).toEqual([]); }); test("remounts landing canvases after forced WEBGL_lose_context", async ({ diff --git a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts index 9f204dc1..ebc16f91 100644 --- a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts +++ b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts @@ -21,19 +21,17 @@ export const useClientCanonicalPlaygroundRedirect = (): void => { const redirectingRef = useRef(false); const routeProjectSlug = route?.slug[0] ?? ""; - const fallbackProjectSlug = serverInitialData?.allBrief[0]?.slug ?? ""; - const queryProjectSlug = routeProjectSlug || fallbackProjectSlug; - const projectQuery = api.project.getBySlug.useQuery(queryProjectSlug, { - enabled: Boolean(route && queryProjectSlug), + const projectQuery = api.project.getBySlug.useQuery(routeProjectSlug, { + enabled: Boolean(route && routeProjectSlug), initialData: - serverInitialData?.projectBySlug?.slug === queryProjectSlug + serverInitialData?.projectBySlug?.slug === routeProjectSlug ? serverInitialData.projectBySlug : undefined, }); useEffect(() => { - if (!route || !projectQuery.data || redirectingRef.current) { + if (!route || !routeProjectSlug || !projectQuery.data || redirectingRef.current) { return; } diff --git a/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts index 2730f355..438e23c7 100644 --- a/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts +++ b/src/server/playground/__tests__/resolveCanonicalPlaygroundRedirect.test.ts @@ -46,7 +46,7 @@ describe("resolveCanonicalPlaygroundRedirect", () => { }); }); - it("redirects bare /playground to the first public project with defaults", async () => { + it("keeps bare /playground indexable when there is no last-visit cookie", async () => { const { resolveCanonicalPlaygroundRedirect } = await import("#/server/playground/resolveCanonicalPlaygroundRedirect"); @@ -56,8 +56,8 @@ describe("resolveCanonicalPlaygroundRedirect", () => { lastPathCookie: null, }); - expect(redirectPath).toBe("/playground/two-sum/case-1/solution-1"); - expect(mockLoadCachedPublicProjectsBrief).toHaveBeenCalled(); + expect(redirectPath).toBeNull(); + expect(mockLoadCachedPublicProjectsBrief).not.toHaveBeenCalled(); }); it("restores the last path from cookie when landing on bare /playground", async () => { diff --git a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts index f6b2a558..6ec33ae6 100644 --- a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts +++ b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts @@ -105,6 +105,13 @@ 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) { + return null; + } + const caller = await createPlaygroundCaller(); const project = await resolveProjectSlug( caller, @@ -117,7 +124,6 @@ export async function resolveCanonicalPlaygroundRedirect({ return null; } - const restoredPath = getRestorablePlaygroundPath(lastPathCookie, basePath); const restoredParsed = restoredPath ? parsePlaygroundPathname(restoredPath) : null; From ed9a36888890daa631c4fc8310a27f9f2c3f0625 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 20:54:54 +0000 Subject: [PATCH 17/19] Fix lint: add routeProjectSlug to canonical redirect effect deps Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- .../hooks/useClientCanonicalPlaygroundRedirect.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts index ebc16f91..2e5ef02f 100644 --- a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts +++ b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts @@ -31,7 +31,12 @@ export const useClientCanonicalPlaygroundRedirect = (): void => { }); useEffect(() => { - if (!route || !routeProjectSlug || !projectQuery.data || redirectingRef.current) { + if ( + !route || + !routeProjectSlug || + !projectQuery.data || + redirectingRef.current + ) { return; } @@ -50,5 +55,5 @@ export const useClientCanonicalPlaygroundRedirect = (): void => { route.navigateTo(buildPlaygroundPath(route.basePath, canonicalSlug), { replace: true, }); - }, [projectQuery.data, route]); + }, [projectQuery.data, route, routeProjectSlug]); }; From cfd3026549224c350ed4ed4a10f6286019750193 Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Sun, 6 Sep 2026 21:52:50 +0000 Subject: [PATCH 18/19] Fix app bar scroll state on privacy via persistent marketing layout - Add MarketingLayoutClient with shared MainLayout/PageScrollContainer for home and privacy - Home hero reads scroll viewport from MarketingScrollContext - Remove per-page PrivacyPageShell that remounted scroll container on instant nav - Bail setIsScrolled reducer when value unchanged; simplify scroll handler Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- .../(default-locale)/(marketing)/layout.tsx | 12 ++++ src/app/[lang]/(marketing)/layout.tsx | 12 ++++ src/app/locale-app/pages/privacyPage.tsx | 7 +-- src/features/appBar/model/appBarSlice.ts | 13 ++-- .../homePage/ui/MarketingHomeView.tsx | 14 ++--- .../context/MarketingScrollContext.tsx | 55 ++++++++++++++++ .../marketing/ui/MarketingLayoutClient.tsx | 62 +++++++++++++++++++ .../privacy/ui/PrivacyPageContent.tsx | 2 +- src/features/privacy/ui/PrivacyPageShell.tsx | 10 --- .../ui/templates/PageScrollContainer.tsx | 13 +--- 10 files changed, 159 insertions(+), 41 deletions(-) create mode 100644 src/app/(default-locale)/(marketing)/layout.tsx create mode 100644 src/app/[lang]/(marketing)/layout.tsx create mode 100644 src/features/marketing/context/MarketingScrollContext.tsx create mode 100644 src/features/marketing/ui/MarketingLayoutClient.tsx delete mode 100644 src/features/privacy/ui/PrivacyPageShell.tsx diff --git a/src/app/(default-locale)/(marketing)/layout.tsx b/src/app/(default-locale)/(marketing)/layout.tsx new file mode 100644 index 00000000..bb56e304 --- /dev/null +++ b/src/app/(default-locale)/(marketing)/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +import { MarketingLayoutClient } from "#/features/marketing/ui/MarketingLayoutClient"; + +/** Shared marketing shell (home, privacy) — instant navigations keep scroll + app bar. */ +export default function DefaultLocaleMarketingLayout({ + children, +}: { + children: ReactNode; +}) { + return <MarketingLayoutClient>{children}</MarketingLayoutClient>; +} diff --git a/src/app/[lang]/(marketing)/layout.tsx b/src/app/[lang]/(marketing)/layout.tsx new file mode 100644 index 00000000..348dd203 --- /dev/null +++ b/src/app/[lang]/(marketing)/layout.tsx @@ -0,0 +1,12 @@ +import type { ReactNode } from "react"; + +import { MarketingLayoutClient } from "#/features/marketing/ui/MarketingLayoutClient"; + +/** Shared marketing shell (home, privacy) — instant navigations keep scroll + app bar. */ +export default function LangMarketingLayout({ + children, +}: { + children: ReactNode; +}) { + return <MarketingLayoutClient>{children}</MarketingLayoutClient>; +} diff --git a/src/app/locale-app/pages/privacyPage.tsx b/src/app/locale-app/pages/privacyPage.tsx index 479b0ae3..929ca9d3 100644 --- a/src/app/locale-app/pages/privacyPage.tsx +++ b/src/app/locale-app/pages/privacyPage.tsx @@ -1,5 +1,4 @@ import { PrivacyPageContent } from "#/features/privacy/ui/PrivacyPageContent"; -import { PrivacyPageShell } from "#/features/privacy/ui/PrivacyPageShell"; import { createTranslationFunctions } from "#/i18n/createTranslationFunctions"; import type { Locales, Translation } from "#/i18n/i18n-types"; import { baseLocale } from "#/i18n/i18n-util"; @@ -54,9 +53,5 @@ export async function PrivacyPage({ params }: PrivacyPageProps = {}) { const LL = createTranslationFunctions(locale, translation); const homePath = locale === baseLocale ? "/" : `/${locale}`; - return ( - <PrivacyPageShell> - <PrivacyPageContent LL={LL} homePath={homePath} /> - </PrivacyPageShell> - ); + return <PrivacyPageContent LL={LL} homePath={homePath} />; } diff --git a/src/features/appBar/model/appBarSlice.ts b/src/features/appBar/model/appBarSlice.ts index cb34b121..61ac3db7 100644 --- a/src/features/appBar/model/appBarSlice.ts +++ b/src/features/appBar/model/appBarSlice.ts @@ -30,10 +30,15 @@ export const appBarSlice = createSlice({ name: "APP_BAR", initialState, reducers: { - setIsScrolled: (state, action: PayloadAction<boolean>) => ({ - ...state, - isScrolled: action.payload, - }), + setIsScrolled: (state, action: PayloadAction<boolean>) => { + if (state.isScrolled === action.payload) { + return state; + } + return { + ...state, + isScrolled: action.payload, + }; + }, setIsLightMode: (state, action: PayloadAction<boolean>) => ({ ...state, isLightMode: action.payload, diff --git a/src/features/homePage/ui/MarketingHomeView.tsx b/src/features/homePage/ui/MarketingHomeView.tsx index 19e20a81..3dd17622 100644 --- a/src/features/homePage/ui/MarketingHomeView.tsx +++ b/src/features/homePage/ui/MarketingHomeView.tsx @@ -3,27 +3,21 @@ /** * Marketing home UI. Public `/` and `app/[lang]` reuse this. */ -import { useState } from "react"; - import { HomeLandingFaq } from "#/features/homePage/ui/landing/HomeLandingFaq"; import { HomeLandingHero } from "#/features/homePage/ui/landing/HomeLandingHero"; import { HomeLandingSections } from "#/features/homePage/ui/landing/HomeLandingSections"; +import { useMarketingScrollViewport } from "#/features/marketing/context/MarketingScrollContext"; import { useI18nContext } from "#/shared/hooks"; -import { MainLayout } from "#/shared/ui/templates/MainLayout"; export const MarketingHomeView: React.FC = () => { const { LL } = useI18nContext(); - const [pageScrollViewport, setPageScrollViewport] = - useState<HTMLDivElement | null>(null); + const pageScrollViewport = useMarketingScrollViewport(); return ( - <MainLayout - headerPosition="fixed" - pageScrollViewportRef={setPageScrollViewport} - > + <> <HomeLandingHero LL={LL} pageScrollViewport={pageScrollViewport} /> <HomeLandingSections LL={LL} /> <HomeLandingFaq LL={LL} /> - </MainLayout> + </> ); }; diff --git a/src/features/marketing/context/MarketingScrollContext.tsx b/src/features/marketing/context/MarketingScrollContext.tsx new file mode 100644 index 00000000..c7c5beed --- /dev/null +++ b/src/features/marketing/context/MarketingScrollContext.tsx @@ -0,0 +1,55 @@ +"use client"; + +import React, { + createContext, + type ReactNode, + useContext, + useMemo, + useState, +} from "react"; + +type MarketingScrollContextValue = { + pageScrollViewport: HTMLDivElement | null; + setPageScrollViewport: (viewport: HTMLDivElement | null) => void; +}; + +const MarketingScrollContext = + createContext<MarketingScrollContextValue | null>(null); + +export const MarketingScrollProvider: React.FC<{ children: ReactNode }> = ({ + children, +}) => { + const [pageScrollViewport, setPageScrollViewport] = + useState<HTMLDivElement | null>(null); + + const value = useMemo( + () => ({ + pageScrollViewport, + setPageScrollViewport, + }), + [pageScrollViewport], + ); + + return ( + <MarketingScrollContext.Provider value={value}> + {children} + </MarketingScrollContext.Provider> + ); +}; + +export const useMarketingScrollViewport = (): HTMLDivElement | null => { + const context = useContext(MarketingScrollContext); + return context?.pageScrollViewport ?? null; +}; + +export const useMarketingScrollViewportRef = (): (( + viewport: HTMLDivElement | null, +) => void) => { + const context = useContext(MarketingScrollContext); + if (!context) { + throw new Error( + "useMarketingScrollViewportRef must be used within MarketingScrollProvider", + ); + } + return context.setPageScrollViewport; +}; diff --git a/src/features/marketing/ui/MarketingLayoutClient.tsx b/src/features/marketing/ui/MarketingLayoutClient.tsx new file mode 100644 index 00000000..366483a6 --- /dev/null +++ b/src/features/marketing/ui/MarketingLayoutClient.tsx @@ -0,0 +1,62 @@ +"use client"; + +import React, { type ReactNode, useEffect } from "react"; + +import { appBarSlice } from "#/features/appBar/model/appBarSlice"; +import { + MarketingScrollProvider, + useMarketingScrollViewport, + useMarketingScrollViewportRef, +} from "#/features/marketing/context/MarketingScrollContext"; +import { useRoutePathname } from "#/shared/hooks"; +import { MainLayout } from "#/shared/ui/templates/MainLayout"; +import { useAppDispatch } from "#/store/hooks"; + +type MarketingLayoutChromeProps = { + children: ReactNode; +}; + +/** + * Persistent marketing chrome — survives instant navigations between `/` and + * `/privacy` so {@link PageScrollContainer} keeps driving app bar scroll state. + */ +const MarketingLayoutChrome: React.FC<MarketingLayoutChromeProps> = ({ + children, +}) => { + const dispatch = useAppDispatch(); + const pathname = useRoutePathname(); + const pageScrollViewport = useMarketingScrollViewport(); + const setPageScrollViewport = useMarketingScrollViewportRef(); + + // Reset scroll position and app bar state when switching marketing routes. + useEffect(() => { + if (!pageScrollViewport) { + dispatch(appBarSlice.actions.setIsScrolled(false)); + return; + } + + pageScrollViewport.scrollTo(0, 0); + dispatch(appBarSlice.actions.setIsScrolled(false)); + }, [dispatch, pageScrollViewport, pathname]); + + return ( + <MainLayout + headerPosition="fixed" + pageScrollViewportRef={setPageScrollViewport} + > + {children} + </MainLayout> + ); +}; + +type MarketingLayoutClientProps = { + children: ReactNode; +}; + +export const MarketingLayoutClient: React.FC<MarketingLayoutClientProps> = ({ + children, +}) => ( + <MarketingScrollProvider> + <MarketingLayoutChrome>{children}</MarketingLayoutChrome> + </MarketingScrollProvider> +); diff --git a/src/features/privacy/ui/PrivacyPageContent.tsx b/src/features/privacy/ui/PrivacyPageContent.tsx index 8bf0d832..878f3a57 100644 --- a/src/features/privacy/ui/PrivacyPageContent.tsx +++ b/src/features/privacy/ui/PrivacyPageContent.tsx @@ -30,7 +30,7 @@ type PrivacyPageContentProps = { homePath: string; }; -/** Server-rendered privacy policy body (passed into {@link PrivacyPageShell}). */ +/** Server-rendered privacy policy body (marketing layout supplies client chrome). */ export const PrivacyPageContent: React.FC<PrivacyPageContentProps> = ({ LL, homePath, diff --git a/src/features/privacy/ui/PrivacyPageShell.tsx b/src/features/privacy/ui/PrivacyPageShell.tsx deleted file mode 100644 index 5d8f271b..00000000 --- a/src/features/privacy/ui/PrivacyPageShell.tsx +++ /dev/null @@ -1,10 +0,0 @@ -"use client"; - -import React, { type ReactNode } from "react"; - -import { MainLayout } from "#/shared/ui/templates/MainLayout"; - -/** Client chrome for privacy — server-rendered content is passed as `children`. */ -export const PrivacyPageShell: React.FC<{ children: ReactNode }> = ({ - children, -}) => <MainLayout>{children}</MainLayout>; diff --git a/src/shared/ui/templates/PageScrollContainer.tsx b/src/shared/ui/templates/PageScrollContainer.tsx index 869e3374..9f84285c 100644 --- a/src/shared/ui/templates/PageScrollContainer.tsx +++ b/src/shared/ui/templates/PageScrollContainer.tsx @@ -6,11 +6,8 @@ import { import type { OverlayScrollbarsComponentProps } from "overlayscrollbars-react"; import React, { type Ref } from "react"; -import { - appBarSlice, - selectIsAppBarScrolled, -} from "#/features/appBar/model/appBarSlice"; -import { useAppDispatch, useAppSelector } from "#/store/hooks"; +import { appBarSlice } from "#/features/appBar/model/appBarSlice"; +import { useAppDispatch } from "#/store/hooks"; const assignViewportRef = ( viewportRef: Ref<HTMLDivElement | null> | undefined, @@ -55,7 +52,6 @@ export const PageScrollContainer: React.FC<PageScrollContainerProps> = ({ }) => { const dispatch = useAppDispatch(); const theme = useTheme(); - const isScrolled = useAppSelector(selectIsAppBarScrolled); const overlayScrollbarsRef = ( instance: OverlayScrollbarsComponentRef<"div"> | null, @@ -91,10 +87,7 @@ export const PageScrollContainer: React.FC<PageScrollContainerProps> = ({ isPage ? { scroll: (_, ev) => { - if ( - ev.target instanceof Element && - ev.target.scrollTop > 0 !== isScrolled - ) { + if (ev.target instanceof Element) { dispatch( appBarSlice.actions.setIsScrolled( ev.target.scrollTop > 0, From 231684796020be2ba825dacb7dca4a2bf81db99b Mon Sep 17 00:00:00 2001 From: Cursor Agent <cursoragent@cursor.com> Date: Mon, 7 Sep 2026 06:47:49 +0000 Subject: [PATCH 19/19] Address code review: bare playground browse, cache invalidation, daily chrome - Desktop bare /playground: panels ready + auto-open project browser - Revalidate PUBLIC_PROJECTS_BRIEF_CACHE_TAG on public project mutations - Share appendPlaygroundMobileViewQuery for server + client canonical redirects - Reset redirectingRef on pathname change; append ?view=code on mobile client nav - Persistent AppChromeLayoutClient for daily (app bar scroll on instant nav) - Tests for mobile view query, cache revalidation, desktop bare panels ready Co-authored-by: maxim.kayander1 <maxim.kayander1@gmail.com> --- .../(default-locale)/(app)/daily/layout.tsx | 10 +- src/app/[lang]/(app)/daily/layout.tsx | 14 +- .../appChrome/ui/AppChromeLayoutClient.tsx | 17 ++ src/features/homePage/ui/DailyPageView.tsx | 149 +++++++++--------- .../usePlaygroundPanelsReady.test.tsx | 19 +++ .../hooks/useBarePlaygroundBrowseLanding.ts | 38 +++++ .../useClientCanonicalPlaygroundRedirect.ts | 29 +++- .../hooks/usePlaygroundPanelsReady.ts | 6 +- .../playgroundProjectSeoCache.test.ts | 25 +++ .../playground/lib/playgroundCacheTags.ts | 5 + .../lib/playgroundProjectSeoCache.ts | 19 ++- .../playground/ui/PlaygroundLayoutClient.tsx | 22 ++- src/server/api/routers/project.ts | 12 +- .../loadCachedPublicProjectsBrief.ts | 4 +- .../resolveCanonicalPlaygroundRedirect.ts | 15 +- .../appendPlaygroundMobileViewQuery.test.ts | 35 ++++ .../lib/appendPlaygroundMobileViewQuery.ts | 15 ++ 17 files changed, 318 insertions(+), 116 deletions(-) create mode 100644 src/features/appChrome/ui/AppChromeLayoutClient.tsx create mode 100644 src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts create mode 100644 src/features/playground/lib/__tests__/playgroundProjectSeoCache.test.ts create mode 100644 src/features/playground/lib/playgroundCacheTags.ts create mode 100644 src/shared/lib/__tests__/appendPlaygroundMobileViewQuery.test.ts create mode 100644 src/shared/lib/appendPlaygroundMobileViewQuery.ts diff --git a/src/app/(default-locale)/(app)/daily/layout.tsx b/src/app/(default-locale)/(app)/daily/layout.tsx index cf388857..ab9e2b48 100644 --- a/src/app/(default-locale)/(app)/daily/layout.tsx +++ b/src/app/(default-locale)/(app)/daily/layout.tsx @@ -1,8 +1,12 @@ -/** Daily route — Apollo client is created in {@link DailyPage} with server prefetch. */ +import type { ReactNode } from "react"; + +import { AppChromeLayoutClient } from "#/features/appChrome/ui/AppChromeLayoutClient"; + +/** Persistent chrome for daily — Apollo prefetch stays in {@link DailyPage}. */ export default function DefaultLocaleDailyLayout({ children, }: { - children: React.ReactNode; + children: ReactNode; }) { - return children; + return <AppChromeLayoutClient>{children}</AppChromeLayoutClient>; } diff --git a/src/app/[lang]/(app)/daily/layout.tsx b/src/app/[lang]/(app)/daily/layout.tsx index 305a2fd0..90eb1644 100644 --- a/src/app/[lang]/(app)/daily/layout.tsx +++ b/src/app/[lang]/(app)/daily/layout.tsx @@ -1,8 +1,8 @@ -/** Daily route — Apollo client is created in {@link DailyPage} with server prefetch. */ -export default function LangDailyLayout({ - children, -}: { - children: React.ReactNode; -}) { - return children; +import type { ReactNode } from "react"; + +import { AppChromeLayoutClient } from "#/features/appChrome/ui/AppChromeLayoutClient"; + +/** Persistent chrome for daily — Apollo prefetch stays in {@link DailyPage}. */ +export default function LangDailyLayout({ children }: { children: ReactNode }) { + return <AppChromeLayoutClient>{children}</AppChromeLayoutClient>; } diff --git a/src/features/appChrome/ui/AppChromeLayoutClient.tsx b/src/features/appChrome/ui/AppChromeLayoutClient.tsx new file mode 100644 index 00000000..e6544fef --- /dev/null +++ b/src/features/appChrome/ui/AppChromeLayoutClient.tsx @@ -0,0 +1,17 @@ +"use client"; + +import React, { type ReactNode } from "react"; + +import { MainLayout } from "#/shared/ui/templates/MainLayout"; + +type AppChromeLayoutClientProps = { + children: ReactNode; +}; + +/** + * Persistent MainLayout for app routes that need marketing-style chrome + * (daily) without remounting scroll container on instant navigations. + */ +export const AppChromeLayoutClient: React.FC<AppChromeLayoutClientProps> = ({ + children, +}) => <MainLayout>{children}</MainLayout>; diff --git a/src/features/homePage/ui/DailyPageView.tsx b/src/features/homePage/ui/DailyPageView.tsx index 8ed7c065..bcd4395f 100644 --- a/src/features/homePage/ui/DailyPageView.tsx +++ b/src/features/homePage/ui/DailyPageView.tsx @@ -16,7 +16,6 @@ import { useDailyQuestionData } from "#/api"; import { DailyProblem } from "#/features/homePage/ui/DailyProblem/DailyProblem"; import { QuestionSummary } from "#/features/homePage/ui/QuestionSummary"; import { useI18nContext } from "#/shared/hooks"; -import { MainLayout } from "#/shared/ui/templates/MainLayout"; /** Daily problem page content. Shared by Pages `/daily` and App Router pilot. */ export const DailyPageView: React.FC = () => { @@ -26,88 +25,86 @@ export const DailyPageView: React.FC = () => { const isMediumScreen = useMediaQuery(theme.breakpoints.between("sm", "lg")); return ( - <MainLayout> - <Box - sx={{ - bgcolor: "background.default", - py: { xs: 8, md: 12 }, - }} + <Box + sx={{ + bgcolor: "background.default", + py: { xs: 8, md: 12 }, + }} + > + <Container + maxWidth={isMediumScreen ? "lg" : "xl"} + sx={{ px: { xs: 2, sm: 3, md: 4 } }} > - <Container - maxWidth={isMediumScreen ? "lg" : "xl"} - sx={{ px: { xs: 2, sm: 3, md: 4 } }} - > - <Box sx={{ textAlign: "center", mb: 6 }}> - <Typography - variant="h4" + <Box sx={{ textAlign: "center", mb: 6 }}> + <Typography + variant="h4" + sx={{ + fontWeight: "bold", + color: "text.primary", + mb: 2, + }} + > + {LL.HOME_DAILY_SECTION_TITLE()} + </Typography> + <Typography + variant="body1" + sx={{ + color: "text.secondary", + mb: 4, + maxWidth: 560, + mx: "auto", + lineHeight: 1.65, + }} + > + {LL.HOME_DAILY_SECTION_LEAD()}{" "} + <MuiLink + href="https://leetcode.com/" + target="_blank" + rel="noreferrer" sx={{ - fontWeight: "bold", - color: "text.primary", - mb: 2, + color: "primary.main", + textDecoration: "none", + fontWeight: 600, + "&:hover": { + textDecoration: "underline", + }, }} > - {LL.HOME_DAILY_SECTION_TITLE()} - </Typography> - <Typography - variant="body1" + LeetCode + </MuiLink> + </Typography> + </Box> + + {questionDataQuery.error ? ( + <Alert severity="error" sx={{ mb: 4 }}> + {LL.HOME_DAILY_QUESTION_ERROR()} + </Alert> + ) : null} + + <Grid + container + spacing={4} + sx={{ + justifyContent: "center", + }} + > + <Grid size={{ xs: 12, lg: 8 }}> + <QuestionSummary + questionDataQuery={questionDataQuery} sx={{ - color: "text.secondary", mb: 4, - maxWidth: 560, - mx: "auto", - lineHeight: 1.65, + p: 3, + borderRadius: 2, + bgcolor: "background.paper", + boxShadow: "0 2px 8px rgba(0,0,0,0.1)", }} - > - {LL.HOME_DAILY_SECTION_LEAD()}{" "} - <MuiLink - href="https://leetcode.com/" - target="_blank" - rel="noreferrer" - sx={{ - color: "primary.main", - textDecoration: "none", - fontWeight: 600, - "&:hover": { - textDecoration: "underline", - }, - }} - > - LeetCode - </MuiLink> - </Typography> - </Box> - - {questionDataQuery.error ? ( - <Alert severity="error" sx={{ mb: 4 }}> - {LL.HOME_DAILY_QUESTION_ERROR()} - </Alert> - ) : null} - - <Grid - container - spacing={4} - sx={{ - justifyContent: "center", - }} - > - <Grid size={{ xs: 12, lg: 8 }}> - <QuestionSummary - questionDataQuery={questionDataQuery} - sx={{ - mb: 4, - p: 3, - borderRadius: 2, - bgcolor: "background.paper", - boxShadow: "0 2px 8px rgba(0,0,0,0.1)", - }} - /> - </Grid> - <Grid size={{ xs: 12 }}> - <DailyProblem questionDataQuery={questionDataQuery} /> - </Grid> + /> + </Grid> + <Grid size={{ xs: 12 }}> + <DailyProblem questionDataQuery={questionDataQuery} /> </Grid> - </Container> - </Box> - </MainLayout> + </Grid> + </Container> + </Box> ); }; diff --git a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx index 953fde63..35d32600 100644 --- a/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx +++ b/src/features/playground/hooks/__tests__/usePlaygroundPanelsReady.test.tsx @@ -74,4 +74,23 @@ describe("usePlaygroundPanelsReady", () => { expect(result.current).toBe(true); }); + + it("returns true on desktop bare /playground after split layout loads", async () => { + mockUsePlaygroundRoute.mockReturnValue({ + basePath: "/playground", + slug: [], + pathname: "/playground", + navigateTo: vi.fn(), + }); + + const store = makeStore(); + + const { result } = renderHook(() => usePlaygroundPanelsReady(), { + wrapper: ({ children }) => <Provider store={store}>{children}</Provider>, + }); + + await vi.waitFor(() => { + expect(result.current).toBe(true); + }); + }); }); diff --git a/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts b/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts new file mode 100644 index 00000000..b8d95d86 --- /dev/null +++ b/src/features/playground/hooks/useBarePlaygroundBrowseLanding.ts @@ -0,0 +1,38 @@ +"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<string | null>(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 2e5ef02f..7a8d3f7e 100644 --- a/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts +++ b/src/features/playground/hooks/useClientCanonicalPlaygroundRedirect.ts @@ -1,10 +1,13 @@ "use client"; +import { useSearchParams } from "next/navigation"; 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 { usePlaygroundRoute } from "#/shared/hooks/usePlaygroundRoute"; +import { appendPlaygroundMobileViewQuery } from "#/shared/lib/appendPlaygroundMobileViewQuery"; import { buildCanonicalPlaygroundSlug, playgroundSlugKey, @@ -17,10 +20,14 @@ import { buildPlaygroundPath } from "#/shared/lib/playgroundRoute"; */ export const useClientCanonicalPlaygroundRedirect = (): void => { const route = usePlaygroundRoute(); + const searchParams = useSearchParams(); + const isMobile = usePlaygroundMobileLayout(); const serverInitialData = usePlaygroundInitialData(); const redirectingRef = useRef(false); const routeProjectSlug = route?.slug[0] ?? ""; + const viewParam = searchParams?.get("view") ?? null; + const routePath = route?.pathname ?? ""; const projectQuery = api.project.getBySlug.useQuery(routeProjectSlug, { enabled: Boolean(route && routeProjectSlug), @@ -30,6 +37,10 @@ export const useClientCanonicalPlaygroundRedirect = (): void => { : undefined, }); + useEffect(() => { + redirectingRef.current = false; + }, [routePath]); + useEffect(() => { if ( !route || @@ -52,8 +63,22 @@ export const useClientCanonicalPlaygroundRedirect = (): void => { } redirectingRef.current = true; - route.navigateTo(buildPlaygroundPath(route.basePath, canonicalSlug), { + const canonicalPath = buildPlaygroundPath(route.basePath, canonicalSlug); + const targetPath = appendPlaygroundMobileViewQuery(canonicalPath, { + isMobile, + hasViewParam: Boolean(viewParam), + hasCanonicalSlug: canonicalSlug.length > 0, + }); + + route.navigateTo(targetPath, { replace: true, }); - }, [projectQuery.data, route, routeProjectSlug]); + }, [ + isMobile, + projectQuery.data, + route, + routeProjectSlug, + routePath, + viewParam, + ]); }; diff --git a/src/features/playground/hooks/usePlaygroundPanelsReady.ts b/src/features/playground/hooks/usePlaygroundPanelsReady.ts index c3ed3bbf..3044a555 100644 --- a/src/features/playground/hooks/usePlaygroundPanelsReady.ts +++ b/src/features/playground/hooks/usePlaygroundPanelsReady.ts @@ -51,10 +51,14 @@ export const usePlaygroundPanelsReady = (): boolean => { return isInitialized; } - if (!splitLayoutReady || !projectSlug) { + if (!splitLayoutReady) { return false; } + if (!projectSlug) { + return true; + } + return isInitialized; }, [isInitialized, isMobile, projectSlug, route, splitLayoutReady]); }; diff --git a/src/features/playground/lib/__tests__/playgroundProjectSeoCache.test.ts b/src/features/playground/lib/__tests__/playgroundProjectSeoCache.test.ts new file mode 100644 index 00000000..f14e425b --- /dev/null +++ b/src/features/playground/lib/__tests__/playgroundProjectSeoCache.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it, vi } from "vitest"; + +const mockRevalidateTag = vi.fn(); + +vi.mock("next/cache", () => ({ + revalidateTag: (...args: unknown[]) => mockRevalidateTag(...args), +})); + +describe("playgroundProjectSeoCache", () => { + it("revalidates public list when a public project changes", async () => { + const { revalidatePublicPlaygroundProject } = + await import("#/features/playground/lib/playgroundProjectSeoCache"); + + revalidatePublicPlaygroundProject("two-sum"); + + expect(mockRevalidateTag).toHaveBeenCalledWith( + "playground-project-seo:two-sum", + "hours", + ); + expect(mockRevalidateTag).toHaveBeenCalledWith( + "playground-public-projects-brief", + "hours", + ); + }); +}); diff --git a/src/features/playground/lib/playgroundCacheTags.ts b/src/features/playground/lib/playgroundCacheTags.ts new file mode 100644 index 00000000..cc14b44b --- /dev/null +++ b/src/features/playground/lib/playgroundCacheTags.ts @@ -0,0 +1,5 @@ +export const PUBLIC_PROJECTS_BRIEF_CACHE_TAG = + "playground-public-projects-brief"; + +export const playgroundProjectSeoCacheTag = (slug: string) => + `playground-project-seo:${slug}`; diff --git a/src/features/playground/lib/playgroundProjectSeoCache.ts b/src/features/playground/lib/playgroundProjectSeoCache.ts index 58d971a5..d4cff2e5 100644 --- a/src/features/playground/lib/playgroundProjectSeoCache.ts +++ b/src/features/playground/lib/playgroundProjectSeoCache.ts @@ -1,9 +1,24 @@ import { revalidateTag } from "next/cache"; -export const playgroundProjectSeoCacheTag = (slug: string) => - `playground-project-seo:${slug}`; +import { + playgroundProjectSeoCacheTag, + PUBLIC_PROJECTS_BRIEF_CACHE_TAG, +} from "#/features/playground/lib/playgroundCacheTags"; + +export { playgroundProjectSeoCacheTag } from "#/features/playground/lib/playgroundCacheTags"; /** Bust cached public SEO fields after project create/update/delete. */ export function revalidatePlaygroundProjectSeo(slug: string): void { revalidateTag(playgroundProjectSeoCacheTag(slug), "hours"); } + +/** Bust cached public project list used for server canonical redirects. */ +export function revalidatePublicProjectsBrief(): void { + revalidateTag(PUBLIC_PROJECTS_BRIEF_CACHE_TAG, "hours"); +} + +/** Revalidate SEO + public list when a public playground project changes. */ +export function revalidatePublicPlaygroundProject(slug: string): void { + revalidatePlaygroundProjectSeo(slug); + revalidatePublicProjectsBrief(); +} diff --git a/src/features/playground/ui/PlaygroundLayoutClient.tsx b/src/features/playground/ui/PlaygroundLayoutClient.tsx index c5c5b7db..6b1307a8 100644 --- a/src/features/playground/ui/PlaygroundLayoutClient.tsx +++ b/src/features/playground/ui/PlaygroundLayoutClient.tsx @@ -2,6 +2,7 @@ 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"; @@ -17,26 +18,31 @@ type PlaygroundLayoutClientProps = { children: ReactNode; }; -/** - * Persistent playground segment chrome — survives loading.tsx → page swaps - * so instant navigations do not remount the header or restart Pyodide. - */ -export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ - children, -}) => { +const PlaygroundRouteEffects: React.FC = () => { usePlaygroundRuntimeRelease(); usePlaygroundPyodideWarmup(); useClientCanonicalPlaygroundRedirect(); usePlaygroundSlugLoadingSync(); + useBarePlaygroundBrowseLanding(); - // Prefetch split layout chunk while route loading skeleton is visible. useEffect(() => { void prefetchSplitPanelsLayout(); }, []); + return null; +}; + +/** + * Persistent playground segment chrome — survives loading.tsx → page swaps + * so instant navigations do not remount the header or restart Pyodide. + */ +export const PlaygroundLayoutClient: React.FC<PlaygroundLayoutClientProps> = ({ + children, +}) => { return ( <ApolloHydrationProvider initialCache={null}> <ProjectBrowserProvider> + <PlaygroundRouteEffects /> <PlaygroundPageShell>{children}</PlaygroundPageShell> <ProjectBrowserOverlay /> </ProjectBrowserProvider> diff --git a/src/server/api/routers/project.ts b/src/server/api/routers/project.ts index 27d606ed..14cd6183 100644 --- a/src/server/api/routers/project.ts +++ b/src/server/api/routers/project.ts @@ -17,7 +17,7 @@ import { getDefaultCodeSnippets, getMergedCodeContent, } from "#/features/codeRunner/lib/getDefaultCodeSnippets"; -import { revalidatePlaygroundProjectSeo } from "#/features/playground/lib/playgroundProjectSeoCache"; +import { revalidatePublicPlaygroundProject } from "#/features/playground/lib/playgroundProjectSeoCache"; import { createTRPCRouter, protectedProcedure, @@ -565,7 +565,7 @@ export const projectRouter = createTRPCRouter({ }); if (created.isPublic) { - revalidatePlaygroundProjectSeo(created.slug); + revalidatePublicPlaygroundProject(created.slug); } return created; @@ -612,7 +612,7 @@ export const projectRouter = createTRPCRouter({ }); if (existing?.isPublic || updated.isPublic) { - revalidatePlaygroundProjectSeo(existing?.slug ?? updated.slug); + revalidatePublicPlaygroundProject(existing?.slug ?? updated.slug); } if ( data.slug && @@ -620,7 +620,7 @@ export const projectRouter = createTRPCRouter({ data.slug !== existing.slug && updated.isPublic ) { - revalidatePlaygroundProjectSeo(data.slug); + revalidatePublicPlaygroundProject(data.slug); } return updated; @@ -646,7 +646,7 @@ export const projectRouter = createTRPCRouter({ }); if (existing?.isPublic) { - revalidatePlaygroundProjectSeo(existing.slug); + revalidatePublicPlaygroundProject(existing.slug); } return deleted; @@ -666,7 +666,7 @@ export const projectRouter = createTRPCRouter({ }); for (const project of projects) { - revalidatePlaygroundProjectSeo(project.slug); + revalidatePublicPlaygroundProject(project.slug); } return result; diff --git a/src/server/playground/loadCachedPublicProjectsBrief.ts b/src/server/playground/loadCachedPublicProjectsBrief.ts index 50e4f551..37d5ef9c 100644 --- a/src/server/playground/loadCachedPublicProjectsBrief.ts +++ b/src/server/playground/loadCachedPublicProjectsBrief.ts @@ -4,11 +4,11 @@ import { calculateIsNew, getNewProjectMarginMs, } from "#/entities/projectEntity/lib/calculateIsNew"; +import { PUBLIC_PROJECTS_BRIEF_CACHE_TAG } from "#/features/playground/lib/playgroundCacheTags"; import { db } from "#/server/db/client"; import type { RouterOutputs } from "#/shared/api"; -export const PUBLIC_PROJECTS_BRIEF_CACHE_TAG = - "playground-public-projects-brief"; +export { PUBLIC_PROJECTS_BRIEF_CACHE_TAG }; type PublicProjectsBrief = RouterOutputs["project"]["allBrief"]; diff --git a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts index 6ec33ae6..d860ba71 100644 --- a/src/server/playground/resolveCanonicalPlaygroundRedirect.ts +++ b/src/server/playground/resolveCanonicalPlaygroundRedirect.ts @@ -4,6 +4,7 @@ import { createInnerTRPCContext } from "#/server/api/context"; import { createCaller } from "#/server/api/root"; import { authOptions } from "#/server/auth/authOptions"; import type { RouterOutputs } from "#/shared/api"; +import { appendPlaygroundMobileViewQuery } from "#/shared/lib/appendPlaygroundMobileViewQuery"; import { buildCanonicalPlaygroundSlug, playgroundSlugKey, @@ -87,15 +88,11 @@ function appendMobileViewQuery( ssrDeviceType: SsrDeviceType | undefined, viewParam: string | null | undefined, ): string { - if (ssrDeviceType !== "mobile" || canonicalSlug.length === 0) { - return path; - } - - if (viewParam) { - return path; - } - - return `${path}?view=code`; + return appendPlaygroundMobileViewQuery(path, { + isMobile: ssrDeviceType === "mobile", + hasViewParam: Boolean(viewParam), + hasCanonicalSlug: canonicalSlug.length > 0, + }); } export async function resolveCanonicalPlaygroundRedirect({ diff --git a/src/shared/lib/__tests__/appendPlaygroundMobileViewQuery.test.ts b/src/shared/lib/__tests__/appendPlaygroundMobileViewQuery.test.ts new file mode 100644 index 00000000..2bebdf6a --- /dev/null +++ b/src/shared/lib/__tests__/appendPlaygroundMobileViewQuery.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest"; + +import { appendPlaygroundMobileViewQuery } from "#/shared/lib/appendPlaygroundMobileViewQuery"; + +describe("appendPlaygroundMobileViewQuery", () => { + it("appends ?view=code on mobile when canonicalizing without an existing view", () => { + expect( + appendPlaygroundMobileViewQuery("/playground/two-sum/case-1/solution-1", { + isMobile: true, + hasViewParam: false, + hasCanonicalSlug: true, + }), + ).toBe("/playground/two-sum/case-1/solution-1?view=code"); + }); + + it("leaves the path unchanged on desktop", () => { + expect( + appendPlaygroundMobileViewQuery("/playground/two-sum/case-1/solution-1", { + isMobile: false, + hasViewParam: false, + hasCanonicalSlug: true, + }), + ).toBe("/playground/two-sum/case-1/solution-1"); + }); + + it("does not append when a view param is already set", () => { + expect( + appendPlaygroundMobileViewQuery("/playground/two-sum/case-1/solution-1", { + isMobile: true, + hasViewParam: true, + hasCanonicalSlug: true, + }), + ).toBe("/playground/two-sum/case-1/solution-1"); + }); +}); diff --git a/src/shared/lib/appendPlaygroundMobileViewQuery.ts b/src/shared/lib/appendPlaygroundMobileViewQuery.ts new file mode 100644 index 00000000..64b095a8 --- /dev/null +++ b/src/shared/lib/appendPlaygroundMobileViewQuery.ts @@ -0,0 +1,15 @@ +/** Append `?view=code` for mobile canonical playground navigations when no view is set. */ +export function appendPlaygroundMobileViewQuery( + path: string, + options: { + isMobile: boolean; + hasViewParam: boolean; + hasCanonicalSlug: boolean; + }, +): string { + if (!options.isMobile || !options.hasCanonicalSlug || options.hasViewParam) { + return path; + } + + return `${path}?view=code`; +}