Skip to content
Draft
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions packages/runtime/src/createRoot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -253,7 +258,7 @@ export function unstable_createRoot(
applyMode();
}

mountTree(renderRoot);
disposeMount = mountTree(renderRoot);

if (scope === "page") {
window.scrollTo(0, 0);
Expand Down Expand Up @@ -283,6 +288,8 @@ export function unstable_createRoot(
isolatedFunctionRegistry.delete(isolatedRoot);
isolatedTemplateRegistryMap.delete(isolatedRoot);
}
disposeMount?.();
disposeMount = undefined;
unmountTree(container);
if (portal) {
unmountTree(portal);
Expand Down
9 changes: 7 additions & 2 deletions packages/runtime/src/internal/Router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
} from "./Runtime.js";
import { getPageInfo } from "../getPageInfo.js";
import type {
Dispose,
MenuRequestNode,
RenderRoot,
RuntimeContext,
Expand Down Expand Up @@ -376,7 +377,11 @@ export class Router {
createPortal: portal,
};

let disposeMount: Dispose | undefined;

const cleanUpPreviousRender = (): void => {
disposeMount?.();
disposeMount = undefined;
unmountTree(main);
unmountTree(portal);

Expand Down Expand Up @@ -547,7 +552,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
Expand Down Expand Up @@ -606,7 +611,7 @@ export class Router {
);
renderRoot.child = node;

mountTree(renderRoot);
disposeMount = mountTree(renderRoot);

// Scroll to top after each rendering.
window.scrollTo(0, 0);
Expand Down
4 changes: 4 additions & 0 deletions packages/runtime/src/internal/bindListeners.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
{
Expand All @@ -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(
{
Expand All @@ -695,6 +698,7 @@ describe("listenerFactory for event.*", () => {
runtimeContext
)(event);
expect(event.stopPropagation).toHaveBeenCalledWith();
consoleError.mockReset();
});

test("non-Event object", () => {
Expand Down
35 changes: 14 additions & 21 deletions packages/runtime/src/internal/bindListeners.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -45,39 +46,31 @@ 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,
});
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]);
}
disposables.push(() => {
brick.removeEventListener(eventType, listener);
});
});
}

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;
};
}

