diff --git a/etc/brick-kit.api.md b/etc/brick-kit.api.md index ea05b3db9d..0c3320f5a7 100644 --- a/etc/brick-kit.api.md +++ b/etc/brick-kit.api.md @@ -59,6 +59,7 @@ import type { PresetBricksConf } from '@next-core/brick-types'; import { default as React_2 } from 'react'; import { RefForProxy } from '@next-core/brick-types'; import { ResolveConf } from '@next-core/brick-types'; +import { ResolveOptions } from '@next-core/brick-types'; import { RouteConf } from '@next-core/brick-types'; import type { RuntimeBootstrapData } from '@next-core/brick-types'; import type { RuntimeMisc } from '@next-core/brick-types'; diff --git a/etc/brick-types.api.md b/etc/brick-types.api.md index afc68ffa61..7dbdad724c 100644 --- a/etc/brick-types.api.md +++ b/etc/brick-types.api.md @@ -716,7 +716,7 @@ export interface BuilderSnippetNode extends BuilderBaseNode { // @public export interface BuiltinBrickEventHandler { - action: "history.push" | "history.replace" | "history.goBack" | "history.goForward" | "history.reload" | "history.pushQuery" | "history.replaceQuery" | "history.pushAnchor" | "history.block" | "history.unblock" | "segue.push" | "segue.replace" | "alias.push" | "alias.replace" | "localStorage.setItem" | "localStorage.removeItem" | "sessionStorage.setItem" | "sessionStorage.removeItem" | "legacy.go" | "location.reload" | "location.assign" | "window.open" | "event.preventDefault" | "console.log" | "console.error" | "console.warn" | "console.info" | "message.success" | "message.error" | "message.info" | "message.warn" | "handleHttpError" | "context.assign" | "context.replace" | "context.refresh" | "state.update" | "state.refresh" | "tpl.dispatchEvent" | "message.subscribe" | "message.unsubscribe" | "theme.setDarkTheme" | "theme.setLightTheme" | "theme.setTheme" | "mode.setDashboardMode" | "mode.setDefaultMode" | "menu.clearMenuTitleCache" | "menu.clearMenuCache" | "preview.debug" | "analytics.event"; + action: "history.push" | "history.replace" | "history.goBack" | "history.goForward" | "history.reload" | "history.pushQuery" | "history.replaceQuery" | "history.pushAnchor" | "history.block" | "history.unblock" | "segue.push" | "segue.replace" | "alias.push" | "alias.replace" | "localStorage.setItem" | "localStorage.removeItem" | "sessionStorage.setItem" | "sessionStorage.removeItem" | "legacy.go" | "location.reload" | "location.assign" | "window.open" | "event.preventDefault" | "console.log" | "console.error" | "console.warn" | "console.info" | "message.success" | "message.error" | "message.info" | "message.warn" | "handleHttpError" | "context.assign" | "context.replace" | "context.refresh" | "context.load" | "state.update" | "state.refresh" | "state.load" | "tpl.dispatchEvent" | "message.subscribe" | "message.unsubscribe" | "theme.setDarkTheme" | "theme.setLightTheme" | "theme.setTheme" | "mode.setDashboardMode" | "mode.setDefaultMode" | "menu.clearMenuTitleCache" | "menu.clearMenuCache" | "preview.debug" | "analytics.event"; args?: unknown[]; callback?: BrickEventHandlerCallback; if?: string | boolean; @@ -1721,6 +1721,13 @@ export interface ResolveMenuConf { type: "resolve"; } +// Warning: (ae-internal-missing-underscore) The name "ResolveOptions" should be prefixed with an underscore because the declaration is marked as @internal +// +// @internal (undocumented) +export interface ResolveOptions { + cache?: "default" | "reload"; +} + // Warning: (ae-internal-missing-underscore) The name "RouteAliasConf" should be prefixed with an underscore because the declaration is marked as @internal // // @internal (undocumented) @@ -2123,7 +2130,11 @@ export interface StoryboardContextItemFreeVariable { // (undocumented) eventTarget?: EventTarget; // (undocumented) - refresh?: () => Promise; + load?: (options?: ResolveOptions) => Promise; + // (undocumented) + loaded?: boolean; + // (undocumented) + loading?: boolean; // (undocumented) type: "free-variable"; // (undocumented) diff --git a/packages/brick-kit/src/core/Resolver.ts b/packages/brick-kit/src/core/Resolver.ts index b1fd7fbe2a..9b6cdb2386 100644 --- a/packages/brick-kit/src/core/Resolver.ts +++ b/packages/brick-kit/src/core/Resolver.ts @@ -13,6 +13,7 @@ import { HandleReject, HandleRejectByCatch, GeneralTransform, + ResolveOptions, } from "@next-core/brick-types"; import { asyncProcessBrick } from "@next-core/brick-utils"; import { computeRealValue } from "../internal/setProperties"; @@ -99,21 +100,24 @@ export class Resolver { resolveConf: ResolveConf, conf: BrickConf, brick?: RuntimeBrick, - context?: PluginRuntimeContext + context?: PluginRuntimeContext, + options?: ResolveOptions ): Promise; async resolveOne( type: "reference", resolveConf: ResolveConf, conf: Record, brick?: RuntimeBrick, - context?: PluginRuntimeContext + context?: PluginRuntimeContext, + options?: ResolveOptions ): Promise; async resolveOne( type: "brick" | "reference", resolveConf: ResolveConf, conf: BrickConf | Record, brick?: RuntimeBrick, - context?: PluginRuntimeContext + context?: PluginRuntimeContext, + options?: ResolveOptions ): Promise { const brickConf = conf as BrickConf; const propsReference = conf as Record; @@ -238,7 +242,7 @@ export class Resolver { } let promise: Promise; - if (this.cache.has(cacheKey)) { + if (options?.cache !== "reload" && this.cache.has(cacheKey)) { promise = this.cache.get(cacheKey); } else { promise = (async () => { diff --git a/packages/brick-kit/src/core/StoryboardContext.spec.ts b/packages/brick-kit/src/core/StoryboardContext.spec.ts index 9f9caa6512..a76b520473 100644 --- a/packages/brick-kit/src/core/StoryboardContext.spec.ts +++ b/packages/brick-kit/src/core/StoryboardContext.spec.ts @@ -1,4 +1,4 @@ -import { RuntimeBrickElement } from "@next-core/brick-types"; +import { ResolveOptions, RuntimeBrickElement } from "@next-core/brick-types"; import { CustomTemplateContext } from "./CustomTemplates/CustomTemplateContext"; import { StoryboardContextWrapper } from "./StoryboardContext"; import * as runtime from "./Runtime"; @@ -9,9 +9,16 @@ const consoleWarn = jest let resolveValue = "lazily updated"; const resolveOne = jest.fn( - async (a: unknown, b: unknown, c: Record) => { + async ( + type: unknown, + resolveConf: unknown, + conf: Record, + brick?: unknown, + context?: unknown, + options?: ResolveOptions + ) => { await Promise.resolve(); - c.value = resolveValue; + conf.value = `[cache:${options?.cache ?? "default"}] ${resolveValue}`; } ); jest.spyOn(runtime, "_internalApiGetResolver").mockReturnValue({ @@ -19,6 +26,10 @@ jest.spyOn(runtime, "_internalApiGetResolver").mockReturnValue({ } as any); describe("StoryboardContextWrapper", () => { + afterEach(() => { + jest.clearAllMocks(); + }); + it("should work", () => { const tplContext = new CustomTemplateContext({}); const ctx = tplContext.state; @@ -87,8 +98,10 @@ describe("StoryboardContextWrapper", () => { expect(ctx.getValue("asyncValue")).toBe("initial"); await (global as any).flushPromises(); - expect(ctx.getValue("asyncValue")).toBe("lazily updated"); - expect(ctx.getValue("processedData")).toBe("processed: lazily updated"); + expect(ctx.getValue("asyncValue")).toBe("[cache:reload] lazily updated"); + expect(ctx.getValue("processedData")).toBe( + "processed: [cache:reload] lazily updated" + ); }); it("should refresh when deps updated", async () => { @@ -120,16 +133,63 @@ describe("StoryboardContextWrapper", () => { brick ); - expect(ctx.getValue("asyncValue")).toBe("initial"); + expect(ctx.getValue("asyncValue")).toBe("[cache:default] initial"); expect(ctx.getValue("dep")).toBe("first"); resolveValue = originalResolveValue; ctx.updateValue("dep", "second", "replace"); expect(ctx.getValue("dep")).toBe("second"); + expect(ctx.getValue("asyncValue")).toBe("[cache:default] initial"); + + await (global as any).flushPromises(); + expect(ctx.getValue("asyncValue")).toBe("[cache:default] lazily updated"); + }); + + it("should load", 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", + }, + { + name: "processedData", + value: "<% `processed: ${STATE.asyncValue}` %>", + track: true, + }, + ], + { + tplContextId: tplContext.id, + } as any, + brick + ); + 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"); + expect(ctx.getValue("asyncValue")).toBe("initial"); + + await (global as any).flushPromises(); + // Will not load again if it is already LOADING. + expect(resolveOne).toBeCalledTimes(1); + expect(ctx.getValue("asyncValue")).toBe("[cache:default] lazily updated"); + expect(ctx.getValue("processedData")).toBe( + "processed: [cache:default] lazily updated" + ); + ctx.updateValue("asyncValue", undefined, "load"); await (global as any).flushPromises(); - expect(ctx.getValue("asyncValue")).toBe("lazily updated"); + // Will not load again if it is already LOADED. + expect(resolveOne).toBeCalledTimes(1); }); 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 4dfa5c36f5..ef32b3dc26 100644 --- a/packages/brick-kit/src/core/StoryboardContext.ts +++ b/packages/brick-kit/src/core/StoryboardContext.ts @@ -3,6 +3,7 @@ import { BrickEventHandler, ContextConf, PluginRuntimeContext, + ResolveOptions, StoryboardContextItem, StoryboardContextItemFreeVariable, } from "@next-core/brick-types"; @@ -51,7 +52,7 @@ export class StoryboardContextWrapper { updateValue( name: string, value: unknown, - method: "assign" | "replace" | "refresh" + method: "assign" | "replace" | "refresh" | "load" ): void { if (!this.data.has(name)) { if (this.tplContextId) { @@ -73,28 +74,48 @@ export class StoryboardContextWrapper { if (item.type !== "free-variable") { // eslint-disable-next-line no-console console.error( - `Conflict storyboard context "${name}", expected "free-variable", received "${item.type}".` + `Unexpected storyboard context "${name}", expected "free-variable", received "${item.type}".` ); return; } - if (method === "refresh") { - if (!item.refresh) { + // Todo(steve): support callback for refresh/load. + if (method === "refresh" || method === "load") { + if (!item.load) { throw new Error( - `You can not refresh the storyboard context "${name}" which has no resolve.` + `You can not ${method} the storyboard context "${name}" which has no resolve.` ); } - item.refresh().then((val) => { - item.value = val; - item.eventTarget?.dispatchEvent( - new CustomEvent( - this.tplContextId ? "state.change" : "context.change", - { - detail: item.value, - } - ) + if ((item.loaded || item.loading) && method === "load") { + // Do not load again. + return; + } + item.loading = true; + // Defaults to ignore cache when refreshing. + item + .load({ + cache: method === "load" ? "default" : "reload", + ...(value as ResolveOptions), + }) + .then( + (val) => { + item.loading = false; + item.loaded = true; + item.value = val; + item.eventTarget?.dispatchEvent( + new CustomEvent( + this.tplContextId ? "state.change" : "context.change", + { + detail: item.value, + } + ) + ); + }, + (err) => { + item.loading = false; + handleHttpError(err); + } ); - }, handleHttpError); return; } @@ -207,12 +228,12 @@ async function resolveNormalStoryboardContext( } const isTemplateState = !!storyboardContextWrapper.tplContextId; let value = getDefinedTemplateState(isTemplateState, contextConf, brick); - let refresh: () => Promise = null; + let load: StoryboardContextItemFreeVariable["load"] = null; let isLazyResolve = false; if (value === undefined) { if (contextConf.resolve) { if (looseCheckIf(contextConf.resolve, mergedContext)) { - refresh = async () => { + load = async (options) => { const valueConf: Record = {}; await _internalApiGetResolver().resolveOne( "reference", @@ -223,19 +244,20 @@ async function resolveNormalStoryboardContext( }, valueConf, null, - mergedContext + mergedContext, + options ); return valueConf.value; }; isLazyResolve = contextConf.resolve.lazy; if (!isLazyResolve) { - value = await refresh(); + value = await load(); } } else if (!hasOwnProperty(contextConf, "value")) { return false; } } - if ((!refresh || isLazyResolve) && contextConf.value !== undefined) { + if ((!load || isLazyResolve) && contextConf.value !== undefined) { // If the context has no resolve, just use its `value`. // Or if the resolve is ignored or lazy, use its `value` as a fallback. value = computeRealValue(contextConf.value, mergedContext, true); @@ -244,7 +266,7 @@ async function resolveNormalStoryboardContext( if (contextConf.track) { // Track its dependencies and auto update when each of them changed. const deps = (isTemplateState ? trackUsedState : trackUsedContext)( - refresh ? contextConf.resolve : contextConf.value + load ? contextConf.resolve : contextConf.value ); for (const dep of deps) { const ctx = storyboardContextWrapper.get().get(dep); @@ -253,10 +275,10 @@ async function resolveNormalStoryboardContext( )?.eventTarget?.addEventListener( isTemplateState ? "state.change" : "context.change", () => { - if (refresh) { + if (load) { storyboardContextWrapper.updateValue( contextConf.name, - undefined, + { cache: "default" }, "refresh" ); } else { @@ -278,7 +300,8 @@ async function resolveNormalStoryboardContext( mergedContext, storyboardContextWrapper, brick, - refresh + load, + !isLazyResolve ); return true; } @@ -333,14 +356,16 @@ function resolveFreeVariableValue( mergedContext: PluginRuntimeContext, storyboardContextWrapper: StoryboardContextWrapper, brick?: RuntimeBrick, - refresh?: () => Promise + load?: StoryboardContextItemFreeVariable["load"], + loaded?: boolean ): void { const newContext: StoryboardContextItem = { type: "free-variable", value, // This is required for tracking context, even if no `onChange` is specified. eventTarget: new EventTarget(), - refresh, + load, + loaded, }; if (contextConf.onChange) { for (const handler of ([] as BrickEventHandler[]).concat( diff --git a/packages/brick-kit/src/internal/bindListeners.spec.ts b/packages/brick-kit/src/internal/bindListeners.spec.ts index 95c613600d..67d12b1cfc 100644 --- a/packages/brick-kit/src/internal/bindListeners.spec.ts +++ b/packages/brick-kit/src/internal/bindListeners.spec.ts @@ -266,8 +266,8 @@ describe("bindListeners", () => { { type: "free-variable", value: "initial", - refresh() { - return Promise.resolve("lazily updated"); + load(options) { + return Promise.resolve(`[cache:${options.cache}] lazily updated`); }, }, ], @@ -866,7 +866,9 @@ describe("bindListeners", () => { expect(storyboardContext.get("myNewContext").value).toEqual({ hello: "world", }); - expect(storyboardContext.get("myLazyContext").value).toBe("lazily updated"); + expect(storyboardContext.get("myLazyContext").value).toBe( + "[cache:reload] lazily updated" + ); expect(mockMessageDispatcher.subscribe).toHaveBeenLastCalledWith( "task1", @@ -967,8 +969,8 @@ describe("bindListeners", () => { tplContext.state.set("myLazyState", { type: "free-variable", value: "initial", - refresh() { - return Promise.resolve("lazily updated"); + load(options) { + return Promise.resolve(`[cache:${options.cache}] lazily updated`); }, }); @@ -994,7 +996,7 @@ describe("bindListeners", () => { args: ["myState", "<% `${STATE.myState}:updated` %>"], }, { - action: "state.refresh", + action: "state.load", args: ["myLazyState"], }, ], @@ -1053,7 +1055,9 @@ describe("bindListeners", () => { expect(tplContext.state.getValue("myLazyState")).toBe("initial"); await (global as any).flushPromises(); - expect(tplContext.state.getValue("myLazyState")).toBe("lazily updated"); + expect(tplContext.state.getValue("myLazyState")).toBe( + "[cache:default] lazily updated" + ); tplElement.remove(); (console.error as jest.Mock).mockRestore(); diff --git a/packages/brick-kit/src/internal/bindListeners.ts b/packages/brick-kit/src/internal/bindListeners.ts index 7dda080a7c..96c8993a96 100644 --- a/packages/brick-kit/src/internal/bindListeners.ts +++ b/packages/brick-kit/src/internal/bindListeners.ts @@ -223,6 +223,7 @@ export function listenerFactory( case "context.assign": case "context.replace": case "context.refresh": + case "context.load": return builtinContextListenerFactory( method, handler.args, @@ -231,6 +232,7 @@ export function listenerFactory( ); case "state.update": case "state.refresh": + case "state.load": return builtinStateListenerFactory( method, handler.args, @@ -363,7 +365,7 @@ function builtinTplDispatchEventFactory( } function builtinContextListenerFactory( - method: "assign" | "replace" | "refresh", + method: "assign" | "replace" | "refresh" | "load", args: unknown[], ifContainer: IfContainer, context: PluginRuntimeContext @@ -379,7 +381,7 @@ function builtinContextListenerFactory( } function builtinStateListenerFactory( - method: "update" | "refresh", + method: "update" | "refresh" | "load", args: unknown[], ifContainer: IfContainer, context: PluginRuntimeContext @@ -393,7 +395,7 @@ function builtinStateListenerFactory( tplContext.state.updateValue( name as string, value, - method === "refresh" ? method : "replace" + method === "update" ? "replace" : method ); } as EventListener; } diff --git a/packages/brick-types/src/manifest.ts b/packages/brick-types/src/manifest.ts index 3af703382e..b133951132 100644 --- a/packages/brick-types/src/manifest.ts +++ b/packages/brick-types/src/manifest.ts @@ -1179,10 +1179,12 @@ export interface BuiltinBrickEventHandler { | "context.assign" | "context.replace" | "context.refresh" + | "context.load" // Update template state | "state.update" | "state.refresh" + | "state.load" // Find related tpl and dispatch event. | "tpl.dispatchEvent" diff --git a/packages/brick-types/src/runtime.ts b/packages/brick-types/src/runtime.ts index 53a6d239c3..ba557db7be 100644 --- a/packages/brick-types/src/runtime.ts +++ b/packages/brick-types/src/runtime.ts @@ -227,7 +227,22 @@ export interface StoryboardContextItemFreeVariable { type: "free-variable"; value: unknown; eventTarget?: EventTarget; - refresh?: () => Promise; + loaded?: boolean; + loading?: boolean; + load?: (options?: ResolveOptions) => Promise; +} + +/** @internal */ +export interface ResolveOptions { + /** + * Cache mode of resolve. + * + * See https://developer.mozilla.org/en-US/docs/Web/API/Request/cache + * + * - `default`: Looks for a matching cache. + * - `reload`: Without looking for a matching cache. + */ + cache?: "default" | "reload"; } /** @internal */