Skip to content

Commit 264e8c0

Browse files
Merge pull request #8 from MobilityData/feat/1559-feed-detail-caching
Feat: feed detail page ssr caching
2 parents 97a5b4c + 6995a67 commit 264e8c0

31 files changed

+2331
-1180
lines changed

cypress/e2e/feed-isr-caching.cy.ts

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Feed ISR Caching e2e tests (unauthenticated users)
3+
*
4+
* Architecture overview:
5+
* - Unauthenticated users are routed by proxy.ts to /[locale]/feeds/[type]/[id]/static/
6+
* - That route uses `force-static` + `revalidate: 1209600` (14 days)
7+
* - On the first visit, Next.js renders the page and caches it
8+
* - On subsequent visits, Next.js serves the cached HTML without re-rendering
9+
*
10+
* How we detect cache hits/misses:
11+
* - Next.js sets the `x-nextjs-cache` response header on ISR routes:
12+
* MISS → page was freshly rendered (first visit or after revalidation)
13+
* HIT → page was served from the ISR cache
14+
* STALE → page was served from stale cache while revalidation runs in background
15+
* - We intercept the browser's GET request to the feed page and inspect this header.
16+
*
17+
*/
18+
19+
export {};
20+
21+
const TEST_FEED_ID = 'test-516';
22+
const TEST_FEED_DATA_TYPE = 'gtfs';
23+
const FEED_URL = `/feeds/${TEST_FEED_DATA_TYPE}/${TEST_FEED_ID}`;
24+
25+
/**
26+
* Calls the /api/revalidate endpoint to bust the ISR cache for the test feed.
27+
* This simulates what happens when the backend triggers a revalidation webhook
28+
* (e.g. after a feed update), which in production invalidates the cached page.
29+
*
30+
* The REVALIDATE_SECRET must match the value set in the Next.js server's env.
31+
* It is read from Cypress env (loaded from .env.development via cypress.config.ts).
32+
*/
33+
function revalidateTestFeed(): void {
34+
const secret = Cypress.env('REVALIDATE_SECRET') as string;
35+
cy.request({
36+
method: 'POST',
37+
url: '/api/revalidate',
38+
headers: {
39+
'x-revalidate-secret': secret,
40+
'content-type': 'application/json',
41+
},
42+
body: {
43+
type: 'specific-feeds',
44+
feedIds: [TEST_FEED_ID],
45+
},
46+
})
47+
.its('status')
48+
.should('eq', 200);
49+
}
50+
51+
describe('Feed ISR Caching - Unauthenticated', () => {
52+
/**
53+
* Ensure the ISR cache is busted before the suite runs so we always
54+
* start from a known MISS state, regardless of prior test runs.
55+
*/
56+
before(() => {
57+
revalidateTestFeed();
58+
});
59+
60+
describe('First visit (cache MISS)', () => {
61+
it('should render the page dynamically on the first visit', () => {
62+
// Intercept the page request and capture the x-nextjs-cache header.
63+
// The alias lets us assert on the response after cy.visit() resolves.
64+
cy.intercept('GET', FEED_URL).as('feedPageRequest');
65+
66+
cy.visit(FEED_URL, { timeout: 30000 });
67+
68+
// Wait for the page request and assert the cache header is MISS.
69+
// On the very first visit (or after revalidation), Next.js renders
70+
// the page fresh and populates the ISR cache.
71+
cy.wait('@feedPageRequest')
72+
.its('response.headers.x-nextjs-cache')
73+
// MISS means the page was freshly rendered (not served from cache).
74+
// STALE is also acceptable here if a prior cached version existed but
75+
// was invalidated — Next.js serves stale while revalidating in background.
76+
.should('be.oneOf', ['MISS', 'STALE']);
77+
78+
// Sanity check: the page content is actually rendered
79+
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
80+
'contain',
81+
'Metropolitan Transit Authority (MTA)',
82+
);
83+
});
84+
});
85+
86+
describe('Second visit (cache HIT)', () => {
87+
it('should serve the page from the ISR cache on a revisit', () => {
88+
// Intercept the page request again for the second visit.
89+
cy.intercept('GET', FEED_URL).as('feedPageCacheHit');
90+
91+
// Visit the same URL again — Next.js should now serve from ISR cache.
92+
cy.visit(FEED_URL, { timeout: 30000 });
93+
94+
cy.wait('@feedPageCacheHit')
95+
.its('response.headers.x-nextjs-cache')
96+
// HIT means the page was served from the ISR cache without re-rendering.
97+
.should('eq', 'HIT');
98+
99+
// Content should still be correct when served from cache
100+
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
101+
'contain',
102+
'Metropolitan Transit Authority (MTA)',
103+
);
104+
});
105+
});
106+
107+
describe('After revalidation (cache MISS again)', () => {
108+
it('should bust the ISR cache when the revalidate endpoint is called', () => {
109+
// First, confirm the page is currently cached (HIT) before we bust it.
110+
cy.intercept('GET', FEED_URL).as('feedPageBeforeRevalidate');
111+
cy.visit(FEED_URL, { timeout: 30000 });
112+
cy.wait('@feedPageBeforeRevalidate')
113+
.its('response.headers.x-nextjs-cache')
114+
.should('eq', 'HIT');
115+
116+
// Trigger cache invalidation via the revalidate API endpoint.
117+
// This simulates a backend webhook call after a feed update.
118+
revalidateTestFeed();
119+
120+
// Visit the page again — the cache was busted, so Next.js should
121+
// re-render the page (MISS or STALE).
122+
cy.intercept('GET', FEED_URL).as('feedPageAfterRevalidate');
123+
cy.visit(FEED_URL, { timeout: 30000 });
124+
125+
cy.wait('@feedPageAfterRevalidate')
126+
.its('response.headers.x-nextjs-cache')
127+
// After revalidation, the cache is invalidated. Next.js will either:
128+
// - MISS: render fresh immediately
129+
// - STALE: serve the old cache while re-rendering in background
130+
// Either way, the cache was busted — a HIT here would be a failure.
131+
.should('be.oneOf', ['MISS', 'STALE']);
132+
133+
// Content should still be correct after revalidation
134+
cy.get('[data-testid="feed-provider"]', { timeout: 10000 }).should(
135+
'contain',
136+
'Metropolitan Transit Authority (MTA)',
137+
);
138+
});
139+
});
140+
});

