diff --git a/etc/brick-types.api.md b/etc/brick-types.api.md index 7dbdad724c..ee34d73cc6 100644 --- a/etc/brick-types.api.md +++ b/etc/brick-types.api.md @@ -2134,7 +2134,7 @@ export interface StoryboardContextItemFreeVariable { // (undocumented) loaded?: boolean; // (undocumented) - loading?: boolean; + loading?: Promise; // (undocumented) type: "free-variable"; // (undocumented) diff --git a/packages/brick-kit/src/core/StoryboardContext.spec.ts b/packages/brick-kit/src/core/StoryboardContext.spec.ts index a76b520473..23c382f0b0 100644 --- a/packages/brick-kit/src/core/StoryboardContext.spec.ts +++ b/packages/brick-kit/src/core/StoryboardContext.spec.ts @@ -3,11 +3,11 @@ import { CustomTemplateContext } from "./CustomTemplates/CustomTemplateContext"; import { StoryboardContextWrapper } from "./StoryboardContext"; import * as runtime from "./Runtime"; -const consoleWarn = jest - .spyOn(console, "warn") - .mockImplementation(() => void 0); +const consoleWarn = jest.spyOn(console, "warn").mockImplementation(); +const consoleInfo = jest.spyOn(console, "info").mockImplementation(); -let resolveValue = "lazily updated"; +let resolveValue: string; +let rejectReason: Error; const resolveOne = jest.fn( async ( type: unknown, @@ -17,6 +17,9 @@ const resolveOne = jest.fn( context?: unknown, options?: ResolveOptions ) => { + if (rejectReason) { + throw rejectReason; + } await Promise.resolve(); conf.value = `[cache:${options?.cache ?? "default"}] ${resolveValue}`; } @@ -26,7 +29,9 @@ jest.spyOn(runtime, "_internalApiGetResolver").mockReturnValue({ } as any); describe("StoryboardContextWrapper", () => { - afterEach(() => { + beforeEach(() => { + resolveValue = "lazily updated"; + rejectReason = undefined; jest.clearAllMocks(); }); @@ -94,14 +99,80 @@ describe("StoryboardContextWrapper", () => { expect(ctx.getValue("asyncValue")).toBe("initial"); expect(ctx.getValue("processedData")).toBe("processed: initial"); - ctx.updateValue("asyncValue", undefined, "refresh"); + ctx.updateValue("asyncValue", undefined, "refresh", { + success: { + action: "console.info", + args: ["success", "<% EVENT.detail %>"], + }, + error: { + action: "console.info", + args: ["error", "<% EVENT.detail.message %>"], + }, + finally: { + action: "console.info", + args: ["finally", "<% EVENT.detail %>"], + }, + }); expect(ctx.getValue("asyncValue")).toBe("initial"); + expect(consoleInfo).not.toBeCalled(); await (global as any).flushPromises(); expect(ctx.getValue("asyncValue")).toBe("[cache:reload] lazily updated"); expect(ctx.getValue("processedData")).toBe( "processed: [cache:reload] lazily updated" ); + expect(consoleInfo).toBeCalledTimes(2); + expect(consoleInfo).toHaveBeenNthCalledWith(1, "success", { + value: "[cache:reload] lazily updated", + }); + expect(consoleInfo).toHaveBeenNthCalledWith(2, "finally", null); + }); + + it("should handle error when load failed", async () => { + const brick = { properties: {} }; + const tplContext = new CustomTemplateContext(brick); + const ctx = tplContext.state; + await ctx.define( + [ + { + name: "asyncValue", + resolve: { + useProvider: "my-provider", + lazy: true, + }, + value: "initial", + }, + ], + { + tplContextId: tplContext.id, + } as any, + brick + ); + + rejectReason = new Error("oops"); + ctx.updateValue("asyncValue", undefined, "load", { + success: { + action: "console.info", + args: ["success", "<% EVENT.detail %>"], + }, + error: { + action: "console.info", + args: ["error", "<% EVENT.detail.message %>"], + }, + finally: { + action: "console.info", + args: ["finally", "<% EVENT.detail %>"], + }, + }); + + expect(ctx.getValue("asyncValue")).toBe("initial"); + expect(consoleInfo).not.toBeCalled(); + + await (global as any).flushPromises(); + expect(ctx.getValue("asyncValue")).toBe("initial"); + expect(consoleInfo).toBeCalledTimes(2); + expect(consoleInfo).toHaveBeenNthCalledWith(1, "error", "oops"); + expect(consoleInfo).toHaveBeenNthCalledWith(2, "finally", null); }); it("should refresh when deps updated", async () => { @@ -109,7 +180,6 @@ describe("StoryboardContextWrapper", () => { const tplContext = new CustomTemplateContext(brick); const ctx = tplContext.state; - const originalResolveValue = resolveValue; resolveValue = "initial"; await ctx.define( @@ -136,7 +206,7 @@ describe("StoryboardContextWrapper", () => { expect(ctx.getValue("asyncValue")).toBe("[cache:default] initial"); expect(ctx.getValue("dep")).toBe("first"); - resolveValue = originalResolveValue; + resolveValue = "lazily updated"; ctx.updateValue("dep", "second", "replace"); expect(ctx.getValue("dep")).toBe("second"); expect(ctx.getValue("asyncValue")).toBe("[cache:default] initial"); @@ -174,9 +244,28 @@ describe("StoryboardContextWrapper", () => { expect(ctx.getValue("asyncValue")).toBe("initial"); expect(ctx.getValue("processedData")).toBe("processed: initial"); // Trigger load twice. - ctx.updateValue("asyncValue", undefined, "load"); - ctx.updateValue("asyncValue", undefined, "load"); + ctx.updateValue("asyncValue", undefined, "load", { + success: { + action: "console.info", + args: ["[1] success", "<% EVENT.detail %>"], + }, + finally: { + action: "console.info", + args: ["[1] finally", "<% EVENT.detail %>"], + }, + }); + ctx.updateValue("asyncValue", undefined, "load", { + success: { + action: "console.info", + args: ["[2] success", "<% EVENT.detail %>"], + }, + finally: { + action: "console.info", + args: ["[2] finally", "<% EVENT.detail %>"], + }, + }); expect(ctx.getValue("asyncValue")).toBe("initial"); + expect(consoleInfo).not.toBeCalled(); await (global as any).flushPromises(); // Will not load again if it is already LOADING. @@ -185,11 +274,34 @@ describe("StoryboardContextWrapper", () => { expect(ctx.getValue("processedData")).toBe( "processed: [cache:default] lazily updated" ); + expect(consoleInfo).toBeCalledTimes(4); + expect(consoleInfo).toHaveBeenNthCalledWith(1, "[1] success", { + value: "[cache:default] lazily updated", + }); + expect(consoleInfo).toHaveBeenNthCalledWith(2, "[1] finally", null); + expect(consoleInfo).toHaveBeenNthCalledWith(3, "[2] success", { + value: "[cache:default] lazily updated", + }); + expect(consoleInfo).toHaveBeenNthCalledWith(4, "[2] finally", null); - ctx.updateValue("asyncValue", undefined, "load"); + ctx.updateValue("asyncValue", undefined, "load", { + success: { + action: "console.info", + args: ["[3] success", "<% EVENT.detail %>"], + }, + finally: { + action: "console.info", + args: ["[3] finally", "<% EVENT.detail %>"], + }, + }); await (global as any).flushPromises(); // Will not load again if it is already LOADED. expect(resolveOne).toBeCalledTimes(1); + expect(consoleInfo).toBeCalledTimes(6); + expect(consoleInfo).toHaveBeenNthCalledWith(5, "[3] success", { + value: "[cache:default] lazily updated", + }); + expect(consoleInfo).toHaveBeenNthCalledWith(6, "[3] finally", null); }); it("should throw if use resolve with syncDefine", () => { diff --git a/packages/brick-kit/src/core/StoryboardContext.ts b/packages/brick-kit/src/core/StoryboardContext.ts index ef32b3dc26..04b70124f0 100644 --- a/packages/brick-kit/src/core/StoryboardContext.ts +++ b/packages/brick-kit/src/core/StoryboardContext.ts @@ -1,6 +1,7 @@ import EventTarget from "@ungap/event-target"; import { BrickEventHandler, + BrickEventHandlerCallback, ContextConf, PluginRuntimeContext, ResolveOptions, @@ -16,9 +17,12 @@ import { trackUsedState, } from "@next-core/brick-utils"; import { looseCheckIf } from "../checkIf"; -import { listenerFactory } from "../internal/bindListeners"; +import { + eventCallbackFactory, + listenerFactory, +} from "../internal/bindListeners"; import { computeRealValue } from "../internal/setProperties"; -import { RuntimeBrick } from "./exports"; +import { RuntimeBrick, _internalApiGetCurrentContext } from "./exports"; import { _internalApiGetResolver } from "./Runtime"; import { handleHttpError } from "../handleHttpError"; @@ -52,7 +56,8 @@ export class StoryboardContextWrapper { updateValue( name: string, value: unknown, - method: "assign" | "replace" | "refresh" | "load" + method: "assign" | "replace" | "refresh" | "load", + callback?: BrickEventHandlerCallback ): void { if (!this.data.has(name)) { if (this.tplContextId) { @@ -79,27 +84,31 @@ export class StoryboardContextWrapper { return; } - // Todo(steve): support callback for refresh/load. if (method === "refresh" || method === "load") { if (!item.load) { throw new Error( `You can not ${method} the storyboard context "${name}" which has no resolve.` ); } - if ((item.loaded || item.loading) && method === "load") { - // Do not load again. - return; + + let promise: Promise; + if (method === "load") { + // Try to reuse previous request when calling `load`. + if (item.loaded) { + promise = Promise.resolve(item.value); + } else if (item.loading) { + promise = item.loading; + } } - item.loading = true; - // Defaults to ignore cache when refreshing. - item - .load({ + + if (!promise) { + promise = item.loading = item.load({ cache: method === "load" ? "default" : "reload", ...(value as ResolveOptions), - }) - .then( + }); + // Do not use the chained promise, since the callbacks need the original promise. + promise.then( (val) => { - item.loading = false; item.loaded = true; item.value = val; item.eventTarget?.dispatchEvent( @@ -112,10 +121,34 @@ export class StoryboardContextWrapper { ); }, (err) => { - item.loading = false; - handleHttpError(err); + // Let users to override error handling. + if (!callback?.error) { + handleHttpError(err); + } + } + ); + } + + if (callback) { + const callbackFactory = eventCallbackFactory( + callback, + () => + this.getResolveOptions(_internalApiGetCurrentContext()) + .mergedContext + ); + + promise.then( + (val) => { + callbackFactory("success")({ value: val }); + callbackFactory("finally")(); + }, + (err) => { + callbackFactory("error")(err); + callbackFactory("finally")(); } ); + } + return; } diff --git a/packages/brick-kit/src/internal/bindListeners.spec.ts b/packages/brick-kit/src/internal/bindListeners.spec.ts index 67d12b1cfc..0032030003 100644 --- a/packages/brick-kit/src/internal/bindListeners.spec.ts +++ b/packages/brick-kit/src/internal/bindListeners.spec.ts @@ -267,7 +267,9 @@ describe("bindListeners", () => { type: "free-variable", value: "initial", load(options) { - return Promise.resolve(`[cache:${options.cache}] lazily updated`); + return Promise.resolve( + `[cache:${options.cache ?? "default"}] lazily updated` + ); }, }, ], @@ -970,7 +972,9 @@ describe("bindListeners", () => { type: "free-variable", value: "initial", load(options) { - return Promise.resolve(`[cache:${options.cache}] lazily updated`); + return Promise.resolve( + `[cache:${options.cache ?? "default"}] lazily updated` + ); }, }); diff --git a/packages/brick-kit/src/internal/bindListeners.ts b/packages/brick-kit/src/internal/bindListeners.ts index 96c8993a96..59ab2ca488 100644 --- a/packages/brick-kit/src/internal/bindListeners.ts +++ b/packages/brick-kit/src/internal/bindListeners.ts @@ -228,6 +228,7 @@ export function listenerFactory( method, handler.args, handler, + handler.callback, context ); case "state.update": @@ -237,6 +238,7 @@ export function listenerFactory( method, handler.args, handler, + handler.callback, context ); case "tpl.dispatchEvent": @@ -368,6 +370,7 @@ function builtinContextListenerFactory( method: "assign" | "replace" | "refresh" | "load", args: unknown[], ifContainer: IfContainer, + callback: BrickEventHandlerCallback, context: PluginRuntimeContext ): EventListener { return function (event: CustomEvent): void { @@ -376,7 +379,7 @@ function builtinContextListenerFactory( } const storyboardContext = _internalApiGetStoryboardContextWrapper(); const [name, value] = argsFactory(args, context, event); - storyboardContext.updateValue(name as string, value, method); + storyboardContext.updateValue(name as string, value, method, callback); } as EventListener; } @@ -384,6 +387,7 @@ function builtinStateListenerFactory( method: "update" | "refresh" | "load", args: unknown[], ifContainer: IfContainer, + callback: BrickEventHandlerCallback, context: PluginRuntimeContext ): EventListener { return function (event: CustomEvent): void { @@ -395,7 +399,8 @@ function builtinStateListenerFactory( tplContext.state.updateValue( name as string, value, - method === "update" ? "replace" : method + method === "update" ? "replace" : method, + callback ); } as EventListener; } @@ -648,6 +653,37 @@ function customListenerFactory( } as EventListener; } +export function eventCallbackFactory( + callback: BrickEventHandlerCallback, + getContext: () => PluginRuntimeContext +) { + return function callbackFactory( + type: "success" | "error" | "finally" | "progress" + ) { + return function (result?: unknown) { + if (callback?.[type]) { + try { + const event = new CustomEvent(`callback.${type}`, { + detail: result, + }); + const context = getContext(); + [].concat(callback[type]).forEach((eachHandler) => { + listenerFactory(eachHandler, context, null)(event); + }); + } catch (err) { + // Do not throw errors in `callback.success` or `callback.progress`, + // to avoid the following triggering of `callback.error`. + // eslint-disable-next-line + console.error(err); + } + } else if (type === "error") { + // eslint-disable-next-line + console.error("Unhandled callback error:", result); + } + }; + }; +} + async function brickCallback( target: HTMLElement, handler: ExecuteCustomBrickEventHandler | UseProviderEventHandler, @@ -677,49 +713,18 @@ async function brickCallback( return (target as any)[method](...computedArgs); }; - const { - success, - error, - finally: finallyHook, - progress, - } = handler.callback ?? {}; - - if (!(success || error || finallyHook || progress)) { + if (!handler.callback) { task(); return; } - const callbackFactory = - ( - eventType: string, - specificHandler: BrickEventHandler | BrickEventHandler[] - ): PollableCallbackFunction => - (result: unknown) => { - if (specificHandler) { - try { - const event = new CustomEvent(eventType, { - detail: result, - }); - [].concat(specificHandler).forEach((eachHandler) => { - listenerFactory(eachHandler, context, runtimeBrick)(event); - }); - } catch (err) { - // Do not throw errors in `callback.success` or `callback.progress`, - // to avoid the following triggering of `callback.error`. - // eslint-disable-next-line - console.error(err); - } - } else if (eventType === "callback.error") { - // eslint-disable-next-line - console.error("Unhandled callback error:", result); - } - }; + const callbackFactory = eventCallbackFactory(handler.callback, () => context); const pollableCallback: Required = { - progress: callbackFactory("callback.progress", progress), - success: callbackFactory("callback.success", success), - error: callbackFactory("callback.error", error), - finally: callbackFactory("callback.finally", finallyHook), + progress: callbackFactory("progress"), + success: callbackFactory("success"), + error: callbackFactory("error"), + finally: callbackFactory("finally"), }; let poll: ProviderPollOptions; diff --git a/packages/brick-types/src/runtime.ts b/packages/brick-types/src/runtime.ts index ba557db7be..686c1bcf45 100644 --- a/packages/brick-types/src/runtime.ts +++ b/packages/brick-types/src/runtime.ts @@ -228,7 +228,7 @@ export interface StoryboardContextItemFreeVariable { value: unknown; eventTarget?: EventTarget; loaded?: boolean; - loading?: boolean; + loading?: Promise; load?: (options?: ResolveOptions) => Promise; }