From c5815b27c537860f05a76eed67618d1c957e7e48 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:18:10 +0000 Subject: [PATCH 1/7] fix(rest): Keep custom endpoints in resource().extend({...}) result type resource().extend({ get: ... }) returned CustomResource, which only typed the five standard endpoints, so members added earlier with extend('key', options) (and the deprecated create) disappeared from the type while still present at runtime. Also add regression tests that extend() option types are checked (dataExpiryLength etc.) across endpoint and resource extend forms. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0117wFk9RgsnzgqUgJHDepyY --- .changeset/resource-extend-keeps-members.md | 17 ++++++ .../src-4.1-types/resourceExtendable.d.ts | 2 +- packages/rest/src/resourceExtendable.ts | 4 +- .../typescript-tests/extendOptions.test.ts | 58 +++++++++++++++++++ 4 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 .changeset/resource-extend-keeps-members.md create mode 100644 packages/rest/typescript-tests/extendOptions.test.ts diff --git a/.changeset/resource-extend-keeps-members.md b/.changeset/resource-extend-keeps-members.md new file mode 100644 index 000000000000..159bcaf420b7 --- /dev/null +++ b/.changeset/resource-extend-keeps-members.md @@ -0,0 +1,17 @@ +--- +'@data-client/rest': patch +--- + +Fix `resource().extend({ get: ... })` dropping endpoints added earlier from its type + +Customizing a resource's standard endpoints with `.extend({ ... })` kept every endpoint at runtime, but TypeScript lost the ones added before it with `.extend('name', options)`, and the deprecated `create`. Using them was a type error even though they worked. + +```ts +const UserResource = resource({ path: '/users/:id', schema: User }) + .extend('current', { path: '/user' }) + .extend({ get: { dataExpiryLength: 60000 } }); + +// Before: Property 'current' does not exist +// After: no error +const me = await ctrl.fetch(UserResource.current); +``` diff --git a/packages/rest/src-4.1-types/resourceExtendable.d.ts b/packages/rest/src-4.1-types/resourceExtendable.d.ts index aa00b3ea7a57..4c46be2ecd1f 100644 --- a/packages/rest/src-4.1-types/resourceExtendable.d.ts +++ b/packages/rest/src-4.1-types/resourceExtendable.d.ts @@ -15,7 +15,7 @@ export interface Extendable(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; - extend(this: R, options: ResourceEndpointExtensions): CustomResource; + extend(this: R, options: ResourceEndpointExtensions): CustomResource & Omit; extend>(this: R, extender: (baseResource: R) => T): ExtendedResource; } //# sourceMappingURL=resourceExtendable.d.ts.map \ No newline at end of file diff --git a/packages/rest/src/resourceExtendable.ts b/packages/rest/src/resourceExtendable.ts index 3fccc3796b81..07eb3788284e 100644 --- a/packages/rest/src/resourceExtendable.ts +++ b/packages/rest/src/resourceExtendable.ts @@ -87,7 +87,9 @@ export interface Extendable< PartialUpdate, Delete >, - ): CustomResource; + ): CustomResource & + // keeps members like those added by extend('key', options) + Omit; extend< R extends ResourceInterface, T extends Record, diff --git a/packages/rest/typescript-tests/extendOptions.test.ts b/packages/rest/typescript-tests/extendOptions.test.ts new file mode 100644 index 000000000000..93aeb6515901 --- /dev/null +++ b/packages/rest/typescript-tests/extendOptions.test.ts @@ -0,0 +1,58 @@ +// Options passed to extend() are checked against their types, and +// resource().extend() keeps every member of the resource it extends. +// Each @ts-expect-error line must keep erroring; plain lines must keep compiling. +import { User } from '__tests__/new'; + +import resource from '../src/resource'; +import RestEndpoint from '../src/RestEndpoint'; + +const ep = new RestEndpoint({ path: '/users/:id', schema: User }); +const UserResource = resource({ path: '/users/:id', schema: User }); + +/* ---------------- endpoint option types ---------------- */ +export const okExpiry = ep.extend({ dataExpiryLength: 5 }); +// @ts-expect-error +ep.extend({ dataExpiryLength: 'long' }); +// @ts-expect-error +ep.extend({ path: '/u/:uid' }).extend({ dataExpiryLength: 'long' }); +// @ts-expect-error +ep.extend({ path: '/u/:uid', dataExpiryLength: 'long' }); +// @ts-expect-error +ep.extend({ errorExpiryLength: 'long' }); +// @ts-expect-error +ep.extend({ pollFrequency: 'long' }); +// @ts-expect-error +ep.extend({ invalidIfStale: 'yes' }); +// @ts-expect-error +ep.extend({ urlPrefix: 5 }); +// @ts-expect-error +UserResource.get.extend({ dataExpiryLength: 'long' }); +// @ts-expect-error +UserResource.extend('get', { dataExpiryLength: 'long' }); +// @ts-expect-error +UserResource.extend('current', { dataExpiryLength: 'long' }); +// @ts-expect-error +UserResource.extend({ get: { dataExpiryLength: 'long' } }); + +/* ---------------- resource().extend() keeps members ---------------- */ +const CurrentUserResource = UserResource.extend('current', { + path: '/user', +}).extend({ get: { dataExpiryLength: 1000 } }); +export const current: () => Promise = CurrentUserResource.current; +export const get = CurrentUserResource.get({ id: 5 }); +// deprecated, but still there at runtime +export const create = CurrentUserResource.create; +// @ts-expect-error not a member +CurrentUserResource.other; + +// extend({...}) results can be extended again +const Chained = CurrentUserResource.extend({ + update: { dataExpiryLength: 5 }, +}).extend('me', { path: '/me' }); +export const chained = [ + Chained.current(), + Chained.me(), + Chained.get({ id: 1 }), +]; +// @ts-expect-error +Chained.extend({ update: { dataExpiryLength: 'long' } }); From 4dfee81f4a368b311d51162c92fc9f6c316afc27 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:18:47 +0000 Subject: [PATCH 2/7] docs(blog): Note resource().extend() keeps custom endpoints in v0.19 Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0117wFk9RgsnzgqUgJHDepyY --- website/blog/2026-10-03-v0.19-batch-set.md | 1 + 1 file changed, 1 insertion(+) 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 46ecdb184028..64cf84a9595f 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -38,6 +38,7 @@ endpoint code about 2x faster, and fixes a round of TypeScript and Vue issues. - [useCache()](/docs/api/useCache) and [useDLE()](/docs/api/useDLE) return `undefined` for a deleted entity whose refetch failed, instead of a truthy `Symbol` that slipped past `if (!data)` checks; [remove any workarounds](/blog/2026/10/03/v0.19-batch-set#deleted-entity-undefined) ([#4150](https://github.com/reactive/data-client/pull/4150)) - Vue [useFetch()](/vue/api/useFetch) keeps its data from being garbage collected while mounted, like [useSuspense()](/vue/api/useSuspense), so a configured `gcPolicy` no longer evicts prefetched data that components read later ([#4152](https://github.com/reactive/data-client/pull/4152)) - Apps with [Redux DevTools](/docs/getting-started/debugging) open no longer stutter in development on large stores or frequent updates; each update [serializes 40-60x faster](/blog/2026/10/03/v0.19-batch-set#devtools-perf) ([#4163](https://github.com/reactive/data-client/pull/4163)) +- [resource().extend()](/rest/api/resource#extend) with an object of endpoint overrides keeps endpoints added earlier with `.extend('name', options)` in its type, so using them is no longer a TypeScript error ([#4184](https://github.com/reactive/data-client/pull/4184)) - Vue [useFetch()](/vue/api/useFetch) is typed as the read-only `Ref` it returns, so `promise.resolved` (always `undefined`) is now a TypeScript error; read `promise.value.resolved` instead ([#4114](https://github.com/reactive/data-client/pull/4114)) - Vue `createDataClient()` is deprecated, since it's internal to [DataClientPlugin](/vue/api/DataClientPlugin); install the plugin with `app.use(DataClientPlugin, options)` instead of calling it directly ([#4193](https://github.com/reactive/data-client/pull/4193)) From b01046066d90952bc04388f09321f061493196c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:21:13 +0000 Subject: [PATCH 3/7] test(rest): Trim extend option type tests already covered elsewhere Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0117wFk9RgsnzgqUgJHDepyY --- .../rest/typescript-tests/extendOptions.test.ts | 17 ++--------------- 1 file changed, 2 insertions(+), 15 deletions(-) diff --git a/packages/rest/typescript-tests/extendOptions.test.ts b/packages/rest/typescript-tests/extendOptions.test.ts index 93aeb6515901..2c3e49e595bd 100644 --- a/packages/rest/typescript-tests/extendOptions.test.ts +++ b/packages/rest/typescript-tests/extendOptions.test.ts @@ -9,23 +9,12 @@ import RestEndpoint from '../src/RestEndpoint'; const ep = new RestEndpoint({ path: '/users/:id', schema: User }); const UserResource = resource({ path: '/users/:id', schema: User }); -/* ---------------- endpoint option types ---------------- */ -export const okExpiry = ep.extend({ dataExpiryLength: 5 }); -// @ts-expect-error -ep.extend({ dataExpiryLength: 'long' }); +/* ---------------- option types (plain ep.extend() is in extendPathsGetPage) ---------------- */ // @ts-expect-error ep.extend({ path: '/u/:uid' }).extend({ dataExpiryLength: 'long' }); // @ts-expect-error ep.extend({ path: '/u/:uid', dataExpiryLength: 'long' }); // @ts-expect-error -ep.extend({ errorExpiryLength: 'long' }); -// @ts-expect-error -ep.extend({ pollFrequency: 'long' }); -// @ts-expect-error -ep.extend({ invalidIfStale: 'yes' }); -// @ts-expect-error -ep.extend({ urlPrefix: 5 }); -// @ts-expect-error UserResource.get.extend({ dataExpiryLength: 'long' }); // @ts-expect-error UserResource.extend('get', { dataExpiryLength: 'long' }); @@ -38,7 +27,7 @@ UserResource.extend({ get: { dataExpiryLength: 'long' } }); const CurrentUserResource = UserResource.extend('current', { path: '/user', }).extend({ get: { dataExpiryLength: 1000 } }); -export const current: () => Promise = CurrentUserResource.current; +export const current = CurrentUserResource.current(); export const get = CurrentUserResource.get({ id: 5 }); // deprecated, but still there at runtime export const create = CurrentUserResource.create; @@ -54,5 +43,3 @@ export const chained = [ Chained.me(), Chained.get({ id: 1 }), ]; -// @ts-expect-error -Chained.extend({ update: { dataExpiryLength: 'long' } }); From 205699662fa318c37635bd785a50fb22af46fff9 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:36:06 +0000 Subject: [PATCH 4/7] enhance(rest): Type process() params in RestEndpoint constructor and resource().extend({...}) process(value, params) passed to new RestEndpoint() or to resource().extend({ get: {...} }) still typed params as any. Move the typed process member into RestEndpointOptions, so every options form gets it, and constrain the constructor and resource().extend({...}) options by generics without process so TypeScript 4.1-5.x contextually types it. Type params and body by position instead of a rest tuple. A deferred tuple (a generic path, as in endpoint factory functions) rejected process(value, params) for having too many params, which also broke generic .extend() calls since #4183. Endpoints that take no args keep params as any, so existing annotated process(value, params: any) compiles. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0117wFk9RgsnzgqUgJHDepyY --- .../references/RestEndpoint.md | 4 +- .../references/RestEndpoint.vue.md | 4 +- .../references/RestEndpoint.md | 4 +- .../references/RestEndpoint.vue.md | 4 +- .changeset/extend-process-params.md | 4 +- docs/rest/api/RestEndpoint.md | 4 +- .../src-4.1-types/resourceExtendable.d.ts | 4 +- packages/rest/src/RestEndpointTypes.ts | 31 ++++-- packages/rest/src/resourceExtendable.ts | 11 +- packages/rest/src/resourceExtensionTypes.ts | 11 +- .../typescript-tests/extendProcess.test.ts | 102 +++++++++++++++++- website/blog/2026-10-03-v0.19-batch-set.md | 13 +-- 12 files changed, 150 insertions(+), 46 deletions(-) diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md index f787625296b1..d7d293054d49 100644 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.md +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.md @@ -778,8 +778,8 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). -`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the -resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md index 86daf4d4baf4..25a212592c53 100644 --- a/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest-setup/references/RestEndpoint.vue.md @@ -778,8 +778,8 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). -`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the -resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.md b/.agents/skills/data-client-rest/references/RestEndpoint.md index def2076036ed..fe7256e03f0c 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.md @@ -778,8 +778,8 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). -`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the -resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md index ba46e7b661a4..a42827146643 100644 --- a/.agents/skills/data-client-rest/references/RestEndpoint.vue.md +++ b/.agents/skills/data-client-rest/references/RestEndpoint.vue.md @@ -778,8 +778,8 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). -`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the -resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/.changeset/extend-process-params.md b/.changeset/extend-process-params.md index 1957f4c3b373..29b6af3714db 100644 --- a/.changeset/extend-process-params.md +++ b/.changeset/extend-process-params.md @@ -2,9 +2,9 @@ '@data-client/rest': patch --- -Type the `params` and `body` that `process()` receives in `.extend()` +Type the `params` and `body` that `process()` receives in `RestEndpoint` options -A `process(value, params)` method passed to `RestEndpoint.extend()` or `resource().extend('get', {...})` used to get `params` typed as `any`, so a typo or a wrong assumption about a path parameter went unnoticed until runtime. They are now typed from the extended endpoint's `path`, `searchParams` and `body`, including a `path` set in the same `.extend()` call. +A `process(value, params)` method passed to `new RestEndpoint()`, `RestEndpoint.extend()` or `resource().extend()` used to get `params` typed as `any`, so a typo or a wrong assumption about a path parameter went unnoticed until runtime. They are now typed from the endpoint's `path`, `searchParams` and `body`, including a `path` set in the same `.extend()` call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/docs/rest/api/RestEndpoint.md b/docs/rest/api/RestEndpoint.md index c8a08aca8ff2..4005e9cd4be6 100644 --- a/docs/rest/api/RestEndpoint.md +++ b/docs/rest/api/RestEndpoint.md @@ -900,8 +900,8 @@ Override this for advanced cases like extracting headers alongside the body. Perform any transforms with the parsed result. Defaults to identity function (do nothing). -`args` are the arguments the endpoint was called with. In [extend()](#extend), they are typed from the -resulting endpoint's [path](#path), [searchParams](#searchParams) and [body](#body). +`args` are the arguments the endpoint was called with. They are typed from the endpoint's [path](#path), +[searchParams](#searchParams) and [body](#body), including those set in the same [extend()](#extend) call. ```ts const getUser = new RestEndpoint({ path: '/users/:id' }); diff --git a/packages/rest/src-4.1-types/resourceExtendable.d.ts b/packages/rest/src-4.1-types/resourceExtendable.d.ts index 4c46be2ecd1f..a9155575af6a 100644 --- a/packages/rest/src-4.1-types/resourceExtendable.d.ts +++ b/packages/rest/src-4.1-types/resourceExtendable.d.ts @@ -2,7 +2,7 @@ import type { EndpointInterface, EndpointToFunction } from '@data-client/endpoin import type { ResourcePath } from './pathTypes.js'; import type { ResourceExtension, ResourceEndpointExtensions, CustomResource, ExtendedResource } from './resourceExtensionTypes.js'; import type { ResourceGenerics, ResourceInterface } from './resourceTypes.js'; -import type { ExtendableRestGenerics, PartialRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, RestInstanceBase } from './RestEndpoint.js'; +import type { ExtendableRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, RestInstanceBase } from './RestEndpoint.js'; export interface Extendable(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; - extend(this: R, options: ResourceEndpointExtensions): CustomResource & Omit; + extend(this: R, options: ResourceEndpointExtensions): CustomResource & Omit; extend>(this: R, extender: (baseResource: R) => T): ExtendedResource; } //# sourceMappingURL=resourceExtendable.d.ts.map \ No newline at end of file diff --git a/packages/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index 3341c76771c0..33b9e5ef243b 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -272,13 +272,7 @@ export type RestEndpointExtendOptions< OptionsToFunction, 'schema' extends keyof O ? Extract : E['schema'] -> & { - /** @see https://dataclient.io/rest/api/RestEndpoint#process */ - process?( - value: any, - ...args: ProcessArgs>> - ): any; -} & +> & // 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 @@ -298,7 +292,8 @@ export type RestEndpointExtendOptions< type ProcessArgs = // fast path for fixed-length tuples; [A['length']] can't be checked against a union of lengths number extends A['length'] ? A - : [A['length']] extends [0] ? A + : // takes no args: keep accepting process(value, params: any) and similar + [A['length']] extends [0] ? any[] : [A['length']] extends [1] ? A : [A['length']] extends [2] ? A : IsUnion extends false ? A @@ -475,8 +470,9 @@ export type RestExtendedEndpoint< (keyof E extends KeyofRestEndpoint ? unknown : Omit); -/** PartialRestGenerics without `process`. extend() constrains its options to this, so the - * `process` member of RestEndpointExtendOptions is the only contextual type for process() params. +/** PartialRestGenerics without `process`. Options passed to the constructor, extend() and + * resource().extend() are constrained by this, so RestEndpointOptions' typed `process` member is + * the only contextual type for process() params. */ export interface ExtendableRestGenerics { /** @see https://dataclient.io/rest/api/RestEndpoint#path */ @@ -507,6 +503,10 @@ export interface PartialRestGenerics extends ExtendableRestGenerics { export interface RestGenerics extends PartialRestGenerics { readonly path: string; } +/** RestGenerics without `process`; see ExtendableRestGenerics */ +interface ConstructorRestGenerics extends ExtendableRestGenerics { + readonly path: string; +} export type PaginationEndpoint< E extends FetchFunction & RestGenerics & { sideEffect?: boolean | undefined }, @@ -695,6 +695,15 @@ export interface RestEndpointOptions< fetch?: F; key?(...args: Parameters): string; url?(...args: Parameters): string; + /** @see https://dataclient.io/rest/api/RestEndpoint#process */ + // Positional rather than `...args: ProcessArgs<>`: when F is generic (like a path type parameter), + // a deferred rest tuple would reject process(value, params) for having too many params. + process?( + value: any, + params: ProcessArgs>[0], + body: ProcessArgs>[1], + ...rest: any[] + ): any; update?: EndpointUpdateFunction; } @@ -754,7 +763,7 @@ export interface RestEndpointConstructor { * * @see https://dataclient.io/rest/api/RestEndpoint */ - new ({ + new ({ method, sideEffect, name, diff --git a/packages/rest/src/resourceExtendable.ts b/packages/rest/src/resourceExtendable.ts index 07eb3788284e..7c9f7f6738c6 100644 --- a/packages/rest/src/resourceExtendable.ts +++ b/packages/rest/src/resourceExtendable.ts @@ -17,7 +17,6 @@ import type { } from './resourceTypes.js'; import type { ExtendableRestGenerics, - PartialRestGenerics, RestEndpointExtendOptions, RestExtendedEndpoint, RestInstanceBase, @@ -72,11 +71,11 @@ export interface Extendable< }; extend< R extends ResourceInterface, - Get extends PartialRestGenerics = {}, - GetList extends PartialRestGenerics = {}, - Update extends PartialRestGenerics = {}, - PartialUpdate extends PartialRestGenerics = {}, - Delete extends PartialRestGenerics = {}, + Get extends ExtendableRestGenerics = {}, + GetList extends ExtendableRestGenerics = {}, + Update extends ExtendableRestGenerics = {}, + PartialUpdate extends ExtendableRestGenerics = {}, + Delete extends ExtendableRestGenerics = {}, >( this: R, options: ResourceEndpointExtensions< diff --git a/packages/rest/src/resourceExtensionTypes.ts b/packages/rest/src/resourceExtensionTypes.ts index 9a6e224248b3..41d958ca25bb 100644 --- a/packages/rest/src/resourceExtensionTypes.ts +++ b/packages/rest/src/resourceExtensionTypes.ts @@ -8,6 +8,7 @@ import type { ResourcePath } from './pathTypes.js'; import { Extendable } from './resourceExtendable.js'; import { ResourceGenerics, ResourceInterface } from './resourceTypes.js'; import type { + ExtendableRestGenerics, PartialRestGenerics, RestExtendedEndpoint, RestInstanceBase, @@ -59,11 +60,11 @@ export type ExtendedResource< export interface ResourceEndpointExtensions< R extends ResourceInterface, - Get extends PartialRestGenerics = {}, - GetList extends PartialRestGenerics = {}, - Update extends PartialRestGenerics = {}, - PartialUpdate extends PartialRestGenerics = {}, - Delete extends PartialRestGenerics = {}, + Get extends ExtendableRestGenerics = {}, + GetList extends ExtendableRestGenerics = {}, + Update extends ExtendableRestGenerics = {}, + PartialUpdate extends ExtendableRestGenerics = {}, + Delete extends ExtendableRestGenerics = {}, > { readonly get?: RestEndpointOptions< unknown extends Get ? EndpointToFunction diff --git a/packages/rest/typescript-tests/extendProcess.test.ts b/packages/rest/typescript-tests/extendProcess.test.ts index f1f6a6aea434..8cd8eae0a0ba 100644 --- a/packages/rest/typescript-tests/extendProcess.test.ts +++ b/packages/rest/typescript-tests/extendProcess.test.ts @@ -1,4 +1,5 @@ -// Type-level regression tests for the params passed to process() in RestEndpoint.extend(). +// Type-level regression tests for the params passed to process() in RestEndpoint options +// (constructor, .extend() and resource().extend()). // Each @ts-expect-error line must keep erroring; plain lines must keep compiling. import { Entity, RestEndpoint, resource } from '@data-client/rest'; @@ -151,7 +152,7 @@ export const optionalBodyChild = new OptionalBodyEndpoint({ }, }); -// a single custom fetch signature is kept as is, including a third argument +// a custom fetch's params are kept as is; arguments after body are untyped class ExtraArgEndpoint extends RestEndpoint<{ path: '/users/:id' }> { fetch = async ( params: { id: string | number }, @@ -163,9 +164,102 @@ export const extraArgChild = new ExtraArgEndpoint({ path: '/users/:id', }).extend({ process(value, params, body, extra) { + // @ts-expect-error id is string | number + params.id.toFixed(); const e: number | undefined = extra; - // @ts-expect-error extra may be undefined - extra.toFixed(); return [params.id, e]; }, }); + +/* ---------------- constructor ---------------- */ +export const constructed = new RestEndpoint({ + path: '/users/:id', + process(value, params) { + // @ts-expect-error id is string | number + params.id.toFixed(); + return `${params.id}`; + }, +}); +export const constructedResult: Promise = constructed({ id: 1 }); + +export const constructedPost = new RestEndpoint({ + path: '/users/:id', + method: 'POST', + body: {} as { name: string }, + process(value, params, body) { + // @ts-expect-error not in body + body.age; + return body.name; + }, +}); +export const constructedPostResult: Promise = constructedPost( + { id: 1 }, + { name: 'a' }, +); + +export const constructedSearch = new RestEndpoint({ + path: '/users', + searchParams: {} as { page?: number }, + process(value, params) { + // @ts-expect-error page is a number + params?.page?.toUpperCase(); + return params?.page; + }, +}); + +/* ---------------- resource().extend({ ... }) ---------------- */ +export const CustomUser = UserResource.extend({ + get: { + process(value, params) { + // @ts-expect-error id is string | number + params.id.toFixed(); + return value; + }, + }, + getList: { + path: '/groups/:group/users', + process(value, params) { + const g: string | number = params.group; + // @ts-expect-error not a param of getList's new path + params.id; + return value; + }, + }, + update: { + process(value, params, body) { + const id: string | number = params.id; + return [id, body]; + }, + }, +}); + +/* ---------------- existing code keeps compiling ---------------- */ +// annotated params on an endpoint that takes no args +export const noArgs = new RestEndpoint({ + path: '/users', + process(value: any, params: any) { + return value; + }, +}); +// generic path, as in a factory function +export function makeEndpoint

(path: P) { + return [ + new RestEndpoint({ + path, + process(value: any, params: any) { + return value; + }, + }), + new RestEndpoint({ + path, + process(value, params) { + return [value, params]; + }, + }), + new RestEndpoint({ path }).extend({ + process(value: any, params: any) { + return value; + }, + }), + ]; +} 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 64cf84a9595f..ed0272bdfdd0 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -23,7 +23,7 @@ endpoint code about 2x faster, and fixes a 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)) - [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)) -- [process() in .extend() gets typed params](/blog/2026/10/03/v0.19-batch-set#extend-process-params), so a wrong path parameter is a TypeScript error instead of `undefined` at runtime ([#4183](https://github.com/reactive/data-client/pull/4183)) +- [process() gets typed params](/blog/2026/10/03/v0.19-batch-set#extend-process-params) in `new RestEndpoint()`, `.extend()` and `resource().extend()`, so a wrong path parameter is a TypeScript error instead of `undefined` at runtime ([#4183](https://github.com/reactive/data-client/pull/4183), [#4184](https://github.com/reactive/data-client/pull/4184)) - 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)) - Vue [useSuspense()](/vue/api/useSuspense), [useDLE()](/vue/api/useDLE) and [useFetch()](/vue/api/useFetch) no longer refetch stale data on every store update, so a `controller.set()` is not overwritten ([#4134](https://github.com/reactive/data-client/pull/4134)) @@ -357,12 +357,13 @@ reversing `process()`. When each member declares its discriminator as a literal If code that previously compiled now fails here, it was writing data its schema doesn't describe. Fix the value, or widen the Entity's field types if the data really can take that shape. -### Typed process() params in extend() {#extend-process-params} +### Typed process() params {#extend-process-params} -A [process()](/rest/api/RestEndpoint#process) method passed to [.extend()](/rest/api/RestEndpoint#extend) got -`params` typed as `any`, so reading a parameter the endpoint doesn't have compiled and returned `undefined` at -runtime. `params` and `body` are now typed from the extended endpoint, including a `path` set in the same call -([#4183](https://github.com/reactive/data-client/pull/4183)): +A [process()](/rest/api/RestEndpoint#process) method passed to `new RestEndpoint()`, +[.extend()](/rest/api/RestEndpoint#extend) or [resource().extend()](/rest/api/resource#extend) got `params` typed as +`any`, so reading a parameter the endpoint doesn't have compiled and returned `undefined` at runtime. `params` and +`body` are now typed from the endpoint, including a `path` set in the same `.extend()` call +([#4183](https://github.com/reactive/data-client/pull/4183), [#4184](https://github.com/reactive/data-client/pull/4184)): From 24239d0e09251f70ddcd4b62800319e9695fd823 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:41:11 +0000 Subject: [PATCH 5/7] refactor(rest): Index process() params directly instead of merging a tuple Same diagnostics; positional params made ProcessArgs' tuple merging unnecessary. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0117wFk9RgsnzgqUgJHDepyY --- packages/rest/src/RestEndpointTypes.ts | 50 +++++--------------------- 1 file changed, 9 insertions(+), 41 deletions(-) diff --git a/packages/rest/src/RestEndpointTypes.ts b/packages/rest/src/RestEndpointTypes.ts index 33b9e5ef243b..aac2c9a4ac2b 100644 --- a/packages/rest/src/RestEndpointTypes.ts +++ b/packages/rest/src/RestEndpointTypes.ts @@ -282,45 +282,13 @@ export type RestEndpointExtendOptions< keyof E extends ExtendOmitKeys ? never : Exclude >; -/** Parameters as a single tuple, so process() accepts every way the endpoint can be called. - * Endpoints with optional params or body have a union like `[params] | []` or `[params, body] | [body]` - * (see ParamFetchNoBody/ParamFetchWithBody); it merges position-wise, with an element optional when - * some call omits it. A single tuple (like a custom `fetch(params?)`) is kept as is. - * Only for contextually typing an options callback: TypeScript can't infer callback parameters from a - * union of tuples. The instance `process()` keeps `Parameters`, the stricter signature for callers. +/** Argument I passed to process(), from Parameters. + * Indexing a union like `[params] | []` or `[params, body] | [body]` (see ParamFetchNoBody/ParamFetchWithBody) + * merges position-wise, giving `undefined` where some call omits the argument. + * Endpoints that take no args keep `any`, so process(value, params: any) and similar still compile. */ -type ProcessArgs = - // fast path for fixed-length tuples; [A['length']] can't be checked against a union of lengths - number extends A['length'] ? A - : // takes no args: keep accepting process(value, params: any) and similar - [A['length']] extends [0] ? any[] - : [A['length']] extends [1] ? A - : [A['length']] extends [2] ? A - : IsUnion extends false ? A - : [] extends A ? - [ArgAt1] extends [never] ? - [params?: ArgAt0] - : [params?: ArgAt0, body?: ArgAt1] - : [params: ArgAt0, body?: ArgAt1]; -type IsUnion = - T extends any ? - [U] extends [T] ? - false - : true - : never; -// Distribute over the union; the length check gives never for a position a call omits -type ArgAt0 = - A extends unknown ? - A['length'] extends 0 ? - never - : A[0] - : never; -type ArgAt1 = - A extends unknown ? - A['length'] extends 0 | 1 ? - never - : A[1] - : never; +type ProcessArg = + [A['length']] extends [0] ? any : A[I]; type ExtendOmitKeys = KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; @@ -696,12 +664,12 @@ export interface RestEndpointOptions< key?(...args: Parameters): string; url?(...args: Parameters): string; /** @see https://dataclient.io/rest/api/RestEndpoint#process */ - // Positional rather than `...args: ProcessArgs<>`: when F is generic (like a path type parameter), + // Positional rather than a rest tuple: when F is generic (like a path type parameter), // a deferred rest tuple would reject process(value, params) for having too many params. process?( value: any, - params: ProcessArgs>[0], - body: ProcessArgs>[1], + params: ProcessArg, 0>, + body: ProcessArg, 1>, ...rest: any[] ): any; update?: EndpointUpdateFunction; From c4246e5a9382ed74bb2e8f9d8e2a12815eb35618 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 18:43:11 +0000 Subject: [PATCH 6/7] internal: Regenerate playground editor types Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0117wFk9RgsnzgqUgJHDepyY --- .../editor-types/@data-client/rest.d.ts | 45 +++++++++---------- .../Playground/editor-types/globals.d.ts | 45 +++++++++---------- 2 files changed, 40 insertions(+), 50 deletions(-) diff --git a/website/src/components/Playground/editor-types/@data-client/rest.d.ts b/website/src/components/Playground/editor-types/@data-client/rest.d.ts index 029220f5d00d..db3842a0de4a 100644 --- a/website/src/components/Playground/editor-types/@data-client/rest.d.ts +++ b/website/src/components/Playground/editor-types/@data-client/rest.d.ts @@ -1691,27 +1691,15 @@ type RestEndpointExtendOptions = RestEndpointOptions, 'schema' extends keyof O ? Extract : E['schema']> & { - /** @see https://dataclient.io/rest/api/RestEndpoint#process */ - process?(value: any, ...args: ProcessArgs>>): any; -} & PartialPick>; -/** Parameters as a single tuple, so process() accepts every way the endpoint can be called. - * Endpoints with optional params or body have a union like `[params] | []` or `[params, body] | [body]` - * (see ParamFetchNoBody/ParamFetchWithBody); it merges position-wise, with an element optional when - * some call omits it. A single tuple (like a custom `fetch(params?)`) is kept as is. - * Only for contextually typing an options callback: TypeScript can't infer callback parameters from a - * union of tuples. The instance `process()` keeps `Parameters`, the stricter signature for callers. +}, F extends FetchFunction> = RestEndpointOptions, 'schema' extends keyof O ? Extract : E['schema']> & PartialPick>; +/** Argument I passed to process(), from Parameters. + * Indexing a union like `[params] | []` or `[params, body] | [body]` (see ParamFetchNoBody/ParamFetchWithBody) + * merges position-wise, giving `undefined` where some call omits the argument. + * Endpoints that take no args keep `any`, so process(value, params: any) and similar still compile. */ -type ProcessArgs = number extends A['length'] ? A : [A['length']] extends [0] ? A : [A['length']] extends [1] ? A : [A['length']] extends [2] ? A : IsUnion extends false ? A : [] extends A ? [ - ArgAt1 -] extends [never] ? [ - params?: ArgAt0 -] : [params?: ArgAt0, body?: ArgAt1] : [params: ArgAt0, body?: ArgAt1]; -type IsUnion = T extends any ? [ - U -] extends [T] ? false : true : never; -type ArgAt0 = A extends unknown ? A['length'] extends 0 ? never : A[0] : never; -type ArgAt1 = A extends unknown ? A['length'] extends 0 | 1 ? never : A[1] : never; +type ProcessArg = [ + A['length'] +] extends [0] ? any : A[I]; type ExtendOmitKeys = KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; /** Partial> as a single homomorphic mapped type */ type PartialPick = { @@ -1760,8 +1748,9 @@ type RestExtendedEndpoint) => O['process'] extends {} ? Promise> : 'content' extends keyof O ? Promise> : ReturnType, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect']>> & (keyof O extends KeyofRestEndpoint ? unknown : Omit) & (keyof E extends KeyofRestEndpoint ? unknown : Omit); -/** PartialRestGenerics without `process`. extend() constrains its options to this, so the - * `process` member of RestEndpointExtendOptions is the only contextual type for process() params. +/** PartialRestGenerics without `process`. Options passed to the constructor, extend() and + * resource().extend() are constrained by this, so RestEndpointOptions' typed `process` member is + * the only contextual type for process() params. */ interface ExtendableRestGenerics { /** @see https://dataclient.io/rest/api/RestEndpoint#path */ @@ -1792,6 +1781,10 @@ interface PartialRestGenerics extends ExtendableRestGenerics { interface RestGenerics extends PartialRestGenerics { readonly path: string; } +/** RestGenerics without `process`; see ExtendableRestGenerics */ +interface ConstructorRestGenerics extends ExtendableRestGenerics { + readonly path: string; +} type PaginationEndpoint = RestInstanceBase>, E['schema'], E['sideEffect'], Pick & { @@ -1903,6 +1896,8 @@ interface RestEndpointOptions): string; url?(...args: Parameters): string; + /** @see https://dataclient.io/rest/api/RestEndpoint#process */ + process?(value: any, params: ProcessArg, 0>, body: ProcessArg, 1>, ...rest: any[]): any; update?: EndpointUpdateFunction; } type RestEndpointConstructorOptions = RestEndpointOptions({ method, sideEffect, name, ...options }: RestEndpointConstructorOptions & Readonly & O & ContentSchemaGuard): RestEndpoint$1; + new ({ method, sideEffect, name, ...options }: RestEndpointConstructorOptions & Readonly & O & ContentSchemaGuard): RestEndpoint$1; readonly prototype: RestInstanceBase; } type MethodToSide = M extends string ? M extends 'GET' ? undefined : true : undefined; @@ -2027,7 +2022,7 @@ interface CustomResource; } type ExtendedResource> = Omit & T; -interface ResourceEndpointExtensions { +interface ResourceEndpointExtensions { readonly get?: RestEndpointOptions : OptionsToFunction>, R['get']['schema']> & Readonly & Get; readonly getList?: RestEndpointOptions : OptionsToFunction>, R['getList']['schema']> & Readonly & GetList; readonly update?: RestEndpointOptions : OptionsToFunction>, R['update']['schema']> & Readonly & Update; @@ -2051,7 +2046,7 @@ interface Extendable(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; - extend(this: R, options: ResourceEndpointExtensions): CustomResource; + extend(this: R, options: ResourceEndpointExtensions): CustomResource & Omit; extend>(this: R, extender: (baseResource: R) => T): ExtendedResource; } diff --git a/website/src/components/Playground/editor-types/globals.d.ts b/website/src/components/Playground/editor-types/globals.d.ts index ca3264975d2f..b90d390602bf 100644 --- a/website/src/components/Playground/editor-types/globals.d.ts +++ b/website/src/components/Playground/editor-types/globals.d.ts @@ -1695,27 +1695,15 @@ type RestEndpointExtendOptions = RestEndpointOptions, 'schema' extends keyof O ? Extract : E['schema']> & { - /** @see https://dataclient.io/rest/api/RestEndpoint#process */ - process?(value: any, ...args: ProcessArgs>>): any; -} & PartialPick>; -/** Parameters as a single tuple, so process() accepts every way the endpoint can be called. - * Endpoints with optional params or body have a union like `[params] | []` or `[params, body] | [body]` - * (see ParamFetchNoBody/ParamFetchWithBody); it merges position-wise, with an element optional when - * some call omits it. A single tuple (like a custom `fetch(params?)`) is kept as is. - * Only for contextually typing an options callback: TypeScript can't infer callback parameters from a - * union of tuples. The instance `process()` keeps `Parameters`, the stricter signature for callers. +}, F extends FetchFunction> = RestEndpointOptions, 'schema' extends keyof O ? Extract : E['schema']> & PartialPick>; +/** Argument I passed to process(), from Parameters. + * Indexing a union like `[params] | []` or `[params, body] | [body]` (see ParamFetchNoBody/ParamFetchWithBody) + * merges position-wise, giving `undefined` where some call omits the argument. + * Endpoints that take no args keep `any`, so process(value, params: any) and similar still compile. */ -type ProcessArgs = number extends A['length'] ? A : [A['length']] extends [0] ? A : [A['length']] extends [1] ? A : [A['length']] extends [2] ? A : IsUnion extends false ? A : [] extends A ? [ - ArgAt1 -] extends [never] ? [ - params?: ArgAt0 -] : [params?: ArgAt0, body?: ArgAt1] : [params: ArgAt0, body?: ArgAt1]; -type IsUnion = T extends any ? [ - U -] extends [T] ? false : true : never; -type ArgAt0 = A extends unknown ? A['length'] extends 0 ? never : A[0] : never; -type ArgAt1 = A extends unknown ? A['length'] extends 0 | 1 ? never : A[1] : never; +type ProcessArg = [ + A['length'] +] extends [0] ? any : A[I]; type ExtendOmitKeys = KeyofRestEndpoint | keyof PartialRestGenerics | keyof RestEndpointOptions; /** Partial> as a single homomorphic mapped type */ type PartialPick = { @@ -1764,8 +1752,9 @@ type RestExtendedEndpoint) => O['process'] extends {} ? Promise> : 'content' extends keyof O ? Promise> : ReturnType, 'schema' extends keyof O ? O['schema'] : E['schema'], 'sideEffect' extends keyof O ? Extract : 'method' extends keyof O ? MethodToSide : E['sideEffect']>> & (keyof O extends KeyofRestEndpoint ? unknown : Omit) & (keyof E extends KeyofRestEndpoint ? unknown : Omit); -/** PartialRestGenerics without `process`. extend() constrains its options to this, so the - * `process` member of RestEndpointExtendOptions is the only contextual type for process() params. +/** PartialRestGenerics without `process`. Options passed to the constructor, extend() and + * resource().extend() are constrained by this, so RestEndpointOptions' typed `process` member is + * the only contextual type for process() params. */ interface ExtendableRestGenerics { /** @see https://dataclient.io/rest/api/RestEndpoint#path */ @@ -1796,6 +1785,10 @@ interface PartialRestGenerics extends ExtendableRestGenerics { interface RestGenerics extends PartialRestGenerics { readonly path: string; } +/** RestGenerics without `process`; see ExtendableRestGenerics */ +interface ConstructorRestGenerics extends ExtendableRestGenerics { + readonly path: string; +} type PaginationEndpoint = RestInstanceBase>, E['schema'], E['sideEffect'], Pick & { @@ -1907,6 +1900,8 @@ interface RestEndpointOptions): string; url?(...args: Parameters): string; + /** @see https://dataclient.io/rest/api/RestEndpoint#process */ + process?(value: any, params: ProcessArg, 0>, body: ProcessArg, 1>, ...rest: any[]): any; update?: EndpointUpdateFunction; } type RestEndpointConstructorOptions = RestEndpointOptions({ method, sideEffect, name, ...options }: RestEndpointConstructorOptions & Readonly & O & ContentSchemaGuard): RestEndpoint$1; + new ({ method, sideEffect, name, ...options }: RestEndpointConstructorOptions & Readonly & O & ContentSchemaGuard): RestEndpoint$1; readonly prototype: RestInstanceBase; } type MethodToSide = M extends string ? M extends 'GET' ? undefined : true : undefined; @@ -2031,7 +2026,7 @@ interface CustomResource; } type ExtendedResource> = Omit & T; -interface ResourceEndpointExtensions { +interface ResourceEndpointExtensions { readonly get?: RestEndpointOptions : OptionsToFunction>, R['get']['schema']> & Readonly & Get; readonly getList?: RestEndpointOptions : OptionsToFunction>, R['getList']['schema']> & Readonly & GetList; readonly update?: RestEndpointOptions : OptionsToFunction>, R['update']['schema']> & Readonly & Update; @@ -2055,7 +2050,7 @@ interface Extendable(this: R, key: ExtendKey, options: Readonly> & ExtendOptions> & ExtendOptions): R & { [key in ExtendKey]: RestExtendedEndpoint; }; - extend(this: R, options: ResourceEndpointExtensions): CustomResource; + extend(this: R, options: ResourceEndpointExtensions): CustomResource & Omit; extend>(this: R, extender: (baseResource: R) => T): ExtendedResource; } From 41e7f4d514567bcd638b8601f6b435bed0d70234 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 19:53:36 +0000 Subject: [PATCH 7/7] docs(blog): Update v0.19 instantiation counts for typed process() params Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_0117wFk9RgsnzgqUgJHDepyY --- website/blog/2026-10-03-v0.19-batch-set.md | 4 ++-- 1 file changed, 2 insertions(+), 2 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 ed0272bdfdd0..0b0457b6323c 100644 --- a/website/blog/2026-10-03-v0.19-batch-set.md +++ b/website/blog/2026-10-03-v0.19-batch-set.md @@ -206,8 +206,8 @@ deterministic, so they compare cleanly across machines. Union does more work tha valueLabel="v0.19" unit="K" rows={[ - { label: 'Long paths', baseline: 822, value: 346 }, - { label: 'Typical app', baseline: 20.2, value: 13.0 }, + { label: 'Long paths', baseline: 822, value: 341 }, + { label: 'Typical app', baseline: 20.2, value: 13.4 }, { label: 'React hooks', baseline: 145, value: 122 }, { label: 'Vue', baseline: 62.6, value: 47.3 }, { label: '300 fields', baseline: 17.0, value: 12.8 },