docs/feed-detail-caching-flow.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
```mermaid
2+
sequenceDiagram
3+
autonumber
4+
actor U as User
5+
participant B as Browser
6+
participant P as Next.js Proxy / Middleware
7+
participant EC as Edge CDN (Page Cache)
8+
participant S as Static Feed Pages (anon: /feeds/... and /feeds/.../map)
9+
participant D as Dynamic Feed Pages (authed)
10+
participant FC as Next Fetch Cache (Data Cache)
11+
participant API as External Feed API
12+
participant GCP as GCP Workflow
13+
participant RV as Next.js Revalidate Endpoint
14+
15+
rect rgb(235,245,255)
16+
note over U,API: Request flow (feed detail page)
17+
18+
U->>B: Navigate to /feeds/{type}/{id} (or /map)
19+
B->>P: HTTP GET /feeds/{type}/{id}[/{subpath}]
20+
21+
P->>P: Check cookie "session_md"
22+
alt Not authenticated (no/invalid session_md)
23+
P->>EC: Lookup cached page response (key: full path)
24+
alt Page Cache HIT (edge)
25+
EC-->>B: Return cached HTML/headers
26+
else Page Cache MISS
27+
EC->>S: Render static page (anon)
28+
note over S,FC: 1) Fetch data (cache to speed /map <-> base nav)\n2) Render page\n3) Cache full page at edge
29+
S->>FC: fetch(feedData, cache key = feedId + public) (revalidate: e.g., 2 week)
30+
alt Data Cache HIT
31+
FC-->>S: Return cached data
32+
else Data Cache MISS
33+
FC->>API: GET feed data (public)
34+
API-->>FC: Feed data
35+
FC-->>S: Cached data stored
36+
end
37+
S-->>EC: Store rendered page (TTL ~ 2 week)
38+
EC-->>B: Return rendered HTML
39+
end
40+
41+
else Authenticated (valid session_md)
42+
P->>D: Route to dynamic authed page
43+
note over D,FC: Cache only the API call for 10 minutes\n(per-user-per-feed)
44+
D->>FC: fetch(feedData, cache key = userId + feedId) (revalidate: 10 min)
45+
alt Per-user Data Cache HIT (<=10 min)
46+
FC-->>D: Return cached user-scoped data
47+
else Per-user Data Cache MISS
48+
FC->>API: GET feed data (authed token)
49+
API-->>FC: Feed data
50+
FC-->>D: Cached data stored (10 min)
51+
end
52+
D-->>B: Return fresh HTML (no shared edge page cache)
53+
note over D,B: Authed page response should be private (not shared)\nbut data calls are cached per-user-per-feed
54+
end
55+
end
56+
57+
rect rgb(255,245,235)
58+
note over GCP,RV: External revalidation (invalidate anon caches + data caches)
59+
60+
GCP->>GCP: Detect feed changes (diff / updated_at)
61+
GCP->>RV: POST /api/revalidate (paths or tags) + secret
62+
RV->>EC: Invalidate edge page cache (anon paths: base + /map)
63+
RV->>FC: Invalidate data cache (public feed data tag/key)
64+
FC-->>RV: OK
65+
EC-->>RV: OK
66+
RV-->>GCP: 200 success
67+
end
68+
```

