Skip to content

Commit 4c6f364

Browse files
committed
fix: rework fix for #631 to be less likely to cause breaking changes
1 parent d3b44a7 commit 4c6f364

File tree

10 files changed

+215
-75
lines changed

10 files changed

+215
-75
lines changed

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# 6.1.0
22
- Added support for .NET 10
3-
- `c-loader-status`: The `initial-content` flag now behaves more consistently. A loader is now considered to have initial content if it has ever successfully invoked. Previously, some types of failures (e.g. network errors) would not clear this state, but other kinds of failures (explicit server error responses) did clear this state. If errors should result in hidden content, the `no-error-content` flag should be used.
3+
- API Callers now set `hasResult = true` for void-returning endpoints that have successfully loaded. This means that the `no-initial-content` flag on `c-loader-status` works as expected for void-returning endpoints.
44

55
# 6.0.4
66
- AuditLogging: Stored procedure cache was not being correctly persisted to avoid redundant `SELECT OBJECT_DEFINITION` queries.

docs/stacks/vue/coalesce-vue-vuetify/components/c-loader-status.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -175,7 +175,7 @@ The available flags are as follows, all of which default to `true` except for `s
175175
| - | - |
176176
| `no-loading-content` | Controls whether the default slot is rendered while any API caller is loading (i.e. when `caller.isLoading === true`). |
177177
| `no-error-content` | Controls whether the default slot is rendered while any API Caller is in an error state (i.e. when `caller.wasSuccessful === false`). |
178-
| `no-initial-content` | Controls whether the default slot is rendered while any API Caller has yet to receive a successful response for the first time. |
178+
| `no-initial-content` | Controls whether the default slot is rendered while any API Caller does not have a result (i.e. when `caller.hasResult === false`). |
179179
| `no-progress` | Master toggle for whether the progress indicator is shown in any scenario. |
180180
| `no-initial-progress` | Controls whether the progress indicator is shown when an API Caller is loading for the very first time (i.e. when `caller.wasSuccessful === null`). |
181181
| `no-secondary-progress` | Controls whether the progress indicator is shown when an API Caller is loading any time after its first invocation (i.e. when `caller.wasSuccessful !== null`). |

docs/stacks/vue/layers/api-clients.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -216,7 +216,7 @@ A message from the last response. Typically an error message if the last request
216216
readonly hasResult: boolean
217217
```
218218

219-
True if `result` is non-null. This prop can be useful in niche, performance-critical scenarios where checking `result` directly will cause an overabundance of re-renders in high-churn scenarios.
219+
True if `result` is non-null. This is also true for void-returning endpoints that were successful.
220220

221221
### args {#args}
222222

src/IntelliTect.Coalesce.Tests/TargetClasses/TestDbContext/ComplexModel.cs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -339,6 +339,12 @@ public static ItemResult<IFile> DownloadAttachmentStatic()
339339
return new File(new byte[] { 0x42 }) { Name = "42.png" };
340340
}
341341

342+
[Coalesce]
343+
public ListResult<ComplexModel> ReturnsListResult()
344+
{
345+
return new();
346+
}
347+
342348
[ControllerAction(Method = HttpMethod.Get)]
343349
[Coalesce]
344350
[SemanticKernel("MethodWithOptionalCancellationToken")]

src/coalesce-vue-vuetify3/src/components/display/c-loader-status.spec.tsx

Lines changed: 145 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { mountApp, mockEndpoint, flushPromises } from "@test/util";
22
import CLS from "./c-loader-status.vue";
33
import { ComplexModelViewModel } from "@test-targets/viewmodels.g";
4+
import { AxiosError } from "axios";
45

