diff --git a/.changeset/faster-rest-types.md b/.changeset/faster-rest-types.md new file mode 100644 index 000000000000..48743f15d640 --- /dev/null +++ b/.changeset/faster-rest-types.md @@ -0,0 +1,23 @@ +--- +'@data-client/rest': patch +'@data-client/endpoint': patch +'@data-client/graphql': patch +--- + +Speed up TypeScript checking of `RestEndpoint`, `resource()` and `.extend()` + +Editors and `tsc` check code that defines or calls endpoints faster and with less memory. In our stress tests, a file of 150 RestEndpoints with long paths, `.extend()` and `.paginated()` checked in about half the time (3.1s → 1.6s) and memory (345MB → 207MB). Files that use `resource()` with React or Vue hooks did 22-25% less type work. TypeScript reports the same errors as before. + +On TypeScript 5.x and earlier, a `process(value, params)` method passed to `.extend()` no longer fails with "implicitly has an 'any' type" under `strict`, matching TypeScript 6+. TypeScript 4.0 also accepts a chained `.extend().extend()`. + +```ts +const getUser = new RestEndpoint({ path: '/users/:id', schema: User }); + +// Before (TypeScript 5.x and earlier): error TS7006: Parameter 'value' implicitly has an 'any' type. +// After: no error +const getUserName = getUser.extend({ + process(value, params) { + return value.name; + }, +}); +``` diff --git a/examples/todo-app/typetest-libcheck.ts b/examples/todo-app/typetest-libcheck.ts index ff7a12505085..c3c56673a93a 100644 --- a/examples/todo-app/typetest-libcheck.ts +++ b/examples/todo-app/typetest-libcheck.ts @@ -36,9 +36,18 @@ const PostResource = resource({ }); PostResource.getList.getPage({ cursor: 'a' }); const search = new RestEndpoint({ path: '/search' }); +// extend() methods' parameters aren't implicitly any on any TypeScript version, +// including on a chained extend() +const getPostTitle = new RestEndpoint({ path: '/posts/:id', schema: Post }) + .extend({ dataExpiryLength: 5 }) + .extend({ + process(value, params) { + return `${params.id}: ${value.title}`; + }, + }); const memo = new MemoCache(); const { result, entities } = normalize(Post, { id: '1', title: 'hi' }); denormalize(Post, result, entities); -export { getUser, feed, search, memo }; +export { getUser, feed, search, memo, getPostTitle }; diff --git a/packages/endpoint/src-4.2-types/endpointTypes.d.ts b/packages/endpoint/src-4.2-types/endpointTypes.d.ts index d697ee2a4c26..aaefda4eeccc 100644 --- a/packages/endpoint/src-4.2-types/endpointTypes.d.ts +++ b/packages/endpoint/src-4.2-types/endpointTypes.d.ts @@ -63,8 +63,8 @@ export interface EndpointInstance< Record, >( this: E, - options: Readonly, - ): ExtendedEndpoint; + options: Readonly & O, + ): ExtendedEndpoint, E, F>; } /** * Defines an async data source. diff --git a/packages/endpoint/src/endpointTypes.ts b/packages/endpoint/src/endpointTypes.ts index 1c7fe5a67a64..e41ec3aaf4a5 100644 --- a/packages/endpoint/src/endpointTypes.ts +++ b/packages/endpoint/src/endpointTypes.ts @@ -1,11 +1,11 @@ /* eslint-disable @typescript-eslint/no-unsafe-function-type */ import type { EndpointInterface, Schema } from './interface.js'; +import type { RemoveArray } from './tupleTypes.js'; import type { EndpointExtraOptions, FetchFunction, PartialParameters, } from './types.js'; -import type { RemoveArray } from './tupleTypes.js'; export interface EndpointOptions< F extends FetchFunction = FetchFunction, @@ -69,8 +69,8 @@ export interface EndpointInstance< Record, >( this: E, - options: Readonly, - ): ExtendedEndpoint; + options: Readonly & O, + ): ExtendedEndpoint, E, F>; } /** diff --git a/packages/rest/src-4.1-types/resourceExtendable.d.ts b/packages/rest/src-4.1-types/resourceExtendable.d.ts index ff032eb79c55..c32b27be8b93 100644 --- a/packages/rest/src-4.1-types/resourceExtendable.d.ts +++ b/packages/rest/src-4.1-types/resourceExtendable.d.ts @@ -9,10 +9,10 @@ export interface Extendable { extend, 'extend'>, ExtendOptions extends PartialRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly> & ExtendOptions>): ResourceExtension; + }, ExtendKey extends Exclude, 'extend'>, ExtendOptions extends PartialRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): ResourceExtension; extend(this: R, key: ExtendKey, options: Readonly> & ExtendOptions>): R & { + }, ExtendKey extends string, ExtendOptions extends PartialRestGenerics | {}>(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; extend(this: R, options: ResourceEndpointExtensions): CustomResource; diff --git a/packages/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index e1efe25e1ff9..c78888d23506 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -32,6 +32,56 @@ type ContentSchemaGuard = { schema?: undefined } : {}; +/* Generic `this: E` methods (extend, paginated) live in non-generic mixin + * interfaces. TypeScript re-instantiates every member of a generic interface + * (with fresh signature type parameters) for each distinct instantiation; + * here the signatures are shared across all endpoints, and compare as + * identical when relating one RestInstanceBase to another. extend() reads + * `E['fetch']` instead of `F` so it doesn't depend on the outer generics. + */ +interface RestInstanceExtenders { + // TODO: `ExtendOptions extends PartialRestGenerics | {}` is a hack for options with no + // PartialRestGenerics members. Overloads (like paginated) can't tell the cases apart + // since every member is optional. + /** Creates a child endpoint that inherits from this while overriding provided `options`. + * @see https://dataclient.io/rest/api/RestEndpoint#extend + */ + extend< + E extends RestInstanceBase, + ExtendOptions extends PartialRestGenerics | {}, + >( + this: E, + options: Readonly< + RestEndpointExtendOptions & ExtendOptions + > & + // naked ExtendOptions wins inference (plain object type instead of a + // reverse-mapped type); Readonly<> still provides literal-preserving + // contextual types for path/method/etc. + ExtendOptions & + ContentSchemaGuard, + ): RestExtendedEndpoint; +} + +interface RestInstancePaginators { + /** Creates an Endpoint to append the next page extending a list for pagination + * @see https://dataclient.io/rest/api/RestEndpoint#paginated + */ + paginated< + E extends RestInstanceBase, + A extends any[], + >( + this: E, + removeCursor: (...args: A) => readonly [...Parameters], + ): PaginationEndpoint; + paginated< + E extends RestInstanceBase, + C extends string, + >( + this: E, + cursorField: C, + ): PaginationFieldEndpoint; +} + export interface RestInstanceBase< F extends FetchFunction = FetchFunction, S extends Schema | undefined = any, @@ -42,7 +92,8 @@ export interface RestInstanceBase< searchParams?: any; method?: string; } = { path: string }, -> extends EndpointInstanceInterface { +> + extends EndpointInstanceInterface, RestInstanceExtenders { /** @see https://dataclient.io/rest/api/RestEndpoint#body */ readonly body?: 'body' extends keyof O ? O['body'] : any; /** @see https://dataclient.io/rest/api/RestEndpoint#searchParams */ @@ -109,25 +160,6 @@ export interface RestInstanceBase< * @see https://dataclient.io/rest/api/RestEndpoint#testKey */ testKey(key: string): boolean; - - /* extenders */ - // TODO: figure out better way than wrapping whole options in Readonly<> + making O extend from {} - // this is just a hack to handle when no members of PartialRestGenerics are present - // Note: Using overloading (like paginated did) struggles because typescript does not have a clear way of distinguishing one - // should be used from the other (due to same problem with every member being partial) - /** Creates a child endpoint that inherits from this while overriding provided `options`. - * @see https://dataclient.io/rest/api/RestEndpoint#extend - */ - extend< - E extends RestInstanceBase, - ExtendOptions extends PartialRestGenerics | {}, - >( - this: E, - options: Readonly< - RestEndpointExtendOptions & ExtendOptions - > & - ContentSchemaGuard, - ): RestExtendedEndpoint; } export interface RestInstance< @@ -141,31 +173,21 @@ export interface RestInstance< method?: string; paginationField?: string; } = { path: string }, -> extends RestInstanceBase { - /** Creates an Endpoint to append the next page extending a list for pagination - * @see https://dataclient.io/rest/api/RestEndpoint#paginated - */ - paginated< - E extends RestInstanceBase, - A extends any[], - >( - this: E, - removeCursor: (...args: A) => readonly [...Parameters], - ): PaginationEndpoint; - paginated< - E extends RestInstanceBase, - C extends string, - >( - this: E, - cursorField: C, - ): PaginationFieldEndpoint; +> + extends RestInstanceBase, RestInstancePaginators { /** Concatinate the next page of results (GET) * @see https://dataclient.io/rest/api/RestEndpoint#getPage */ getPage: 'paginationField' extends keyof O ? O['paginationField'] extends string ? PaginationFieldEndpoint< - F & { schema: S; sideEffect: M } & O, + // A plain fetch function (no members) only contributes ResolveType<>; + // collapsing it to one signature avoids distributing this intersection + // over each member when F is a union of fetch signatures. + ([keyof F] extends [never] ? (...args: any) => ReturnType : F) & { + schema: S; + sideEffect: M; + } & O, // TypeScript <4.6 doesn't narrow O['paginationField'] here Extract > @@ -251,13 +273,21 @@ export type RestEndpointExtendOptions< 'schema' extends keyof O ? Extract : E['schema'] > & - Partial< - Omit< - E, - KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions - > + // Same as Partial>, but skips the per-key Exclude<> work + // (and the double mapped type) when E has no members beyond the standard ones. + // Keep the guard inside the mapped type's keys: a `? unknown : ...` conditional + // here would be deferred and break ExtendOptions inference on chained extend(). + PartialPick< + E, + keyof E extends ExtendOmitKeys ? never : Exclude >; +type ExtendOmitKeys = + KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; + +/** Partial> as a single homomorphic mapped type */ +type PartialPick = { [P in K]?: T[P] }; + type OptionsToRestEndpoint< O extends PartialRestGenerics, E extends RestInstanceBase & { body?: any; paginationField?: string }, @@ -394,8 +424,11 @@ export type RestExtendedEndpoint< : E['sideEffect'] > > & - Omit & - Omit; + // Equivalent to Omit & Omit; + // the guards avoid per-key Exclude<> work when there are no extra members + (keyof O extends KeyofRestEndpoint ? unknown : Omit) & + (keyof E extends KeyofRestEndpoint ? unknown + : Omit); export interface PartialRestGenerics { /** @see https://dataclient.io/rest/api/RestEndpoint#path */ @@ -678,6 +711,8 @@ export interface RestEndpointConstructor { ...options }: RestEndpointConstructorOptions & Readonly & + // naked O wins inference (plain object type instead of a reverse-mapped type) + O & ContentSchemaGuard): RestEndpoint; readonly prototype: RestInstanceBase; } diff --git a/packages/rest/src/pathTypes.ts b/packages/rest/src/pathTypes.ts index c3b90540c4e5..e47b5c24d1c6 100644 --- a/packages/rest/src/pathTypes.ts +++ b/packages/rest/src/pathTypes.ts @@ -1,11 +1,21 @@ -type CleanKey = S extends `"${infer K}"` ? K : S; +// The non-`infer` pre-checks are matched without instantiating any types, +// so plain keys (the common case) skip the inferring templates entirely. +type CleanKey = + S extends `"${string}"` ? + S extends `"${infer K}"` ? + K + : S + : S; -type KeyName = CleanKey< - K extends `*${infer N}}` ? N - : K extends `*${infer N}` ? N - : K extends `${infer N}}` ? N - : K ->; +type KeyName = + K extends `*${string}` | `${string}}` ? + CleanKey< + K extends `*${infer N}}` ? N + : K extends `*${infer N}` ? N + : K extends `${infer N}}` ? N + : K + > + : CleanKey; type KeyVal = K extends `*${string}` ? string[] : string | number; @@ -28,29 +38,92 @@ export type SoftPathArgs

