From 1c9ba746f4504e6b13d29e02286d07b5f9f0dd5c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:37:57 +0000 Subject: [PATCH 01/15] enhance(rest): Faster type checking for RestEndpoint, resource() and extend() - Move generic this-typed extend()/paginated() into non-generic mixin interfaces so their signatures are shared instead of re-instantiated per endpoint type - Infer options from a naked O next to Readonly so inference yields a plain object type instead of a reverse-mapped type - Skip Omit<> work in extend() option/result types when there are no extra members; collapse member-less fetch types in getPage - Linear path-key scan and as-free KeysToArgs for literal keys Same diagnostics on TS 4.0-7 (verified with every @ts-expect-error neutralized); paths stress test 822K -> 342K instantiations. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- .changeset/faster-rest-types.md | 23 ++++ .../endpoint/src-4.2-types/endpointTypes.d.ts | 4 +- packages/endpoint/src/endpointTypes.ts | 6 +- .../src-4.1-types/resourceExtendable.d.ts | 4 +- packages/rest/src/RestEndpointTypes.ts | 127 ++++++++++------- packages/rest/src/pathTypes.ts | 130 ++++++++++++++---- packages/rest/src/resource.ts | 12 +- packages/rest/src/resourceExtendable.ts | 6 +- packages/rest/src/resourceExtensionTypes.ts | 15 +- 9 files changed, 234 insertions(+), 93 deletions(-) create mode 100644 .changeset/faster-rest-types.md diff --git a/.changeset/faster-rest-types.md b/.changeset/faster-rest-types.md new file mode 100644 index 000000000000..0bb74f038e9d --- /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.4s → 1.7s) and memory (365MB → 211MB). Files that use `resource()` with React or Vue hooks did about 25% less type work. TypeScript reports the same errors as before. + +On TypeScript 5.x and earlier, methods passed to `.extend()`, like `process()`, now get their parameter types from the endpoint, as they already did on 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; `params` is typed as { id: string | number } +const getUserName = getUser.extend({ + process(value, params) { + return value.name; + }, +}); +``` 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..dbf54b832d31 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -32,6 +32,58 @@ type ContentSchemaGuard = { schema?: undefined } : {}; +/** Generic `this: E` methods live in non-generic mixin interfaces. + * + * TypeScript re-instantiates every member of a generic interface (with fresh + * signature type parameters) for each distinct instantiation. Keeping these + * methods free of F/S/M/O (we read `E['fetch']` instead of `F`) in a + * non-generic interface means one shared signature across all endpoints, + * and identical members when comparing one RestInstanceBase to another. + */ +interface RestInstanceExtenders { + // 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 + > & + // 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 +94,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 +162,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 +175,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 +275,19 @@ 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. + 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..92e8a99e6a32 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,91 @@ 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 & + (Exclude 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. +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 OptionalKeys = Key extends `${string}}` ? Key : never; +type RequiredKeys = Key extends `${string}}` ? never : Key; export type PathArgsAndSearch = unknown extends S ? any diff --git a/packages/rest/src/resource.ts b/packages/rest/src/resource.ts index ab357f878a13..2912636f8f57 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,11 +78,15 @@ 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. + const extraBaseOptions: Record = extraOptions; + const extraMutateOptions: Record = { ...extraOptions }; + const extraPartialOptions: Record = { ...extraOptions }; const get: GetEndpoint<{ path: O['path']; schema: O['schema'] }> = new Endpoint({ - ...extraOptions, + ...extraBaseOptions, path, schema, name: getName('get'), 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; } From 698b471006f358a8891e1c2ed7ae87172509af9c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:38:50 +0000 Subject: [PATCH 02/15] docs(blog): Note faster TypeScript checking in v0.19 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- website/blog/2026-10-03-v0.19-batch-set.md | 34 ++++++++++++++++++++++ 1 file changed, 34 insertions(+) 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 a295d9d35c9b..c2a0dcb58754 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -17,6 +17,7 @@ round of TypeScript and Vue issues. - Fix TypeScript 7 module resolution for package `exports`; imports now resolve to declaration files ([#4019](https://github.com/reactive/data-client/pull/4019)) - [renderDataHook()](/docs/api/renderDataHook) runs provider mount effects when the first render suspends ([#4099](https://github.com/reactive/data-client/pull/4099)) +- [Faster TypeScript checking](/blog/2026/10/03/v0.19-batch-set#faster-types) for [RestEndpoint](/rest/api/RestEndpoint), [resource()](/rest/api/resource) and `.extend()`: up to half the check time and memory, with the same errors reported ([#4173](https://github.com/reactive/data-client/pull/4173)) - [Controller.set() values are typed by the schema](/blog/2026/10/03/v0.19-batch-set#typed-set), so `ctrl.set(new schema.All(Todo), 42)` is a TypeScript error ([#4133](https://github.com/reactive/data-client/pull/4133)) - Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) keep the previous data while new arguments load, instead of returning `undefined` ([#4131](https://github.com/reactive/data-client/pull/4131)) - Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) send fetch errors after arguments change to `onErrorCaptured()` instead of an unhandled promise rejection ([#4135](https://github.com/reactive/data-client/pull/4135)) @@ -186,6 +187,39 @@ is the Array schema, so match `action.schema[0]` for `[Ticker]` rather than the ## Other improvements +### Faster TypeScript checking {#faster-types} + +Your editor and `tsc` now check code that defines or calls endpoints with much less type work. TypeScript still +reports exactly the same errors. + +We measured stress tests that each use one pattern many times: + +- **Endpoints with long paths:** 150 [RestEndpoints](/rest/api/RestEndpoint), each with a long path, `.extend()` and + `.paginated()`, check in about half the time (3.4s to 1.7s on TypeScript 6) and memory (365MB to 211MB). +- **Resources with hooks:** 40 [resource()](/rest/api/resource) definitions called through every React hook, or + every Vue composable, do about 25% less type work. +- **A typical app:** a few resources with `.extend()` and `.paginated()` do 36% less type work. + +

+ +
+ +```mermaid +xychart-beta + title "TypeScript type instantiations (v0.18 = 100, lower is better)" + x-axis ["Long paths", "React hooks", "Vue composables", "Typical app"] + y-axis "Relative work" 0 --> 100 + bar [42, 74, 75, 64] +``` + +
+ +
+ +On TypeScript 5.x and earlier, methods passed to `.extend()`, like `process()`, now get their parameter types from +the endpoint instead of failing with "implicitly has an 'any' type" under `strict` +([#4173](https://github.com/reactive/data-client/pull/4173)). + ### Typed set() values {#typed-set} [Controller.set()](/docs/api/Controller#set) previously accepted any value for a schema, so a typo or a wrong From d66078db789a598c709be4681dff6e6c426fd10c Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:51:56 +0000 Subject: [PATCH 03/15] test(rest): Commit extend/paginate/path type probes; simplify cleanups - Add the must-fail probes used to prove zero type-checking loss as @ts-expect-error tests in packages/rest/typescript-tests - typetest-libcheck: extend() process params aren't implicitly any on a chained extend() (checked on TS 4.0-7 in CI) - Rename path key helpers to avoid clashing with utiltypes RequiredKeys - Drop redundant annotations/casts in resource(); refresh mixin comments - Correct changeset/blog: process params are no longer implicitly any Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- .changeset/faster-rest-types.md | 4 +- examples/todo-app/typetest-libcheck.ts | 11 +- packages/rest/src/RestEndpointTypes.ts | 22 +-- packages/rest/src/pathTypes.ts | 21 +- packages/rest/src/resource.ts | 9 +- .../typescript-tests/extendPaginate.test.ts | 183 ++++++++++++++++++ .../extendPathsGetPage.test.ts | 166 ++++++++++++++++ website/blog/2026-10-03-v0.19-batch-set.md | 4 +- 8 files changed, 390 insertions(+), 30 deletions(-) create mode 100644 packages/rest/typescript-tests/extendPaginate.test.ts create mode 100644 packages/rest/typescript-tests/extendPathsGetPage.test.ts diff --git a/.changeset/faster-rest-types.md b/.changeset/faster-rest-types.md index 0bb74f038e9d..ba181e8bf27d 100644 --- a/.changeset/faster-rest-types.md +++ b/.changeset/faster-rest-types.md @@ -8,13 +8,13 @@ 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.4s → 1.7s) and memory (365MB → 211MB). Files that use `resource()` with React or Vue hooks did about 25% less type work. TypeScript reports the same errors as before. -On TypeScript 5.x and earlier, methods passed to `.extend()`, like `process()`, now get their parameter types from the endpoint, as they already did on TypeScript 6+. TypeScript 4.0 also accepts a chained `.extend().extend()`. +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; `params` is typed as { id: string | number } +// 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/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index dbf54b832d31..c78888d23506 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -32,19 +32,17 @@ type ContentSchemaGuard = { schema?: undefined } : {}; -/** Generic `this: E` methods live in non-generic mixin interfaces. - * - * TypeScript re-instantiates every member of a generic interface (with fresh - * signature type parameters) for each distinct instantiation. Keeping these - * methods free of F/S/M/O (we read `E['fetch']` instead of `F`) in a - * non-generic interface means one shared signature across all endpoints, - * and identical members when comparing one RestInstanceBase to another. +/* 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: 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) + // 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 */ @@ -277,6 +275,8 @@ export type RestEndpointExtendOptions< > & // 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 diff --git a/packages/rest/src/pathTypes.ts b/packages/rest/src/pathTypes.ts index 92e8a99e6a32..e47b5c24d1c6 100644 --- a/packages/rest/src/pathTypes.ts +++ b/packages/rest/src/pathTypes.ts @@ -76,7 +76,7 @@ type PathTokenSlow = K extends `${infer H}${PathDelimiter}${string}` ? PathToken : K; export type KeysToArgs = OptionalArgs & - (Exclude extends never ? unknown : RequiredArgs); + (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. */ @@ -94,16 +94,17 @@ type HasWideKey = // 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. +// 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}`>> ? + [N in KeyName>]?: + | (N extends KeyName, `*${string}`>> ? string[] : never) - | (N extends KeyName, `*${string}`>> ? + | (N extends KeyName, `*${string}`>> ? string | number : never); }; @@ -112,17 +113,17 @@ type RequiredArgs = HasWideKey extends true ? { [K in Key as K extends `${string}}` ? never : KeyName]: KeyVal } : { - [N in KeyName>]: - | (N extends KeyName, `*${string}`>> ? + [N in KeyName>]: + | (N extends KeyName, `*${string}`>> ? string[] : never) - | (N extends KeyName, `*${string}`>> ? + | (N extends KeyName, `*${string}`>> ? string | number : never); }; -type OptionalKeys = Key extends `${string}}` ? Key : never; -type RequiredKeys = Key extends `${string}}` ? never : Key; +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 2912636f8f57..2a4fd76b54d0 100644 --- a/packages/rest/src/resource.ts +++ b/packages/rest/src/resource.ts @@ -81,9 +81,10 @@ This warning will not show in production.`, // 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: Record = { ...extraOptions }; - const extraPartialOptions: Record = { ...extraOptions }; + const extraMutateOptions = { ...extraBaseOptions }; + const extraPartialOptions = { ...extraBaseOptions }; const get: GetEndpoint<{ path: O['path']; schema: O['schema'] }> = new Endpoint({ ...extraBaseOptions, @@ -92,9 +93,9 @@ This warning will not show in production.`, 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/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/2026-10-03-v0.19-batch-set.md b/website/blog/2026-10-03-v0.19-batch-set.md index c2a0dcb58754..ff40a1037556 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -216,8 +216,8 @@ xychart-beta -On TypeScript 5.x and earlier, methods passed to `.extend()`, like `process()`, now get their parameter types from -the endpoint instead of failing with "implicitly has an 'any' type" under `strict` +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` ([#4173](https://github.com/reactive/data-client/pull/4173)). ### Typed set() values {#typed-set} From e426394d28a872ef1c5002d3757096bf30a06b75 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 4 Oct 2026 22:52:10 +0000 Subject: [PATCH 04/15] docs: Update type-check numbers against latest master Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- .changeset/faster-rest-types.md | 2 +- website/blog/2026-10-03-v0.19-batch-set.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.changeset/faster-rest-types.md b/.changeset/faster-rest-types.md index ba181e8bf27d..48743f15d640 100644 --- a/.changeset/faster-rest-types.md +++ b/.changeset/faster-rest-types.md @@ -6,7 +6,7 @@ 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.4s → 1.7s) and memory (365MB → 211MB). Files that use `resource()` with React or Vue hooks did about 25% less type work. TypeScript reports the same errors as before. +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()`. 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 ff40a1037556..d0f7c9ffd3a2 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -195,9 +195,9 @@ reports exactly the same errors. We measured stress tests that each use one pattern many times: - **Endpoints with long paths:** 150 [RestEndpoints](/rest/api/RestEndpoint), each with a long path, `.extend()` and - `.paginated()`, check in about half the time (3.4s to 1.7s on TypeScript 6) and memory (365MB to 211MB). + `.paginated()`, check in about half the time (3.1s to 1.6s on TypeScript 6) and memory (345MB to 207MB). - **Resources with hooks:** 40 [resource()](/rest/api/resource) definitions called through every React hook, or - every Vue composable, do about 25% less type work. + every Vue composable, do 22-25% less type work. - **A typical app:** a few resources with `.extend()` and `.paginated()` do 36% less type work.
@@ -209,7 +209,7 @@ xychart-beta title "TypeScript type instantiations (v0.18 = 100, lower is better)" x-axis ["Long paths", "React hooks", "Vue composables", "Typical app"] y-axis "Relative work" 0 --> 100 - bar [42, 74, 75, 64] + bar [42, 78, 75, 64] ``` From 10d7312884820ef394df34e9c64089d47167bbef Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:32:01 +0000 Subject: [PATCH 05/15] docs(blog): Headline faster TypeScript in v0.19 post with charts and full results Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- website/blog/2026-10-03-v0.19-batch-set.md | 110 ++++++++++++++++----- 1 file changed, 87 insertions(+), 23 deletions(-) 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 fd60eb30dbc5..0942e775aeb5 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -1,23 +1,26 @@ --- -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 up to 2.9x 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 up to 2.9x 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 [up to 2.9x faster with 40% less memory](/blog/2026/10/03/v0.19-batch-set#faster-types-results), catching exactly the same errors + **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)) - [renderDataHook()](/docs/api/renderDataHook) runs provider mount effects when the first render suspends ([#4099](https://github.com/reactive/data-client/pull/4099)) -- [Faster TypeScript checking](/blog/2026/10/03/v0.19-batch-set#faster-types) for [RestEndpoint](/rest/api/RestEndpoint), [resource()](/rest/api/resource) and `.extend()`: up to half the check time and memory, with the same errors reported ([#4173](https://github.com/reactive/data-client/pull/4173)) - [Controller.set() values are typed by the schema](/blog/2026/10/03/v0.19-batch-set#typed-set), so `ctrl.set(new schema.All(Todo), 42)` is a TypeScript error ([#4133](https://github.com/reactive/data-client/pull/4133)) - Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) keep the previous data while new arguments load, instead of returning `undefined` ([#4131](https://github.com/reactive/data-client/pull/4131)) - Vue [useSuspense()](/vue/api/useSuspense) and [useLive()](/vue/api/useLive) send fetch errors after arguments change to `onErrorCaptured()` instead of an unhandled promise rejection ([#4135](https://github.com/reactive/data-client/pull/4135)) @@ -188,40 +191,101 @@ is the Array schema, so match `action.schema[0]` for `[Ticker]` rather than the ::: -## Other improvements +## 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 exactly the same errors +([#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.9x faster on TypeScript 7**, using about **40% less memory**. + +
+ +
-### Faster TypeScript checking {#faster-types} +```mermaid +xychart-beta + title "Check time in seconds (lower is better)" + x-axis ["TypeScript 6", "TypeScript 7"] + y-axis "Seconds" 0 --> 3.5 + bar [3.13, 1.64] + bar [1.59, 0.57] +``` -Your editor and `tsc` now check code that defines or calls endpoints with much less type work. TypeScript still -reports exactly the same errors. +
-We measured stress tests that each use one pattern many times: +
-- **Endpoints with long paths:** 150 [RestEndpoints](/rest/api/RestEndpoint), each with a long path, `.extend()` and - `.paginated()`, check in about half the time (3.1s to 1.6s on TypeScript 6) and memory (345MB to 207MB). -- **Resources with hooks:** 40 [resource()](/rest/api/resource) definitions called through every React hook, or - every Vue composable, do 22-25% less type work. -- **A typical app:** a few resources with `.extend()` and `.paginated()` do 36% less type work. +```mermaid +xychart-beta + title "Memory in MB (lower is better)" + x-axis ["TypeScript 6", "TypeScript 7"] + y-axis "MB" 0 --> 360 + bar [345, 219] + bar [207, 127] +``` + +
+ +
-
+Tall bars are v0.18, short bars are v0.19. + +
+ +Every pattern we measured does less type work. Type instantiations are TypeScript's unit of work: they're +deterministic, so they compare cleanly across machines. + +
+ +
```mermaid xychart-beta - title "TypeScript type instantiations (v0.18 = 100, lower is better)" - x-axis ["Long paths", "React hooks", "Vue composables", "Typical app"] - y-axis "Relative work" 0 --> 100 - bar [42, 78, 75, 64] + title "Type instantiations vs v0.18 (lower is better)" + x-axis ["Long paths", "Typical app", "React hooks", "Vue", "300 fields", "Schemas", "Union"] + y-axis "v0.18 = 100" 0 --> 100 + bar [100, 100, 100, 100, 100, 100, 100] + bar [42, 64, 78, 75, 78, 82, 88] ```
-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` -([#4173](https://github.com/reactive/data-client/pull/4173)). +| Stress test | Type instantiations | TS 6 check time | TS 7 check time | TS 6 memory | +| --- | --- | --- | --- | --- | +| **Long paths:** 150 RestEndpoints with 6-param paths, `.extend()` and `.paginated()` | 822K → 341K (**-58%**) | 3.13s → 1.59s | 1.64s → 0.57s | 345MB → 207MB | +| **Typical app:** a few resources with `.extend()`, `.paginated()` and hooks | 20.2K → 13.0K (**-36%**) | 0.39s → 0.36s | 0.050s → 0.048s | 106MB → 108MB | +| **React hooks:** 40 resources through every hook, plus `ctrl.fetch()` and `ctrl.set()` | 172K → 134K (**-22%**) | 1.20s → 0.98s | 0.38s → 0.29s | 162MB → 160MB | +| **Vue:** the same 40 resources through every composable | 62.9K → 47.3K (**-25%**) | 0.58s → 0.53s | 0.14s → 0.12s | 146MB → 136MB | +| **300 fields:** one Entity with 300 fields, read and updated 100 times | 22.1K → 17.2K (**-22%**) | 0.42s → 0.35s | 0.052s → 0.044s | 107MB → 106MB | +| **Schemas:** All, Query, Invalidate, Array, Object and Collection | 123K → 101K (**-18%**) | 0.88s → 0.84s | 0.28s → 0.26s | 160MB → 152MB | +| **Union:** a 30-member Union in a Collection and Values | 15.4K → 13.6K (**-12%**) | 0.35s → 0.35s | 0.057s → 0.054s | 99MB → 110MB | + +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 +the same errors appear in the same places. + +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} From 063647ced18f579dc010addb73c1bd27b7205947 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:40:24 +0000 Subject: [PATCH 06/15] docs(website): Show draft pages on Vercel preview deploys Production and local builds still drop draft: true pages. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- website/docusaurus.config.ts | 9 +++++++++ 1 file changed, 9 insertions(+) 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: [ { From 03b15ea082a8671a0a811e21d39138236c1fc9ed Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:48:16 +0000 Subject: [PATCH 07/15] docs(blog): Use PerfChart for faster TypeScript results Adds a ratioLabel prop to PerfChart so memory and instantiation charts don't label their multiplier column "Speedup". Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- website/blog/.cursor/rules/blog-posts.mdc | 1 + website/blog/2026-10-03-v0.19-batch-set.md | 105 +++++++++------------ website/src/components/PerfChart.tsx | 5 +- 3 files changed, 51 insertions(+), 60 deletions(-) diff --git a/website/blog/.cursor/rules/blog-posts.mdc b/website/blog/.cursor/rules/blog-posts.mdc index 8b5463e59783..06b3c5720be4 100644 --- a/website/blog/.cursor/rules/blog-posts.mdc +++ b/website/blog/.cursor/rules/blog-posts.mdc @@ -103,6 +103,7 @@ import PerfChart from '@site/src/components/PerfChart'; ``` `unit` defaults to `ms`, with lower being better; set `higherIsBetter` and `unit="ops/sec"` for throughput. +For metrics that aren't speed, like memory, set `ratioLabel` (for example `"Less memory"`) to rename the multiplier column. Bar lengths switch to a log scale on their own when speedups differ by more than 10x, and the chart labels it. ## 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 bf9022682391..5e3a246bdc54 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -202,71 +202,58 @@ to check, with no code changes on your side. TypeScript still reports exactly th Our heaviest stress test, a file of 150 RestEndpoints with long paths, `.extend()` and `.paginated()`, now checks **2x faster on TypeScript 6** and **2.9x faster on TypeScript 7**, using about **40% less memory**. -
- -
- -```mermaid -xychart-beta - title "Check time in seconds (lower is better)" - x-axis ["TypeScript 6", "TypeScript 7"] - y-axis "Seconds" 0 --> 3.5 - bar [3.13, 1.64] - bar [1.59, 0.57] -``` - -
- -
- -```mermaid -xychart-beta - title "Memory in MB (lower is better)" - x-axis ["TypeScript 6", "TypeScript 7"] - y-axis "MB" 0 --> 360 - bar [345, 219] - bar [207, 127] -``` - -
- -
- -
- -Tall bars are v0.18, short bars are v0.19. + -
+ Every pattern we measured does less type work. Type instantiations are TypeScript's unit of work: they're deterministic, so they compare cleanly across machines. -
- -
- -```mermaid -xychart-beta - title "Type instantiations vs v0.18 (lower is better)" - x-axis ["Long paths", "Typical app", "React hooks", "Vue", "300 fields", "Schemas", "Union"] - y-axis "v0.18 = 100" 0 --> 100 - bar [100, 100, 100, 100, 100, 100, 100] - bar [42, 64, 78, 75, 78, 82, 88] -``` - -
- -
+ -| Stress test | Type instantiations | TS 6 check time | TS 7 check time | TS 6 memory | -| --- | --- | --- | --- | --- | -| **Long paths:** 150 RestEndpoints with 6-param paths, `.extend()` and `.paginated()` | 822K → 341K (**-58%**) | 3.13s → 1.59s | 1.64s → 0.57s | 345MB → 207MB | -| **Typical app:** a few resources with `.extend()`, `.paginated()` and hooks | 20.2K → 13.0K (**-36%**) | 0.39s → 0.36s | 0.050s → 0.048s | 106MB → 108MB | -| **React hooks:** 40 resources through every hook, plus `ctrl.fetch()` and `ctrl.set()` | 172K → 134K (**-22%**) | 1.20s → 0.98s | 0.38s → 0.29s | 162MB → 160MB | -| **Vue:** the same 40 resources through every composable | 62.9K → 47.3K (**-25%**) | 0.58s → 0.53s | 0.14s → 0.12s | 146MB → 136MB | -| **300 fields:** one Entity with 300 fields, read and updated 100 times | 22.1K → 17.2K (**-22%**) | 0.42s → 0.35s | 0.052s → 0.044s | 107MB → 106MB | -| **Schemas:** All, Query, Invalidate, Array, Object and Collection | 123K → 101K (**-18%**) | 0.88s → 0.84s | 0.28s → 0.26s | 160MB → 152MB | -| **Union:** a 30-member Union in a Collection and Values | 15.4K → 13.6K (**-12%**) | 0.35s → 0.35s | 0.057s → 0.054s | 99MB → 110MB | +| Stress test | TS 6 check time | TS 7 check time | TS 6 memory | +| --- | --- | --- | --- | +| **Long paths:** 150 RestEndpoints with 6-param paths, `.extend()` and `.paginated()` | 3.13s → 1.59s | 1.64s → 0.57s | 345MB → 207MB | +| **Typical app:** a few resources with `.extend()`, `.paginated()` and hooks | 0.39s → 0.36s | 0.050s → 0.048s | 106MB → 108MB | +| **React hooks:** 40 resources through every hook, plus `ctrl.fetch()` and `ctrl.set()` | 1.20s → 0.98s | 0.38s → 0.29s | 162MB → 160MB | +| **Vue:** the same 40 resources through every composable | 0.58s → 0.53s | 0.14s → 0.12s | 146MB → 136MB | +| **300 fields:** one Entity with 300 fields, read and updated 100 times | 0.42s → 0.35s | 0.052s → 0.044s | 107MB → 106MB | +| **Schemas:** All, Query, Invalidate, Array, Object and Collection | 0.88s → 0.84s | 0.28s → 0.26s | 160MB → 152MB | +| **Union:** a 30-member Union in a Collection and Values | 0.35s → 0.35s | 0.057s → 0.054s | 99MB → 110MB | 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. diff --git a/website/src/components/PerfChart.tsx b/website/src/components/PerfChart.tsx index 6566df308c1c..686ce9b72125 100644 --- a/website/src/components/PerfChart.tsx +++ b/website/src/components/PerfChart.tsx @@ -18,6 +18,7 @@ export default function PerfChart({ valueLabel = 'After', unit = 'ms', higherIsBetter = false, + ratioLabel = 'Speedup', }: { title: string; rows: PerfRow[]; @@ -26,6 +27,8 @@ export default function PerfChart({ unit?: string; /** Set for throughput metrics like ops/sec; defaults to durations where lower is better */ higherIsBetter?: boolean; + /** Column header for the multiplier, like "Less memory" for non-time metrics */ + ratioLabel?: string; }) { const data = rows.map(row => { const speedup = @@ -73,7 +76,7 @@ export default function PerfChart({ {valueLabel} ({unit}) - Speedup + {ratioLabel} From 3fc327dc576a45590b91ae43c57d5dc0d75b3918 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 02:54:41 +0000 Subject: [PATCH 08/15] docs(website): Mark 1x in PerfChart and add visual PerfTable PerfChart bars are shaded up to a dashed 1x line so gains read against no change. PerfTable replaces the v0.19 faster TypeScript details table with before/after values, change badges and paired bars. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- website/blog/.cursor/rules/blog-posts.mdc | 18 +++++ website/blog/2026-10-03-v0.19-batch-set.md | 54 ++++++++++--- website/src/components/PerfChart.module.css | 31 +++++++- website/src/components/PerfChart.tsx | 20 ++++- website/src/components/PerfTable.module.css | 77 ++++++++++++++++++ website/src/components/PerfTable.tsx | 87 +++++++++++++++++++++ 6 files changed, 273 insertions(+), 14 deletions(-) create mode 100644 website/src/components/PerfTable.module.css create mode 100644 website/src/components/PerfTable.tsx diff --git a/website/blog/.cursor/rules/blog-posts.mdc b/website/blog/.cursor/rules/blog-posts.mdc index 06b3c5720be4..88bf6233cd80 100644 --- a/website/blog/.cursor/rules/blog-posts.mdc +++ b/website/blog/.cursor/rules/blog-posts.mdc @@ -105,6 +105,24 @@ import PerfChart from '@site/src/components/PerfChart'; `unit` defaults to `ms`, with lower being better; set `higherIsBetter` and `unit="ops/sec"` for throughput. For metrics that aren't speed, like memory, set `ratioLabel` (for example `"Less memory"`) to rename the multiplier column. 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. + +For several metrics per row (like time and memory), use `` instead of a markdown table. Each cell shows +before → after, a colored percent-change badge and paired bars; 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 5e3a246bdc54..3bf4afdeb078 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -47,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'; @@ -245,15 +246,50 @@ deterministic, so they compare cleanly across machines. ]} /> -| Stress test | TS 6 check time | TS 7 check time | TS 6 memory | -| --- | --- | --- | --- | -| **Long paths:** 150 RestEndpoints with 6-param paths, `.extend()` and `.paginated()` | 3.13s → 1.59s | 1.64s → 0.57s | 345MB → 207MB | -| **Typical app:** a few resources with `.extend()`, `.paginated()` and hooks | 0.39s → 0.36s | 0.050s → 0.048s | 106MB → 108MB | -| **React hooks:** 40 resources through every hook, plus `ctrl.fetch()` and `ctrl.set()` | 1.20s → 0.98s | 0.38s → 0.29s | 162MB → 160MB | -| **Vue:** the same 40 resources through every composable | 0.58s → 0.53s | 0.14s → 0.12s | 146MB → 136MB | -| **300 fields:** one Entity with 300 fields, read and updated 100 times | 0.42s → 0.35s | 0.052s → 0.044s | 107MB → 106MB | -| **Schemas:** All, Query, Invalidate, Array, Object and Collection | 0.88s → 0.84s | 0.28s → 0.26s | 160MB → 152MB | -| **Union:** a 30-member Union in a Collection and Values | 0.35s → 0.35s | 0.057s → 0.054s | 99MB → 110MB | +150 RestEndpoints with 6-param paths, .extend() and .paginated(), + values: [[3.13, 1.59], [1.64, 0.57], [345, 207]], + }, + { + label: 'Typical app', + description: <>A few resources with .extend(), .paginated() and hooks, + values: [[0.39, 0.36], [0.05, 0.048], [106, 108]], + }, + { + label: 'React hooks', + description: <>40 resources through every hook, plus ctrl.fetch() and ctrl.set(), + values: [[1.2, 0.98], [0.38, 0.29], [162, 160]], + }, + { + label: 'Vue', + description: 'The same 40 resources through every composable', + values: [[0.58, 0.53], [0.14, 0.12], [146, 136]], + }, + { + label: '300 fields', + description: 'One Entity with 300 fields, read and updated 100 times', + values: [[0.42, 0.35], [0.052, 0.044], [107, 106]], + }, + { + label: 'Schemas', + description: 'All, Query, Invalidate, Array, Object and Collection', + values: [[0.88, 0.84], [0.28, 0.26], [160, 152]], + }, + { + label: 'Union', + description: 'A 30-member Union in a Collection and Values', + values: [[0.35, 0.35], [0.057, 0.054], [99, 110]], + }, + ]} +/> 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. diff --git a/website/src/components/PerfChart.module.css b/website/src/components/PerfChart.module.css index 1d17aaf69e3c..ab103e6f1bab 100644 --- a/website/src/components/PerfChart.module.css +++ b/website/src/components/PerfChart.module.css @@ -16,12 +16,41 @@ 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); + /* 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 { diff --git a/website/src/components/PerfChart.tsx b/website/src/components/PerfChart.tsx index 686ce9b72125..e78e3c007447 100644 --- a/website/src/components/PerfChart.tsx +++ b/website/src/components/PerfChart.tsx @@ -1,4 +1,4 @@ -import { Fragment } from 'react'; +import { type CSSProperties, Fragment } from 'react'; import styles from './PerfChart.module.css'; @@ -41,6 +41,10 @@ export default function PerfChart({ const log = max / Math.min(...speedups) > 10; const scale = (n: number) => (log ? Math.log(Math.max(n, 1)) : n); const scaledMax = scale(max) || 1; + // where 1x (no change) falls on the bar track, so bars read against it + const one = { + '--perf-one': `${(scale(1) / scaledMax) * 100}%`, + } as CSSProperties; return (
@@ -52,14 +56,22 @@ export default function PerfChart({ )} -
); } From c5fca9daffa3bed8127c8c2bd6ad36161e1d907e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:01:00 +0000 Subject: [PATCH 11/15] docs(website): Scroll PerfTable in place on narrow screens Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- website/src/components/PerfTable.module.css | 8 ++ website/src/components/PerfTable.tsx | 124 ++++++++++---------- 2 files changed, 71 insertions(+), 61 deletions(-) diff --git a/website/src/components/PerfTable.module.css b/website/src/components/PerfTable.module.css index 85d3c7fafe0e..7322e0c4c7ad 100644 --- a/website/src/components/PerfTable.module.css +++ b/website/src/components/PerfTable.module.css @@ -1,4 +1,12 @@ +/* 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; } diff --git a/website/src/components/PerfTable.tsx b/website/src/components/PerfTable.tsx index 6fc6d52ac7cf..26afe27593b6 100644 --- a/website/src/components/PerfTable.tsx +++ b/website/src/components/PerfTable.tsx @@ -19,69 +19,71 @@ export default function PerfTable({ rows: PerfTableRow[]; }) { 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}
- )} -
-
-
+ ); } From 3970ebb7466cff7d50aef89c4596df7b5c892b9c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 03:05:53 +0000 Subject: [PATCH 12/15] docs(website): Align neighboring PerfChart 1x lines and add tap tooltips PerfChart takes scaleMax so charts next to each other share a scale, and fixed label and multiplier column widths keep their 1x lines aligned. PerfChart rows and PerfTable cells now toggle their exact numbers on tap; hover styles only apply on devices that hover. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01A9V2RBxtoPnFRffXJEqvnk --- website/blog/.cursor/rules/blog-posts.mdc | 3 ++- website/blog/2026-10-03-v0.19-batch-set.md | 3 +++ website/src/components/PerfChart.module.css | 27 ++++++++++++++++----- website/src/components/PerfChart.tsx | 9 +++++-- website/src/components/PerfTable.module.css | 21 ++++++++++++---- website/src/components/PerfTable.tsx | 4 ++- website/src/components/usePerfTip.ts | 12 +++++++++ 7 files changed, 64 insertions(+), 15 deletions(-) create mode 100644 website/src/components/usePerfTip.ts diff --git a/website/blog/.cursor/rules/blog-posts.mdc b/website/blog/.cursor/rules/blog-posts.mdc index bff85191f92d..b9ddcf5d13f5 100644 --- a/website/blog/.cursor/rules/blog-posts.mdc +++ b/website/blog/.cursor/rules/blog-posts.mdc @@ -104,7 +104,8 @@ 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. +Each bar is shaded up to a dashed 1x line (no change), so the bright part is the gain. 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. 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 04f055138b94..81efaffbff7b 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -205,6 +205,7 @@ Our heaviest stress test, a file of 150 RestEndpoints with long paths, `.extend( { const speedup = @@ -33,12 +37,13 @@ export default function PerfChart({ return { ...row, speedup, multiplier: formatSpeedup(speedup) }; }); const speedups = data.map(({ speedup }) => speedup); - const max = Math.max(...speedups); + const max = Math.max(...speedups, scaleMax ?? 0); // 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); 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; @@ -59,7 +64,7 @@ export default function PerfChart({ 1x {data.map(({ label, baseline, value, speedup, multiplier }) => ( -
+
{label}