From 05ed1d39c841acd85c076c22a8bd7d2d1c8e9b33 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 20 May 2026 22:43:05 -0700 Subject: [PATCH 1/6] feat!: The generated ViewModel stubs for abstract model types have been replaced by static objects with a static `.load(id)` method. --- CHANGELOG.md | 6 + playground/Coalesce.Web.Vue3/src/models.g.ts | 4 +- .../Coalesce.Web.Vue3/src/viewmodels.g.ts | 14 +- .../Generators/Scripts/TsViewModels.cs | 4 +- src/coalesce-vue/src/api-client.ts | 85 +++++++--- src/coalesce-vue/src/viewmodel.ts | 159 +++--------------- src/coalesce-vue/test/api-client.spec.ts | 41 +++-- .../test/viewmodel.inheritance.spec.ts | 82 +++------ src/test-targets/viewmodels.g.ts | 4 +- .../src/viewmodels.g.ts | 2 +- 10 files changed, 158 insertions(+), 243 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 95ae60aa6..7327570a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,9 @@ +# 7.0.0 +- Breaking: The generated ViewModel stubs for abstract model types have been replaced by static objects with a static `.load(id)` method that returns a standard `ItemApiState` caller. They are no longer exposed as instantiable proxy objects that mutate themselves into the correct implementation type after `$load`ing from the server - this approach did not fully satisfy the TypeScript contract of the derived types at runtime and otherwise attempted (and failed) to provide a concrete instance of a type that should not actually be instantiable. +- API callers (`ItemApiState`, `ListApiState`) are now awaitable. `await caller` now resolves to `caller.result` after the current or previous operation is completed. + - `await vm.$load(1)` - performs a new load call and waits for completion + - `await vm.$load` - waits for completion of the pending load operation, or immediately resolves with the last result if no operation is pending. + # 6.6.0 - Added `returnViewModel` prop to `c-select`, enabling ViewModel instances to be returned directly when bound with `for="TypeName"`. - Added `adminOverrides` option to `createCoalesceVuetify()`, allowing custom Vue components to replace the default input and/or display components used in admin pages (`c-admin-editor`, `c-admin-method`, `c-table`) for specific model properties, method parameters, or method return values. diff --git a/playground/Coalesce.Web.Vue3/src/models.g.ts b/playground/Coalesce.Web.Vue3/src/models.g.ts index 52fbee25d..acc2ea358 100644 --- a/playground/Coalesce.Web.Vue3/src/models.g.ts +++ b/playground/Coalesce.Web.Vue3/src/models.g.ts @@ -67,7 +67,7 @@ export namespace AbstractClass { /** A default data source declared with an open generic parameter constrained to AbstractClass. - Because is constrained to AbstractClass, + Because T is constrained to AbstractClass, this data source is automatically used as the default for AbstractClass and every derived type (e.g. AbstractClassImpl) without needing to declare a separate data source on each derived class. @@ -109,7 +109,7 @@ export namespace AbstractClassImpl { /** A default data source declared with an open generic parameter constrained to AbstractClass. - Because is constrained to AbstractClass, + Because T is constrained to AbstractClass, this data source is automatically used as the default for AbstractClass and every derived type (e.g. AbstractClassImpl) without needing to declare a separate data source on each derived class. diff --git a/playground/Coalesce.Web.Vue3/src/viewmodels.g.ts b/playground/Coalesce.Web.Vue3/src/viewmodels.g.ts index 1c8420cec..29756d02a 100644 --- a/playground/Coalesce.Web.Vue3/src/viewmodels.g.ts +++ b/playground/Coalesce.Web.Vue3/src/viewmodels.g.ts @@ -1,10 +1,10 @@ import * as $metadata from './metadata.g' import * as $models from './models.g' import * as $apiClients from './api-clients.g' -import { ViewModel, ListViewModel, ViewModelCollection, ServiceViewModel, type DeepPartial, defineProps, createAbstractProxyViewModelType } from 'coalesce-vue/lib/viewmodel' +import { ViewModel, ListViewModel, ViewModelCollection, ServiceViewModel, type DeepPartial, defineProps, createAbstractLoader } from 'coalesce-vue/lib/viewmodel' export type AbstractClassViewModel = AbstractClassImplViewModel -export const AbstractClassViewModel = createAbstractProxyViewModelType<$models.AbstractClass, AbstractClassViewModel>($metadata.AbstractClass, $apiClients.AbstractClassApiClient) +export const AbstractClassViewModel = createAbstractLoader($apiClients.AbstractClassApiClient) export class AbstractClassListViewModel extends ListViewModel<$models.AbstractClass, $apiClients.AbstractClassApiClient, AbstractClassViewModel> { static DataSources = $models.AbstractClass.DataSources; @@ -1132,7 +1132,7 @@ export class WeatherServiceViewModel extends ServiceViewModel BuildOutputAsync() b.Line("import * as $metadata from './metadata.g'"); b.Line("import * as $models from './models.g'"); b.Line("import * as $apiClients from './api-clients.g'"); - b.Line("import { ViewModel, ListViewModel, ViewModelCollection, ServiceViewModel, type DeepPartial, defineProps, createAbstractProxyViewModelType } from 'coalesce-vue/lib/viewmodel'"); + b.Line("import { ViewModel, ListViewModel, ViewModelCollection, ServiceViewModel, type DeepPartial, defineProps, createAbstractLoader } from 'coalesce-vue/lib/viewmodel'"); b.Line(); foreach (var model in Model.CrudApiBackedClasses.OrderBy(e => e.ClientTypeName)) @@ -80,7 +80,7 @@ private void WriteViewModel(TypeScriptCodeBuilder b, ClassViewModel model) if (model.Type.IsAbstract) { b.Line($"export type {viewModelName} = {string.Join(" | ", model.ClientDerivedTypes.Select(t => new VueType(t.Type).TsType(viewModel: true)))}"); - b.Line($"export const {viewModelName} = createAbstractProxyViewModelType<{modelName}, {viewModelName}>({metadataName}, $apiClients.{name}ApiClient)"); + b.Line($"export const {viewModelName} = createAbstractLoader<{viewModelName}>($apiClients.{name}ApiClient)"); b.Line(); return; } diff --git a/src/coalesce-vue/src/api-client.ts b/src/coalesce-vue/src/api-client.ts index 8dfb17b28..d32d18929 100644 --- a/src/coalesce-vue/src/api-client.ts +++ b/src/coalesce-vue/src/api-client.ts @@ -1390,9 +1390,9 @@ abstract class ApiStateBase { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type invoker: Function, args: any[], - ): Promise | ListResult>; + ): Promise; - protected _pendingPromise: Promise | undefined; + protected _pendingPromise: Promise | undefined; constructor( apiClient: ApiClient, @@ -1446,7 +1446,7 @@ export abstract class ApiState< /** The metadata of the method being called, if it was provided. */ abstract $metadata?: Method; - abstract result: TResult | TResult[] | null; + abstract result: TResult | null; private readonly __isLoading = ref(false); /** True if a request is currently pending. */ @@ -1654,6 +1654,31 @@ export abstract class ApiState< return this; } + /** + * Returns the promise of the currently pending request, + * or `undefined` if no request is pending. + */ + getPromise() { + return this._pendingPromise; + } + + /** + * Implements the thenable protocol, allowing the caller to be used with `await`. + * If a request is currently pending, awaits that request. + * If no request is pending, resolves immediately with the current `result` value. + */ + then( + onfulfilled?: + | ((value: TResult | null) => TResult1 | PromiseLike) + | null, + onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, + ): Promise { + return (this._pendingPromise ?? Promise.resolve(this.result)).then( + onfulfilled, + onrejected, + ); + } + protected abstract setResponseProps(data: ApiResult): void; private _debounceSignal: { @@ -1667,7 +1692,7 @@ export abstract class ApiState< // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type invoker: Function, args: any[], - ) { + ): Promise { let apiClient = this.apiClient; if (this.isLoading) { @@ -1703,6 +1728,10 @@ export abstract class ApiState< // Similar to the "cancel" mode, // aborted requests are not thrown as rejected promises, // but instead as a fulfilled promise with a void resolved value. + + // @ts-expect-error This is a deliberate and long-standing break from the + // type contract of ApiState objects that prematurely halted requests + // resolve to undefined (added in 7300ac92aaa9cd577fe461717fa78b98c73174ac). return undefined; } } @@ -1843,6 +1872,11 @@ export abstract class ApiState< if (!promise) { this.isLoading = false; + + // @ts-expect-error Added in 2833516271642f65a29e494cd4aa10a2861671be - it is deliberate + // that invoker funcs that return undefined are treated as a cancellation + // and return undefined (in violation of the type contract) like other cancellations. + // Note that this is exceedingly rare and would require the invoker func to not be async. return undefined; } @@ -1850,6 +1884,9 @@ export abstract class ApiState< if (!resp) { this.isLoading = false; + + // @ts-expect-error Added for https://github.com/IntelliTect/Coalesce/issues/416 - + // similar to just above, this allows async invoker funcs to return undefined. return undefined; } @@ -1870,7 +1907,8 @@ export abstract class ApiState< this.isLoading = false; - return data?.object ?? data?.list; + // NB: `this.result` will have been set non-null in `setResponseProps`. + return this.result!; } catch (thrown) { if (axios.isCancel(thrown)) { // No handling of anything for cancellations. @@ -1879,6 +1917,10 @@ export abstract class ApiState< // it should probably be implemented as a separate set of callbacks. // We don't set isLoading to false here - we set it in the cancel() method to ensure that we don't set isLoading=false for a subsequent call, // since the promise won't reject immediately after requesting cancellation. There could already be another request pending when this code is being executed. + + // @ts-expect-error This is a deliberate and long-standing break from the + // type contract of ApiState objects that prematurely halted requests + // resolve to undefined (added in 7300ac92aaa9cd577fe461717fa78b98c73174ac). return; } else if ( windowUnloading && @@ -1891,7 +1933,16 @@ export abstract class ApiState< // This seems to only happen on Firefox - Chrome doesn't abort requests // on unload in a way that ever propagates up to user code. // https://github.com/IntelliTect/Coalesce/issues/296 - return; + + // Wait 1s matching the windowUnloading reset timer. If the page truly unloads, + // nothing matters. If the unload was user-canceled, this gives the browser time + // to reset before we resolve, avoiding spurious downstream continuations. + await new Promise((resolve) => setTimeout(resolve, 1000)); + + // @ts-expect-error This is a deliberate and long-standing break from the + // type contract of ApiState objects that prematurely halted requests + // resolve to undefined (added in 7300ac92aaa9cd577fe461717fa78b98c73174ac). + return undefined; } else { // eslint-disable-next-line no-var var error = thrown as AxiosError | ApiResult | Error | string; @@ -1999,14 +2050,6 @@ export class ItemApiState< /** The metadata of the method being called, if it was provided. */ $metadata?: TMethod; - /** - * Returns the promise of the currently pending request, - * or `undefined` if no request is pending. - */ - getPromise(): Promise | undefined { - return this._pendingPromise; - } - private readonly __validationIssues = ref(null); /** Validation issues returned by the previous request. */ get validationIssues() { @@ -2155,7 +2198,7 @@ export class ItemApiStateWithArgs< /** Invokes a call to this API endpoint. * If `args` is not provided, the values in `this.args` will be used for the method's parameters. */ - public invokeWithArgs(args: TArgsObj = this.args): Promise { + public invokeWithArgs(args: TArgsObj = this.args) { // Copy args so that if we're debouncing, // the args at the point in time at which invokeWithArgs() was // called will be used, rather than the state at the time when the actual API call gets made. @@ -2228,18 +2271,10 @@ export class ListApiState< TArgs extends any[], TResult, TMethod extends ListMethod | undefined = ListMethod, -> extends ApiState { +> extends ApiState { /** The metadata of the method being called, if it was provided. */ $metadata?: TMethod; - /** - * Returns the promise of the currently pending request, - * or `undefined` if no request is pending. - */ - getPromise(): Promise | undefined { - return this._pendingPromise; - } - private readonly __page = ref(null); /** Page number returned by the previous request. */ get page() { @@ -2361,7 +2396,7 @@ export class ListApiStateWithArgs< this, this.argsInvoker, [args], - )); + )) as Promise; } /** Replace `this.args` with a new, blank object containing default values (typically nulls) */ diff --git a/src/coalesce-vue/src/viewmodel.ts b/src/coalesce-vue/src/viewmodel.ts index 548088c51..232a1b2bb 100644 --- a/src/coalesce-vue/src/viewmodel.ts +++ b/src/coalesce-vue/src/viewmodel.ts @@ -1360,134 +1360,33 @@ export abstract class ViewModel< } } -const $loadProxy = Symbol("$loadProxy"); -const $protoVersion = Symbol("$protoVersion"); -/** Create a class that represents an abstract model type - * and will turn itself into the proper concrete ViewModel type - * when loaded. - */ -export function createAbstractProxyViewModelType< - TModel extends Model, +/** Create a loader for an abstract model type that produces concrete ViewModel instances. */ +export function createAbstractLoader< TViewModel extends ViewModel, ->( - metadata: ModelType, - apiClientCtor: { new (): ModelApiClient }, -): { - new (initialData?: DeepPartial | null): TViewModel; +>(apiClientCtor: { + new (): ModelApiClient; +}): { + /** Load a concrete ViewModel instance by its primary key. + * Returns a new API caller each invocation. The caller's `result` will be the concrete ViewModel once loaded. */ + load(id: any): ItemApiState<[id?: any], TViewModel>; } { - function unsupportedError() { - return new Error(`"Operation not supported: This ViewModel instance is a proxy for an abstract type, with its concrete implementation not yet decided. - - Try one of the following to obtain a concrete implementation: - - $load(...) or $loadFromModel(...) data for a concrete implementation - - Instantiate a concrete implementation of this abstract type instead of this abstract proxy - `); - } - - class AbstractVmProxy extends ViewModel> { - [$loadProxy]?: ItemApiState; - [$protoVersion]? = ref(0); - - override get $load() { - return this[$loadProxy]!; - } - override get $save() { - return this.$apiClient.$makeCaller("item", (c) => { - throw unsupportedError(); - }) as any; - } - override get $bulkSave() { - return this.$apiClient.$makeCaller("item", (c) => { - throw unsupportedError(); - }) as any; - } - override get $delete() { - return this.$apiClient.$makeCaller("item", (c) => { - throw unsupportedError(); - }) as any; - } - - constructor(initialDirtyData?: any) { - if ( - initialDirtyData && - "$metadata" in initialDirtyData && - ViewModel.typeLookup?.[initialDirtyData.$metadata.name] - ) { - // We know the real concrete type of this ViewModel from the metadata. - // Return the proper instance directly, bypassing the conversion process. - return ViewModelFactory.get( - initialDirtyData.$metadata.name, - initialDirtyData, - false, - ) as any; - } - - super(metadata, new apiClientCtor(), initialDirtyData); - - // eslint-disable-next-line @typescript-eslint/no-this-alias - const vm = this; - - let $load = (vm[$loadProxy] = vm.$apiClient - .$makeCaller("item", (c, id?: any) => { - return c.get(id != null ? id : vm.$primaryKey, vm.$params); - }) - .onFulfilled((state) => { - const result = state.result!; - - const realCtor = ViewModel.typeLookup![result.$metadata.name]; - - // Grab real metadata and API client instances of the concrete type. - const { $apiClient, $metadata } = new realCtor(); - - // Update the tracking ref for prototype version so that instanceof is reactive. - vm[$protoVersion]!.value++; - delete vm[$protoVersion]; - - // Convert the ViewModel instance to the target type: - Object.setPrototypeOf(vm, realCtor.prototype); - - // Convert the ApiClient instance to the target type: - Object.setPrototypeOf( - vm.$apiClient, - Object.getPrototypeOf($apiClient), - ); - vm.$apiClient.$metadata = $metadata; - - // Update $data.$metadata so it reflects the concrete type. - (vm as any).$data.$metadata = $metadata; - - // Populate the properties of the $load caller on the real $load caller instance. - //@ts-expect-error protected prop or fn - vm.$load.setResponseProps($load.rawResponse.data); - //@ts-expect-error protected prop or fn - vm.$load.__rawResponse.value = $load.rawResponse; - vm.$load.isLoading = false; - vm.$load.concurrencyMode = $load.concurrencyMode; - - //@ts-expect-error cleaning up for GC - $load = undefined; - delete vm[$loadProxy]; - - vm.$loadCleanData(result); - })); - } - } - - Object.defineProperty(AbstractVmProxy, "name", { - value: metadata.name + "ViewModelProxy", - }); - - defineProps(AbstractVmProxy, metadata); + return { + load(id: any): ItemApiState<[id?: any], TViewModel> { + const apiClient = new apiClientCtor(); + const caller = apiClient + .$makeCaller("item", (c, id?: any) => c.get(id)) + .onFulfilled(() => { + const data = caller.result!; + const realCtor = ViewModel.typeLookup![(data as any).$metadata.name]; + const vm = new realCtor() as TViewModel; + vm.$loadCleanData(data); + caller.result = vm; + }); - // Make `$metadata` reactive so components depending on it update when it changes. - Object.defineProperty(AbstractVmProxy.prototype, "$metadata", { - get() { - this[$protoVersion]?.value.toString(); - return metadata; + caller(id); + return caller as any; }, - }); - - return AbstractVmProxy as any; + }; } export interface BulkSaveRequestRawItem { @@ -2295,18 +2194,6 @@ export function defineProps ViewModel>( const props = Object.values(metadata.props); const descriptors = {} as PropertyDescriptorMap; - if (metadata.baseTypes?.some((base) => base.abstract)) { - // Make the `instanceof` operator against this type reactive if the type - // has a known abstract base class such that an instance could change types via AbstractVmProxy - Object.defineProperty(ctor, Symbol.hasInstance, { - value(x: any) { - // Take a dependency, tracked per instance, that will update when the instance changes type. - x[$protoVersion]?.value.toString(); - return Object[Symbol.hasInstance].call(this, x); - }, - }); - } - descriptors["$metadata"] = { enumerable: true, configurable: true, diff --git a/src/coalesce-vue/test/api-client.spec.ts b/src/coalesce-vue/test/api-client.spec.ts index b1167bb5a..ec3e736fe 100644 --- a/src/coalesce-vue/test/api-client.spec.ts +++ b/src/coalesce-vue/test/api-client.spec.ts @@ -684,7 +684,7 @@ describe("$makeCaller", () => { ); const arg = 42; - const result = await caller(arg); + const result: number = await caller(arg); expect(endpointMock.mock.calls[0][0]).toBe(arg); expect(caller.result).toBe(arg); expect(result).toBe(arg); @@ -1516,11 +1516,28 @@ describe("$makeCaller with args object", () => { }); describe.each(["item", "list"] as const)("for %s transport", (type) => { - const makeCaller = ( - endpointMock: ReturnType< - typeof makeEndpointMock - >, - ) => + const makeTransportMock = () => + vitest.fn((arg?: number | null | undefined) => + Promise.resolve({ + data: + type === "list" + ? { + wasSuccessful: true, + list: [arg], + page: 1, + pageCount: 1, + pageSize: 10, + totalCount: 1, + } + : { wasSuccessful: true, object: arg }, + status: 200, + statusText: "OK", + headers: {}, + config: {} as any, + }), + ); + + const makeCaller = (endpointMock: ReturnType) => new PersonApiClient().$makeCaller( type, (c, num: number) => endpointMock(num), @@ -1529,7 +1546,7 @@ describe("$makeCaller with args object", () => { ); test("uses own args if args not specified", () => { - const endpointMock = makeEndpointMock(); + const endpointMock = makeTransportMock(); const caller = makeCaller(endpointMock); caller.args.num = 42; @@ -1538,7 +1555,7 @@ describe("$makeCaller with args object", () => { }); test("own args are reactive", async () => { - const endpointMock = makeEndpointMock(); + const endpointMock = makeTransportMock(); const caller = makeCaller(endpointMock); const vue = mountData({ caller }); @@ -1558,7 +1575,7 @@ describe("$makeCaller with args object", () => { }); test("uses custom args if specified", () => { - const endpointMock = makeEndpointMock(); + const endpointMock = makeTransportMock(); const caller = makeCaller(endpointMock); caller.args.num = 42; @@ -1569,7 +1586,7 @@ describe("$makeCaller with args object", () => { }); test("sets state properties appropriately", async () => { - const endpointMock = makeEndpointMock(); + const endpointMock = makeTransportMock(); const caller = makeCaller(endpointMock); caller.args.num = 42; @@ -1581,7 +1598,7 @@ describe("$makeCaller with args object", () => { }); test("debounce ignores redundant requests when resolving", async () => { - const endpointMock = makeEndpointMock(); + const endpointMock = makeTransportMock(); const caller = new PersonApiClient() .$makeCaller( type, @@ -1613,7 +1630,7 @@ describe("$makeCaller with args object", () => { expect(endpointMock.mock.calls[1][0]).toBe(3); await expect(calls[0]).resolves.toBeTruthy(); - await expect(calls[1]).resolves.toBeFalsy(); + await expect(calls[1]).resolves.toBeUndefined(); await expect(calls[2]).resolves.toBeTruthy(); }); }); diff --git a/src/coalesce-vue/test/viewmodel.inheritance.spec.ts b/src/coalesce-vue/test/viewmodel.inheritance.spec.ts index 83f1d89e2..bcc827e2f 100644 --- a/src/coalesce-vue/test/viewmodel.inheritance.spec.ts +++ b/src/coalesce-vue/test/viewmodel.inheritance.spec.ts @@ -77,7 +77,7 @@ describe("ViewModel", () => { }); }); -describe("abstract proxy", () => { +describe("abstract loader", () => { const expectedData = { id: 1, discriminator: "discrim1", @@ -97,81 +97,42 @@ describe("abstract proxy", () => { ); } - test("class name", async () => { - const vm = new AbstractModelViewModel(); - expect(vm.constructor.name).toBe("AbstractModelViewModelProxy"); - }); - - test("becomes concrete type when loaded with initial data", async () => { - const vm = new AbstractModelViewModel(new AbstractImpl1(expectedData)); - - expect(vm.$getPropDirty("id")).toBeTruthy(); - assertIsImpl1(vm); - }); - - test("can load when ID is provided by initial data", async () => { - mockGet(); - - const vm = new AbstractModelViewModel({ id: 1 }); - expect(vm.id).toBe(1); - await vm.$load(); - - assertIsImpl1(vm); - assertLoaded(vm); - }); - - test("becomes concrete type after $load", async () => { + test("load produces concrete ViewModel in result", async () => { mockGet(); - const vm = new AbstractModelViewModel(); - await vm.$load(1); - - assertIsImpl1(vm); - assertLoaded(vm); + const vm = await AbstractModelViewModel.load(1); + assertIsImpl1(vm!); }); - test("instanceof is reactive", async () => { + test("load caller has standard reactive state", async () => { mockGet(); - const vm = new AbstractModelViewModel(); + const loader = AbstractModelViewModel.load(1); + expect(loader.isLoading).toBe(true); + expect(loader.wasSuccessful).toBeNull(); + expect(loader.result).toBeNull(); - // Dummy ref because Vue will always recompute a computed on every access if the computed has no deps. - const dummyRef = ref(1); - - const isImpl1 = computed( - () => dummyRef.value && vm instanceof AbstractImpl1ViewModel, - ); - expect(isImpl1.value).toBeFalsy(); + await loader.getPromise(); - await vm.$load(1); - expect(isImpl1.value).toBeTruthy(); + expect(loader.isLoading).toBe(false); + expect(loader.wasSuccessful).toBe(true); + expect(loader.result).not.toBeNull(); + assertIsImpl1(loader.result!); }); - test("$metadata is reactive", async () => { + test("load result is reactive", async () => { mockGet(); - const vm = new AbstractModelViewModel(); + const loader = AbstractModelViewModel.load(1); - // Dummy ref because Vue will always recompute a computed on every access if the computed has no deps. const dummyRef = ref(1); + const isLoaded = computed(() => dummyRef.value && loader.result != null); + expect(isLoaded.value).toBe(false); - const isImpl1 = computed( - () => dummyRef.value && vm.$metadata.name == "AbstractImpl1", - ); - expect(isImpl1.value).toBeFalsy(); - - await vm.$load(1); - expect(isImpl1.value).toBeTruthy(); + await loader.getPromise(); + expect(isLoaded.value).toBe(true); }); - function assertLoaded(vm: AbstractModelViewModel) { - expect(vm.$load.wasSuccessful).toBe(true); - expect(vm.$load.isLoading).toBe(false); - expect(vm.$load.result).toBeInstanceOf(AbstractImpl1); - expect(vm.$load.message).toBeFalsy(); - expect(vm.$load.hasResult).toBeTruthy(); - } - function assertIsImpl1(vm: AbstractModelViewModel) { expect(vm.id).toBe(1); expect(vm.discriminator).toBe("discrim1"); @@ -192,5 +153,8 @@ describe("abstract proxy", () => { const impl1 = vm as AbstractImpl1ViewModel; expect(impl1.impl1OnlyField).toBe("foo"); + + // Data was loaded clean (not dirty) + expect(vm.$getPropDirty("id")).toBe(false); } }); diff --git a/src/test-targets/viewmodels.g.ts b/src/test-targets/viewmodels.g.ts index 15bf3cb94..18f42d81e 100644 --- a/src/test-targets/viewmodels.g.ts +++ b/src/test-targets/viewmodels.g.ts @@ -1,7 +1,7 @@ import * as $metadata from './metadata.g' import * as $models from './models.g' import * as $apiClients from './api-clients.g' -import { ViewModel, ListViewModel, ViewModelCollection, ServiceViewModel, type DeepPartial, defineProps, createAbstractProxyViewModelType } from 'coalesce-vue/lib/viewmodel' +import { ViewModel, ListViewModel, ViewModelCollection, ServiceViewModel, type DeepPartial, defineProps, createAbstractLoader } from 'coalesce-vue/lib/viewmodel' export interface AbstractImpl1ViewModel extends $models.AbstractImpl1 { impl1OnlyField: string | null; @@ -141,7 +141,7 @@ export class AbstractImpl2ListViewModel extends ListViewModel<$models.AbstractIm export type AbstractModelViewModel = AbstractImpl1ViewModel | AbstractImpl2ViewModel -export const AbstractModelViewModel = createAbstractProxyViewModelType<$models.AbstractModel, AbstractModelViewModel>($metadata.AbstractModel, $apiClients.AbstractModelApiClient) +export const AbstractModelViewModel = createAbstractLoader($apiClients.AbstractModelApiClient) export class AbstractModelListViewModel extends ListViewModel<$models.AbstractModel, $apiClients.AbstractModelApiClient, AbstractModelViewModel> { static DataSources = $models.AbstractModel.DataSources; diff --git a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/viewmodels.g.ts b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/viewmodels.g.ts index 7c0e874ec..c2ba0335e 100644 --- a/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/viewmodels.g.ts +++ b/templates/Coalesce.Vue.Template/content/Coalesce.Starter.Vue.Web/src/viewmodels.g.ts @@ -1,7 +1,7 @@ import * as $metadata from './metadata.g' import * as $models from './models.g' import * as $apiClients from './api-clients.g' -import { ViewModel, ListViewModel, ViewModelCollection, ServiceViewModel, type DeepPartial, defineProps, createAbstractProxyViewModelType } from 'coalesce-vue/lib/viewmodel' +import { ViewModel, ListViewModel, ViewModelCollection, ServiceViewModel, type DeepPartial, defineProps, createAbstractLoader } from 'coalesce-vue/lib/viewmodel' export interface AuditLogViewModel extends $models.AuditLog { userId: string | null; From b2d7a155fdc4a9683b15bf5fa8ce565d90955e49 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Wed, 20 May 2026 23:17:04 -0700 Subject: [PATCH 2/6] types: propagate `undefined` returns out of invoker funcs --- src/coalesce-vue/src/api-client.ts | 126 +++++++++++++++++++---- src/coalesce-vue/test/api-client.spec.ts | 21 ++-- 2 files changed, 118 insertions(+), 29 deletions(-) diff --git a/src/coalesce-vue/src/api-client.ts b/src/coalesce-vue/src/api-client.ts index d32d18929..a7bf3911e 100644 --- a/src/coalesce-vue/src/api-client.ts +++ b/src/coalesce-vue/src/api-client.ts @@ -728,10 +728,21 @@ export type ApiStateType< T extends TransportTypeSpecifier, TArgs extends any[], TResult, + TCallVoid = never, > = T extends ItemTransportTypeSpecifier - ? ItemApiState + ? ItemApiState< + TArgs, + TResult, + T extends ItemMethod ? T : ItemMethod, + TCallVoid + > : T extends ListTransportTypeSpecifier - ? ListApiState + ? ListApiState< + TArgs, + TResult, + T extends ListMethod ? T : ListMethod, + TCallVoid + > : never; export type ApiStateTypeWithArgs< @@ -739,19 +750,22 @@ export type ApiStateTypeWithArgs< TArgs extends any[], TArgsObj extends object, TResult, + TCallVoid = never, > = T extends ItemTransportTypeSpecifier ? ItemApiStateWithArgs< TArgs, TArgsObj, TResult, - T extends ItemMethod ? T : ItemMethod + T extends ItemMethod ? T : ItemMethod, + TCallVoid > : T extends ListTransportTypeSpecifier ? ListApiStateWithArgs< TArgs, TArgsObj, TResult, - T extends ListMethod ? T : ListMethod + T extends ListMethod ? T : ListMethod, + TCallVoid > : never; @@ -826,8 +840,40 @@ export class ApiClient { return clone; } + // There are two overloads for $makeCaller without argsFactory: + // 1. Strict (no void): invoker must always return a promise. Call signature is `Promise`. + // 2. Void-able: invoker may return void/undefined. Call signature is `Promise`. + // + // We need two overloads because `| undefined | void` in the invoker's expected return type + // acts as a "void sink" — TypeScript matches a void return against it rather than inferring + // undefined into TResult. Without the sink, void-returning invokers wouldn't compile + // (void isn't assignable to Promise<...>). But with it, TResult stays clean (just `number`, + // not `number | undefined`). The two-overload approach lets the strict overload reject + // void-returning invokers (falling through to the second), which then adds undefined + // only to the call signature return, not to `.result` (which remains `TResult | null`). + + /** + * Create a wrapper function for an API call. This function maintains properties which represent the state of its previous invocation. + * @param resultType An indicator of whether the API endpoint returns an ItemResult or a ListResult + * @param invokerFactory method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. + */ + $makeCaller< + TArgs extends any[], + TResult, + TTransportType extends TransportTypeSpecifier, + >( + resultType: TTransportType, + invoker: ApiCallerInvoker< + TArgs, + ResultPromiseType, + this + >, + ): ApiStateType; + /** * Create a wrapper function for an API call. This function maintains properties which represent the state of its previous invocation. + * The returned caller's invocation will resolve to `undefined` if the invoker function returns void/undefined + * (i.e. the invoker conditionally skips the API call). * @param resultType An indicator of whether the API endpoint returns an ItemResult or a ListResult * @param invokerFactory method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. */ @@ -844,7 +890,33 @@ export class ApiClient { | void, this >, - ): ApiStateType; + ): ApiStateType; + + /** + * Create a wrapper function for an API call. This function maintains properties which represent the state of its previous invocation. + * @param resultType An indicator of whether the API endpoint returns an ItemResult or a ListResult + * @param invokerFactory method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. + * @param invokerFactory method that will call the API with an args object as the only parameter. This may be called by using `.withArgs()` on the function that is returned from `$makeCaller`. The value of the args object will default to `.args` if not specified. + */ + $makeCaller< + TArgs extends any[], + TArgsObj extends object, + TResult, + TTransportType extends TransportTypeSpecifier, + >( + resultType: TTransportType, + invoker: ApiCallerInvoker< + TArgs, + ResultPromiseType, + this + >, + argsFactory: () => TArgsObj, + argsInvoker: ApiCallerArgsInvoker< + TArgsObj, + ResultPromiseType, + this + >, + ): ApiStateTypeWithArgs; /** * Create a wrapper function for an API call. This function maintains properties which represent the state of its previous invocation. @@ -874,7 +946,7 @@ export class ApiClient { | void, this >, - ): ApiStateTypeWithArgs; + ): ApiStateTypeWithArgs; $makeCaller< TArgs extends any[], @@ -1375,7 +1447,7 @@ export type ResponseCachingConfiguration = { // Base class for ApiState that contains nothing but the logic for // subclassing Function. Specifically, we do this to avoid a need to call // `super()`, which triggers CSP unsafe-eval. -abstract class ApiStateBase { +abstract class ApiStateBase { /** Invokes a call to this API endpoint. */ public readonly invoke!: this; @@ -1390,9 +1462,9 @@ abstract class ApiStateBase { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type invoker: Function, args: any[], - ): Promise; + ): Promise; - protected _pendingPromise: Promise | undefined; + protected _pendingPromise: Promise | undefined; constructor( apiClient: ApiClient, @@ -1437,7 +1509,8 @@ ApiStateBase.prototype.__proto__ = Function; export abstract class ApiState< TArgs extends any[], TResult, -> extends ApiStateBase { + TCallVoid = never, +> extends ApiStateBase { /** See comments on ReactiveFlags_SKIP for explanation. * @internal */ @@ -1667,9 +1740,11 @@ export abstract class ApiState< * If a request is currently pending, awaits that request. * If no request is pending, resolves immediately with the current `result` value. */ - then( + then( onfulfilled?: - | ((value: TResult | null) => TResult1 | PromiseLike) + | (( + value: TResult | TCallVoid | null, + ) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { @@ -1692,7 +1767,7 @@ export abstract class ApiState< // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type invoker: Function, args: any[], - ): Promise { + ): Promise { let apiClient = this.apiClient; if (this.isLoading) { @@ -2037,16 +2112,18 @@ export interface ItemApiState< TResult, // eslint-disable-next-line @typescript-eslint/no-unused-vars TMethod extends ItemMethod | undefined = ItemMethod, + TCallVoid = never, > { // Do not put a doc comment on the call signature: // it'll hide the doc comment of the caller's definition. - (...args: TArgs): Promise; + (...args: TArgs): Promise; } export class ItemApiState< TArgs extends any[], TResult, TMethod extends ItemMethod | undefined = ItemMethod, -> extends ApiState { + TCallVoid = never, +> extends ApiState { /** The metadata of the method being called, if it was provided. */ $metadata?: TMethod; @@ -2179,7 +2256,8 @@ export class ItemApiStateWithArgs< TArgsObj, TResult, TMethod extends ItemMethod | undefined = ItemMethod, -> extends ItemApiState { + TCallVoid = never, +> extends ItemApiState { private readonly __args = ref() as Ref; /** Values that will be used as arguments if the method is invoked with `this.invokeWithArgs()`. */ get args() { @@ -2263,15 +2341,22 @@ export class ItemApiStateWithArgs< } } -export interface ListApiState { +export interface ListApiState< + TArgs extends any[], + TResult, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + TMethod extends ListMethod | undefined = ListMethod, + TCallVoid = never, +> { /** Invokes a call to this API endpoint. */ - (...args: TArgs): Promise; + (...args: TArgs): Promise; } export class ListApiState< TArgs extends any[], TResult, TMethod extends ListMethod | undefined = ListMethod, -> extends ApiState { + TCallVoid = never, +> extends ApiState { /** The metadata of the method being called, if it was provided. */ $metadata?: TMethod; @@ -2369,7 +2454,8 @@ export class ListApiStateWithArgs< TArgsObj, TResult, TMethod extends ListMethod | undefined = ListMethod, -> extends ListApiState { + TCallVoid = never, +> extends ListApiState { private readonly __args = ref() as Ref; /** Values that will be used as arguments if the method is invoked with `this.invokeWithArgs()`. */ get args() { diff --git a/src/coalesce-vue/test/api-client.spec.ts b/src/coalesce-vue/test/api-client.spec.ts index ec3e736fe..ef21d2915 100644 --- a/src/coalesce-vue/test/api-client.spec.ts +++ b/src/coalesce-vue/test/api-client.spec.ts @@ -727,15 +727,16 @@ describe("$makeCaller", () => { const arg = 42; const result = await caller(arg); - // The typings are actually wrong at the moment - `undefined` is not one of the types of `result`, but it should be. + // `result` can be undefined since `undefined` is part of the return signature of the invoker func. + const _resultAllowsNumber: typeof result = 24; + const _resultAllowsUndefined: typeof result = undefined; + expect(result).toBeUndefined(); expect(endpointMock.mock.calls.length).toBe(0); expect(caller.result).toBeNull(); // Typescript typing tests - all of these are valid types of `result`. - // Note that Typescript intellisense in VS code seems to be really messed up - // right now and shows that `result` is only `string`.: caller.result = null; caller.result = 13; @@ -759,15 +760,16 @@ describe("$makeCaller", () => { const arg = 42; const result = await caller(arg); - // The typings are actually wrong at the moment - `undefined` is not one of the types of `result`, but it should be. + // `result` can be undefined since `undefined` is part of the return signature of the invoker func. + const _resultAllowsNumber: typeof result = 24; + const _resultAllowsUndefined: typeof result = undefined; + expect(result).toBeUndefined(); expect(endpointMock.mock.calls.length).toBe(0); expect(caller.result).toBeNull(); // Typescript typing tests - all of these are valid types of `result`. - // Note that Typescript intellisense in VS code seems to be really messed up - // right now and shows that `result` is only `string`.: caller.result = null; caller.result = 13; @@ -1437,15 +1439,16 @@ describe("$makeCaller with args object", () => { const arg = 42; const result = await caller.invokeWithArgs({ num: arg }); - // The typings are actually wrong at the moment - `undefined` is not one of the types of `result`, but it should be. + // `result` can be undefined since `undefined` is part of the return signature of the invoker func. + const _resultAllowsNumber: typeof result = 24; + const _resultAllowsUndefined: typeof result = undefined; + expect(result).toBeUndefined(); expect(endpointMock.mock.calls.length).toBe(0); expect(caller.result).toBeNull(); // Typescript typing tests - all of these are valid types of `result`. - // Note that Typescript intellisense in VS code seems to be really messed up - // right now and shows that `result` is only `string`.: caller.result = null; caller.result = 13; From 7ba0e159980a973557ddcd92feab50e1f2e5ed5f Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 21 May 2026 01:12:48 -0700 Subject: [PATCH 3/6] simplify $makeCaller types, stop exporting its utility types --- .../src/components/c-metadata-component.ts | 17 +- .../src/components/input/c-input.spec.tsx | 1 - .../src/components/input/c-input.vue | 40 ++- .../src/components/input/c-select.vue | 3 +- src/coalesce-vue/src/api-client.ts | 286 +++++++----------- 5 files changed, 134 insertions(+), 213 deletions(-) diff --git a/src/coalesce-vue-vuetify3/src/components/c-metadata-component.ts b/src/coalesce-vue-vuetify3/src/components/c-metadata-component.ts index 2416b76bc..9310bcb69 100644 --- a/src/coalesce-vue-vuetify3/src/components/c-metadata-component.ts +++ b/src/coalesce-vue-vuetify3/src/components/c-metadata-component.ts @@ -20,7 +20,6 @@ import type { BooleanValue, CollectionValue, ObjectValue, - ApiStateTypeWithArgs, ListViewModel, ServiceViewModel, ModelCollectionValue, @@ -74,18 +73,10 @@ TModel extends Model ? }[keyof PropsOf] // Handle binding of `:model` to an API caller (which binds values to the caller's args object): -: TModel extends ApiStateTypeWithArgs< - // eslint-disable-next-line @typescript-eslint/no-unused-vars - infer TMethod extends Method, - any, - infer TArgsObj, - any -> ? - // HACK: Pulling types off of TArgsObj is a concession we make - // due to ApiStateTypeWithArgs's constituent types not actually capturing - // the type of their metadata. At some point this could be made better if - // we were able to pull metadata off of `TMethod. In other words, what we'd - // really like to do here is do the same thing we do for props on a model. +: TModel extends AnyArgCaller ? + // HACK: Pulling types off of TArgsObj is suboptimal. + // Can we instead extract from `& { $metadata: infer TMethod extends Method }`, + // like we do for props on a model? | { [K in keyof TArgsObj]: TArgsObj[K] extends (( ValueKind extends ModelValue ? Model : diff --git a/src/coalesce-vue-vuetify3/src/components/input/c-input.spec.tsx b/src/coalesce-vue-vuetify3/src/components/input/c-input.spec.tsx index 5c89b26f7..623f78f76 100644 --- a/src/coalesce-vue-vuetify3/src/components/input/c-input.spec.tsx +++ b/src/coalesce-vue-vuetify3/src/components/input/c-input.spec.tsx @@ -149,7 +149,6 @@ describe("CInput", () => { //@ts-expect-error bad prop value () => ; - // ****** // Enum filter // ****** diff --git a/src/coalesce-vue-vuetify3/src/components/input/c-input.vue b/src/coalesce-vue-vuetify3/src/components/input/c-input.vue index 414e50b7d..3ae4469c6 100644 --- a/src/coalesce-vue-vuetify3/src/components/input/c-input.vue +++ b/src/coalesce-vue-vuetify3/src/components/input/c-input.vue @@ -38,7 +38,7 @@ type _ValueType< TModel extends Model | DataSource | AnyArgCaller | undefined, TFor extends ForSpec, > = - TModel extends ApiStateTypeWithArgs + TModel extends AnyArgCaller ? TFor extends keyof TArgsObj ? TArgsObj[TFor] : any @@ -69,26 +69,25 @@ type _ValueType< type _MetadataType< TModel extends Model | DataSource | AnyArgCaller | undefined, TFor extends ForSpec, -> = - TModel extends ApiStateTypeWithArgs - ? TMethod extends Method - ? TFor extends keyof TMethod["params"] - ? TMethod["params"][TFor] - : never - : never - : TFor extends Value - ? TFor - : TFor extends string - ? TFor extends keyof EnumTypeLookup - ? // Wrap in synthetic EnumValue so stage 2's guard is uniform. - // MetadataToModelType resolves this via typeDef.name → EnumTypeLookup. - EnumValue & { readonly typeDef: { readonly name: TFor } } - : TModel extends Model - ? TFor extends PropNames - ? TModel["$metadata"]["props"][TFor] - : never +> = TModel extends AnyArgCaller & { + $metadata?: infer TMethod extends Method; +} + ? TFor extends keyof TMethod["params"] + ? TMethod["params"][TFor] + : never + : TFor extends Value + ? TFor + : TFor extends string + ? TFor extends keyof EnumTypeLookup + ? // Wrap in synthetic EnumValue so stage 2's guard is uniform. + // MetadataToModelType resolves this via typeDef.name → EnumTypeLookup. + EnumValue & { readonly typeDef: { readonly name: TFor } } + : TModel extends Model + ? TFor extends PropNames + ? TModel["$metadata"]["props"][TFor] : never - : never; + : never + : never; type SelectSlotItemType< TModel extends Model | DataSource | AnyArgCaller | undefined, @@ -211,7 +210,6 @@ import { type AnyArgCaller, mapValueToModel, parseValue, - ApiStateTypeWithArgs, ModelTypeLookup, PropNames, Value, diff --git a/src/coalesce-vue-vuetify3/src/components/input/c-select.vue b/src/coalesce-vue-vuetify3/src/components/input/c-select.vue index c3f8ee400..e216b9e23 100644 --- a/src/coalesce-vue-vuetify3/src/components/input/c-select.vue +++ b/src/coalesce-vue-vuetify3/src/components/input/c-select.vue @@ -270,7 +270,7 @@ type _SelectedModelType< | ModelValue | ForeignKeyProperty ? ValueOrFkToModelType - : TModel extends ApiStateTypeWithArgs + : TModel extends AnyArgCaller ? TFor extends keyof TArgsObj ? TMultiple extends true ? TArgsObj[TFor] extends Array | null | undefined @@ -364,7 +364,6 @@ import { ModelTypeLookup, PrimaryKeyProperty, PropNames, - ApiStateTypeWithArgs, ModelCollectionValue, modelDisplay, MetadataToModelType, diff --git a/src/coalesce-vue/src/api-client.ts b/src/coalesce-vue/src/api-client.ts index a7bf3911e..b6d276d13 100644 --- a/src/coalesce-vue/src/api-client.ts +++ b/src/coalesce-vue/src/api-client.ts @@ -695,21 +695,10 @@ type TransportTypeSpecifier = type ResultType< T extends TransportTypeSpecifier, TResult, - TNonResult = never, > = T extends ItemTransportTypeSpecifier - ? AxiosResponse> | TNonResult + ? AxiosResponse> : T extends ListTransportTypeSpecifier - ? AxiosResponse> | TNonResult - : never; - -type ResultPromiseType< - T extends TransportTypeSpecifier, - TResult, - TNonResult = never, -> = T extends ItemTransportTypeSpecifier - ? Promise> | TNonResult> - : T extends ListTransportTypeSpecifier - ? Promise> | TNonResult> + ? AxiosResponse> : never; type ApiCallerInvoker< @@ -724,48 +713,76 @@ type ApiCallerArgsInvoker> = ( args: TArgs, ) => TReturn; -export type ApiStateType< +/** Extracts the data type from an invoker's return type. + * For item endpoints: R from AxiosResponse>. + * For list endpoints: R[] from AxiosResponse>. */ +type InferResultData = + Extract< + Awaited>>, + AxiosResponse + > extends AxiosResponse + ? T extends ItemTransportTypeSpecifier + ? TData extends ItemResult + ? R + : never + : T extends ListTransportTypeSpecifier + ? TData extends ListResult + ? R[] + : never + : never + : never; + +/** Evaluates to `undefined` if the invoker's return type indicates + * the API call may be conditionally skipped (returns void/undefined). */ +type InferVoidable = void extends TReturn + ? undefined + : undefined extends TReturn + ? undefined + : undefined extends Awaited>> + ? undefined + : never; + +/** The full result type for an API caller, combining the data type with voidability. */ +type InferCallerResult = + | InferResultData + | InferVoidable; + +type MakeCallerResult< T extends TransportTypeSpecifier, TArgs extends any[], - TResult, - TCallVoid = never, + TReturn, > = T extends ItemTransportTypeSpecifier ? ItemApiState< TArgs, - TResult, - T extends ItemMethod ? T : ItemMethod, - TCallVoid + InferCallerResult, + T extends ItemMethod ? T : ItemMethod > : T extends ListTransportTypeSpecifier ? ListApiState< TArgs, - TResult, - T extends ListMethod ? T : ListMethod, - TCallVoid + InferCallerResult, + T extends ListMethod ? T : ListMethod > : never; -export type ApiStateTypeWithArgs< +type MakeArgsCallerResult< T extends TransportTypeSpecifier, TArgs extends any[], TArgsObj extends object, TResult, - TCallVoid = never, > = T extends ItemTransportTypeSpecifier ? ItemApiStateWithArgs< TArgs, TArgsObj, TResult, - T extends ItemMethod ? T : ItemMethod, - TCallVoid + T extends ItemMethod ? T : ItemMethod > : T extends ListTransportTypeSpecifier ? ListApiStateWithArgs< TArgs, TArgsObj, TResult, - T extends ListMethod ? T : ListMethod, - TCallVoid + T extends ListMethod ? T : ListMethod > : never; @@ -840,137 +857,59 @@ export class ApiClient { return clone; } - // There are two overloads for $makeCaller without argsFactory: - // 1. Strict (no void): invoker must always return a promise. Call signature is `Promise`. - // 2. Void-able: invoker may return void/undefined. Call signature is `Promise`. - // - // We need two overloads because `| undefined | void` in the invoker's expected return type - // acts as a "void sink" — TypeScript matches a void return against it rather than inferring - // undefined into TResult. Without the sink, void-returning invokers wouldn't compile - // (void isn't assignable to Promise<...>). But with it, TResult stays clean (just `number`, - // not `number | undefined`). The two-overload approach lets the strict overload reject - // void-returning invokers (falling through to the second), which then adds undefined - // only to the call signature return, not to `.result` (which remains `TResult | null`). + // $makeCaller uses a single overload per variant (no-args and with-args). + // TReturn is captured from the invoker, and conditional types (InferResultData, InferVoidable) + // extract the data type and detect whether the invoker can return void/undefined. /** * Create a wrapper function for an API call. This function maintains properties which represent the state of its previous invocation. * @param resultType An indicator of whether the API endpoint returns an ItemResult or a ListResult - * @param invokerFactory method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. + * @param invoker method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. */ $makeCaller< TArgs extends any[], - TResult, + TReturn extends Promise | undefined | void, TTransportType extends TransportTypeSpecifier, >( resultType: TTransportType, - invoker: ApiCallerInvoker< - TArgs, - ResultPromiseType, - this - >, - ): ApiStateType; + invoker: ApiCallerInvoker, + ): MakeCallerResult; /** * Create a wrapper function for an API call. This function maintains properties which represent the state of its previous invocation. - * The returned caller's invocation will resolve to `undefined` if the invoker function returns void/undefined - * (i.e. the invoker conditionally skips the API call). * @param resultType An indicator of whether the API endpoint returns an ItemResult or a ListResult - * @param invokerFactory method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. - */ - $makeCaller< - TArgs extends any[], - TResult, - TTransportType extends TransportTypeSpecifier, - >( - resultType: TTransportType, - invoker: ApiCallerInvoker< - TArgs, - | ResultPromiseType - | undefined - | void, - this - >, - ): ApiStateType; - - /** - * Create a wrapper function for an API call. This function maintains properties which represent the state of its previous invocation. - * @param resultType An indicator of whether the API endpoint returns an ItemResult or a ListResult - * @param invokerFactory method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. - * @param invokerFactory method that will call the API with an args object as the only parameter. This may be called by using `.withArgs()` on the function that is returned from `$makeCaller`. The value of the args object will default to `.args` if not specified. + * @param invoker method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. + * @param argsFactory A factory function that will create the args object for the method. + * @param argsInvoker method that will call the API with an args object as the only parameter. This may be called by using `.withArgs()` on the function that is returned from `$makeCaller`. The value of the args object will default to `.args` if not specified. */ $makeCaller< TArgs extends any[], TArgsObj extends object, - TResult, + TReturn extends Promise | undefined | void, TTransportType extends TransportTypeSpecifier, >( resultType: TTransportType, - invoker: ApiCallerInvoker< - TArgs, - ResultPromiseType, - this - >, + invoker: ApiCallerInvoker, argsFactory: () => TArgsObj, - argsInvoker: ApiCallerArgsInvoker< - TArgsObj, - ResultPromiseType, - this - >, - ): ApiStateTypeWithArgs; - - /** - * Create a wrapper function for an API call. This function maintains properties which represent the state of its previous invocation. - * @param resultType An indicator of whether the API endpoint returns an ItemResult or a ListResult - * @param invokerFactory method that will call the API. The signature of the function, minus the apiClient parameter, will be the call signature of the wrapper. - * @param invokerFactory method that will call the API with an args object as the only parameter. This may be called by using `.withArgs()` on the function that is returned from `$makeCaller`. The value of the args object will default to `.args` if not specified. - */ - $makeCaller< - TArgs extends any[], - TArgsObj extends object, - TResult, - TTransportType extends TransportTypeSpecifier, - >( - resultType: TTransportType, - invoker: ApiCallerInvoker< - TArgs, - | ResultPromiseType - | undefined - | void, - this - >, - argsFactory: () => TArgsObj, - argsInvoker: ApiCallerArgsInvoker< - TArgsObj, - | ResultPromiseType - | undefined - | void, - this - >, - ): ApiStateTypeWithArgs; + argsInvoker: ApiCallerArgsInvoker, + ): MakeArgsCallerResult< + TTransportType, + TArgs, + TArgsObj, + InferCallerResult + >; $makeCaller< TArgs extends any[], TArgsObj extends object, - TResult, + TReturn extends Promise | undefined | void, TTransportType extends TransportTypeSpecifier, >( resultType: TTransportType, - invoker: ApiCallerInvoker< - TArgs, - | ResultPromiseType - | undefined - | void, - this - >, + invoker: ApiCallerInvoker, argsFactory?: () => TArgsObj, - argsInvoker?: ApiCallerArgsInvoker< - TArgsObj, - | ResultPromiseType - | undefined - | void, - this - >, - ): any { + argsInvoker?: ApiCallerArgsInvoker, + ) { let localResultType: TransportTypeSpecifier = resultType; let meta: Method | undefined = undefined; if (typeof localResultType === "function") { @@ -988,7 +927,7 @@ export class ApiClient { if (argsFactory && argsInvoker) { switch (localResultType) { case "item": - instance = new ItemApiStateWithArgs( + instance = new ItemApiStateWithArgs( this, invoker as any, argsFactory, @@ -996,7 +935,7 @@ export class ApiClient { ); break; case "list": - instance = new ListApiStateWithArgs( + instance = new ListApiStateWithArgs( this, invoker as any, argsFactory, @@ -1009,10 +948,10 @@ export class ApiClient { } else { switch (localResultType) { case "item": - instance = new ItemApiState(this, invoker as any); + instance = new ItemApiState(this, invoker as any); break; case "list": - instance = new ListApiState(this, invoker as any); + instance = new ListApiState(this, invoker as any); break; default: throw `Unknown result type ${localResultType}`; @@ -1447,7 +1386,7 @@ export type ResponseCachingConfiguration = { // Base class for ApiState that contains nothing but the logic for // subclassing Function. Specifically, we do this to avoid a need to call // `super()`, which triggers CSP unsafe-eval. -abstract class ApiStateBase { +abstract class ApiStateBase { /** Invokes a call to this API endpoint. */ public readonly invoke!: this; @@ -1462,9 +1401,9 @@ abstract class ApiStateBase { // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type invoker: Function, args: any[], - ): Promise; + ): Promise; - protected _pendingPromise: Promise | undefined; + protected _pendingPromise: Promise | undefined; constructor( apiClient: ApiClient, @@ -1509,8 +1448,7 @@ ApiStateBase.prototype.__proto__ = Function; export abstract class ApiState< TArgs extends any[], TResult, - TCallVoid = never, -> extends ApiStateBase { +> extends ApiStateBase { /** See comments on ReactiveFlags_SKIP for explanation. * @internal */ @@ -1519,7 +1457,7 @@ export abstract class ApiState< /** The metadata of the method being called, if it was provided. */ abstract $metadata?: Method; - abstract result: TResult | null; + abstract result: Exclude | null; private readonly __isLoading = ref(false); /** True if a request is currently pending. */ @@ -1740,11 +1678,9 @@ export abstract class ApiState< * If a request is currently pending, awaits that request. * If no request is pending, resolves immediately with the current `result` value. */ - then( + then( onfulfilled?: - | (( - value: TResult | TCallVoid | null, - ) => TResult1 | PromiseLike) + | ((value: TResult | null) => TResult1 | PromiseLike) | null, onrejected?: ((reason: any) => TResult2 | PromiseLike) | null, ): Promise { @@ -1767,7 +1703,7 @@ export abstract class ApiState< // eslint-disable-next-line @typescript-eslint/no-unsafe-function-type invoker: Function, args: any[], - ): Promise { + ): Promise { let apiClient = this.apiClient; if (this.isLoading) { @@ -1983,7 +1919,7 @@ export abstract class ApiState< this.isLoading = false; // NB: `this.result` will have been set non-null in `setResponseProps`. - return this.result!; + return this.result! as TResult; } catch (thrown) { if (axios.isCancel(thrown)) { // No handling of anything for cancellations. @@ -2112,18 +2048,16 @@ export interface ItemApiState< TResult, // eslint-disable-next-line @typescript-eslint/no-unused-vars TMethod extends ItemMethod | undefined = ItemMethod, - TCallVoid = never, > { // Do not put a doc comment on the call signature: // it'll hide the doc comment of the caller's definition. - (...args: TArgs): Promise; + (...args: TArgs): Promise; } export class ItemApiState< TArgs extends any[], TResult, TMethod extends ItemMethod | undefined = ItemMethod, - TCallVoid = never, -> extends ApiState { +> extends ApiState { /** The metadata of the method being called, if it was provided. */ $metadata?: TMethod; @@ -2136,12 +2070,14 @@ export class ItemApiState< this.__validationIssues.value = v; } - private readonly __result = ref(null) as Ref; + private readonly __result = ref | null>( + null, + ) as Ref | null>; /** Principal data returned by the previous request. */ - get result() { + get result(): Exclude | null { return this.__result.value; } - set result(v) { + set result(v: Exclude | null) { this.__result.value = v; if (this.$metadata?.return.type == "void") { this.hasResult = !!this.wasSuccessful; @@ -2151,7 +2087,9 @@ export class ItemApiState< } override get rawResponse() { - return super.rawResponse as AxiosResponse>; + return super.rawResponse as AxiosResponse< + ItemResult> + >; } constructor( @@ -2244,7 +2182,7 @@ export class ItemApiState< this.validationIssues = null; } if ("object" in data) { - this.result = data.object ?? null; + this.result = (data.object ?? null) as Exclude | null; } else { this.result = null; } @@ -2256,8 +2194,7 @@ export class ItemApiStateWithArgs< TArgsObj, TResult, TMethod extends ItemMethod | undefined = ItemMethod, - TCallVoid = never, -> extends ItemApiState { +> extends ItemApiState { private readonly __args = ref() as Ref; /** Values that will be used as arguments if the method is invoked with `this.invokeWithArgs()`. */ get args() { @@ -2276,7 +2213,7 @@ export class ItemApiStateWithArgs< /** Invokes a call to this API endpoint. * If `args` is not provided, the values in `this.args` will be used for the method's parameters. */ - public invokeWithArgs(args: TArgsObj = this.args) { + public invokeWithArgs(args: TArgsObj = this.args): Promise { // Copy args so that if we're debouncing, // the args at the point in time at which invokeWithArgs() was // called will be used, rather than the state at the time when the actual API call gets made. @@ -2326,13 +2263,13 @@ export class ItemApiStateWithArgs< apiClient: ApiClient, invoker: ApiCallerInvoker< TArgs, - ItemResultPromise, + ApiResultPromise | undefined | void, ApiClient >, private argsFactory: () => TArgsObj, private argsInvoker: ApiCallerArgsInvoker< TArgsObj, - ItemResultPromise, + ApiResultPromise | undefined | void, ApiClient >, ) { @@ -2346,17 +2283,15 @@ export interface ListApiState< TResult, // eslint-disable-next-line @typescript-eslint/no-unused-vars TMethod extends ListMethod | undefined = ListMethod, - TCallVoid = never, > { /** Invokes a call to this API endpoint. */ - (...args: TArgs): Promise; + (...args: TArgs): Promise; } export class ListApiState< TArgs extends any[], TResult, TMethod extends ListMethod | undefined = ListMethod, - TCallVoid = never, -> extends ApiState { +> extends ApiState { /** The metadata of the method being called, if it was provided. */ $metadata?: TMethod; @@ -2396,20 +2331,20 @@ export class ListApiState< this.__totalCount.value = v; } - private readonly __result = ref(null) as Ref< - TResult[] | null - >; + private readonly __result = ref | null>( + null, + ) as Ref | null>; /** Principal data returned by the previous request. */ - get result() { + get result(): Exclude | null { return this.__result.value; } - set result(v) { + set result(v: Exclude | null) { this.__result.value = v; this.hasResult = v != null; } override get rawResponse() { - return super.rawResponse as AxiosResponse>; + return super.rawResponse as AxiosResponse>; } constructor( @@ -2432,7 +2367,7 @@ export class ListApiState< this.page = null; } - protected setResponseProps(data: ListResult) { + protected setResponseProps(data: ListResult) { this.wasSuccessful = data.wasSuccessful; this.message = data.message || null; @@ -2442,7 +2377,7 @@ export class ListApiState< this.totalCount = data.totalCount ?? null; if ("list" in data) { - this.result = data.list || []; + this.result = (data.list || []) as Exclude | null; } else { this.result = null; } @@ -2454,8 +2389,7 @@ export class ListApiStateWithArgs< TArgsObj, TResult, TMethod extends ListMethod | undefined = ListMethod, - TCallVoid = never, -> extends ListApiState { +> extends ListApiState { private readonly __args = ref() as Ref; /** Values that will be used as arguments if the method is invoked with `this.invokeWithArgs()`. */ get args() { @@ -2482,7 +2416,7 @@ export class ListApiStateWithArgs< this, this.argsInvoker, [args], - )) as Promise; + )); } /** Replace `this.args` with a new, blank object containing default values (typically nulls) */ @@ -2503,13 +2437,13 @@ export class ListApiStateWithArgs< apiClient: ApiClient, invoker: ApiCallerInvoker< TArgs, - ListResultPromise, + ApiResultPromise | undefined | void, ApiClient >, private argsFactory: () => TArgsObj, private argsInvoker: ApiCallerArgsInvoker< TArgsObj, - ListResultPromise, + ApiResultPromise | undefined | void, ApiClient >, ) { From 5418779ded272260cc413538bc5b520cc28a71a8 Mon Sep 17 00:00:00 2001 From: Andrew Scott Date: Thu, 21 May 2026 09:13:07 -0700 Subject: [PATCH 4/6] fix: update loader reference in c-loader-status and use computed for ViewModel --- .../src/examples/core/abstract-viewmodel-load.vue | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/playground/Coalesce.Web.Vue3/src/examples/core/abstract-viewmodel-load.vue b/playground/Coalesce.Web.Vue3/src/examples/core/abstract-viewmodel-load.vue index 6f9d6f5a3..5c4d2fc4c 100644 --- a/playground/Coalesce.Web.Vue3/src/examples/core/abstract-viewmodel-load.vue +++ b/playground/Coalesce.Web.Vue3/src/examples/core/abstract-viewmodel-load.vue @@ -1,5 +1,5 @@