Merge remote-tracking branch 'origin/main' into gbo/prefetch-data-rnd-7317

This commit is contained in:
Nicolas Dorseuil
2025-06-16 15:04:46 +02:00
13 changed files with 712 additions and 761 deletions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Fix UX issue about highlighting the search term in search result sections
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook-v2": patch
---
Optimize performances by using a smarter per-request cache arround data cached functions
+5
View File
@@ -0,0 +1,5 @@
---
"gitbook": patch
---
Ignore case while highlighting search results.
+8 -2
View File
@@ -56,13 +56,19 @@ git clone https://github.com/gitbookIO/gitbook.git
bun install
```
4. Start your local development server.
4. Run build.
```
bun build:v2
```
5. Start your local development server.
```
bun dev:v2
```
5. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
6. Open a published GitBook space in your web browser, prefixing it with `http://localhost:3000/`.
examples:
+3
View File
@@ -169,6 +169,7 @@
"assert-never": "^1.2.1",
"jwt-decode": "^4.0.0",
"next": "^15.3.2",
"object-identity": "^0.1.2",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"rison": "^0.1.1",
@@ -2438,6 +2439,8 @@
"object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="],
"object-identity": ["object-identity@0.1.2", "", {}, "sha512-Px5puVllX5L2aBjbcfXpiG5xXeq6OE8RckryTeP2Zq+0PgYrCGJXmC6LblWgknKSJs11Je2W4U2NOWFj3t/QXQ=="],
"object-inspect": ["object-inspect@1.13.2", "", {}, "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g=="],
"object-treeify": ["object-treeify@1.1.33", "", {}, "sha512-EFVjAYfzWqWsBMRHPMAXLCDIJnpMhdWAqR7xG6M6a2cs6PMFpl/+Z20w9zDW4vkxOFfddegBKq9Rehd0bxWE7A=="],
+2 -1
View File
@@ -14,7 +14,8 @@
"react-dom": "^19.0.0",
"rison": "^0.1.1",
"server-only": "^0.0.1",
"warn-once": "^0.1.1"
"warn-once": "^0.1.1",
"object-identity": "^0.1.2"
},
"devDependencies": {
"gitbook": "*",
+80
View File
@@ -0,0 +1,80 @@
import { describe, expect, it } from 'bun:test';
import { withStableRef } from './cache';
describe('withStableRef', () => {
it('should return primitive values as is', () => {
const toStableRef = withStableRef();
expect(toStableRef(42)).toBe(42);
expect(toStableRef('hello')).toBe('hello');
expect(toStableRef(true)).toBe(true);
expect(toStableRef(null)).toBe(null);
expect(toStableRef(undefined)).toBe(undefined);
});
it('should return the same reference for identical objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: 1, b: 2 };
const obj2 = { a: 1, b: 2 };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(obj1);
expect(ref1).not.toBe(obj2);
});
it('should return the same reference for identical arrays', () => {
const toStableRef = withStableRef();
const arr1 = [1, 2, 3];
const arr2 = [1, 2, 3];
const ref1 = toStableRef(arr1);
const ref2 = toStableRef(arr2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(arr1);
expect(ref1).not.toBe(arr2);
});
it('should return the same reference for identical nested objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: { b: 1 }, c: [2, 3] };
const obj2 = { a: { b: 1 }, c: [2, 3] };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).toBe(ref2);
expect(ref1).toBe(obj1);
expect(ref1).not.toBe(obj2);
});
it('should return different references for different objects', () => {
const toStableRef = withStableRef();
const obj1 = { a: 1 };
const obj2 = { a: 2 };
const ref1 = toStableRef(obj1);
const ref2 = toStableRef(obj2);
expect(ref1).not.toBe(ref2);
});
it('should maintain reference stability across multiple calls', () => {
const toStableRef = withStableRef();
const obj = { a: 1 };
const ref1 = toStableRef(obj);
const ref2 = toStableRef(obj);
const ref3 = toStableRef(obj);
expect(ref1).toBe(ref2);
expect(ref2).toBe(ref3);
});
});
+59
View File
@@ -0,0 +1,59 @@
import { identify } from 'object-identity';
import * as React from 'react';
/**
* Equivalent to `React.cache` but with support for non-primitive arguments.
* As `React.cache` only uses `Object.is` to compare arguments, it will not work with non-primitive arguments.
*/
export function cache<Args extends any[], Return>(fn: (...args: Args) => Return) {
const cached = React.cache(fn);
return (...args: Args) => {
const toStableRef = getWithStableRef();
const stableArgs = args.map((value) => {
return toStableRef(value);
}) as Args;
return cached(...stableArgs);
};
}
/**
* To ensure memory is garbage collected between each request, we use a per-request cache to store the ref maps.
*/
const getWithStableRef = React.cache(withStableRef);
/**
* Create a function that converts a value to a stable reference.
*/
export function withStableRef(): <T>(value: T) => T {
const reverseIndex = new WeakMap<object, string>();
const refIndex = new Map<string, object>();
return <T>(value: T) => {
if (isPrimitive(value)) {
return value;
}
const objectValue = value as object;
const index = reverseIndex.get(objectValue);
if (index !== undefined) {
return refIndex.get(index) as T;
}
const hash = identify(objectValue);
reverseIndex.set(objectValue, hash);
const existing = refIndex.get(hash);
if (existing !== undefined) {
return existing as T;
}
// first time we've seen this shape
refIndex.set(hash, objectValue);
return value;
};
}
function isPrimitive(value: any): boolean {
return value === null || typeof value !== 'object';
}
File diff suppressed because it is too large Load Diff
+22 -75
View File
@@ -1,7 +1,7 @@
import { race, tryCatch } from '@/lib/async';
import { joinPath, joinPathWithBaseURL } from '@/lib/paths';
import { trace } from '@/lib/tracing';
import type { GitBookAPI, PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import type { PublishedSiteContentLookup, SiteVisitorPayload } from '@gitbook/api';
import { apiClient } from './api';
import { getExposableError } from './errors';
import type { DataFetcherResponse } from './types';
@@ -18,85 +18,32 @@ interface LookupPublishedContentByUrlInput {
* Lookup a content by its URL using the GitBook resolvePublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*/
export async function resolvePublishedContentByUrl(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'resolvePublishedContentByUrl',
name: url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
);
},
});
}
/**
* Lookup a content by its URL using the GitBook getPublishedContentByUrl API endpoint.
* To optimize caching, we try multiple lookup alternatives and return the first one that matches.
*
* @deprecated use resolvePublishedContentByUrl.
*
*/
export async function getPublishedContentByURL(input: LookupPublishedContentByUrlInput) {
return lookupPublishedContentByUrl({
url: input.url,
fetchLookupAPIResult: ({ url, signal }) => {
const api = apiClient({ apiToken: input.apiToken });
return trace(
{
operation: 'getPublishedContentByURL',
name: url,
},
() =>
tryCatch(
api.urls.getPublishedContentByUrl(
{
url,
visitorAuthToken: input.visitorPayload.jwtToken ?? undefined,
redirectOnError: input.redirectOnError,
// @ts-expect-error - cacheVersion is not a real query param
cacheVersion: 'v2',
},
{ signal }
)
)
);
},
});
}
type TryCatch<T> = ReturnType<typeof tryCatch<T>>;
async function lookupPublishedContentByUrl(input: {
url: string;
fetchLookupAPIResult: (args: {
url: string;
signal: AbortSignal;
}) => TryCatch<Awaited<ReturnType<GitBookAPI['urls']['resolvePublishedContentByUrl']>>>;
}): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
export async function lookupPublishedContentByUrl(
input: LookupPublishedContentByUrlInput
): Promise<DataFetcherResponse<PublishedSiteContentLookup>> {
const lookupURL = new URL(input.url);
const url = stripURLSearch(lookupURL);
const lookup = getURLLookupAlternatives(url);
const result = await race(lookup.urls, async (alternative, { signal }) => {
const callResult = await input.fetchLookupAPIResult({
url: alternative.url,
signal,
});
const api = apiClient({ apiToken: input.apiToken });
const callResult = await trace(
{
operation: 'resolvePublishedContentByUrl',
name: alternative.url,
},
() =>
tryCatch(
api.urls.resolvePublishedContentByUrl(
{
url: alternative.url,
...(input.visitorPayload ? { visitor: input.visitorPayload } : {}),
redirectOnError: input.redirectOnError,
},
{ signal }
)
)
);
if (callResult.error) {
if (alternative.primary) {
@@ -1,92 +0,0 @@
import { cache } from 'react';
// This is used to create a context specific to the current request.
// This version works both in cloudflare and in vercel.
const getRequestContext = cache(() => ({}));
/**
* Wrap a function by preventing concurrent executions of the same function.
* With a logic to work per-request in Cloudflare Workers.
*/
export function withoutConcurrentExecution<ArgsType extends any[], ReturnType>(
wrapped: (key: string, ...args: ArgsType) => Promise<ReturnType>
): (cacheKey: string, ...args: ArgsType) => Promise<ReturnType> {
const globalPromiseCache = new WeakMap<object, Map<string, Promise<ReturnType>>>();
return (key: string, ...args: ArgsType) => {
const globalContext = getRequestContext();
/**
* Cache storage that is scoped to the current request when executed in Cloudflare Workers,
* to avoid "Cannot perform I/O on behalf of a different request" errors.
*/
const promiseCache =
globalPromiseCache.get(globalContext) ?? new Map<string, Promise<ReturnType>>();
globalPromiseCache.set(globalContext, promiseCache);
const concurrent = promiseCache.get(key);
if (concurrent) {
return concurrent;
}
const promise = (async () => {
try {
const result = await wrapped(key, ...args);
return result;
} finally {
promiseCache.delete(key);
}
})();
promiseCache.set(key, promise);
return promise;
};
}
/**
* Wrap a function by passing it a cache key that is computed from the function arguments.
*/
export function withCacheKey<ArgsType extends any[], ReturnType>(
wrapped: (cacheKey: string, ...args: ArgsType) => Promise<ReturnType>
): (...args: ArgsType) => Promise<ReturnType> {
return (...args: ArgsType) => {
const cacheKey = getCacheKey(args);
return wrapped(cacheKey, ...args);
};
}
/**
* Compute a cache key from the function arguments.
*/
function getCacheKey(args: any[]) {
return JSON.stringify(deepSortValue(args));
}
function deepSortValue(value: unknown): unknown {
if (
typeof value === 'string' ||
typeof value === 'number' ||
typeof value === 'boolean' ||
value === null ||
value === undefined
) {
return value;
}
if (Array.isArray(value)) {
return value.map(deepSortValue);
}
if (value && typeof value === 'object') {
return Object.entries(value)
.map(([key, subValue]) => {
return [key, deepSortValue(subValue)] as const;
})
.sort((a, b) => {
return a[0].localeCompare(b[0]);
});
}
return value;
}
+2 -18
View File
@@ -16,10 +16,9 @@ import {
import { serveResizedImage } from '@/routes/image';
import {
DataFetcherError,
getPublishedContentByURL,
getVisitorAuthBasePath,
lookupPublishedContentByUrl,
normalizeURL,
resolvePublishedContentByUrl,
throwIfDataError,
} from '@v2/lib/data';
import { isGitBookAssetsHostURL, isGitBookHostURL } from '@v2/lib/env';
@@ -34,18 +33,6 @@ export const config = {
type URLWithMode = { url: URL; mode: 'url' | 'url-host' };
/**
* Temporary list of hosts to test adaptive content using the new resolution API.
*/
const ADAPTIVE_CONTENT_HOSTS = [
'docs.gitbook.com',
'paypal.gitbook.com',
'adaptive-docs.gitbook-staging.com',
'enriched-content-playground.gitbook-staging.io',
'docs.testgitbook.com',
'launchdarkly-site.gitbook.education',
];
export async function middleware(request: NextRequest) {
try {
const requestURL = new URL(request.url);
@@ -104,11 +91,8 @@ async function serveSiteRoutes(requestURL: URL, request: NextRequest) {
});
const withAPIToken = async (apiToken: string | null) => {
const resolve = ADAPTIVE_CONTENT_HOSTS.includes(siteRequestURL.hostname)
? resolvePublishedContentByUrl
: getPublishedContentByURL;
const siteURLData = await throwIfDataError(
resolve({
lookupPublishedContentByUrl({
url: siteRequestURL.toString(),
visitorPayload: {
jwtToken: visitorToken?.token ?? undefined,
@@ -66,11 +66,7 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
<HighlightQuery query={query} text={item.title} />
</p>
) : null}
{item.body ? (
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
<HighlightQuery query={query} text={item.body} />
</p>
) : null}
{item.body ? highlightQueryInBody(item.body, query) : null}
</div>
<div
className={tcls(
@@ -90,3 +86,14 @@ export const SearchSectionResultItem = React.forwardRef(function SearchSectionRe
</Link>
);
});
function highlightQueryInBody(body: string, query: string) {
const idx = body.toLocaleLowerCase().indexOf(query.toLocaleLowerCase());
// Ensure the query to be highlighted is visible in the body.
return (
<p className={tcls('text-sm', 'line-clamp-3', 'relative')}>
<HighlightQuery query={query} text={idx < 20 ? body : `...${body.slice(idx - 10)}`} />
</p>
);
}