This is an automated email from the ASF dual-hosted git repository.
dengliming pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/shenyu-dashboard.git
The following commit(s) were added to refs/heads/master by this push:
new c035003c fix: clear loading state when an effect throws or is
cancelled (#711)
c035003c is described below
commit c035003cea34b40c0efdfe3f77a9849cf11b2e87
Author: Animesh Kumar <[email protected]>
AuthorDate: Sat Oct 10 22:16:30 2026 +0530
fix: clear loading state when an effect throws or is cancelled (#711)
* fix: clear loading state when an effect throws or is cancelled
dva-loading 2.x dispatches HIDE only after the wrapped effect returns.
With no onError hook registered, dva-core rethrows effect errors, so a
rejected request skips HIDE and leaves loading.effects, the model flag
and the global flag stuck at true. Wrap dva-loading so HIDE is always
dispatched from a finally block, keeping its reducer and only/except
filtering.
Fixes #602
* fix: return the wrapped effect's result and await the cancelled task in
the test
---
src/index.js | 2 +-
src/utils/loading.js | 48 ++++++++++++++++++
src/utils/loading.test.js | 122 ++++++++++++++++++++++++++++++++++++++++++++++
3 files changed, 171 insertions(+), 1 deletion(-)
diff --git a/src/index.js b/src/index.js
index def17205..42bed748 100644
--- a/src/index.js
+++ b/src/index.js
@@ -21,7 +21,7 @@ import dva from "dva";
import { createHashHistory as createHistory } from "history";
// user BrowserHistory
// import createHistory from 'history/createBrowserHistory';
-import createLoading from "dva-loading";
+import createLoading from "./utils/loading";
import { initIntl } from "./utils/IntlUtils";
// import 'moment/locale/zh-cn';
diff --git a/src/utils/loading.js b/src/utils/loading.js
new file mode 100644
index 00000000..6aef91ee
--- /dev/null
+++ b/src/utils/loading.js
@@ -0,0 +1,48 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import createDvaLoading from "dva-loading";
+
+// Action types used by dva-loading's reducer (not exported by the package).
+const SHOW = "@@DVA_LOADING/SHOW";
+const HIDE = "@@DVA_LOADING/HIDE";
+
+// dva-loading 2.x dispatches HIDE only after the effect returns, so an effect
+// that throws (e.g. a rejected request) or is cancelled leaves its loading
+// flags set forever. Keep its reducer and only/except filtering, but always
+// dispatch HIDE from a finally block.
+export default function createLoading(opts = {}) {
+ const loading = createDvaLoading(opts);
+
+ function onEffect(effect, sagaEffects, model, actionType) {
+ if (loading.onEffect(effect, sagaEffects, model, actionType) === effect) {
+ return effect;
+ }
+ const { put } = sagaEffects;
+ const payload = { namespace: model.namespace, actionType };
+ return function* loadingEffect(...args) {
+ yield put({ type: SHOW, payload });
+ try {
+ return yield effect(...args);
+ } finally {
+ yield put({ type: HIDE, payload });
+ }
+ };
+ }
+
+ return { ...loading, onEffect };
+}
diff --git a/src/utils/loading.test.js b/src/utils/loading.test.js
new file mode 100644
index 00000000..dde2c16a
--- /dev/null
+++ b/src/utils/loading.test.js
@@ -0,0 +1,122 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { runSaga, effects } from "dva/saga";
+import createLoading from "./loading";
+import globalModel from "../models/global";
+import { queryPlatform } from "../services/api";
+
+jest.mock("../services/api", () => ({ queryPlatform: jest.fn() }));
+jest.mock("antd", () => ({ message: { warn: jest.fn(), success: jest.fn() }
}));
+jest.mock("../utils/IntlUtils", () => ({ getIntlContent: (key) => key }));
+jest.mock("../components/_utils/utils", () => ({
+ defaultNamespaceId: "default-namespace",
+}));
+
+function setup(opts) {
+ const loading = createLoading(opts);
+ const reducer = loading.extraReducers.loading;
+ let state = reducer(undefined, { type: "@@INIT" });
+ const actions = [];
+ const dispatch = (action) => {
+ actions.push(action.type);
+ state = reducer(state, action);
+ };
+ const run = (effect, model, actionType) =>
+ runSaga(
+ { dispatch, getState: () => ({}), onError: () => {} },
+ loading.onEffect(effect, effects, model, actionType),
+ { type: actionType },
+ effects,
+ );
+ return { loading, run, actions, getState: () => state };
+}
+
+describe("createLoading", () => {
+ it("hides loading after an effect resolves", async () => {
+ const { run, actions, getState } = setup();
+ function* ok() {
+ yield effects.call(() => Promise.resolve());
+ }
+
+ await run(ok, { namespace: "demo" }, "demo/ok").done;
+
+ expect(actions).toEqual(["@@DVA_LOADING/SHOW", "@@DVA_LOADING/HIDE"]);
+ expect(getState()).toEqual({
+ global: false,
+ models: { demo: false },
+ effects: { "demo/ok": false },
+ });
+ });
+
+ it("returns the wrapped effect's result", async () => {
+ const { run } = setup();
+ function* withResult() {
+ return yield effects.call(() => Promise.resolve("result"));
+ }
+
+ await expect(
+ run(withResult, { namespace: "demo" }, "demo/result").done,
+ ).resolves.toBe("result");
+ });
+
+ it("hides loading when a request rejects inside an effect", async () => {
+ queryPlatform.mockRejectedValue(new Error("network down"));
+ const { run, actions, getState } = setup();
+
+ await expect(
+ run(
+ globalModel.effects.fetchPlatform,
+ globalModel,
+ "global/fetchPlatform",
+ ).done,
+ ).rejects.toThrow("network down");
+
+ expect(actions).toEqual(["@@DVA_LOADING/SHOW", "@@DVA_LOADING/HIDE"]);
+ expect(getState()).toEqual({
+ global: false,
+ models: { global: false },
+ effects: { "global/fetchPlatform": false },
+ });
+ });
+
+ it("hides loading when an effect is cancelled", async () => {
+ const { run, getState } = setup();
+ function* pending() {
+ yield effects.call(() => new Promise(() => {}));
+ }
+
+ const task = run(pending, { namespace: "demo" }, "demo/pending");
+ expect(getState().effects["demo/pending"]).toBe(true);
+ task.cancel();
+ await task.done;
+
+ expect(getState().global).toBe(false);
+ expect(getState().effects["demo/pending"]).toBe(false);
+ });
+
+ it("leaves effects excluded by only/except unwrapped", () => {
+ const { loading } = setup({ only: ["demo/tracked"] });
+ function* untracked() {
+ yield effects.call(() => Promise.resolve());
+ }
+
+ expect(
+ loading.onEffect(untracked, effects, { namespace: "demo" },
"demo/other"),
+ ).toBe(untracked);
+ });
+});