Merge remote-tracking branch 'origin/main' into peter/rnd-13071-search-space-scope

This commit is contained in:
Peter White
2026-10-07 15:08:16 +02:00
9 changed files with 167 additions and 4 deletions
+43 -1
View File
@@ -1,6 +1,8 @@
import { describe, expect, it } from 'bun:test';
import { getPreviewRequestIdentifier, isPreviewRequest } from './preview';
import type { PublishedSiteContent } from '@gitbook/api';
import { getPreviewCookieResponse, getPreviewRequestIdentifier, isPreviewRequest } from './preview';
describe('isPreviewRequest', () => {
it('should return true for preview requests', () => {
@@ -23,3 +25,43 @@ describe('getPreviewRequestIdentifier', () => {
expect(getPreviewRequestIdentifier(previewRequestURL)).toBe('site_foo');
});
});
describe('getPreviewCookieResponse', () => {
// The site's canonical base path — deliberately different from the preview route prefix so the
// test fails if the cookie is scoped to it instead of the preview route.
const siteURLData = { siteBasePath: '/docs/' } as unknown as PublishedSiteContent;
it('scopes the cookie to the preview route prefix in url-host mode, not the site base path', () => {
const cookie = getPreviewCookieResponse({
name: 'gitbook-customization',
value: 'v',
mode: 'url-host',
siteRequestURL: new URL('https://sites.gitbook.com/preview/site_foo/hello/world'),
siteURLData,
});
expect(cookie.options?.path).toBe('/preview/site_foo');
expect(cookie.options?.sameSite).toBe('lax');
});
it('scopes the cookie to the proxied preview route prefix in url mode', () => {
const cookie = getPreviewCookieResponse({
name: 'gitbook-customization',
value: 'v',
mode: 'url',
siteRequestURL: new URL('https://sites.gitbook.com/preview/site_foo/hello/world'),
siteURLData,
});
expect(cookie.options?.path).toBe('/url/sites.gitbook.com/preview/site_foo');
});
it('falls back to the site base path for a non-preview url-host request', () => {
const cookie = getPreviewCookieResponse({
name: 'gitbook-customization',
value: 'v',
mode: 'url-host',
siteRequestURL: new URL('https://example.com/docs/hello/world'),
siteURLData,
});
expect(cookie.options?.path).toBe('/docs/');
});
});
+15 -2
View File
@@ -51,8 +51,21 @@ export function getPreviewCookieResponse(args: {
gitbookPreviewBaseURL.host + gitbookPreviewBaseURL.pathname.replace(/\/$/, '');
return `/url/${gitbookPreviewHostPath}/${getPreviewRequestIdentifier(siteRequestURL)}`;
}
case 'url-host':
return siteURLData.siteBasePath;
case 'url-host': {
// The whole site preview (all variants/sections/pages) is served under the preview
// route prefix `<previewBasePath>/<siteId>` (e.g. `/preview/<siteId>`). Scope the
// cookie to that prefix, derived from the request, so it is resent on every
// in-preview navigation. The resolved `siteBasePath` is the site's canonical base
// path and does not prefix the preview route, so relying on it makes the browser
// drop the cookie on the next page — the actual cause of the override being lost.
// Mirrors getVisitorAuthBasePath, which uses the request identifier for proxy
// requests. Fall back to `siteBasePath` for any preview not served under the route.
if (!isPreviewRequest(siteRequestURL)) {
return siteURLData.siteBasePath;
}
const gitbookPreviewBaseURL = new URL(GITBOOK_PREVIEW_BASE_URL);
return `${gitbookPreviewBaseURL.pathname.replace(/\/$/, '')}/${getPreviewRequestIdentifier(siteRequestURL)}`;
}
default:
assertNever(mode);
}
+2 -1
View File
@@ -508,7 +508,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
siteId: siteURLData.site,
events,
request: {
url: siteRequestURL.toString(),
// A proxied site's request URL is the proxy's (`/sites/site_xxx/...`), not its public one.
url: siteCanonicalURL.toString(),
headers: requestHeaders,
},
})
@@ -0,0 +1,69 @@
import { describe, expect, it } from 'bun:test';
import { renderToReadableStream } from 'react-dom/server';
import { parseOpenAPI } from '@gitbook/openapi-parser';
import type { OpenAPIClientContext } from './context';
import { OpenAPIRootSchema } from './OpenAPISchemaServer';
import { resolveOpenAPISchemas } from './schemas/resolveOpenAPISchemas';
import { translations } from './translations';
const context: OpenAPIClientContext = {
translation: translations.en,
icons: {
chevronDown: null,
chevronRight: null,
plus: null,
copy: null,
check: null,
lock: null,
mcp: null,
hashtag: null,
},
expandAllModelSections: true,
scalarRuntimeURL: '',
$$isClientContext$$: true,
};
describe('OpenAPIRootSchema', () => {
it('should stop at a self-reference behind a nullable union when expanded', async () => {
const { filesystem } = await parseOpenAPI({
value: JSON.stringify({
openapi: '3.1.0',
info: { title: 'Test', version: '1.0.0' },
paths: {},
components: {
schemas: {
FilterClause: {
type: 'object',
properties: {
AND: {
anyOf: [
{
type: 'array',
items: { $ref: '#/components/schemas/FilterClause' },
},
{ type: 'null' },
],
},
},
},
},
},
}),
rootURL: 'memory://spec.json',
});
const resolved = await resolveOpenAPISchemas(filesystem, { schemas: ['FilterClause'] });
const schema = resolved?.schemas[0]?.schema;
if (!schema) {
throw new Error('FilterClause not resolved');
}
const stream = await renderToReadableStream(
<OpenAPIRootSchema schema={schema} context={context} />
);
const html = await new Response(stream).text();
expect(html).toContain('AND');
});
});
+8
View File
@@ -190,6 +190,14 @@ describe('extractNonNullTypes', () => {
});
describe('normalizeNullableUnion', () => {
it('should return the same object for the same input', () => {
const schema: OpenAPIV3_1.SchemaObject = {
anyOf: [{ type: 'array', items: { type: 'string' } }, { type: 'null' }],
};
expect(normalizeNullableUnion(schema)).toBe(normalizeNullableUnion(schema));
});
it('should collapse anyOf with a single non-null member into a nullable schema', () => {
const schema: OpenAPIV3_1.SchemaObject = {
anyOf: [{ type: 'string' }, { type: 'null' }],
+14
View File
@@ -287,6 +287,10 @@ function isNullSchema(schema: OpenAPIV3.SchemaObject | OpenAPIV3.ReferenceObject
return type === 'null';
}
// Circular-ref tracking compares schemas by identity: a fresh copy on every render lets a
// self-referencing nullable property (`anyOf: [{ items: Self }, null]`) recurse forever when expanded.
const normalizedNullableUnions = new WeakMap<OpenAPIV3.SchemaObject, OpenAPIV3.SchemaObject>();
/**
* Normalize the OpenAPI 3.1+ idiom of expressing nullability through `anyOf`/`oneOf`
* (e.g. `anyOf: [{ type: 'string' }, { type: 'null' }]`) into a regular nullable schema,
@@ -301,7 +305,17 @@ export function normalizeNullableUnion(
schema: OpenAPIV3.SchemaObject | OpenAPIV3_1.SchemaObject
): OpenAPIV3.SchemaObject {
const typed = schema as OpenAPIV3.SchemaObject;
const cached = normalizedNullableUnions.get(typed);
if (cached) {
return cached;
}
const normalized = normalizeNullableUnionUncached(typed);
normalizedNullableUnions.set(typed, normalized);
return normalized;
}
function normalizeNullableUnionUncached(typed: OpenAPIV3.SchemaObject): OpenAPIV3.SchemaObject {
const isAnyOf = Array.isArray(typed.anyOf);
const isOneOf = !isAnyOf && Array.isArray(typed.oneOf);
if (!isAnyOf && !isOneOf) {