diff --git a/etc/runtime.api.md b/etc/runtime.api.md index 60789eccf4..792ebced9c 100644 --- a/etc/runtime.api.md +++ b/etc/runtime.api.md @@ -11,7 +11,6 @@ import type { BatchUpdateContextItem } from '@next-core/types'; import type { BootstrapData } from '@next-core/types'; import { BreadcrumbItemConf } from '@next-core/types'; import type { BrickConf } from '@next-core/types'; -import type { BrickEventHandler } from '@next-core/types'; import type { BrickEventHandlerCallback } from '@next-core/types'; import type { BrickEventsMap } from '@next-core/types'; import type { BrickLifeCycle } from '@next-core/types'; @@ -58,8 +57,6 @@ declare namespace __secret_internals { updateStoryboardByTemplate, updateTemplatePreviewSettings, updateStoryboardBySnippet, - getContextValue, - getAllContextValues, getBrickPackagesById, loadBricks, loadEditors, @@ -187,9 +184,6 @@ export function fetchByProvider(provider: string, args: unknown[], options?: Res // @public (undocumented) function getAddedContracts(storyboardPatch: PreviewStoryboardPatch, { appId, updateStoryboardType, collectUsedContracts }: PreviewOption): Promise; -// @public (undocumented) -function getAllContextValues({ tplStateStoreId, }: DataValueOption): Record; - // @public @deprecated (undocumented) export function getAuth(): object | undefined; @@ -202,9 +196,6 @@ function getBrickPackages(): BrickPackage[]; // @public (undocumented) function getBrickPackagesById(id: string): BrickPackage | undefined; -// @public (undocumented) -function getContextValue(name: string, { tplStateStoreId }: DataValueOption): unknown; - // @public (undocumented) export function getCssPropertyValue(name: string, el?: HTMLElement): string; @@ -333,6 +324,10 @@ function mountUseBrick({ renderRoot, rendererContext, scopedStores }: RenderUseB // @public (undocumented) interface MountUseBrickResult { + // Warning: (ae-forgotten-export) The symbol "Dispose" needs to be exported by the entry point index.d.ts + // + // (undocumented) + dispose: Dispose; // (undocumented) portal?: HTMLElement; } diff --git a/mock-micro-apps/memory-leak/storyboard.yaml b/mock-micro-apps/memory-leak/storyboard.yaml new file mode 100644 index 0000000000..23ec555cf3 --- /dev/null +++ b/mock-micro-apps/memory-leak/storyboard.yaml @@ -0,0 +1,43 @@ +app: + name: Memory Leak + id: memory-leak + homepage: /memory-leak + noAuthGuard: true + standaloneMode: true + +routes: + +- path: '${APP.homepage}' + incrementalSubRoutes: true + bricks: + - brick: h1 + properties: + textContent: Debugging with memory leak + - brick: p + children: + - brick: eo-link + properties: + url: '${APP.homepage}/1' + textContent: Go to 1 + - brick: p + children: + - brick: eo-link + properties: + url: '${APP.homepage}/2' + textContent: Go to 2 + - brick: div + slots: + '': + type: routes + routes: + - path: '${APP.homepage}/1' + bricks: + - brick: ai-portal.elevo-card + properties: + cardTitle: HR + description: Provide standard HR workflows. + - path: '${APP.homepage}/2' + bricks: + - brick: p + properties: + textContent: This is page 2 diff --git a/packages/react-element/src/ReactNextElement.tsx b/packages/react-element/src/ReactNextElement.tsx index fc35bf2252..8ac3d4d1f3 100644 --- a/packages/react-element/src/ReactNextElement.tsx +++ b/packages/react-element/src/ReactNextElement.tsx @@ -12,7 +12,8 @@ export abstract class ReactNextElement extends NextElement { } const ctor = this.constructor as typeof ReactNextElement; if (ctor.shadowOptions) { - const shadowRoot = this.attachShadow(ctor.shadowOptions); + const shadowRoot = + this.shadowRoot || this.attachShadow(ctor.shadowOptions); if (supportsAdoptingStyleSheets()) { if (ctor.styleTexts?.length) { const styleSheet = new CSSStyleSheet(); @@ -39,6 +40,12 @@ export abstract class ReactNextElement extends NextElement { disconnectedCallback() { this.#root?.render(null); + this.__secret_internal_dispose(); + } + + __secret_internal_dispose() { + this.#root?.unmount(); + this.#root = undefined; } protected _render() { diff --git a/packages/runtime/src/createRoot.ts b/packages/runtime/src/createRoot.ts index ecd01247ae..68a63e86aa 100644 --- a/packages/runtime/src/createRoot.ts +++ b/packages/runtime/src/createRoot.ts @@ -19,7 +19,11 @@ import { } from "./internal/Renderer.js"; import { RendererContext } from "./internal/RendererContext.js"; import { DataStore } from "./internal/data/DataStore.js"; -import type { RenderRoot, RuntimeContext } from "./internal/interfaces.js"; +import type { + Dispose, + RenderRoot, + RuntimeContext, +} from "./internal/interfaces.js"; import { mountTree, unmountTree } from "./internal/mount.js"; import { applyMode, applyTheme, setMode, setTheme } from "./themeAndMode.js"; import { RenderTag } from "./internal/enums.js"; @@ -107,6 +111,7 @@ export function unstable_createRoot( let unmounted = false; let rendererContext: RendererContext | undefined; let clearI18nBundles: Function | undefined; + let disposeMount: Dispose | undefined; const isolatedRoot = scope === "page" ? undefined : Symbol("IsolatedRoot"); return { @@ -253,7 +258,7 @@ export function unstable_createRoot( applyMode(); } - mountTree(renderRoot); + disposeMount = mountTree(renderRoot); if (scope === "page") { window.scrollTo(0, 0); @@ -283,6 +288,8 @@ export function unstable_createRoot( isolatedFunctionRegistry.delete(isolatedRoot); isolatedTemplateRegistryMap.delete(isolatedRoot); } + disposeMount?.(); + disposeMount = undefined; unmountTree(container); if (portal) { unmountTree(portal); diff --git a/packages/runtime/src/internal/RendererContext.ts b/packages/runtime/src/internal/RendererContext.ts index 8a490d967a..bec61da08f 100644 --- a/packages/runtime/src/internal/RendererContext.ts +++ b/packages/runtime/src/internal/RendererContext.ts @@ -346,11 +346,18 @@ export class RendererContext { unbindTemplateProxy(brick); delete brick.element?.$$tplStateStore; + brick.element?.$$disposes?.forEach((dispose) => dispose()); + delete brick.element?.$$disposes; // Also remove the element brick.element?.remove(); // Dispose context listeners brick.disposes?.forEach((dispose) => dispose()); delete brick.disposes; + ( + brick.element as { + __secret_internal_dispose?: () => void; + } + )?.__secret_internal_dispose?.(); } // Dispatch unmount events diff --git a/packages/runtime/src/internal/Router.ts b/packages/runtime/src/internal/Router.ts index 419611e3be..dea04a5915 100644 --- a/packages/runtime/src/internal/Router.ts +++ b/packages/runtime/src/internal/Router.ts @@ -39,6 +39,7 @@ import { } from "./Runtime.js"; import { getPageInfo } from "../getPageInfo.js"; import type { + Dispose, MenuRequestNode, RenderRoot, RuntimeContext, @@ -84,6 +85,7 @@ export class Router { #runtimeContext?: RuntimeContext; #rendererContext?: RendererContext; #rendererContextTrashCan = new Set(); + #runtimeContextTrashCan = new Set(); #redirectCount = 0; #renderId?: string; #currentApp?: MicroApp; @@ -354,10 +356,12 @@ export class Router { // Set `Router::#currentApp` before calling `getFeatureFlags()` const flags = getRuntime().getFeatureFlags(); const prevRendererContext = this.#rendererContext; + const prevRuntimeContext = this.#runtimeContext; const redirectTo = (to: string, state?: NextHistoryState): void => { finishPageView?.({ status: "redirected" }); this.#rendererContextTrashCan.add(prevRendererContext); + this.#runtimeContextTrashCan.add(prevRuntimeContext); this.#safeRedirect(to, state, location); }; @@ -376,7 +380,11 @@ export class Router { createPortal: portal, }; + let disposeMount: Dispose | undefined; + const cleanUpPreviousRender = (): void => { + disposeMount?.(); + disposeMount = undefined; unmountTree(main); unmountTree(portal); @@ -389,6 +397,18 @@ export class Router { } } this.#rendererContextTrashCan.clear(); + + this.#runtimeContextTrashCan.add(prevRuntimeContext); + this.#runtimeContextTrashCan.forEach((item) => { + if (item) { + const stores = getDataStores(item); + for (const store of stores) { + store.dispose(); + } + } + }); + this.#runtimeContextTrashCan.clear(); + hooks?.messageDispatcher?.reset(); if (appChanged) { @@ -439,6 +459,7 @@ export class Router { return; } else if (error instanceof HttpAbortError) { this.#rendererContextTrashCan.add(prevRendererContext); + this.#runtimeContextTrashCan.add(prevRuntimeContext); return; } else { const noAuthGuardLoginPath = @@ -547,7 +568,7 @@ export class Router { applyMode(); setUIVersion(currentApp?.uiVersion); - mountTree(renderRoot); + disposeMount = mountTree(renderRoot); // Scroll to top after each rendering. // See https://github.com/ReactTraining/react-router/blob/master/packages/react-router-dom/docs/guides/scroll-restoration.md @@ -606,7 +627,7 @@ export class Router { ); renderRoot.child = node; - mountTree(renderRoot); + disposeMount = mountTree(renderRoot); // Scroll to top after each rendering. window.scrollTo(0, 0); diff --git a/packages/runtime/src/internal/bindListeners.spec.ts b/packages/runtime/src/internal/bindListeners.spec.ts index d4fda44a90..8744d52700 100644 --- a/packages/runtime/src/internal/bindListeners.spec.ts +++ b/packages/runtime/src/internal/bindListeners.spec.ts @@ -675,6 +675,7 @@ describe("listenerFactory for handleHttpError", () => { describe("listenerFactory for event.*", () => { test("event.preventDefault", () => { + consoleError.mockReturnValue(); const event = { preventDefault: jest.fn() } as any; listenerFactory( { @@ -684,9 +685,11 @@ describe("listenerFactory for event.*", () => { runtimeContext )(event); expect(event.preventDefault).toHaveBeenCalledWith(); + consoleError.mockReset(); }); test("event.stopPropagation", () => { + consoleError.mockReturnValue(); const event = { stopPropagation: jest.fn() } as any; listenerFactory( { @@ -695,6 +698,7 @@ describe("listenerFactory for event.*", () => { runtimeContext )(event); expect(event.stopPropagation).toHaveBeenCalledWith(); + consoleError.mockReset(); }); test("non-Event object", () => { diff --git a/packages/runtime/src/internal/bindListeners.ts b/packages/runtime/src/internal/bindListeners.ts index 634769d394..e800dfbea4 100644 --- a/packages/runtime/src/internal/bindListeners.ts +++ b/packages/runtime/src/internal/bindListeners.ts @@ -25,6 +25,7 @@ import { isPreEvaluated } from "./compute/evaluate.js"; import { setProperties } from "./compute/setProperties.js"; import { applyMode, applyTheme } from "../themeAndMode.js"; import type { + Dispose, ElementHolder, RuntimeBrickElement, RuntimeContext, @@ -45,10 +46,13 @@ export function bindListeners( brick: RuntimeBrickElement, eventsMap: BrickEventsMap | undefined, runtimeContext: RuntimeContext -): void { +): Dispose { if (!eventsMap) { - return; + return () => {}; } + + const disposables: Dispose[] = []; + Object.entries(eventsMap).forEach(([eventType, handlers]) => { const listener = listenerFactory(handlers, runtimeContext, { element: brick, @@ -56,28 +60,21 @@ export function bindListeners( brick.addEventListener(eventType, listener); // Remember added listeners for unbinding. - if (!brick.$$listeners) { - brick.$$listeners = []; - } - brick.$$listeners.push([eventType, listener]); - - // Remember added listeners for devtools. - if (!brick.$$eventListeners) { - brick.$$eventListeners = []; - } - for (const handler of ([] as BrickEventHandler[]).concat(handlers)) { - brick.$$eventListeners.push([eventType, null, handler]); - } + const dispose = () => { + brick.removeEventListener(eventType, listener); + }; + disposables.push(dispose); + brick.$$disposes ??= []; + brick.$$disposes.push(dispose); }); -} -export function unbindListeners(brick: RuntimeBrickElement): void { - if (brick.$$listeners) { - for (const [eventType, listener] of brick.$$listeners) { - brick.removeEventListener(eventType, listener); + return () => { + for (const dispose of disposables) { + dispose(); } - brick.$$listeners.length = 0; - } + disposables.length = 0; + delete brick.$$disposes; + }; } export function isBuiltinHandler( diff --git a/packages/runtime/src/internal/data/DataStore.ts b/packages/runtime/src/internal/data/DataStore.ts index a988c4ece5..bc9e0e3141 100644 --- a/packages/runtime/src/internal/data/DataStore.ts +++ b/packages/runtime/src/internal/data/DataStore.ts @@ -19,6 +19,7 @@ import { ResolveOptions, resolveData } from "./resolveData.js"; import { resolveDataStore } from "./resolveDataStore.js"; import type { AsyncPropertyEntry, + Dispose, RouteNode, RuntimeBrick, RuntimeContext, @@ -71,6 +72,7 @@ export class DataStore { private readonly rendererContext?: RendererContext; private routeMap = new WeakMap>(); private routeStackMap = new WeakMap>(); + private disposableMap = new Map(); // 把 `rendererContext` 放在参数列表的最后,并作为可选,以减少测试文件的调整 constructor( @@ -360,12 +362,14 @@ export class DataStore { } } - onChange(dataName: string, listener: EventListener): () => void { + onChange(dataName: string, listener: EventListener): Dispose { const eventTarget = this.data.get(dataName)?.eventTarget; eventTarget?.addEventListener(this.changeEventType, listener); - return () => { + const disposable = () => { eventTarget?.removeEventListener(this.changeEventType, listener); }; + this.addDisposable(dataName, disposable); + return disposable; } async waitFor(dataNames: string[] | Set): Promise { @@ -509,7 +513,6 @@ export class DataStore { }; if (resolvePolicy === "lazy") { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const { trigger } = dataConf.resolve!; if ( trigger && @@ -539,7 +542,7 @@ export class DataStore { ); !load && (newData.deps = [...deps]); for (const dep of deps) { - this.onChange( + const disposable = this.onChange( dep, this.batchAddListener(() => { newData.useResolve = trackConditionalResolve @@ -556,6 +559,7 @@ export class DataStore { } }, dataConf) ); + this.addDisposable(dataConf.name, disposable); } } @@ -579,6 +583,15 @@ export class DataStore { return true; } + private addDisposable(name: string, disposable: Dispose) { + const existingDisposables = this.disposableMap.get(name); + if (existingDisposables) { + existingDisposables.push(disposable); + } else { + this.disposableMap.set(name, [disposable]); + } + } + /** * For sub-routes to be incrementally rendered, * dispose their data and pending tasks. @@ -589,6 +602,13 @@ export class DataStore { if (names !== undefined) { for (const name of names) { this.data.delete(name); + const disposables = this.disposableMap.get(name); + if (disposables) { + for (const disposable of disposables) { + disposable(); + } + } + this.disposableMap.delete(name); } this.routeMap.delete(route); } @@ -600,6 +620,19 @@ export class DataStore { } } + dispose() { + this.data.clear(); + this.pendingStack.length = 0; + this.disposableMap.forEach((disposables) => { + for (const disposable of disposables) { + disposable(); + } + }); + this.disposableMap.clear(); + this.routeMap = new WeakMap>(); + this.routeStackMap = new WeakMap>(); + } + private batchAddListener( listener: EventListener, contextConf: ContextConf diff --git a/packages/runtime/src/internal/interfaces.ts b/packages/runtime/src/internal/interfaces.ts index 26b864ff7e..6a97bcb022 100644 --- a/packages/runtime/src/internal/interfaces.ts +++ b/packages/runtime/src/internal/interfaces.ts @@ -69,7 +69,7 @@ export interface RenderAbstract extends BaseRenderNode { tag: RenderTag.ABSTRACT; return: RenderReturnNode; iid?: string; - disposes?: (() => void)[]; + disposes?: Dispose[]; } export interface BaseRenderNode { @@ -97,7 +97,7 @@ export interface RuntimeBrick { tplHostMetadata?: TemplateHostMetadata; portal?: boolean; ref?: string; - disposes?: (() => void)[]; + disposes?: Dispose[]; disposed?: boolean; } @@ -112,10 +112,7 @@ export type RememberedEventListener = [string, EventListener]; export interface RuntimeBrickElement extends HTMLElement { $$typeof?: "brick" | "provider" | "custom-template" | "native" | "invalid"; - /** Meta info of listeners, for devtools only */ - $$eventListeners?: MetaInfoOfEventListener[]; - /** Remembered listeners for unbinding */ - $$listeners?: RememberedEventListener[]; + $$disposes?: Dispose[]; /** Remembered proxy listeners for unbinding */ $$proxyListeners?: RememberedEventListener[]; /** Find element by ref in a custom template */ @@ -209,3 +206,5 @@ export interface RouteNode { // All ordered sibling routes under the same parent including the route itself routes: RouteConf[]; } + +export type Dispose = () => void; diff --git a/packages/runtime/src/internal/mount.ts b/packages/runtime/src/internal/mount.ts index fe63a3f940..5b1cad2987 100644 --- a/packages/runtime/src/internal/mount.ts +++ b/packages/runtime/src/internal/mount.ts @@ -4,6 +4,7 @@ import { bindListeners } from "./bindListeners.js"; import { setRealProperties } from "./compute/setRealProperties.js"; import { RenderTag } from "./enums.js"; import type { + Dispose, RenderReturnNode, RenderRoot, RuntimeBrickElement, @@ -16,11 +17,12 @@ export function unmountTree(mountPoint: HTMLElement | DocumentFragment) { export function mountTree( root: RenderRoot, initializedElement?: RuntimeBrickElement -): void { +): Dispose { root.mounted = true; window.DISABLE_REACT_FLUSH_SYNC = false; let current = root.child; const portalElements: RuntimeBrickElement[] = []; + const disposables: Dispose[] = []; while (current) { current.mounted = true; if (current.tag === RenderTag.BRICK) { @@ -56,7 +58,10 @@ export function mountTree( current.tplHostMetadata.tplStateStoreId; } setRealProperties(element, current.properties); - bindListeners(element, current.events, current.runtimeContext); + disposables.push( + bindListeners(element, current.events, current.runtimeContext) + ); + if (current.tplHostMetadata) { // 先设置属性,再设置 `$$tplStateStore`,这样,当触发属性设置时, // 避免初始化的一次 state update 操作及其 onChange 事件。 @@ -127,4 +132,10 @@ export function mountTree( setTimeout(() => { window.DISABLE_REACT_FLUSH_SYNC = true; }); + return () => { + for (const dispose of disposables) { + dispose(); + } + disposables.length = 0; + }; } diff --git a/packages/runtime/src/internal/secret_internals.ts b/packages/runtime/src/internal/secret_internals.ts index 477baadf91..7c88c88e64 100644 --- a/packages/runtime/src/internal/secret_internals.ts +++ b/packages/runtime/src/internal/secret_internals.ts @@ -36,6 +36,7 @@ import type { RuntimeDataValueOption, RenderBrick, RenderChildNode, + Dispose, } from "./interfaces.js"; import { mountTree, unmountTree } from "./mount.js"; import { RenderTag } from "./enums.js"; @@ -132,6 +133,7 @@ export async function renderUseBrick( export interface MountUseBrickResult { portal?: HTMLElement; + dispose: Dispose; } export function mountUseBrick( @@ -148,7 +150,7 @@ export function mountUseBrick( return portal; }; - mountTree(renderRoot, element); + const dispose = mountTree(renderRoot, element); rendererContext.dispatchOnMount(); rendererContext.initializeScrollIntoView(); @@ -161,6 +163,7 @@ export function mountUseBrick( return { portal, + dispose, }; } @@ -171,6 +174,7 @@ export function unmountUseBrick( // if (mountResult.mainBrick) { // mountResult.mainBrick.unmount(); // } + mountResult.dispose(); if (mountResult.portal) { unmountTree(mountResult.portal); mountResult.portal.remove();