messages/en.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,6 @@
298298
"cancel": "Cancel"
299299
},
300300
"fullMapView": {
301-
"disabledTitle": "Full map view disabled",
302-
"disabledDescription": "The full map view feature is disabled at the moment. Please try again later.",
303301
"dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn't specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they're combined into one for display.",
304302
"clearAll": "Clear All",
305303
"hideStops": "Hide Stops",

messages/fr.json

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,6 @@
298298
"cancel": "Cancel"
299299
},
300300
"fullMapView": {
301-
"disabledTitle": "Full map view disabled",
302-
"disabledDescription": "The full map view feature is disabled at the moment. Please try again later.",
303301
"dataBlurb": "The visualization reflects data directly from the GTFS feed. Route paths, stops, colors, and labels are all derived from the feed files (routes.txt, trips.txt, stop_times.txt, stops.txt, and shapes.txt where it's defined). If a route doesn't specify a color, it appears in black. When multiple shapes exist for different trips on the same route, they're combined into one for display.",
304302
"clearAll": "Clear All",
305303
"hideStops": "Hide Stops",

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@
6464
"test": "jest",
6565
"test:watch": "jest --watch",
6666
"test:ci": "CI=true jest",
67-
"e2e:setup": "concurrently -k -n \"next,firebase\" -c \"cyan,magenta\" \"NEXT_PUBLIC_API_MOCKING=enabled next dev -p 3001\" \"firebase emulators:start --only auth --project mobility-feeds-dev\"",
67+
"e2e:setup": "next build && concurrently -k -n \"next,firebase\" -c \"cyan,magenta\" \"NEXT_PUBLIC_API_MOCKING=enabled next start -p 3001\" \"firebase emulators:start --only auth --project mobility-feeds-dev\"",
6868
"e2e:run": "CYPRESS_BASE_URL=http://localhost:3001 cypress run",
6969
"e2e:open": "CYPRESS_BASE_URL=http://localhost:3001 cypress open",
7070
"firebase:auth:emulator:dev": "firebase emulators:start --only auth --project mobility-feeds-dev",
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { type ReactNode } from 'react';
2+
import { notFound } from 'next/navigation';
3+
import { headers } from 'next/headers';
4+
import { fetchCompleteFeedData } from '../lib/feed-data';
5+
import { AUTHED_PROXY_HEADER } from '../../../../../utils/proxy-helpers';
6+
7+
/**
8+
* Force dynamic rendering for authenticated route.
9+
* This allows cookie() and headers() access.
10+
*/
11+
export const dynamic = 'force-dynamic';
12+
13+
interface Props {
14+
children: ReactNode;
15+
params: Promise<{ feedDataType: string; feedId: string }>;
16+
}
17+
18+
/**
19+
* Shared layout for AUTHENTICATED feed pages.
20+
*
21+
* This route is reached via proxy rewrite when a session cookie exists.
22+
* It uses cookie-based auth to attach user identity to API calls.
23+
*
24+
* SECURITY: This route is protected from direct access by checking for a
25+
* custom header that only the proxy sets. Direct navigation to /authed/...
26+
* will return 404.
27+
*
28+
*/
29+
export default async function AuthedFeedLayout({
30+
children,
31+
params,
32+
}: Props): Promise<React.ReactElement> {
33+
// Block direct access - only allow requests that came through the proxy
34+
const headersList = await headers();
35+
if (headersList.get(AUTHED_PROXY_HEADER) !== '1') {
36+
notFound();
37+
}
38+
39+
const { feedId, feedDataType } = await params;
40+
41+
// Fetch complete feed data (cached per-user)
42+
// This will be reused by child pages without additional API calls
43+
const feedData = await fetchCompleteFeedData(feedDataType, feedId);
44+
45+
if (feedData == null) {
46+
notFound();
47+
}
48+
49+
return <>{children}</>;
50+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import FullMapView from '../../../../../../screens/Feed/components/FullMapView';
2+
import { type ReactElement } from 'react';
3+
import { fetchCompleteFeedData } from '../../lib/feed-data';
4+
5+
interface Props {
6+
params: Promise<{ feedDataType: string; feedId: string }>;
7+
}
8+
9+
/**
10+
* Force dynamic rendering for authenticated route.
11+
* This allows cookie() and headers() access.
12+
*/
13+
export const dynamic = 'force-dynamic';
14+
15+
/**
16+
* Full map view page for AUTHENTICATED users.
17+
*
18+
* This route is reached via proxy rewrite when a session cookie exists.
19+
* Uses cookie-based auth to:
20+
* - Attach user identity to API calls
21+
* - Provide user session to FullMapView for user-specific features
22+
*
23+
* Pre-fetches feed data server-side (cached per-request via React cache())
24+
* before rendering. FullMapView uses Redux for client-side state management.
25+
*/
26+
export default async function AuthedFullMapViewPage({
27+
params,
28+
}: Props): Promise<ReactElement> {
29+
const { feedId, feedDataType } = await params;
30+
31+
const feedData = await fetchCompleteFeedData(feedDataType, feedId);
32+
33+
if (feedData == null) {
34+
return <div>Feed not found</div>;
35+
}
36+
37+
return <FullMapView feedData={feedData} />;
38+
}
Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
import { type ReactElement } from 'react';
2+
import FeedView from '../../../../../screens/Feed/FeedView';
3+
import type { Metadata, ResolvingMetadata } from 'next';
4+
import { getTranslations } from 'next-intl/server';
5+
import { fetchCompleteFeedData } from '../lib/feed-data';
6+
import { generateFeedMetadata } from '../lib/generate-feed-metadata';
7+
8+
interface Props {
9+
params: Promise<{ locale: string; feedDataType: string; feedId: string }>;
10+
}
11+
12+
export async function generateMetadata(
13+
{ params }: Props,
14+
parent: ResolvingMetadata,
15+
): Promise<Metadata> {
16+
const { locale, feedId, feedDataType } = await params;
17+
const t = await getTranslations({ locale });
18+
19+
// Use complete feed data fetcher - same cache as page component
20+
const feedData = await fetchCompleteFeedData(feedDataType, feedId);
21+
22+
return generateFeedMetadata({
23+
feed: feedData?.feed,
24+
t,
25+
gtfsFeeds: feedData?.relatedFeeds ?? [],
26+
gtfsRtFeeds: feedData?.relatedGtfsRtFeeds ?? [],
27+
});
28+
}
29+
30+
/**
31+
* Feed detail page for AUTHENTICATED users.
32+
*
33+
* This route is reached via proxy rewrite when a session cookie exists.
34+
* Uses cookie-based auth to attach user identity to API calls, enabling
35+
* user-specific features and access control.
36+
*
37+
* Data is fetched via React cache() for per-request deduplication.
38+
*/
39+
export default async function AuthedFeedPage({
40+
params,
41+
}: Props): Promise<ReactElement> {
42+
const { feedId, feedDataType } = await params;
43+
44+
const feedData = await fetchCompleteFeedData(feedDataType, feedId);
45+
46+
if (feedData == null) {
47+
return <div>Feed not found</div>;
48+
}
49+
50+
const {
51+
feed,
52+
initialDatasets,
53+
relatedFeeds,
54+
relatedGtfsRtFeeds,
55+
totalRoutes,
56+
routeTypes,
57+
} = feedData;
58+
59+
return (
60+
<FeedView
61+
feed={feed}
62+
feedDataType={feedDataType}
63+
initialDatasets={initialDatasets}
64+
relatedFeeds={relatedFeeds}
65+
relatedGtfsRtFeeds={relatedGtfsRtFeeds}
66+
totalRoutes={totalRoutes}
67+
routeTypes={routeTypes}
68+
/>
69+
);
70+
}

0 commit comments

Comments
 (0)