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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/web/sentry.server.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,12 @@ Sentry.init({
// inbox at fatal level — the request is already abandoned by the
// client so there's nothing actionable on our side.
const ex = event.exception?.values?.[0];
// A page's required record lookup failed upstream (query-helpers.ts,
// prefetchRequired). Expected, answered as a retryable 503 and counted in
// the ssr-degraded log line with its error: not a code defect.
if (ex?.type === "SsrLookupUnavailableError") {
return null;
}
if (ex?.type === "Error" && ex?.value === "aborted") {
const frames = JSON.stringify(ex?.stacktrace?.frames ?? "");
if (frames.includes("_http_server") || frames.includes("abortIncoming")) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { getCommunityCache } from "@/core/caches";
import { notFound, redirect } from "next/navigation";
import { redirect } from "next/navigation";
import { prefetchGetPostsFeedQuery } from "@/api/queries";
import { EntryListContent } from "@/features/shared/entry-list-content";
import { EntryArchivePager } from "@/features/shared/entry-archive-pager";
import { LinearProgress } from "@/features/shared/linear-progress";
import { dehydrate, HydrationBoundary, InfiniteData } from "@tanstack/react-query";
import { getQueryClient, prefetchQuery } from "@/core/react-query";
import { getQueryClient, prefetchRequired } from "@/core/react-query";
import { cookies } from "next/headers";
import { ACTIVE_USER_COOKIE_NAME } from "@/consts";
import { stripAnonEntryCacheInPlace } from "@/core/react-query/strip-active-votes";
Expand Down Expand Up @@ -61,10 +61,9 @@ export default async function CommunityPostsPage({ params, searchParams }: Props
// fully server-rendered (no infinite scroll) with a crawlable pager.
if (cursor) {
const [communityData, archive] = await Promise.all([
prefetchQuery(getCommunityCache(community)),
prefetchRequired(getCommunityCache(community)),
fetchRankedCursorPage(tag, community, cursor)
]);
if (!communityData) return notFound();
if (archive.entries.length === 0) return redirect(basePath); // stale cursor
const archiveEntries = stripAnonEntryCacheInPlace(
getQueryClient(),
Expand Down Expand Up @@ -98,14 +97,14 @@ export default async function CommunityPostsPage({ params, searchParams }: Props
);
}

// A missing community is notFound(); a lookup that failed throws a retryable
// lookup-unavailable error instead (prefetchRequired).
const [communityData, data, base] = await Promise.all([
prefetchQuery(getCommunityCache(community)),
prefetchRequired(getCommunityCache(community)),
prefetchGetPostsFeedQuery(tag, community),
getServerAppBase()
]);

if (!communityData) return notFound();

if (!data || !Array.isArray(data.pages) || data.pages.length === 0) {
return null;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import { CommunityActivities } from "../_components";
import { getCommunityCache } from "@/core/caches";
import { notFound } from "next/navigation";
import { getQueryClient, prefetchQuery } from "@/core/react-query";
import { getQueryClient, prefetchRequired } from "@/core/react-query";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { Metadata, ResolvingMetadata } from "next";
import { generateCommunityMetadata } from "@/app/(dynamicPages)/community/[community]/_helpers";
Expand All @@ -17,10 +16,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata):

export default async function ActivitiesPage({ params }: Props) {
const { community } = await params;
const communityData = await prefetchQuery(getCommunityCache(community));
if (!communityData) {
return notFound();
}
const communityData = await prefetchRequired(getCommunityCache(community));

return (
<HydrationBoundary state={dehydrate(getQueryClient())}>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"use client";

// Catches a failed community lookup on client-side navigation (#2000). See
// LookupUnavailableError for why a full page load never reaches it.
export { LookupUnavailableError as default } from "@/features/shared/lookup-unavailable";
10 changes: 4 additions & 6 deletions apps/web/src/app/(dynamicPages)/community/[community]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { getCommunityCache } from "@/core/caches";
import { notFound } from "next/navigation";
import { prefetchGetPostsFeedQuery } from "@/api/queries";
import { EntryListContent } from "@/features/shared/entry-list-content";
import { LinearProgress } from "@/features/shared/linear-progress";
Expand All @@ -8,7 +7,7 @@ import { ProfileEntriesLayout } from "@/app/(dynamicPages)/profile/[username]/_c
import { Entry } from "@/entities";
import { CommunityContentInfiniteList } from "@/app/(dynamicPages)/community/[community]/_components/community-content-infinite-list";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient, prefetchQuery } from "@/core/react-query";
import { getQueryClient, prefetchRequired } from "@/core/react-query";
import { cookies } from "next/headers";
import { ACTIVE_USER_COOKIE_NAME } from "@/consts";
import { stripAnonEntryCacheInPlace } from "@/core/react-query/strip-active-votes";
Expand Down Expand Up @@ -45,10 +44,9 @@ export default async function CommunityPostsPage({ params }: Props) {
// rewritten in place so the dehydrated copy and the entries prop share one
// reference.
const loggedInUser = (await cookies()).get(ACTIVE_USER_COOKIE_NAME)?.value;
const communityData = await prefetchQuery(getCommunityCache(community));
if (!communityData) {
return notFound();
}
// A missing community is notFound(); a lookup that failed throws a retryable
// lookup-unavailable error instead (prefetchRequired).
const communityData = await prefetchRequired(getCommunityCache(community));

const fetched = await prefetchGetPostsFeedQuery("created", community);
if (!fetched || !fetched.pages || fetched.pages.length === 0) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { CommunityRoles } from "../_components";
import { getCommunityCache } from "@/core/caches";
import { notFound } from "next/navigation";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient, prefetchQuery } from "@/core/react-query";
import { getQueryClient, prefetchRequired } from "@/core/react-query";
import { Metadata, ResolvingMetadata } from "next";
import { generateCommunityMetadata } from "@/app/(dynamicPages)/community/[community]/_helpers";

Expand All @@ -17,10 +16,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata):

export default async function RolesPage({ params }: Props) {
const { community } = await params;
const communityData = await prefetchQuery(getCommunityCache(community));
if (!communityData) {
return notFound();
}
const communityData = await prefetchRequired(getCommunityCache(community));

return (
<HydrationBoundary state={dehydrate(getQueryClient())}>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { CommunitySubscribers } from "../_components";
import { getCommunityCache } from "@/core/caches";
import { notFound } from "next/navigation";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient, prefetchQuery } from "@/core/react-query";
import { getQueryClient, prefetchRequired } from "@/core/react-query";
import { Metadata, ResolvingMetadata } from "next";
import { generateCommunityMetadata } from "@/app/(dynamicPages)/community/[community]/_helpers";

Expand All @@ -17,10 +16,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata):

export default async function SubscribersPage({ params }: Props) {
const { community } = await params;
const communityData = await prefetchQuery(getCommunityCache(community));
if (!communityData) {
return notFound();
}
const communityData = await prefetchRequired(getCommunityCache(community));

return (
<HydrationBoundary state={dehydrate(getQueryClient())}>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ import { prefetchGetPostsFeedQuery } from "@/api/queries";
import { EcencyEntriesCacheManagement } from "@/core/caches";
import { notFound, redirect } from "next/navigation";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { getQueryClient, prefetchQuery, fetchInfiniteQuery } from "@/core/react-query";
import {
getQueryClient,
prefetchQuery,
prefetchRequired,
fetchInfiniteQuery
} from "@/core/react-query";
import { stripActiveVotesFromDehydratedState } from "@/core/react-query/strip-active-votes";
import { cookies } from "next/headers";
import { ACTIVE_USER_COOKIE_NAME } from "@/consts";
Expand All @@ -35,6 +40,9 @@ import type { SearchResponse } from "@ecency/sdk";
// none of the three. profile-section-page-no-ssr-skeleton.spec.ts pins the
// absence here and on every ancestor segment.

/** The feed tabs this segment serves; any other section is a 404. */
const PROFILE_FEED_SECTIONS = ["", "posts", "comments", "replies", "blog"];

interface Props {
params: Promise<{ username: string; section: string }>;
searchParams: Promise<Record<string, string | undefined>>;
Expand All @@ -58,19 +66,22 @@ export default async function Page({ params, searchParams }: Props) {

const username = usernameParam.replace(/%40/g, "");

// An unknown section is a 404 whatever the account lookup does, so it is
// answered before any lookup (and costs no upstream call).
if (!PROFILE_FEED_SECTIONS.includes(section)) {
return notFound();
}

// Cursor archive page (/@author/<section>?before=<author>/<permlink>): O(1),
// one fetch of the 20 posts older than the cursor. Page 1 is the clean,
// query-less default view; archiveCursor() returns null there.
const cursor = archiveCursor(section, { query: searchParam, before: beforeParam });
if (cursor) {
const basePath = `/@${username}/${section}`;
const [account, archive] = await Promise.all([
prefetchQuery(getAccountFullQueryOptions(username)),
prefetchRequired(getAccountFullQueryOptions(username)),
fetchAuthorCursorPage(username, section, cursor)
]);
if (!account) {
return notFound();
}
// Stale/invalid cursor (nothing older than it): send to the clean first page.
if (archive.entries.length === 0) {
return redirect(basePath);
Expand All @@ -90,8 +101,10 @@ export default async function Page({ params, searchParams }: Props) {
);
}

// A missing account is notFound(); a lookup that failed throws a retryable
// lookup-unavailable error instead (prefetchRequired).
const [account, searchPages, prefetchedFeed] = await Promise.all([
prefetchQuery(getAccountFullQueryOptions(username)),
prefetchRequired(getAccountFullQueryOptions(username)),
searchParam
? fetchInfiniteQuery(
getSearchApiInfiniteQueryOptions(
Expand All @@ -112,7 +125,7 @@ export default async function Page({ params, searchParams }: Props) {
// is load-bearing: getPostQueryOptions trims the permlink while BUILDING the
// options object, so a non-string `pinned` throws before the first flush and
// would take the whole document.
const pinned = pinnedPermlink(account?.profile);
const pinned = pinnedPermlink(account.profile);
if (pinned) {
await prefetchQuery(EcencyEntriesCacheManagement.getEntryQueryByPath(username, pinned));
}
Expand All @@ -128,10 +141,6 @@ export default async function Page({ params, searchParams }: Props) {
: undefined;
const initialFeed = prefetchedFeed as InfiniteData<Entry[], unknown> | undefined;

if (!account || !["", "posts", "comments", "replies", "blog"].includes(section)) {
return notFound();
}

return (
<HydrationBoundary state={stripActiveVotesFromDehydratedState(dehydrate(getQueryClient()), loggedInUser)}>
{searchData && searchData.length > 0 ? (
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { notFound } from "next/navigation";
import { ProfileCommunities } from "./_page";
import { prefetchQuery } from "@/core/react-query";
import { prefetchRequired } from "@/core/react-query";
import { Metadata, ResolvingMetadata } from "next";
import { generateProfileMetadata } from "@/app/(dynamicPages)/profile/[username]/_helpers";
import { getAccountFullQueryOptions } from "@ecency/sdk";
Expand All @@ -17,12 +16,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata):
export default async function ProfileCommunitiesPage({ params }: Props) {
const { username } = await params;

const query = getAccountFullQueryOptions(username.replace(/%40/g, ""));
const account = await prefetchQuery(query);

if (!account) {
return notFound();
}
await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, "")));

return <ProfileCommunities />;
}
5 changes: 5 additions & 0 deletions apps/web/src/app/(dynamicPages)/profile/[username]/error.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
"use client";

// Catches a failed account lookup on client-side navigation (#2000). See
// LookupUnavailableError for why a full page load never reaches it.
export { LookupUnavailableError as default } from "@/features/shared/lookup-unavailable";
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { prefetchQuery } from "@/core/react-query";
import { prefetchRequired } from "@/core/react-query";
import { getAccountFullQueryOptions } from "@ecency/sdk";
import { Metadata, ResolvingMetadata } from "next";
import { generateProfileMetadata } from "@/app/(dynamicPages)/profile/[username]/_helpers";
Expand All @@ -16,11 +15,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata):

export default async function FollowersPage({ params }: Props) {
const { username } = await params;
const account = await prefetchQuery(getAccountFullQueryOptions(username.replace(/%40/g, "")));

if (!account) {
return notFound();
}
const account = await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, "")));

return <ProfileFriends account={account} mode="followers" />;
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import { notFound } from "next/navigation";
import { prefetchQuery } from "@/core/react-query";
import { prefetchRequired } from "@/core/react-query";
import { getAccountFullQueryOptions } from "@ecency/sdk";
import { Metadata, ResolvingMetadata } from "next";
import { generateProfileMetadata } from "@/app/(dynamicPages)/profile/[username]/_helpers";
Expand All @@ -16,11 +15,7 @@ export async function generateMetadata(props: Props, parent: ResolvingMetadata):

export default async function FollowingPage({ params }: Props) {
const { username } = await params;
const account = await prefetchQuery(getAccountFullQueryOptions(username.replace(/%40/g, "")));

if (!account) {
return notFound();
}
const account = await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, "")));

return <ProfileFriends account={account} mode="following" />;
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { generateProfileMetadata } from "../_helpers";
import { Metadata, ResolvingMetadata } from "next";
import { notFound } from "next/navigation";
import { cookies } from "next/headers";
import { prefetchQuery } from "@/core/react-query";
import { prefetchQuery, prefetchRequired } from "@/core/react-query";
import { getAccountFullQueryOptions, getProMembersQueryOptions } from "@ecency/sdk";
import { isProMember } from "@/features/pro/pro-config";
import { ProfileInsights } from "./_page";
Expand All @@ -21,11 +20,7 @@ export default async function InsightsPage({ params }: Props) {
const { username } = await params;
const { get } = await cookies();

const account = await prefetchQuery(getAccountFullQueryOptions(username.replace(/%40/g, "")));

if (!account) {
return notFound();
}
const account = await prefetchRequired(getAccountFullQueryOptions(username.replace(/%40/g, "")));

// Own insights stay free; viewing anyone else's is an Ecency Pro perk. Non-Pro viewers get a
// Go-Pro upsell instead of the data (previously this was a hard owner-only redirect).
Expand Down
18 changes: 10 additions & 8 deletions apps/web/src/app/(dynamicPages)/profile/[username]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { ProfileEntriesList, ProfileSearchContent } from "./_components";
import { prefetchGetPostsFeedQuery } from "@/api/queries";
import { prefetchQuery, getQueryClient, fetchInfiniteQuery } from "@/core/react-query";
import {
prefetchQuery,
prefetchRequired,
getQueryClient,
fetchInfiniteQuery
} from "@/core/react-query";
import { stripActiveVotesFromDehydratedState } from "@/core/react-query/strip-active-votes";
import { cookies } from "next/headers";
import { ACTIVE_USER_COOKIE_NAME } from "@/consts";
import { getAccountFullQueryOptions, getSearchApiInfiniteQueryOptions } from "@ecency/sdk";
import { EcencyEntriesCacheManagement } from "@/core/caches";
import { notFound } from "next/navigation";
import { dehydrate, HydrationBoundary } from "@tanstack/react-query";
import { Metadata, ResolvingMetadata } from "next";
import {
Expand Down Expand Up @@ -39,8 +43,10 @@ export default async function Page({ params, searchParams }: Props) {
const { query: searchParam } = await searchParams;
const loggedInUser = (await cookies()).get(ACTIVE_USER_COOKIE_NAME)?.value;

// A missing account is notFound(); a lookup that failed throws a retryable
// lookup-unavailable error instead (prefetchRequired).
const [account, searchPages, prefetchedFeed] = await Promise.all([
prefetchQuery(getAccountFullQueryOptions(username)),
prefetchRequired(getAccountFullQueryOptions(username)),
searchParam
? fetchInfiniteQuery(
getSearchApiInfiniteQueryOptions(
Expand All @@ -60,7 +66,7 @@ export default async function Page({ params, searchParams }: Props) {
// options object — before the `enabled` gate and outside the queryFn — so a
// non-string pinned value throws here, ahead of the first flush, and now that
// this route has no loading.tsx above it that throw is the whole document.
const pinned = pinnedPermlink(account?.profile);
const pinned = pinnedPermlink(account.profile);
if (pinned) {
await prefetchQuery(EcencyEntriesCacheManagement.getEntryQueryByPath(username, pinned));
}
Expand All @@ -75,10 +81,6 @@ export default async function Page({ params, searchParams }: Props) {
: undefined;
const initialFeed = prefetchedFeed as InfiniteData<Entry[], unknown> | undefined;

if (!account) {
return notFound();
}

return (
<HydrationBoundary state={stripActiveVotesFromDehydratedState(dehydrate(getQueryClient()), loggedInUser)}>
{searchData && searchData.length > 0 ? (
Expand Down
Loading
Loading