= /** Computes the union of keys for a path string */ export type PathKeys = string extends S ? string - : S extends `${infer A}\\${':' | '*' | '}'}${infer B}` ? - PathKeys | PathKeys - : Splits | Splits; - -type Splits = - S extends `${string}${M}${infer K}${M}${infer R}` ? - Splits<`${M}${K}`, M> | Splits<`${M}${R}`, M> - : S extends ( - `${string}${M}${infer K}${'/' | '\\' | '%' | '&' | '*' | ':' | '{' | ';' | ',' | '!' | '@'}${infer R}` + : // cheap (non-inferring) pre-check before the 3-way escape split + S extends `${string}\\${string}` ? + S extends `${infer A}\\${':' | '*' | '}'}${infer B}` ? + PathKeys | PathKeys + : ColonSplits | StarSplits + : ColonSplits | StarSplits; + +/** Characters that end a :param or *wildcard token */ +type PathDelimiter = + '/' | '\\' | '%' | '&' | '*' | ':' | '{' | ';' | ',' | '!' | '@'; + +/** Token after every ':' in S */ +type ColonSplits = + S extends `${string}:${infer K}` ? PathToken | ColonSplits : never; + +/** `*`-prefixed token after every '*' in S */ +type StarSplits = + S extends `${string}*${infer K}` ? `*${PathToken}` | StarSplits : never; + +/** Prefix of K up to (excluding) its first PathDelimiter. + * + * Fast path: no delimiter at all, or the first '/' ends a delimiter-free token. + * The delimiter-union templates without `infer` are matched without instantiation. */ +type PathToken = + K extends `${string}${PathDelimiter}${string}` ? + K extends `${infer H}/${string}` ? + H extends `${string}${PathDelimiter}${string}` ? + PathTokenSlow + : H + : PathTokenSlow + : K; + +/** Cuts at the first occurrence of each delimiter (union); recursing on each + * candidate converges on the shortest, delimiter-free prefix. */ +type PathTokenSlow = + K extends `${infer H}${PathDelimiter}${string}` ? PathToken : K; + +export type KeysToArgs = OptionalArgs & + (RequiredPathKeys extends never ? unknown : RequiredArgs); + +/** Wide keys (`string`, template patterns) keep the original key-remapping + * form so index signatures (and their `keyof`) stay exactly the same. */ +type HasWideKey = + true extends ( + Key extends string ? + {} extends { [P in Key]: 1 } ? + true + : never + : never ) ? - Splits<`${M}${K}`, M> | Splits - : S extends `${string}${M}${infer K}` ? - M extends '*' ? - `*${K}` - : K - : never; - -export type KeysToArgs = { - [K in Key as K extends `${string}}` ? KeyName : never]?: KeyVal; -} & (Exclude extends never ? unknown -: { - [K in Key as K extends `${string}}` ? never : KeyName]: KeyVal; - }); + true + : false; + +// Literal keys: mapped over the computed names without an `as` clause. +// `as` clauses get re-instantiated every time TypeScript asks whether the +// mapped type is generic (on every relation check of hook/fetch params). +// Each value is the KeyVal of the key(s) named N (inlined so errors show the +// resolved type rather than an alias). +type OptionalArgs = + HasWideKey extends true ? + { [K in Key as K extends `${string}}` ? KeyName : never]?: KeyVal } + : { + [N in KeyName>]?: + | (N extends KeyName, `*${string}`>> ? + string[] + : never) + | (N extends KeyName, `*${string}`>> ? + string | number + : never); + }; + +type RequiredArgs = + HasWideKey extends true ? + { [K in Key as K extends `${string}}` ? never : KeyName]: KeyVal } + : { + [N in KeyName>]: + | (N extends KeyName, `*${string}`>> ? + string[] + : never) + | (N extends KeyName, `*${string}`>> ? + string | number + : never); + }; + +type OptionalPathKeys = Extract; +type RequiredPathKeys = Exclude; export type PathArgsAndSearch = unknown extends S ? any diff --git a/packages/rest/src/resource.ts b/packages/rest/src/resource.ts index ab357f878a13..2a4fd76b54d0 100644 --- a/packages/rest/src/resource.ts +++ b/packages/rest/src/resource.ts @@ -30,7 +30,7 @@ export default function resource({ optimistic, paginationField, ...extraOptions -}: Readonly & ResourceOptions): Resource { +}: Readonly & O & ResourceOptions): Resource { if (process.env.NODE_ENV !== 'production') { // if they lowercase and it looks like they meant to use upper-case version if ( @@ -78,19 +78,24 @@ This warning will not show in production.`, extended[key] = extended[key].extend(options); } - const extraMutateOptions = { ...extraOptions }; - const extraPartialOptions = { ...extraOptions }; + // Loosely typed: the public signature infers O from the naked `O` position, + // so spreading the generic rest into `new Endpoint()` would otherwise leave + // ContentSchemaGuard as an unresolvable (deferred) conditional. + // Retype this if a future TypeScript resolves that conditional here. + const extraBaseOptions: Record = extraOptions; + const extraMutateOptions = { ...extraBaseOptions }; + const extraPartialOptions = { ...extraBaseOptions }; const get: GetEndpoint<{ path: O['path']; schema: O['schema'] }> = new Endpoint({ - ...extraOptions, + ...extraBaseOptions, path, schema, name: getName('get'), }) as any; if (optimistic) { - (extraMutateOptions as any).getOptimisticResponse = optimisticUpdate; + extraMutateOptions.getOptimisticResponse = optimisticUpdate; // TODO: Check that schema is a queryable, otherwise this doesn't make sense - (extraPartialOptions as any).getOptimisticResponse = optimisticPartial( + extraPartialOptions.getOptimisticResponse = optimisticPartial( schema as any, ); } diff --git a/packages/rest/src/resourceExtendable.ts b/packages/rest/src/resourceExtendable.ts index ec2927e907c4..077c1fba857d 100644 --- a/packages/rest/src/resourceExtendable.ts +++ b/packages/rest/src/resourceExtendable.ts @@ -46,7 +46,8 @@ export interface Extendable< EndpointToFunction > & ExtendOptions - >, + > & + ExtendOptions, ): ResourceExtension; extend< R extends { get: RestInstanceBase }, @@ -63,7 +64,8 @@ export interface Extendable< EndpointToFunction > & ExtendOptions - >, + > & + ExtendOptions, ): R & { [key in ExtendKey]: RestExtendedEndpoint; }; diff --git a/packages/rest/src/resourceExtensionTypes.ts b/packages/rest/src/resourceExtensionTypes.ts index e930bfa50a25..9a6e224248b3 100644 --- a/packages/rest/src/resourceExtensionTypes.ts +++ b/packages/rest/src/resourceExtensionTypes.ts @@ -70,7 +70,8 @@ export interface ResourceEndpointExtensions< : OptionsToFunction>, R['get']['schema'] > & - Readonly; + Readonly & + Get; readonly getList?: RestEndpointOptions< unknown extends GetList ? EndpointToFunction : OptionsToFunction< @@ -80,13 +81,15 @@ export interface ResourceEndpointExtensions< >, R['getList']['schema'] > & - Readonly; + Readonly & + GetList; readonly update?: RestEndpointOptions< unknown extends Update ? EndpointToFunction : OptionsToFunction>, R['update']['schema'] > & - Readonly; + Readonly & + Update; readonly partialUpdate?: RestEndpointOptions< unknown extends PartialUpdate ? EndpointToFunction : OptionsToFunction< @@ -96,11 +99,13 @@ export interface ResourceEndpointExtensions< >, R['partialUpdate']['schema'] > & - Readonly; + Readonly & + PartialUpdate; readonly delete?: RestEndpointOptions< unknown extends Delete ? EndpointToFunction : OptionsToFunction>, R['delete']['schema'] > & - Readonly; + Readonly & + Delete; } diff --git a/packages/rest/typescript-tests/extendPaginate.test.ts b/packages/rest/typescript-tests/extendPaginate.test.ts new file mode 100644 index 000000000000..b21a286bec51 --- /dev/null +++ b/packages/rest/typescript-tests/extendPaginate.test.ts @@ -0,0 +1,183 @@ +// Type-level regression tests for RestEndpoint extend(), paginated(), resource() and +// path parameters. Each @ts-expect-error line must keep erroring; plain lines must keep compiling. +import { useSuspense, useController } from '@data-client/react'; + +import { RestEndpoint, Entity, resource } from '@data-client/rest'; + +export class P extends Entity { + id = ''; + a = ''; + static key = 'P'; +} + +export const ep = new RestEndpoint({ + path: '/org/:org/repo/:repo{/:sub}', + schema: P, + searchParams: {} as { page?: number }, + custom: 5, +}); +export const epPost = new RestEndpoint({ + path: '/org/:org', + method: 'POST', + body: {} as { a: string }, + schema: P, +}); + +/* ---------------- extend() ---------------- */ +// result uses the NEW path +export const ex1 = ep.extend({ path: '/other/:a/:b' }); +// literal path/method are preserved (these lines error only while the literal is kept) +// @ts-expect-error +export const lit1: string extends typeof ex1.path ? 1 : 2 = 1; +export const ex1m = ep.extend({ method: 'POST', body: {} as { a: string } }); +// @ts-expect-error +export const lit2: string extends typeof ex1m.method ? 1 : 2 = 1; +// content guard: binary content must not carry a schema +// @ts-expect-error +export const ex2 = ep.extend({ content: 'blob', schema: P }); +// option types still checked +// custom members from the original endpoint are typed (Partial>) +export const ex4 = ep.extend({ custom: 'not a number' }); +// process return type flows to the resolve type +export const ex5 = ep.extend({ + process(v, params) { + return 5; + }, +}); +// `this` constraint of extend still applies +// @ts-expect-error +export const ex7 = ep.extend.call({}, {}); +// extend that only changes options keeps the original path arguments +export const ex8 = ep.extend({ dataExpiryLength: 5 }); +// chained extend keeps the intermediate path/body +export const ex9 = ep + .extend({ path: '/other/:a', method: 'POST', body: {} as { a: string } }) + .extend({ dataExpiryLength: 5 }); + +/* ---------------- paginated() ---------------- */ +export const pg = ep.paginated('cursor'); +// @ts-expect-error (only GET endpoints) +export const pgBad = epPost.paginated('cursor'); +// @ts-expect-error +export const pgBad2 = ep.paginated(5); +export const pgFn = ep.paginated( + ({ + cursor, + ...rest + }: { + cursor: string; + org: string; + repo: string; + sub?: string; + }) => [rest] as const, +); +export const pgFnBad = ep.paginated( + // @ts-expect-error + ({ cursor }: { cursor: string }) => [{ nope: 1 }] as const, +); + +export function useProbes() { + const ctrl = useController(); + useSuspense(ex1, { a: '1', b: '2' }); + // @ts-expect-error + useSuspense(ex1, { org: '1', repo: '2' }); + ctrl.fetch(ex1m, { org: '1', repo: '2' }, { a: 'x' }); + // @ts-expect-error + ctrl.fetch(ex1m, { org: '1', repo: '2' }, { b: 'x' }); + // @ts-expect-error + ctrl.fetch(ex1m, { org: '1', repo: '2' }); + // @ts-expect-error + ctrl.fetch(ex8, { wrong: 1 }); + ctrl.fetch(ex9, { a: '1' }, { a: 'x' }); + // @ts-expect-error + ctrl.fetch(ex9, { org: '1', repo: '2' }, { a: 'x' }); + // @ts-expect-error (cursor required) + ctrl.fetch(pg, { org: '1', repo: '2' }); + ctrl.fetch(pg, { org: '1', repo: '2', cursor: 'c' }); + ctrl.fetch(pgFn, { org: '1', repo: '2', cursor: 'c' }); + // @ts-expect-error + ctrl.fetch(pgFn, { org: '1', repo: '2' }); + const r5 = useSuspense(ex5, { org: '1', repo: '2' }); + // @ts-expect-error (process returned number) + const s5: string = r5; +} + +/* ---------------- resource() ---------------- */ +export const R = resource({ + path: '/r/:id', + schema: P, + searchParams: {} as { q?: string }, +}); +export function useResourceProbes() { + const ctrl = useController(); + useSuspense(R.get, { id: 1 }); + // @ts-expect-error + useSuspense(R.get, { idx: 1 }); + useSuspense(R.getList, { q: 'x' }); + // @ts-expect-error + useSuspense(R.getList, { z: 'x' }); + ctrl.fetch(R.update, { id: 1 }, { a: 'x' }); + // @ts-expect-error + ctrl.fetch(R.update, { id: 1 }, { a: 5 }); + // @ts-expect-error + ctrl.fetch(R.partialUpdate, { id: 1 }, { zz: 'x' }); + ctrl.fetch(R.getList.push, { a: 'x' }); + // @ts-expect-error + ctrl.fetch(R.getList.push, { a: 5 }); + ctrl.fetch(R.delete, { id: 1 }); + // @ts-expect-error + ctrl.fetch(R.delete, { id: 1 }, { a: 'x' }); + const ext = R.get.extend({ path: '/r/:id/:sub' }); + // @ts-expect-error (sub required) + ctrl.fetch(ext, { id: 1 }); + ctrl.fetch(ext, { id: 1, sub: 'x' }); +} + +/* ---------------- path template parsing (PathKeys / KeysToArgs) ---------------- */ +export const pth1 = new RestEndpoint({ + path: '/a/:id{/:sub}/*rest\\:lit/:x,:y;:z', + schema: P, +}); +export const pthEsc = new RestEndpoint({ + path: '/esc/\\:notkey/:key', + schema: P, +}); +export const pthWild = new RestEndpoint({ path: '/w/*rest/:tail', schema: P }); +export function usePathProbes() { + const ctrl = useController(); + // all keys present (sub optional), wildcard is a string[] + ctrl.fetch(pth1, { id: 1, rest: ['a'], x: 'x', y: 'y', z: 'z' }); + ctrl.fetch(pth1, { id: 1, sub: 's', rest: ['a'], x: 'x', y: 'y', z: 'z' }); + // @ts-expect-error wildcard must be string[] + ctrl.fetch(pth1, { id: 1, rest: 'a', x: 'x', y: 'y', z: 'z' }); + // @ts-expect-error missing z + ctrl.fetch(pth1, { id: 1, rest: ['a'], x: 'x', y: 'y' }); + // @ts-expect-error escaped segment is not a key + ctrl.fetch(pth1, { id: 1, rest: ['a'], x: 'x', y: 'y', z: 'z', lit: 1 }); + ctrl.fetch(pth1, { + id: 1, + rest: ['a'], + x: 'x', + y: 'y', + z: 'z', + sub: 5, + // @ts-expect-error extra key + extra: 1, + }); + ctrl.fetch(pthEsc, { key: 'k' }); + // @ts-expect-error + ctrl.fetch(pthEsc, { key: 'k', notkey: 'n' }); + // @ts-expect-error missing key + ctrl.fetch(pthEsc, {}); + ctrl.fetch(pthWild, { rest: ['a', 'b'], tail: 't' }); + // @ts-expect-error missing tail + ctrl.fetch(pthWild, { rest: ['a', 'b'] }); + // @ts-expect-error missing rest + ctrl.fetch(pthWild, { tail: 't' }); + ctrl.fetch(ep, { org: 'o', repo: 'r' }); + ctrl.fetch(ep, { org: 'o', repo: 'r', sub: 's', page: 1 }); + // @ts-expect-error missing repo + ctrl.fetch(ep, { org: 'o' }); + // @ts-expect-error sub must be string | number + ctrl.fetch(ep, { org: 'o', repo: 'r', sub: true }); +} diff --git a/packages/rest/typescript-tests/extendPathsGetPage.test.ts b/packages/rest/typescript-tests/extendPathsGetPage.test.ts new file mode 100644 index 000000000000..2eb4807df367 --- /dev/null +++ b/packages/rest/typescript-tests/extendPathsGetPage.test.ts @@ -0,0 +1,166 @@ +// Type-level regression tests for RestEndpoint extend(), paginated(), resource() and +// path parameters. Each @ts-expect-error line must keep erroring; plain lines must keep compiling. +import { useSuspense, useController } from '@data-client/react'; + +import { + Entity, + resource, + RestEndpoint, + RestGenerics, + RestInstanceBase, + PathArgs, + PathKeys, + ShortenPath, + Collection, +} from '@data-client/rest'; + +export function useTypeProbes() { + class User extends Entity { + id = ''; + name = ''; + static key = 'User'; + } + class MyEndpoint extends RestEndpoint { + custom = 5; + method2(x: number): string { + return ''; + } + + optProp?: string; + } + const my = new MyEndpoint({ path: '/my/:id', schema: User }); + const ep = new RestEndpoint({ path: '/a/:b{/:c}/*d', schema: User }); + const epNo = new RestEndpoint({ path: '/a', schema: new Collection([User]) }); + + // ---- extend(): options typing (RestEndpointExtendOptions / PartialPick of extra members) + // @ts-expect-error extra member must keep its type + my.extend({ custom: 'str' }); + my.extend({ + // @ts-expect-error + method2(x: string) { + return 5; + }, + }); + // @ts-expect-error + my.extend({ optProp: 5 }); + // @ts-expect-error chained keeps extra member type + my.extend({ custom: 6 }).extend({ custom: 'x' }); + // @ts-expect-error + ep.extend({ dataExpiryLength: 'x' }); + // @ts-expect-error ContentSchemaGuard + ep.extend({ content: 'blob', schema: User }); + ep.extend({ + // @ts-expect-error + getOptimisticResponse(snap, params: { zzz: number }) { + return params; + }, + }); + ep.extend({ + // @ts-expect-error + key(params: { nope: string }) { + return ''; + }, + }); + // @ts-expect-error + ep.extend({ urlPrefix: 5 }); + + // ---- extend(): result typing (RestExtendedEndpoint Omit/Omit parts) + const x5 = ep.extend({ dataExpiryLength: 5, custom: 'hi' as const }); + // @ts-expect-error O's extra member kept with its type + const n5: number = x5.custom; + const my2 = my.extend({ dataExpiryLength: 1 }); + // @ts-expect-error E's extra member kept with its type + const s2: string = my2.custom; + // @ts-expect-error + const m2: number = my2.method2(1); + const my3 = my.extend({ path: '/z/:q', custom: 7 as const }); + // @ts-expect-error O overrides E member + const c3: 8 = my3.custom; + // @ts-expect-error + const p1: '/wrong' = ep.extend({ path: '/z/:q' }).path; + // @ts-expect-error new path params + ep.extend({ path: '/z/:q' })({ b: '1' }); + ep.extend({ method: 'POST', body: {} as { a: number } })( + { b: '1', d: ['x'] }, + // @ts-expect-error body type + { a: 'x' }, + ); + const x6 = x5.extend({ path: '/p/:p' }); + // @ts-expect-error + x6({ b: 'x' }); + // @ts-expect-error + const n6: number = x6.custom; + + // ---- path parsing (PathKeys / PathArgs) + // @ts-expect-error d required (string[]) + useSuspense(ep, { b: 'x' }); + // @ts-expect-error wildcard is string[] + useSuspense(ep, { b: 'x', d: 'y' }); + // @ts-expect-error excess + useSuspense(ep, { b: 'x', d: ['y'], zz: 1 }); + // @ts-expect-error escaped ':' is not a param + const pa1: PathArgs<'/a\\:b/:c'> = { b: 1, c: 1 }; + // @ts-expect-error b required + const pa2: PathArgs<'/a/:b{/:c}'> = { c: 1 }; + // @ts-expect-error '@' ends token + const pa3: PathArgs<'/a/:b@c/:d'> = { b: 1, 'b@c': 1, d: 1 }; + // @ts-expect-error g required + const pa4: PathArgs<'/a/:b;:c,:d!:e%:f&:g'> = { + b: 1, + c: 1, + d: 1, + e: 1, + f: 1, + }; + const pa5: PathArgs<'/files/*rest/:x{/*opt}'> = { + rest: ['a'], + x: 1, + // @ts-expect-error opt is string[] + opt: 'no', + }; + // @ts-expect-error quotes stripped + const pa6: PathArgs<'/a/:"quoted"'> = { '"quoted"': 1 }; + // @ts-expect-error + const pk2: PathKeys<'/a/:b{/:c}/*d'> = 'c'; + // @ts-expect-error '.' is not a delimiter + const pk3: PathKeys<'/x/:id.json'> = 'id'; + // @ts-expect-error + const pk4: PathKeys<'::a'> = 'b'; + // @ts-expect-error keeps trailing '/' + const sp1: ShortenPath<'/a/:b/:c'> = '/a/:b'; + const ctrl = useController(); + // @ts-expect-error GET endpoint takes no body + ctrl.fetch(my2, { id: 5 }, {}); + // @ts-expect-error + ctrl.fetch(epNo.push, { name: 5 }); + + // ---- getPage (PaginationFieldEndpoint over F & {schema, sideEffect} & O) + const PostResource = resource({ + path: '/groups/:group/posts/:id', + schema: User, + searchParams: {} as { q?: string } | undefined, + paginationField: 'cursor', + }); + // @ts-expect-error cursor required + ctrl.fetch(PostResource.getList.getPage, { group: 'g' }); + // @ts-expect-error + ctrl.fetch(PostResource.getList.getPage, { group: 'g', cursor: ['x'] }); + // @ts-expect-error group required + ctrl.fetch(PostResource.getList.getPage, { cursor: 'x' }); + ctrl.fetch( + PostResource.getList.getPage, + // @ts-expect-error body is Partial + { group: 'g', cursor: 'x' }, + { name: 5 }, + ); + const ExtList = PostResource.extend(Base => ({ + getList: Base.getList.extend({ dataExpiryLength: 5 }), + })); + // @ts-expect-error + ctrl.fetch(ExtList.getList.getPage, { group: 'g' }); + // @ts-expect-error returns User[] + const sGet: string = useSuspense(PostResource.getList.getPage, { + group: 'g', + cursor: 'x', + }); +} diff --git a/website/blog/.cursor/rules/blog-posts.mdc b/website/blog/.cursor/rules/blog-posts.mdc index 8b5463e59783..fbe10b0766f1 100644 --- a/website/blog/.cursor/rules/blog-posts.mdc +++ b/website/blog/.cursor/rules/blog-posts.mdc @@ -85,8 +85,8 @@ As usual, if you have any troubles or questions, feel free to join our [![Chat]( 4. Visualize with ``, not mermaid `xychart-beta` (it has no legend, so overlaid bars are unlabeled) 5. Link to [benchmarks](https://reactive.github.io/data-client/dev/bench/) -`` renders a crawlable table of the raw numbers plus a bar chart of each row's speedup. Pass raw -measurements, not percentages; the component computes the multiplier. +`` renders a bar chart of each row's speedup. The raw numbers appear on hover or tap, and stay in the +page text for crawlers and screen readers. Pass raw measurements, not percentages; the component computes the multiplier. ```mdx import PerfChart from '@site/src/components/PerfChart'; @@ -104,6 +104,26 @@ import PerfChart from '@site/src/components/PerfChart'; `unit` defaults to `ms`, with lower being better; set `higherIsBetter` and `unit="ops/sec"` for throughput. Bar lengths switch to a log scale on their own when speedups differ by more than 10x, and the chart labels it. +Each bar is shaded up to a dashed 1x line (no change), so the bright part is the gain. A row that got slower shows a red, +dashed gap between its bar and 1x; include regressions rather than dropping them. When charts sit next to each other, pass +the same `scaleMax` (the largest speedup among them) so their 1x lines line up. + +For several metrics per row (like time and memory), use `` instead of a markdown table. Each cell shows +paired before/after bars and a colored percent-change badge, with exact numbers on hover or tap; lower is better. + +```mdx +import PerfTable from '@site/src/components/PerfTable'; + + +``` ## Conventions diff --git a/website/blog/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index 24238ad046ec..212c8a09f1ef 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -1,18 +1,22 @@ --- -title: 'v0.19: Batch Controller.set()' -description: Write many entities in one store update with Controller.set([Entity], rows) +title: 'v0.19: Batch Controller.set(), Faster TypeScript' +description: Write many entities in one store update with Controller.set([Entity], rows), and type-check endpoint code about 2x faster authors: [ntucker] tags: [releases, managers, schema] draft: true --- -v0.19 lets a [Manager](/docs/concepts/managers) write a whole batch of streamed rows in one store update, and fixes a -round of TypeScript and Vue issues. +v0.19 lets a [Manager](/docs/concepts/managers) write a whole batch of streamed rows in one store update, type-checks +endpoint code about 2x faster, and fixes a round of TypeScript and Vue issues. **New APIs:** - [Controller.set() with Array schemas](/blog/2026/10/03/v0.19-batch-set#batch-set) - Write many entities in one store update with `ctrl.set([Entity], rows)`, [up to 95x faster](/blog/2026/10/03/v0.19-batch-set#batch-set-performance) than one `set()` per row +**Performance:** + +- [Faster TypeScript](/blog/2026/10/03/v0.19-batch-set#faster-types) - Editors and `tsc` check [RestEndpoint](/rest/api/RestEndpoint) and [resource()](/rest/api/resource) code [about 2x faster with 40% less memory](/blog/2026/10/03/v0.19-batch-set#faster-types-results), catching every error they caught before + **Other Improvements:** - Fix TypeScript 7 module resolution for package `exports`; imports now resolve to declaration files ([#4019](https://github.com/reactive/data-client/pull/4019)) @@ -43,6 +47,7 @@ import AutoPlayVideo from '@site/src/components/AutoPlayVideo'; import DiffEditor from '@site/src/components/DiffEditor'; import HooksPlayground from '@site/src/components/HooksPlayground'; import PerfChart from '@site/src/components/PerfChart'; +import PerfTable from '@site/src/components/PerfTable'; import StackBlitz from '@site/src/components/StackBlitz'; import TypeScriptEditor from '@site/src/components/TypeScriptEditor'; @@ -185,6 +190,141 @@ is the Array schema, so match `action.schema[0]` for `[Ticker]` rather than the ::: +## Faster TypeScript {#faster-types} + +TypeScript re-checks your code on every keystroke in the editor and on every CI build. In apps with many endpoints, +heavy library types show up as laggy autocomplete, red squiggles that take seconds to appear, and slower builds. +v0.19 makes [RestEndpoint](/rest/api/RestEndpoint), [resource()](/rest/api/resource) and `.extend()` much cheaper +to check, with no code changes on your side. TypeScript still reports every error it reported before +([#4173](https://github.com/reactive/data-client/pull/4173)). + +### Results {#faster-types-results} + +Our heaviest stress test, a file of 150 RestEndpoints with long paths, `.extend()` and `.paginated()`, now checks +**2x faster on TypeScript 6** and **2.2x faster on TypeScript 7**, using about **40% less memory**. All numbers compare +the published v0.18.1 packages with v0.19. + + + + + +Endpoint-heavy code does much less type work. Type instantiations are TypeScript's unit of work: they're +deterministic, so they compare cleanly across machines. Union does more work than in v0.18, because v0.19 now +[type-checks set() values](#typed-set) (see below). + + + +Hover or tap a cell for exact numbers. + +150 RestEndpoints with 6-param paths, .extend() and .paginated(), + values: [[3.73, 1.82], [1.61, 0.73], [352, 207]], + }, + { + label: 'Typical app', + description: <>A few resources with .extend(), .paginated() and hooks, + values: [[0.42, 0.42], [0.063, 0.068], [101, 108]], + }, + { + label: 'React hooks', + description: <>40 resources through every hook, plus ctrl.fetch() and ctrl.set(), + values: [[1.21, 1.2], [0.44, 0.42], [164, 160]], + }, + { + label: 'Vue', + description: 'The same 40 resources through every composable', + values: [[0.66, 0.64], [0.17, 0.14], [145, 138]], + }, + { + label: '300 fields', + description: 'One Entity with 300 fields, read and updated 100 times', + values: [[0.43, 0.4], [0.085, 0.058], [111, 105]], + }, + { + label: 'Schemas', + description: 'All, Query, Invalidate, Array, Object and Collection', + values: [[1.01, 1.05], [0.34, 0.33], [155, 151]], + }, + { + label: 'Union', + description: 'A 30-member Union in a Collection and Values', + values: [[0.4, 0.42], [0.075, 0.081], [106, 98]], + }, + { + label: 'set() values', + description: <>1000 ctrl.set() calls on a 30-member Union, a Collection of it and a 300-field Entity, + values: [[1.48, 1.22], [0.5, 0.27], [175, 135]], + }, + { + label: 'set() updaters', + description: <>1000 ctrl.set(Union, args, prev => ...) updaters on a 30-member Union, + values: [[3.2, 10.25], [1.68, 3.77], [378, 404]], + }, + ]} +/> + +The set() rows measure [typed set() values](#typed-set), which v0.18 didn't check at all. Plain values still check +faster than before. Updater functions on large Unions cost more, since TypeScript now checks each updater's return +value; we're working on bringing that back down. + +Small files are dominated by TypeScript's fixed startup cost (loading `lib.dom.d.ts` alone takes about 100MB), so +their time and memory barely move. The savings add up as a codebase grows. + +### What changed {#faster-types-how} + +- `.extend()` and `.paginated()` are shared across all endpoints instead of re-created for each endpoint type. +- Endpoint options infer as plain object types, so TypeScript stops rebuilding them at every use. +- Path parameters like `/users/:id` are read in a single pass. + +Before shipping, we turned off every `@ts-expect-error` in the test suite on TypeScript 4.0 through 7 and confirmed +every error still appears in the same place. + +On TypeScript 5.x and earlier, a `process(value, params)` method passed to `.extend()` also no longer fails with +"implicitly has an 'any' type" under `strict`. + ## Other improvements ### Typed set() values {#typed-set} diff --git a/website/docusaurus.config.ts b/website/docusaurus.config.ts index 4de89d52e80d..5409d05d31c4 100644 --- a/website/docusaurus.config.ts +++ b/website/docusaurus.config.ts @@ -36,6 +36,15 @@ const config: Config = { hooks: { onBrokenMarkdownLinks: 'log', }, + // Vercel preview deploys publish `draft: true` pages so PRs can review them; + // production (VERCEL_ENV=production) and local builds still drop them. + ...(process.env.VERCEL_ENV === 'preview' && { + parseFrontMatter: async params => { + const result = await params.defaultParseFrontMatter(params); + if (result.frontMatter.draft) result.frontMatter.draft = false; + return result; + }, + }), }, headTags: [ { diff --git a/website/src/components/PerfChart.module.css b/website/src/components/PerfChart.module.css index 1d17aaf69e3c..ccd70093ee32 100644 --- a/website/src/components/PerfChart.module.css +++ b/website/src/components/PerfChart.module.css @@ -5,10 +5,65 @@ .bars { display: grid; - grid-template-columns: max-content 1fr max-content; + /* min widths keep bar tracks the same size across charts */ + grid-template-columns: minmax(6.5rem, max-content) 1fr minmax( + 3rem, + max-content + ); align-items: center; gap: 0.5rem 0.75rem; - margin-bottom: 1rem; +} + +.row { + position: relative; + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + align-items: center; + border-radius: var(--ifm-global-radius); + cursor: default; +} + +.row:focus-visible, +.active { + outline: none; + background: var(--ifm-color-emphasis-100); +} + +/* exact numbers, revealed on hover, tap or keyboard focus */ +.tip { + position: absolute; + z-index: 1; + bottom: calc(100% + 0.25rem); + left: 50%; + transform: translateX(-50%); + padding: 0.25rem 0.5rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + font-size: 0.875rem; + white-space: nowrap; + color: var(--ifm-color-emphasis-900); + background: var(--ifm-background-surface-color); + box-shadow: var(--ifm-global-shadow-md); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; +} + +.row:focus-visible .tip, +.active .tip { + opacity: 1; +} + +/* only devices that really hover; on touch, a tap toggles .active instead */ +@media (hover: hover) { + .row:hover { + background: var(--ifm-color-emphasis-100); + } + + .row:hover .tip { + opacity: 1; + } } .label { @@ -16,23 +71,53 @@ color: var(--ifm-color-emphasis-700); } +.track, +.oneLabel { + position: relative; +} + +/* dashed 1x marker across every row */ +.track::after { + content: ''; + position: absolute; + top: -0.25rem; + bottom: -0.25rem; + left: var(--perf-one); + border-left: 2px dashed var(--ifm-color-emphasis-600); +} + +.oneLabel { + left: var(--perf-one); + width: max-content; + transform: translateX(-50%); + font-size: 0.75rem; + line-height: 1; + color: var(--ifm-color-emphasis-700); +} + .bar { display: block; height: 1.25rem; min-width: 2px; border-radius: 0 4px 4px 0; - background: var(--ifm-color-primary); -} - -.table { - font-size: 0.875rem; + /* the part up to 1x is the baseline; the bright part is the gain */ + background: linear-gradient( + to right, + var(--ifm-color-emphasis-300) var(--perf-split, 0), + var(--ifm-color-primary) var(--perf-split, 0) + ); } -.table td { - text-align: right; +.shortfall { + position: absolute; + top: 0; + bottom: 0; + border-radius: 0 4px 4px 0; + background: var(--ifm-color-danger-contrast-background); + border: 1px dashed var(--ifm-color-danger); } -.table th[scope='row'] { - text-align: left; - white-space: nowrap; +.worse { + font-weight: var(--ifm-font-weight-bold); + color: var(--ifm-color-danger); } diff --git a/website/src/components/PerfChart.tsx b/website/src/components/PerfChart.tsx index 6566df308c1c..b06afb5f4fb5 100644 --- a/website/src/components/PerfChart.tsx +++ b/website/src/components/PerfChart.tsx @@ -1,6 +1,7 @@ -import { Fragment } from 'react'; +import type { CSSProperties } from 'react'; import styles from './PerfChart.module.css'; +import usePerfTip from './usePerfTip'; export interface PerfRow { label: string; @@ -10,7 +11,7 @@ export interface PerfRow { value: number; } -/** Benchmark results as a table plus a bar chart of each row's speedup over its baseline */ +/** Bar chart of each row's speedup over its baseline, with exact numbers on hover or focus */ export default function PerfChart({ title, rows, @@ -18,6 +19,7 @@ export default function PerfChart({ valueLabel = 'After', unit = 'ms', higherIsBetter = false, + scaleMax, }: { title: string; rows: PerfRow[]; @@ -26,18 +28,37 @@ export default function PerfChart({ unit?: string; /** Set for throughput metrics like ops/sec; defaults to durations where lower is better */ higherIsBetter?: boolean; + /** Speedup that fills a whole bar; give neighboring charts the same value so their 1x lines align */ + scaleMax?: number; }) { const data = rows.map(row => { const speedup = higherIsBetter ? row.value / row.baseline : row.baseline / row.value; - return { ...row, speedup, multiplier: formatSpeedup(speedup) }; + const multiplier = formatSpeedup(speedup); + // a regression only once it shows below 1x, so 0.99x reads as no change + return { + ...row, + speedup, + multiplier, + worse: speedup < 1 && multiplier !== '1x', + }; }); const speedups = data.map(({ speedup }) => speedup); - const max = Math.max(...speedups); - // log scale keeps a 3x row visible next to a 600x row - const log = max / Math.min(...speedups) > 10; - const scale = (n: number) => (log ? Math.log(Math.max(n, 1)) : n); + // log scale keeps a 3x row visible next to a 600x row; judged on this chart's rows alone + const log = Math.max(...speedups) / Math.min(...speedups) > 10; + const max = Math.max(...speedups, scaleMax ?? 0); + // in log mode the track starts at 1x, or below it when a row regressed, so a slower + // row still gets a bar and a visible gap up to 1x + const min = Math.min(...speedups); + const lo = min < 1 ? min / 2 : 1; + const scale = (n: number) => + log ? Math.log(Math.max(n, lo)) - Math.log(lo) : n; const scaledMax = scale(max) || 1; + // where 1x (no change) falls on the bar track, so bars read against it + const tip = usePerfTip(styles.active); + const one = { + '--perf-one': `${(scale(1) / scaledMax) * 100}%`, + } as CSSProperties; return (

@@ -49,44 +70,48 @@ export default function PerfChart({ )} -
); } diff --git a/website/src/components/PerfTable.module.css b/website/src/components/PerfTable.module.css new file mode 100644 index 000000000000..94e56369d8ee --- /dev/null +++ b/website/src/components/PerfTable.module.css @@ -0,0 +1,123 @@ +/* scroll in place on narrow screens instead of widening the page */ +.scroll { + overflow-x: auto; + margin-bottom: var(--ifm-leading); +} + +.perfTable { + display: table; + margin-bottom: 0; + font-size: 0.875rem; +} + +.perfTable th[scope='row'] { + text-align: left; + min-width: 12rem; +} + +.description { + font-weight: normal; + font-size: 0.8125rem; + color: var(--ifm-color-emphasis-700); +} + +.perfTable td { + position: relative; + min-width: 8rem; + vertical-align: middle; + cursor: default; +} + +.cell { + display: flex; + align-items: center; + gap: 0.5rem; +} + +/* exact numbers, revealed on hover, tap or keyboard focus */ +.tip { + position: absolute; + z-index: 1; + bottom: calc(100% - 0.5rem); + left: 50%; + transform: translateX(-50%); + padding: 0.25rem 0.5rem; + border: 1px solid var(--ifm-color-emphasis-300); + border-radius: var(--ifm-global-radius); + white-space: nowrap; + color: var(--ifm-color-emphasis-900); + background: var(--ifm-background-surface-color); + box-shadow: var(--ifm-global-shadow-md); + opacity: 0; + pointer-events: none; + transition: opacity 0.15s; +} + +.perfTable td:focus-visible .tip, +.perfTable td.active .tip { + opacity: 1; +} + +.perfTable td:focus-visible, +.perfTable td.active { + outline: none; + background: var(--ifm-color-emphasis-100); +} + +/* only devices that really hover; on touch, a tap toggles .active instead */ +@media (hover: hover) { + .perfTable td:hover { + background: var(--ifm-color-emphasis-100); + } + + .perfTable td:hover .tip { + opacity: 1; + } +} + +.better, +.worse, +.same { + min-width: 3.25rem; + padding: 0.1rem 0.4rem; + border-radius: 999px; + text-align: center; + font-size: 0.8125rem; + font-weight: var(--ifm-font-weight-bold); +} + +.better { + color: var(--ifm-color-success-contrast-foreground); + background: var(--ifm-color-success-contrast-background); +} + +.worse { + color: var(--ifm-color-danger-contrast-foreground); + background: var(--ifm-color-danger-contrast-background); +} + +.same { + color: var(--ifm-color-emphasis-700); + background: var(--ifm-color-emphasis-200); +} + +.bars { + display: grid; + flex: 1; + gap: 3px; +} + +.barBefore, +.barAfter { + display: block; + height: 0.5rem; + border-radius: 0 3px 3px 0; +} + +.barBefore { + background: var(--ifm-color-emphasis-400); +} + +.barAfter { + background: var(--ifm-color-primary); +} diff --git a/website/src/components/PerfTable.tsx b/website/src/components/PerfTable.tsx new file mode 100644 index 000000000000..185637edb30f --- /dev/null +++ b/website/src/components/PerfTable.tsx @@ -0,0 +1,91 @@ +import type { ReactNode } from 'react'; + +import styles from './PerfTable.module.css'; +import usePerfTip from './usePerfTip'; + +export interface PerfTableRow { + label: string; + description?: ReactNode; + /** One [before, after] pair per column */ + values: [number, number][]; +} + +/** Before/after results across several metrics; cells show the change, with exact numbers on hover or focus */ +export default function PerfTable({ + columns, + rows, +}: { + /** Header and unit of each metric; lower is better */ + columns: { label: string; unit: string }[]; + rows: PerfTableRow[]; +}) { + const tip = usePerfTip(styles.active); + return ( +
+ + + + + ))} + + + + {rows.map(({ label, description, values }) => ( + + + {values.map(([before, after], i) => { + const { unit } = columns[i]; + const change = Math.round(((after - before) / before) * 100); + const max = Math.max(before, after); + return ( + + ); + })} + + ))} + +
+ {columns.map(({ label }) => ( + {label}
+ {label} + {description && ( +
{description}
+ )} +
+
+ + 0 ? + styles.worse + : styles.same + } + > + {change === 0 ? + 'same' + : `${change > 0 ? '+' : ''}${change}%`} + +
+ {/* exact numbers stay in the DOM for crawlers and screen readers */} + + {before} + {unit} →{' '} + + {after} + {unit} + + +
+
+ ); +} diff --git a/website/src/components/usePerfTip.ts b/website/src/components/usePerfTip.ts new file mode 100644 index 000000000000..295d0ff0bb7d --- /dev/null +++ b/website/src/components/usePerfTip.ts @@ -0,0 +1,12 @@ +import { useState } from 'react'; + +/** Tap (or click) toggles which item shows its exact-numbers tooltip, so touch screens get what hover gives desktop */ +export default function usePerfTip(activeClassName: string) { + const [active, setActive] = useState(); + return (key: string, className = '') => ({ + tabIndex: 0, + className: active === key ? `${className} ${activeClassName}` : className, + onClick: () => setActive(current => (current === key ? undefined : key)), + onBlur: () => setActive(current => (current === key ? undefined : current)), + }); +}