export function isBuiltinHandler(
Expand Down
28 changes: 24 additions & 4 deletions packages/runtime/src/internal/data/DataStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ResolveOptions, resolveData } from "./resolveData.js";
import { resolveDataStore } from "./resolveDataStore.js";
import type {
AsyncPropertyEntry,
Dispose,
RouteNode,
Comment on lines +22 to 23
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

disposableMap 中重复存储同一 Dispose,可能导致长期悬挂引用

目前同一个 Dispose 会被存储两次:

  1. onChange(dataName, listener) 内部调用 this.addDisposable(dataName, disposable);(key 为依赖名 dataName)。
  2. dataConf.track 分支中再次调用 this.addDisposable(dataConf.name, disposable);(key 为当前 data 名)。

在路由销毁逻辑中,disposeDataInRoutes 只按将要删除的 data 名(来自 routeMap)去查询 disposableMap.get(name) 并执行其中的 disposables,然后删除该 key。这样会产生两个问题:

  • 对于作为“依赖”的 data(例如全局 CTX),其 key 下的 Dispose[] 永远不会被 disposeDataInRoutes 删除,即使这些 Dispose 已经在以 dataConf.name 为 key 的数组中被执行过,仍然会在 disposableMap 中保留,持有闭包引用,长期运行有内存上涨风险。
  • 同一个 disposable 被存入两个数组,遇到同时销毁依赖和被依赖 data 的场景时会被调用两次(虽然 DOM/EventTarget 层面是幂等的,但这是不必要的重复工作,也让状态变得更难推理)。

建议把“订阅登记”和“所有者”解耦,避免重复存储同一个 Dispose,比如采取更简单的策略:

  • onChange 仅负责注册监听并返回 Dispose,不在内部调用 addDisposable

    onChange(dataName: string, listener: EventListener): Dispose {
      const eventTarget = this.data.get(dataName)?.eventTarget;
      eventTarget?.addEventListener(this.changeEventType, listener);
  • const disposable = () => {

  • const disposable = () => {
    eventTarget?.removeEventListener(this.changeEventType, listener);
    };
  • this.addDisposable(dataName, disposable);
    return disposable;
    }

- 由真正需要 route 级自动清理的调用方(如 `dataConf.track` 那段)根据“拥有者”名字显式调用 `addDisposable(dataConf.name, disposable)`,避免再按依赖名重复登记。

如果确实有其它地方依赖“按依赖名自动登记”的行为,也可以考虑为 `onChange` 增加一个可选 ownerName 参数,仅以 ownerName 作为 `disposableMap` 的 key 来管理清理。





Also applies to: 75-76, 365-373, 545-563, 586-593, 603-612

<!-- fingerprinting:phantom:poseidon:olive -->

<!-- This is an auto-generated comment by CodeRabbit -->

RuntimeBrick,
RuntimeContext,
Expand Down Expand Up @@ -71,6 +72,7 @@ export class DataStore<T extends DataStoreType = "CTX"> {
private readonly rendererContext?: RendererContext;
private routeMap = new WeakMap<RouteConf, Set<string>>();
private routeStackMap = new WeakMap<RouteConf, Set<PendingStackItem>>();
private disposableMap = new Map<string, Dispose[]>();

// 把 `rendererContext` 放在参数列表的最后,并作为可选,以减少测试文件的调整
constructor(
Expand Down Expand Up @@ -360,12 +362,14 @@ export class DataStore<T extends DataStoreType = "CTX"> {
}
}

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<string>): Promise<void> {
Expand Down Expand Up @@ -509,7 +513,6 @@ export class DataStore<T extends DataStoreType = "CTX"> {
};

if (resolvePolicy === "lazy") {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const { trigger } = dataConf.resolve!;
if (
trigger &&
Expand Down Expand Up @@ -539,7 +542,7 @@ export class DataStore<T extends DataStoreType = "CTX"> {
);
!load && (newData.deps = [...deps]);
for (const dep of deps) {
this.onChange(
const disposable = this.onChange(
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The variable name disposable is inconsistent with the naming in the onChange method which also uses disposable. Consider renaming this to depDisposable or dependencyDisposable to clarify it's a disposable for a dependency listener, distinguishing it from the general disposable in the onChange method.

Copilot uses AI. Check for mistakes.
dep,
this.batchAddListener(() => {
newData.useResolve = trackConditionalResolve
Expand All @@ -556,6 +559,7 @@ export class DataStore<T extends DataStoreType = "CTX"> {
}
}, dataConf)
);
this.addDisposable(dataConf.name, disposable);
}
}

Expand All @@ -579,6 +583,15 @@ export class DataStore<T extends DataStoreType = "CTX"> {
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.
Expand All @@ -589,6 +602,13 @@ export class DataStore<T extends DataStoreType = "CTX"> {
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();
}
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The disposables array cleanup is missing. After calling all dispose functions, the array should be cleared (e.g., disposables.length = 0) before deleting the map entry, similar to the pattern used in bindListeners.ts:72 and mount.ts:139. While the map entry is deleted, this could leave dangling references if the array is still referenced elsewhere.

Suggested change
}
}
disposables.length = 0;

Copilot uses AI. Check for mistakes.
}
this.disposableMap.delete(name);
}
this.routeMap.delete(route);
}
Expand Down
6 changes: 2 additions & 4 deletions packages/runtime/src/internal/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -112,10 +112,6 @@ 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[];
/** Remembered proxy listeners for unbinding */
$$proxyListeners?: RememberedEventListener[];
/** Find element by ref in a custom template */
Expand Down Expand Up @@ -209,3 +205,5 @@ export interface RouteNode {
// All ordered sibling routes under the same parent including the route itself
routes: RouteConf[];
}

export type Dispose = () => void;
15 changes: 13 additions & 2 deletions packages/runtime/src/internal/mount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -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 事件。
Expand Down Expand Up @@ -127,4 +132,10 @@ export function mountTree(
setTimeout(() => {
window.DISABLE_REACT_FLUSH_SYNC = true;
});
return () => {
for (const dispose of disposables) {
dispose();
}
disposables.length = 0;
};
}
6 changes: 5 additions & 1 deletion packages/runtime/src/internal/secret_internals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import type {
RuntimeDataValueOption,
RenderBrick,
RenderChildNode,
Dispose,
} from "./interfaces.js";
import { mountTree, unmountTree } from "./mount.js";
import { RenderTag } from "./enums.js";
Expand Down Expand Up @@ -132,6 +133,7 @@ export async function renderUseBrick(

export interface MountUseBrickResult {
portal?: HTMLElement;
dispose: Dispose;
}

export function mountUseBrick(
Expand All @@ -148,7 +150,7 @@ export function mountUseBrick(
return portal;
};

mountTree(renderRoot, element);
const dispose = mountTree(renderRoot, element);

rendererContext.dispatchOnMount();
rendererContext.initializeScrollIntoView();
Expand All @@ -161,6 +163,7 @@ export function mountUseBrick(

return {
portal,
dispose,
};
}

Expand All @@ -171,6 +174,7 @@ export function unmountUseBrick(
// if (mountResult.mainBrick) {
// mountResult.mainBrick.unmount();
// }
mountResult.dispose();
if (mountResult.portal) {
unmountTree(mountResult.portal);
mountResult.portal.remove();
Expand Down
Loading