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
11 changes: 10 additions & 1 deletion app/services/sitePath.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
const rawBasePath = process.env.NEXT_BASE_PATH?.trim();
// next.config republishes the normalized NEXT_BASE_PATH as
// NEXT_PUBLIC_BASE_PATH so this value survives into the browser bundle. Reading
// the private variable alone would make withBasePath silently wrong in a client
// component: prefixed during the static render, unprefixed after hydration, and
// identical in the exported HTML either way. The private variable stays as a
// fallback for server-only callers and standalone scripts, which never see the
// republished one.
const configuredBasePath =
process.env.NEXT_PUBLIC_BASE_PATH || process.env.NEXT_BASE_PATH;
const rawBasePath = configuredBasePath?.trim();
const normalizedBasePath = rawBasePath
? `/${rawBasePath.replace(/^\/+|\/+$/g, "")}`
: "";
Expand Down
10 changes: 10 additions & 0 deletions next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,16 @@ const nextConfig: NextConfig = {
assetPrefix: normalizedBasePath,
}
: {}),
// NEXT_BASE_PATH is a private build variable, so Next strips it from the
// browser bundle. Anything that prefixes a path by hand - a plain anchor to
// a route Next does not own, an image src - also runs in client components,
// where reading it directly yields one value during the static render and
// another after hydration. Republishing the normalized value under a
// NEXT_PUBLIC_ name inlines it into both bundles, while deployments keep
// setting the single variable they already set.
env: {
NEXT_PUBLIC_BASE_PATH: normalizedBasePath ?? "",
},
};

export default nextConfig;
79 changes: 79 additions & 0 deletions tests/sitePath.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

// sitePath reads the environment once at module scope, so each case re-imports
// it after stubbing.
async function importWithEnv(env: Record<string, string | undefined>) {
vi.resetModules();
for (const [key, value] of Object.entries(env)) {
if (value !== undefined) {
vi.stubEnv(key, value);
}
}
return (await import('../app/services/sitePath')).withBasePath;
}

afterEach(() => {
vi.unstubAllEnvs();
vi.resetModules();
});

describe('withBasePath', () => {
it('returns the path unchanged when no base path is configured', async () => {
const withBasePath = await importWithEnv({});

expect(withBasePath('/blogs/')).toBe('/blogs/');
});

// The bug this guards: NEXT_BASE_PATH is private, so Next strips it from the
// browser bundle. A client component reading it would prefix during the
// static render and not after hydration. next.config republishes the value
// under a NEXT_PUBLIC_ name, which is what must be read.
it('reads the republished public variable, which survives into the browser bundle', async () => {
const withBasePath = await importWithEnv({
NEXT_PUBLIC_BASE_PATH: '/preview',
});

expect(withBasePath('/blogs/')).toBe('/preview/blogs/');
});

it('falls back to the private variable for server-only callers and scripts', async () => {
const withBasePath = await importWithEnv({ NEXT_BASE_PATH: '/preview' });

expect(withBasePath('/blogs/')).toBe('/preview/blogs/');
});

it('agrees whichever variable carries the value', async () => {
const fromPublic = await importWithEnv({ NEXT_PUBLIC_BASE_PATH: '/preview' });
const publicResult = fromPublic('/images/logo.png');

vi.unstubAllEnvs();
const fromPrivate = await importWithEnv({ NEXT_BASE_PATH: '/preview' });

expect(publicResult).toBe(fromPrivate('/images/logo.png'));
});

it('normalizes surrounding slashes', async () => {
const withBasePath = await importWithEnv({
NEXT_PUBLIC_BASE_PATH: 'preview/',
});

expect(withBasePath('/blogs/')).toBe('/preview/blogs/');
});

it('treats an empty republished value as no base path', async () => {
const withBasePath = await importWithEnv({ NEXT_PUBLIC_BASE_PATH: '' });

expect(withBasePath('/blogs/')).toBe('/blogs/');
});

it('leaves relative and absolute URLs alone', async () => {
const withBasePath = await importWithEnv({
NEXT_PUBLIC_BASE_PATH: '/preview',
});

expect(withBasePath('blogs/')).toBe('blogs/');
expect(withBasePath('https://example.com/logo.png')).toBe(
'https://example.com/logo.png',
);
});
});