56
describe("CLoaderStatus", () => {
67
const vm = new ComplexModelViewModel();
@@ -145,61 +146,152 @@ describe("CLoaderStatus", () => {
145146
expect(wrapper.text()).toContain("Success");
146147
});
147148

148-
test("void-returning endpoint with no-initial-content shows content after success then failure", async () => {
149-
// This tests the fix for void-returning endpoints where hasResult should be true
150-
// when the endpoint succeeds, even though result is null.
151-
const vm = new ComplexModelViewModel();
152-
vm.$primaryKey = 1;
153-
const voidCaller = vm.methodWithOptionalEnumParam;
154-
155-
const mockSuccess = mockEndpoint(
156-
vm.$metadata.methods.methodWithOptionalEnumParam,
157-
() => ({
149+
const endpoints = [
150+
{
151+
name: "void-returning",
152+
methodName: "methodWithOptionalEnumParam" as const,
153+
mockSuccessResponse: () => ({
158154
wasSuccessful: true,
159155
// Void endpoints don't have an 'object' property in the response
160156
}),
161-
);
162-
163-
const wrapper = mountApp(() => (
164-
<CLS loaders={voidCaller} no-initial-content>
165-
<div>Content</div>
166-
</CLS>
167-
));
168-
169-
// Initially, content should be hidden (wasSuccessful is null)
170-
expect(wrapper.text()).not.toContain("Content");
171-
172-
// Call the void endpoint successfully
173-
await voidCaller();
174-
await flushPromises();
175-
176-
// After success, content should be visible
177-
expect(voidCaller.wasSuccessful).toBe(true);
178-
expect(voidCaller.result).toBeNull();
179-
expect(voidCaller._hasLoaded.value).toBe(true);
180-
expect(wrapper.text()).toContain("Content");
181-
182-
mockSuccess.destroy();
183-
184-
// Now mock a failure
185-
const mockFailure = mockEndpoint(
186-
vm.$metadata.methods.methodWithOptionalEnumParam,
187-
() => ({
188-
wasSuccessful: false,
189-
message: "Error occurred",
157+
},
158+
{
159+
name: "string-returning",
160+
methodName: "methodWithOptionalParams" as const,
161+
mockSuccessResponse: () => ({
162+
wasSuccessful: true,
163+
object: "test string result",
190164
}),
191-
);
192-
193-
// Call the endpoint again, this time it fails
194-
await expect(voidCaller()).rejects.toThrow();
195-
await flushPromises();
196-
197-
// Content should still be visible because hasLoaded is true
198-
// (the endpoint has successfully loaded before, even though this call failed)
199-
expect(voidCaller.wasSuccessful).toBe(false);
200-
expect(voidCaller._hasLoaded.value).toBe(true);
201-
expect(wrapper.text()).toContain("Content");
202-
203-
mockFailure.destroy();
204-
});
165+
},
166+
{
167+
name: "object-returning",
168+
methodName: "methodWithManyParams" as const,
169+
mockSuccessResponse: () => ({
170+
wasSuccessful: true,
171+
object: { externalParentId: 42, name: "Test Parent" },
172+
}),
173+
},
174+
{
175+
name: "ListResult-returning",
176+
methodName: "returnsListResult" as const,
177+
mockSuccessResponse: () => ({
178+
wasSuccessful: true,
179+
list: [],
180+
}),
181+
},
182+
] as const;
183+
184+
describe.each(endpoints)(
185+
"$name endpoint",
186+
function ({ methodName, mockSuccessResponse }) {
187+
test("no-initial-content shows content after success then Network Error", async () => {
188+
// This tests that hasResult is properly set when an endpoint succeeds,
189+
// regardless of the return type (void, primitive, object, ItemResult).
190+
const vm = new ComplexModelViewModel();
191+
vm.$primaryKey = 1;
192+
const caller = vm[methodName];
193+
194+
const mockSuccess = mockEndpoint(
195+
vm.$metadata.methods[methodName],
196+
mockSuccessResponse,
197+
);
198+
199+
const wrapper = mountApp(() => (
200+
<CLS loaders={caller} no-initial-content>
201+
<div>Content</div>
202+
</CLS>
203+
));
204+
205+
// Initially, content should be hidden (wasSuccessful is null)
206+
expect(wrapper.text()).not.toContain("Content");
207+
208+
// Call the endpoint successfully
209+
await caller();
210+
await flushPromises();
211+
212+
// After success, content should be visible
213+
expect(caller.wasSuccessful).toBe(true);
214+
expect(caller.hasResult).toBe(true);
215+
expect(wrapper.text()).toContain("Content");
216+
217+
mockSuccess.destroy();
218+
219+
// Now mock a failure
220+
const mockFailure = mockEndpoint(
221+
vm.$metadata.methods[methodName],
222+
() => {
223+
throw new AxiosError("Network Error");
224+
},
225+
);
226+
227+
// Call the endpoint again, this time it fails
228+
await expect(caller()).rejects.toThrow();
229+
await flushPromises();
230+
231+
// Content should still be visible because Network errors do not wipe a caller's result.
232+
// This is tested for because its how Coalesce has always worked,
233+
// and would cause all kinds of subtile behavior changes if it ever stopped doing that.
234+
expect(caller.wasSuccessful).toBe(false);
235+
expect(caller.hasResult).toBe(true);
236+
expect(wrapper.text()).toContain("Content");
237+
238+
mockFailure.destroy();
239+
});
240+
241+
test("no-initial-content hides content after explicit failure", async () => {
242+
// This tests that hasResult is properly set when an endpoint succeeds,
243+
// regardless of the return type (void, primitive, object, ItemResult).
244+
const vm = new ComplexModelViewModel();
245+
vm.$primaryKey = 1;
246+
const caller = vm[methodName];
247+
248+
const mockSuccess = mockEndpoint(
249+
vm.$metadata.methods[methodName],
250+
mockSuccessResponse,
251+
);
252+
253+
const wrapper = mountApp(() => (
254+
<CLS loaders={caller} no-initial-content>
255+
<div>Content</div>
256+
</CLS>
257+
));
258+
259+
// Initially, content should be hidden (wasSuccessful is null)
260+
expect(wrapper.text()).not.toContain("Content");
261+
262+
// Call the endpoint successfully
263+
await caller();
264+
await flushPromises();
265+
266+
// After success, content should be visible
267+
expect(caller.wasSuccessful).toBe(true);
268+
expect(caller.hasResult).toBe(true);
269+
expect(wrapper.text()).toContain("Content");
270+
271+
mockSuccess.destroy();
272+
273+
// Now mock a failure
274+
const mockFailure = mockEndpoint(
275+
vm.$metadata.methods[methodName],
276+
() => ({
277+
wasSuccessful: false,
278+
message: "Explicit failure",
279+
}),
280+
);
281+
282+
// Call the endpoint again, this time it fails
283+
await expect(caller()).rejects.toThrow();
284+
await flushPromises();
285+
286+
// Since no-initial-status is based on `hasResult`,
287+
// and our result has been wiped by an explicit server error, the content should hide.
288+
expect(caller.wasSuccessful).toBe(false);
289+
expect(caller.hasResult).toBe(false);
290+
expect(caller.result).toBeFalsy();
291+
expect(wrapper.text()).not.toContain("Content");
292+
293+
mockFailure.destroy();
294+
});
295+
},
296+
);
205297
});

src/coalesce-vue-vuetify3/src/components/display/c-loader-status.vue

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -341,7 +341,10 @@ const showContent = computed(() => {
341341
// initial content is off, and either:
342342
!flags["initial-content"] &&
343343
// loader has not yet loaded
344-
!loader._hasLoaded.value
344+
(loader.wasSuccessful == null ||
345+
// or loader has loaded, but it errored and there's no current result
346+
// (implying it has never successfully loaded).
347+
(loader.wasSuccessful === false && !loader.hasResult))
345348
) {
346349
return false;
347350
}

src/coalesce-vue/src/api-client.ts

Lines changed: 6 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1394,26 +1394,12 @@ export abstract class ApiState<
13941394
this.__isLoading.value = v;
13951395
}
13961396

1397-
/**
1398-
* @internal
1399-
* Whether the endpoint has ever successfully loaded.
1400-
* This is internal state because we don't want to confuse users into using it incorrectly
1401-
* (it's somewhat similar to wasSuccessful and hasResult)
1402-
* Could expose if there's a compelling argument for doing so.
1403-
*/
1404-
_hasLoaded = ref(false);
1405-
14061397
private readonly __wasSuccessful = ref<boolean | null>(null);
14071398
/** True if the previous request was successful. */
14081399
get wasSuccessful() {
14091400
return this.__wasSuccessful.value;
14101401
}
14111402
set wasSuccessful(v) {
1412-
if (v === null) {
1413-
this._hasLoaded.value = false;
1414-
} else if (v) {
1415-
this._hasLoaded.value = true;
1416-
}
14171403
this.__wasSuccessful.value = v;
14181404
}
14191405

@@ -1454,7 +1440,6 @@ export abstract class ApiState<
14541440
public reset() {
14551441
if (this.isLoading)
14561442
throw new Error("Cannot reset while a request is pending.");
1457-
this.hasResult = false;
14581443
this.result = null;
14591444
this.wasSuccessful = null;
14601445
this.message = null;
@@ -1963,7 +1948,11 @@ export class ItemApiState<
19631948
}
19641949
set result(v) {
19651950
this.__result.value = v;
1966-
this.hasResult = v != null;
1951+
if (this.$metadata?.return.type == "void") {
1952+
this.hasResult = !!this.wasSuccessful;
1953+
} else {
1954+
this.hasResult = v != null;
1955+
}
19671956
}
19681957

19691958
override get rawResponse() {
@@ -2064,7 +2053,6 @@ export class ItemApiState<
20642053
} else {
20652054
this.result = null;
20662055
}
2067-
this.hasResult = this.result != null;
20682056
}
20692057
}
20702058

@@ -2208,6 +2196,7 @@ export class ListApiState<
22082196
}
22092197
set result(v) {
22102198
this.__result.value = v;
2199+
this.hasResult = v != null;
22112200
}
22122201

22132202
override get rawResponse() {
@@ -2248,7 +2237,6 @@ export class ListApiState<
22482237
} else {
22492238
this.result = null;
22502239
}
2251-
this.hasResult = this.result != null;
22522240
}
22532241
}
22542242

src/test-targets/api-clients.g.ts

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/test-targets/metadata.g.ts

Lines changed: 31 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)