Skip to content

Commit

Permalink
Add Pages Router examples (#12)
Browse files Browse the repository at this point in the history
* upgrade next

* ensure pages router support in turbopack

* add pages router examples

* fix cards

* re-add node prefix

* remove node prefix

* fix tests

* fix pages router pages
  • Loading branch information
dferber90 authored Jan 10, 2025
1 parent e40be88 commit 1fcd4fc
Show file tree
Hide file tree
Showing 16 changed files with 598 additions and 307 deletions.
10 changes: 10 additions & 0 deletions examples/snippets/app/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,16 @@ export default function Page() {
description="Manually using feature flags in Edge Middleware"
href="/examples/feature-flags-in-edge-middleware"
/>
<ConceptCard
title="Pages Router (Basic)"
description="Using feature flags in Pages Router on dynamic pages"
href="/examples/pages-router-dynamic"
/>
<ConceptCard
title="Pages Router (Precomputed)"
description="Using feature flags in Pages Router on static pages"
href="/examples/pages-router-precomputed"
/>
</div>
</Content>
);
Expand Down
31 changes: 31 additions & 0 deletions examples/snippets/components/pages-layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import localFont from 'next/font/local';
import '../app/globals.css';
import { VercelToolbar } from '@vercel/toolbar/next';

const geistSans = localFont({
src: '../app/fonts/GeistVF.woff',
variable: '--font-geist-sans',
weight: '100 900',
});

const geistMono = localFont({
src: '../app/fonts/GeistMonoVF.woff',
variable: '--font-geist-mono',
weight: '100 900',
});

export default function PagesLayout({
children,
}: {
children: React.ReactNode;
}) {
const shouldInjectToolbar = process.env.NODE_ENV === 'development';
return (
<div
className={`${geistSans.variable} ${geistMono.variable} antialiased prose px-4 m-0`}
>
{children}
{shouldInjectToolbar && <VercelToolbar />}
</div>
);
}
1 change: 1 addition & 0 deletions examples/snippets/lib/pages-router-precomputed/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This folder contains supporting files for the Pages Router example, as those files can not live within the `pages/` folder as they would otherwise count as actualy Next.js pages.
11 changes: 11 additions & 0 deletions examples/snippets/lib/pages-router-precomputed/flags.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { flag } from '@vercel/flags/next';

export const exampleFlag = flag({
key: 'pages-router-precomputed-example-flag',
decide() {
return true;
},
options: [false, true],
});

export const exampleFlags = [exampleFlag];
12 changes: 12 additions & 0 deletions examples/snippets/lib/pages-router-precomputed/middleware.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { precompute } from '@vercel/flags/next';
import { type NextRequest, NextResponse } from 'next/server';
import { exampleFlags } from './flags';

export async function pagesRouterMiddleware(request: NextRequest) {
// precompute the flags
const code = await precompute(exampleFlags);

return NextResponse.rewrite(
new URL(`/examples/pages-router-precomputed/${code}`, request.url),
);
}
6 changes: 6 additions & 0 deletions examples/snippets/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { overviewMiddleware } from './app/getting-started/overview/[code]/middle
import { featureFlagsInEdgeMiddleware } from './app/examples/feature-flags-in-edge-middleware/middleware';
import { manualPrecomputeMiddleware } from './app/concepts/precompute/manual/middleware';
import { automaticPrecomputeMiddleware } from './app/concepts/precompute/automatic/[code]/middleware';
import { pagesRouterMiddleware } from './lib/pages-router-precomputed/middleware';

export function middleware(request: NextRequest) {
if (request.nextUrl.pathname === '/getting-started/overview') {
Expand All @@ -29,6 +30,10 @@ export function middleware(request: NextRequest) {
return featureFlagsInEdgeMiddleware(request);
}

if (request.nextUrl.pathname === '/examples/pages-router-precomputed') {
return pagesRouterMiddleware(request);
}

return NextResponse.next();
}

Expand All @@ -39,5 +44,6 @@ export const config = {
'/concepts/precompute/automatic',
'/examples/marketing-pages',
'/examples/feature-flags-in-edge-middleware',
'/examples/pages-router-precomputed',
],
};
6 changes: 3 additions & 3 deletions examples/snippets/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,9 @@
"class-variance-authority": "0.7.1",
"clsx": "2.0.0",
"nanoid": "5.0.7",
"next": "15.0.3",
"react": "19.0.0-rc-66855b96-20241106",
"react-dom": "19.0.0-rc-66855b96-20241106",
"next": "15.1.4",
"react": "19.0.0-rc-02c0e824-20241028",
"react-dom": "19.0.0-rc-02c0e824-20241028",
"tailwind-merge": "2.5.5",
"tailwindcss-animate": "1.0.7"
},
Expand Down
19 changes: 19 additions & 0 deletions examples/snippets/pages/examples/pages-router-dynamic/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import PagesLayout from '@/components/pages-layout';
import type { GetServerSideProps, InferGetServerSidePropsType } from 'next';
import { exampleFlag } from '@/flags';
import { DemoFlag } from '@/components/demo-flag';

export const getServerSideProps = (async ({ req }) => {
const example = await exampleFlag(req);
return { props: { example } };
}) satisfies GetServerSideProps<{ example: boolean }>;

export default function PageRouter({
example,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<PagesLayout>
<DemoFlag name="example-flag" value={example} />
</PagesLayout>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import PagesLayout from '@/components/pages-layout';
import type {
GetStaticPaths,
GetStaticProps,
InferGetStaticPropsType,
} from 'next';
import {
exampleFlag,
exampleFlags,
} from '@/lib/pages-router-precomputed/flags';
import { DemoFlag } from '@/components/demo-flag';
import { generatePermutations } from '@vercel/flags/next';

export const getStaticPaths = (async () => {
const codes = await generatePermutations(exampleFlags);

return {
paths: codes.map((code) => ({ params: { code } })),
fallback: 'blocking',
};
}) satisfies GetStaticPaths;

export const getStaticProps = (async (context) => {
if (typeof context.params?.code !== 'string') return { notFound: true };

const example = await exampleFlag(context.params.code, exampleFlags);
return { props: { example } };
}) satisfies GetStaticProps<{ example: boolean }>;

export default function PageRouter({
example,
}: InferGetStaticPropsType<typeof getStaticProps>) {
return (
<PagesLayout>
<DemoFlag name="example-flag" value={example} />
</PagesLayout>
);
}
2 changes: 1 addition & 1 deletion packages/flags/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"@vitejs/plugin-react": "4.2.1",
"eslint-config-custom": "workspace:*",
"msw": "2.6.4",
"next": "15.0.3",
"next": "15.1.4",
"react": "canary",
"rimraf": "6.0.1",
"tsconfig": "workspace:*",
Expand Down
62 changes: 48 additions & 14 deletions packages/flags/src/next/dedupe.test.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,37 @@
import { describe, expect, it, Mock, vitest } from 'vitest';
import { describe, expect, it, Mock, vitest, vi } from 'vitest';
import { dedupe } from './dedupe';
import { headers } from 'next/headers';

vitest.mock('next/headers', () => ({
headers: vitest.fn(),
}));
const mocks = vi.hoisted(() => {
return {
headers: vi.fn(async () => new Headers()),
cookies: vi.fn(async () => ({
get: vi.fn(),
})),
};
});

vi.mock('next/headers', async (importOriginal) => {
const mod = await importOriginal<typeof import('next/headers')>();
return {
...mod,
// replace some exports
headers: mocks.headers,
cookies: mocks.cookies,
};
});

const headersMock = headers as Mock;
async function getHeadersMock() {
const headersMock = await import('next/headers').then((mod) => mod.headers);
return headersMock as Mock;
}

describe('dedupe', () => {
describe('no arguments', () => {
it('should dedupe within same request', async () => {
const fn = vitest.fn();
const deduped = dedupe(fn);
const same = new Headers();
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

await deduped();
Expand All @@ -27,7 +45,7 @@ describe('dedupe', () => {
const deduped = dedupe(fn);
const same = new Headers();
const different = new Headers();

const headersMock = await getHeadersMock();
headersMock.mockReturnValueOnce(same);
await deduped();

Expand All @@ -43,6 +61,7 @@ describe('dedupe', () => {
const fn = vitest.fn();
const deduped = dedupe(fn);
const same = new Headers();
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

await deduped(1, 'a');
Expand All @@ -56,7 +75,7 @@ describe('dedupe', () => {
const deduped = dedupe(fn);
const same = new Headers();
const different = new Headers();

const headersMock = await getHeadersMock();
headersMock.mockReturnValueOnce(same);
await deduped(1, 'a');

Expand All @@ -72,6 +91,7 @@ describe('dedupe', () => {
const fn = vitest.fn();
const deduped = dedupe(fn);
const same = new Headers();
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

await deduped(1);
Expand All @@ -84,6 +104,7 @@ describe('dedupe', () => {
const fn = vitest.fn();
const deduped = dedupe(fn);
const same = new Headers();
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

await deduped(1);
Expand All @@ -98,6 +119,7 @@ describe('dedupe', () => {
const fn = vitest.fn();
const deduped = dedupe(fn);
const same = new Headers();
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

const obj1 = { a: 1 };
Expand All @@ -111,6 +133,7 @@ describe('dedupe', () => {
const fn = vitest.fn();
const deduped = dedupe(fn);
const same = new Headers();
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

await deduped({ a: 1 });
Expand All @@ -124,6 +147,7 @@ describe('dedupe', () => {
const fn = vitest.fn();
const deduped = dedupe(fn);
const same = new Headers();
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

const obj1 = { a: 1 };
Expand All @@ -143,6 +167,7 @@ describe('dedupe', () => {
const deduped = dedupe(fn);
const same = new Headers();
const someFn = () => 1;
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

await deduped(someFn);
Expand All @@ -156,6 +181,7 @@ describe('dedupe', () => {
const same = new Headers();
const someFn = () => 1;
const someOtherFn = () => 2;
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

await deduped(someFn);
Expand All @@ -177,6 +203,7 @@ describe('dedupe', () => {
const fn = vitest.fn(() => promise);
const deduped = dedupe(fn);
const same = new Headers();
const headersMock = await getHeadersMock();
headersMock.mockReturnValue(same);

const result1Promise = deduped();
Expand All @@ -194,6 +221,8 @@ describe('dedupe', () => {
throw new Error('resolvePromise not implemented');
};

const headersMock = await getHeadersMock();

const promise = new Promise((resolve, reject) => {
rejectPromise = reject;
});
Expand All @@ -203,13 +232,18 @@ describe('dedupe', () => {
const same = new Headers();
headersMock.mockReturnValue(same);

const result1Promise = deduped();
const result2Promise = deduped();
rejectPromise(1);
await expect(deduped()).rejects.toBe(1);
const result1Promise = expect(deduped()).rejects.toBe('artificial error');
const result2Promise = expect(deduped()).rejects.toBe('artificial error');

rejectPromise('artificial error');

// prevent unhandled promise rejection
await promise.catch(() => {});

await expect(deduped()).rejects.toBe('artificial error');

await Promise.allSettled([result1Promise, result2Promise]);

await expect(result1Promise).rejects.toBe(1);
await expect(result2Promise).rejects.toBe(1);
expect(fn).toHaveBeenCalledTimes(1);
});
});
Expand Down
6 changes: 4 additions & 2 deletions packages/flags/src/next/dedupe.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { headers } from 'next/headers';

enum Status {
UNTERMINATED = 0,
TERMINATED = 1,
Expand Down Expand Up @@ -45,6 +43,10 @@ export function dedupe<A extends Array<unknown>, T>(
const requestStore = new WeakMap<Headers, CacheNode<T>>();

return async function (this: unknown, ...args: A): Promise<T> {
// async import required as turbopack errors in Pages Router
// when next/headers is imported at the top-level
const { headers } = await import('next/headers');

const h = await headers();
let cacheNode = requestStore.get(h);
if (!cacheNode) {
Expand Down
2 changes: 2 additions & 0 deletions packages/flags/src/next/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ describe('flag on app router', () => {

// @ts-expect-error this is defined
rejectPromise(new Error('custom error'));
await promise.catch(() => {});

await expect(value1).resolves.toEqual(false);
expect(catchFn).not.toHaveBeenCalled();
Expand Down Expand Up @@ -315,6 +316,7 @@ describe('flag on pages router', () => {

// @ts-expect-error this is defined
rejectPromise(new Error('custom error'));
await promise.catch(() => {});

await expect(value1).resolves.toEqual(false);
expect(catchFn).not.toHaveBeenCalled();
Expand Down
Loading

0 comments on commit 1fcd4fc

Please sign in to comment.