diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bdea4eee3..64fe880c0c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,19 @@ Changelog _Note: Gaps between patch versions are faulty, broken or test releases._ +## v4.0.0-beta.49 (2024-01-17) + +#### :rocket: New Feature + +* Now the `render` method can accept the name of an asynchronous group to control the invocation of destructors `components/friends/vdom` + +#### :bug: Bug Fix + +* Fixed memory leaks when switching pages `bDynamicPage` +* Fixed a memory leak when creating dynamic components via the VDOM API `core/component/engines/vue3` +* Fixed memory leaks when removing components `core/component/init` +* Added memoization for the `getParent` and `getRoot` props to prevent unnecessary re-renders `build/snakeskin` + ## v4.0.0-beta.48 (2024-01-17) #### :boom: Breaking Change diff --git a/build/snakeskin/CHANGELOG.md b/build/snakeskin/CHANGELOG.md index 5862e341b5..e0894dc125 100644 --- a/build/snakeskin/CHANGELOG.md +++ b/build/snakeskin/CHANGELOG.md @@ -9,6 +9,12 @@ Changelog > - :house: [Internal] > - :nail_care: [Polish] +## v4.0.0-beta.49 (2024-01-17) + +#### :bug: Bug Fix + +* Added memoization for the `getParent` and `getRoot` props to prevent unnecessary re-renders + ## v4.0.0-beta.38 (2023-11-15) #### :bug: Bug Fix diff --git a/build/snakeskin/default-filters.js b/build/snakeskin/default-filters.js index 5a7bd1e1c6..c388dcebb4 100644 --- a/build/snakeskin/default-filters.js +++ b/build/snakeskin/default-filters.js @@ -88,8 +88,8 @@ function tagFilter({name, attrs = {}}, tplName, cursor) { attrs[':componentIdProp'] = [`componentId + ${JSON.stringify(id)}`]; } - attrs[':getRoot'] = ["() => ('getRoot' in self ? self.getRoot?.() : null) ?? self.$root"]; - attrs[':getParent'] = ["() => typeof $restArgs !== 'undefined' ? $restArgs?.ctx ?? self : self"]; + attrs[':getRoot'] = ["self.tmp.__getRoot__ ??= () => ('getRoot' in self ? self.getRoot?.() : null) ?? self.$root"]; + attrs[':getParent'] = ["self.tmp.__getParent__ ??= () => typeof $restArgs !== 'undefined' ? $restArgs?.ctx ?? self : self"]; if (component.inheritMods !== false && !attrs[':modsProp']) { attrs[':modsProp'] = ['sharedMods']; diff --git a/package.json b/package.json index 356e4411f0..437d629072 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,7 @@ "packageManager": "yarn@4.0.2", "typings": "index.d.ts", "license": "MIT", - "version": "4.0.0-beta.48", + "version": "4.0.0-beta.49", "author": { "name": "kobezzza", "email": "kobezzza@gmail.com", diff --git a/src/components/base/b-dynamic-page/CHANGELOG.md b/src/components/base/b-dynamic-page/CHANGELOG.md index 5aa8b915a1..eeb3184575 100644 --- a/src/components/base/b-dynamic-page/CHANGELOG.md +++ b/src/components/base/b-dynamic-page/CHANGELOG.md @@ -9,6 +9,12 @@ Changelog > - :house: [Internal] > - :nail_care: [Polish] +## v4.0.0-beta.49 (2024-01-17) + +#### :bug: Bug Fix + +* Fixed memory leaks when switching pages + ## v4.0.0-alpha.1 (2022-12-14) #### :boom: Breaking Change diff --git a/src/components/base/b-dynamic-page/b-dynamic-page.ss b/src/components/base/b-dynamic-page/b-dynamic-page.ss index 28a1a530e8..f645901bc4 100644 --- a/src/components/base/b-dynamic-page/b-dynamic-page.ss +++ b/src/components/base/b-dynamic-page/b-dynamic-page.ss @@ -23,7 +23,7 @@ ? delete attrs[':keepAlive'] ? delete attrs[':dispatching'] - < template v-for = el in asyncRender.iterate(renderIterator, {filter: renderFilter, group: registerRenderingGroup()}) + < template v-for = el in asyncRender.iterate(renderIterator, {filter: renderFilter, group: registerRenderGroup}) < component.&__component & v-if = !pageTakenFromCache | ref = component | @@ -32,5 +32,6 @@ :dispatching = true | :renderComponentId = true | + v-attrs = {'@hook:destroyed': createPageDestructor()} | ${attrs} . diff --git a/src/components/base/b-dynamic-page/b-dynamic-page.ts b/src/components/base/b-dynamic-page/b-dynamic-page.ts index 01145c1413..81831c3579 100644 --- a/src/components/base/b-dynamic-page/b-dynamic-page.ts +++ b/src/components/base/b-dynamic-page/b-dynamic-page.ts @@ -95,25 +95,24 @@ export default class bDynamicPage extends iDynamicPage { readonly pageGetter!: PageGetter; /** - * If true, then when moving from one page to another, the old page is saved in the cache under its own name. - * When you return to this page, it will be restored. This helps to optimize switching between pages, but increases - * memory consumption. + * If set to true, the previous pages will be cached under their own names, + * allowing them to be restored when revisited. + * This optimization helps improve page switching but may increase memory usage. * - * Note that when a page is switched, it will be deactivated by calling `deactivate`. - * When the page is restored, it will be activated by calling `activate`. + * Please note that when a page is switched, it will be deactivated through the `deactivate` function. + * Similarly, when the page is restored, it will be activated using the `activate` function. */ @prop(Boolean) readonly keepAlive: boolean = false; /** - * The maximum number of pages in the `keepAlive` global cache + * The maximum number of pages that can be stored in the global cache of `keepAlive` */ @prop(Number) readonly keepAliveSize: number = 10; /** - * A dictionary of `keepAlive` caches. - * The keys represent cache groups (the default is `global`). + * A dictionary of `keepAlive` caches, where the keys represent cache groups (with the default being `global`) */ @system((o) => o.sync.link('keepAliveSize', (size: number) => ({ ...o.keepAliveCache, @@ -127,15 +126,17 @@ export default class bDynamicPage extends iDynamicPage { keepAliveCache!: Dictionary>; /** - * A predicate to include pages in `keepAlive` caching: if not specified, all loaded pages will be cached. - * It can be defined as: + * A predicate to determine which pages should be included in `keepAlive` caching. + * If not specified, all loaded pages will be cached. * - * 1. a component name (or a list of names); - * 2. a regular expression; - * 3. a function that takes a component name and returns: - * * `true` (include), `false` (does not include); - * * a string key for caching (used instead of the component name); - * * or a special object with information about the caching strategy being used. + * The predicate can be defined in three ways: + * 1. As a component name or a list of component names. + * 2. As a regular expression. + * 3. As a function that takes a component name and returns one of the following: + * - `true` (to include the page in caching). + * - `false` (to exclude the page from caching). + * - A string key to be used for caching instead of the component name. + * - A special object with information about the caching strategy being used. */ @prop({ type: [String, Array, RegExp, Function], @@ -145,9 +146,11 @@ export default class bDynamicPage extends iDynamicPage { readonly include?: Include; /** - * A predicate to exclude some pages from `keepAlive` caching. - * It can be defined as a component name (or a list of names), regular expression, - * or a function that takes a component name and returns `true` (exclude) or `false` (does not exclude). + * A predicate to exclude certain pages from `keepAlive` caching can be defined in three ways: + * 1. As a component name or a list of component names. + * 2. As a regular expression. + * 3. As a function that takes a component name and returns `true` to exclude the page from caching, + * or `false` to include the page in caching. */ @prop({ type: [String, Array, RegExp, Function], @@ -163,7 +166,7 @@ export default class bDynamicPage extends iDynamicPage { readonly emitter?: EventEmitterLike; /** - * Page switching event name + * The page switching event name */ @prop({ type: String, @@ -179,22 +182,25 @@ export default class bDynamicPage extends iDynamicPage { @computed({cache: false, dependencies: ['page']}) get component(): CanPromise { const - c = this.$refs.component; + that = this, + componentRef = this.$refs.component; - const getComponent = () => { + if (componentRef != null && (!Object.isArray(componentRef) || componentRef.length > 0)) { + return getComponent(); + } + + return this.waitRef('component').then(getComponent); + + function getComponent() { const - c = this.$refs.component!; + componentRef = that.$refs.component!; - if (Object.isArray(c)) { - return c[0]; + if (Object.isArray(componentRef)) { + return componentRef[0]; } - return c; - }; - - return c != null && (!Object.isArray(c) || c.length > 0) ? - getComponent() : - this.waitRef('component').then(getComponent); + return componentRef; + } } override get unsafe(): UnsafeGetter> { @@ -230,10 +236,17 @@ export default class bDynamicPage extends iDynamicPage { * Registered groups of asynchronous render tasks */ @system() - protected renderingGroups: Set = new Set(); + protected renderGroups: Set = new Set(); + + /** + * The name of the current rendering group + */ + protected get currentRenderGroup(): string { + return `pageRendering-${this.renderCounter}`; + } /** - * Render loop iterator (used with `asyncRender`) + * The render loop iterator for `asyncRender` */ protected get renderIterator(): CanPromise { if (SSR) { @@ -289,14 +302,28 @@ export default class bDynamicPage extends iDynamicPage { /** * Registers a new group for asynchronous rendering and returns it */ - protected registerRenderingGroup(): string { - const group = `pageRendering-${this.renderCounter++}`; - this.renderingGroups.add(group); - return group; + protected registerRenderGroup(): string { + this.renderCounter++; + this.renderGroups.add(this.currentRenderGroup); + return this.currentRenderGroup; } /** - * Render loop filter (used with `asyncRender`) + * Creates a page destructor function + */ + protected createPageDestructor(): Function { + const + group = this.currentRenderGroup, + groupRgxp = new RegExp(RegExp.escape(group)); + + return () => { + this.async.clearAll({group: groupRgxp}); + this.renderGroups.delete(group); + }; + } + + /** + * The render loop filter for `asyncRender` */ protected renderFilter(): CanPromise { const canPass = @@ -310,20 +337,17 @@ export default class bDynamicPage extends iDynamicPage { return true; } - const - {unsafe, route} = this; + const { + unsafe, + route + } = this; return new SyncPromise((resolve) => { - [...this.renderingGroups].slice(0, -2).forEach((group) => { - this.async.clearAll({group: new RegExp(RegExp.escape(group))}); - this.renderingGroups.delete(group); - }); - this.onPageChange = onPageChange(resolve, this.route); }); function onPageChange( - resolve: Function, + resolve: (status: boolean) => void, currentRoute: typeof route ): AnyFunction { return (newPage: CanUndef, currentPage: CanUndef) => { diff --git a/src/components/friends/async-render/README.md b/src/components/friends/async-render/README.md index b32bcc334b..58d0b8b6dc 100644 --- a/src/components/friends/async-render/README.md +++ b/src/components/friends/async-render/README.md @@ -190,6 +190,7 @@ This can optimize the browser rendering process. A group name for manually clearing pending tasks via the `async` module. Providing this value disables the automatic cleanup of render tasks on the `update` hook. +If this parameter is set as a function, the group name will be dynamically calculated on each iteration. ``` < .container v-async-target diff --git a/src/components/friends/async-render/helpers/render.ts b/src/components/friends/async-render/helpers/render.ts index b6efe71910..64a1464909 100644 --- a/src/components/friends/async-render/helpers/render.ts +++ b/src/components/friends/async-render/helpers/render.ts @@ -30,7 +30,7 @@ export function addRenderTask( ): Promise { const $a = this.async, - group = opts.group ?? 'asyncComponents'; + group = (Object.isFunction(opts.group) ? opts.group() : opts.group) ?? 'asyncComponents'; return new SyncPromise((resolve, reject) => { const taskDesc = { @@ -70,8 +70,6 @@ export function addRenderTask( * @param [childComponentEls] - root elements of the child components */ export function destroyNode(this: Friend, node: Node, childComponentEls: Element[] = []): void { - node.parentNode?.removeChild(node); - childComponentEls.forEach((child) => { try { (>child).component?.unsafe.$destroy(); @@ -87,4 +85,6 @@ export function destroyNode(this: Friend, node: Node, childComponentEls: Element } catch (err) { stderr(err); } + + node.parentNode?.removeChild(node); } diff --git a/src/components/friends/async-render/interface/task.ts b/src/components/friends/async-render/interface/task.ts index 4ac00ec21f..8fde42a1f4 100644 --- a/src/components/friends/async-render/interface/task.ts +++ b/src/components/friends/async-render/interface/task.ts @@ -32,6 +32,7 @@ export interface TaskOptions { /** * A group name to manual clearing of pending tasks via the [[Async]] module. * Providing this value disables automatically cleanup of render tasks on the `update` hook. + * If this parameter is set as a function, the group name will be dynamically calculated on each iteration. * * @example * ``` @@ -45,7 +46,7 @@ export interface TaskOptions { * Cancel rendering * ``` */ - group?: string; + group?: string | (() => string); /** * A function to filter elements to render. diff --git a/src/components/friends/async-render/iterate.ts b/src/components/friends/async-render/iterate.ts index 5ed217fbbd..23c50dfd05 100644 --- a/src/components/friends/async-render/iterate.ts +++ b/src/components/friends/async-render/iterate.ts @@ -141,7 +141,7 @@ export function iterate( // eslint-disable-next-line no-constant-condition rendering: while (true) { if (opts.group != null) { - group = `asyncComponents:${opts.group}:${chunkI}`; + group = `asyncComponents:${Object.isFunction(opts.group) ? opts.group() : opts.group}:${chunkI}`; } let @@ -334,7 +334,7 @@ export function iterate( renderedVnode = Object.cast(vnode.el); } else { - renderedVnode = render.call(that, Object.cast(vnode)); + renderedVnode = render.call(that, Object.cast(vnode), group); } const diff --git a/src/components/friends/async-render/test/b-friends-async-render-dummy/b-friends-async-render-dummy.ss b/src/components/friends/async-render/test/b-friends-async-render-dummy/b-friends-async-render-dummy.ss index 02fbaa76db..5548ac986c 100644 --- a/src/components/friends/async-render/test/b-friends-async-render-dummy/b-friends-async-render-dummy.ss +++ b/src/components/friends/async-render/test/b-friends-async-render-dummy/b-friends-async-render-dummy.ss @@ -55,14 +55,12 @@ < template v-if = stage === 'updating the parent component state' < .&__result v-async-target - {{ void(tmp.oldRefs = $refs.btn) }} - < template v-for = el in asyncRender.iterate(2) < b-button ref = btn | v-func = false < template #default = {ctx} Element: {{ el }}; Hook: {{ ctx.hook }}; - < button.&__update @click = $forceUpdate + < button.&__update @click = tmp.oldRefs=$refs.btn.slice(), $forceUpdate() Update state < template v-if = stage === 'clearing by the specified group name' diff --git a/src/components/friends/async-render/test/unit/main.ts b/src/components/friends/async-render/test/unit/main.ts index b1d00208db..3342d72b8d 100644 --- a/src/components/friends/async-render/test/unit/main.ts +++ b/src/components/friends/async-render/test/unit/main.ts @@ -180,8 +180,7 @@ test.describe('friends/async-render', () => { const hooks = await target.evaluate((ctx) => { const oldRefs = ctx.unsafe.tmp.oldRefs; - - return [oldRefs[0].hook, oldRefs[1].hook]; + return [oldRefs[0].hook, oldRefs[1]?.hook]; }); test.expect(hooks).toEqual([ diff --git a/src/components/friends/vdom/CHANGELOG.md b/src/components/friends/vdom/CHANGELOG.md index bdc31dba6a..09ea43bde3 100644 --- a/src/components/friends/vdom/CHANGELOG.md +++ b/src/components/friends/vdom/CHANGELOG.md @@ -9,6 +9,12 @@ Changelog > - :house: [Internal] > - :nail_care: [Polish] +## v4.0.0-beta.49 (2024-01-17) + +#### :rocket: New Feature + +* Now the `render` method can accept the name of an asynchronous group to control the invocation of destructors + ## v4.0.0-alpha.1 (2022-12-14) #### :boom: Breaking Change diff --git a/src/components/friends/vdom/render.ts b/src/components/friends/vdom/render.ts index 51c730fa46..8c14bb0176 100644 --- a/src/components/friends/vdom/render.ts +++ b/src/components/friends/vdom/render.ts @@ -16,6 +16,7 @@ import type { RenderFactory, RenderFn } from 'components/friends/vdom/interface' * Renders the specified VNode and returns the result * * @param vnode + * @param [group] - the name of the async group within which rendering takes place * * @example * ```js @@ -25,12 +26,13 @@ import type { RenderFactory, RenderFn } from 'components/friends/vdom/interface' * console.log(div.classList.contains('foo')); // true * ``` */ -export function render(this: Friend, vnode: VNode): Node; +export function render(this: Friend, vnode: VNode, group?: string): Node; /** * Renders the specified list of VNodes and returns the result * * @param vnodes + * @param [group] - the name of the async group within which rendering takes place * * @example * ```js @@ -43,9 +45,10 @@ export function render(this: Friend, vnode: VNode): Node; * console.log(div[1].classList.contains('bar')); // true * ``` */ -export function render(this: Friend, vnodes: VNode[]): Node[]; -export function render(this: Friend, vnode: CanArray): CanArray { - return this.ctx.$renderEngine.r.render(Object.cast(vnode), this.ctx); +export function render(this: Friend, vnodes: VNode[], group?: string): Node[]; + +export function render(this: Friend, vnode: CanArray, group?: string): CanArray { + return this.ctx.$renderEngine.r.render(Object.cast(vnode), this.ctx, group); } /** diff --git a/src/components/super/i-block/event/index.ts b/src/components/super/i-block/event/index.ts index 3d6de54ecf..5bd009130c 100644 --- a/src/components/super/i-block/event/index.ts +++ b/src/components/super/i-block/event/index.ts @@ -17,7 +17,7 @@ import { EventEmitter2 as EventEmitter } from 'eventemitter2'; import SyncPromise from 'core/promise/sync'; import type Async from 'core/async'; -import type { AsyncOptions, EventEmitterWrapper, ReadonlyEventEmitterWrapper } from 'core/async'; +import type { AsyncOptions, EventEmitterWrapper, ReadonlyEventEmitterWrapper, EventId } from 'core/async'; import { component, globalEmitter } from 'core/component'; @@ -91,7 +91,15 @@ export default abstract class iBlockEvent extends iBlockBase { init: (o, d) => (d.async).wrapEventEmitter({ on: (event: string, handler: Function) => o.$on(normalizeEventName(event), handler), once: (event: string, handler: Function) => o.$once(normalizeEventName(event), handler), - off: o.$off.bind(o), + + off: (eventOrLink: string | EventId, handler: Function) => { + if (Object.isString(eventOrLink)) { + return o.$off(normalizeEventName(eventOrLink), handler); + } + + return o.$off(eventOrLink); + }, + emit: o.emit.bind(o), strictEmit: o.emit.bind(o) }) diff --git a/src/core/component/accessor/index.ts b/src/core/component/accessor/index.ts index 4e8715fb2b..82fc2bc6b8 100644 --- a/src/core/component/accessor/index.ts +++ b/src/core/component/accessor/index.ts @@ -63,6 +63,8 @@ import type { ComponentInterface } from 'core/component/interface'; */ export function attachAccessorsFromMeta(component: ComponentInterface): void { const { + async: $a, + meta, // eslint-disable-next-line deprecation/deprecation meta: {params: {deprecatedProps}} @@ -71,7 +73,28 @@ export function attachAccessorsFromMeta(component: ComponentInterface): void { const isFunctional = meta.params.functional === true; - Object.entries(meta.computedFields).forEach(([name, computed]) => { + Object.entries(meta.accessors).forEach(([name, accessor]) => { + const canSkip = + accessor == null || + component[name] != null || + !SSR && isFunctional && accessor.functional === false; + + if (canSkip) { + return; + } + + Object.defineProperty(component, name, { + configurable: true, + enumerable: true, + get: accessor.get, + set: accessor.set + }); + }); + + const + computedFields = Object.entries(meta.computedFields); + + computedFields.forEach(([name, computed]) => { const canSkip = component[name] != null || computed == null || computed.cache === 'auto' || @@ -126,21 +149,10 @@ export function attachAccessorsFromMeta(component: ComponentInterface): void { }); }); - Object.entries(meta.accessors).forEach(([name, accessor]) => { - const canSkip = - accessor == null || - component[name] != null || - !SSR && isFunctional && accessor.functional === false; - - if (canSkip) { - return; - } - - Object.defineProperty(component, name, { - configurable: true, - enumerable: true, - get: accessor.get, - set: accessor.set + // Register a worker to clean up memory upon component destruction + $a.worker(() => { + computedFields.forEach(([name]) => { + delete component[name]?.[cacheStatus]; }); }); diff --git a/src/core/component/context/README.md b/src/core/component/context/README.md index ecb960c7a6..1bbde93a11 100644 --- a/src/core/component/context/README.md +++ b/src/core/component/context/README.md @@ -22,3 +22,11 @@ Returns a wrapped component context object based on the passed one. This function allows developers to override component properties and methods without altering the original object. Essentially, override creates a new object that contains the original object as its prototype, allowing for the addition, modification, or removal of properties and methods without affecting the original object. + +### saveRawComponentContext + +Stores a reference to the "raw" component context in the main context. + +### dropRawComponentContext + +Drops a reference to the "raw" component context from the main context. diff --git a/src/core/component/context/index.ts b/src/core/component/context/index.ts index 68d033b698..48ea2a44a1 100644 --- a/src/core/component/context/index.ts +++ b/src/core/component/context/index.ts @@ -25,16 +25,40 @@ export * from 'core/component/context/const'; * @param component */ export function getComponentContext(component: object): Dictionary & ComponentInterface['unsafe'] { - component = toRaw in component ? component[toRaw] : component; + if (toRaw in component) { + return Object.cast(component); + } let - v = wrappedContexts.get(component); + wrappedCtx = wrappedContexts.get(component); + + if (wrappedCtx == null) { + wrappedCtx = Object.create(component); + saveRawComponentContext(wrappedCtx, component); + wrappedContexts.set(component, wrappedCtx); + } + + return wrappedCtx; +} + +/** + * Stores a reference to the "raw" component context in the main context + * + * @param ctx - the main context object + * @param rawCtx - the raw context object to be stored + */ +export function saveRawComponentContext(ctx: object, rawCtx: object): void { + Object.defineProperty(ctx, toRaw, {configurable: true, value: rawCtx}); +} - if (v == null) { - v = Object.create(component); - Object.defineProperty(v, toRaw, {value: component}); - wrappedContexts.set(component, v); +/** + * Drops a reference to the "raw" component context from the main context + * @param ctx - the main context object + */ +export function dropRawComponentContext(ctx: object): void { + if (toRaw in ctx) { + wrappedContexts.delete(ctx[toRaw]); } - return v; + delete ctx[toRaw]; } diff --git a/src/core/component/engines/vue3/CHANGELOG.md b/src/core/component/engines/vue3/CHANGELOG.md index 651a4fe3fa..a7e6e30036 100644 --- a/src/core/component/engines/vue3/CHANGELOG.md +++ b/src/core/component/engines/vue3/CHANGELOG.md @@ -9,6 +9,12 @@ Changelog > - :house: [Internal] > - :nail_care: [Polish] +## v4.0.0-beta.49 (2024-01-17) + +#### :bug: Bug Fix + +* Fixed a memory leak when creating dynamic components via the VDOM API + ## v4.0.0-beta.23 (2023-09-18) #### :bug: Bug Fix diff --git a/src/core/component/engines/vue3/render.ts b/src/core/component/engines/vue3/render.ts index c871ca7312..653e481254 100644 --- a/src/core/component/engines/vue3/render.ts +++ b/src/core/component/engines/vue3/render.ts @@ -25,7 +25,9 @@ import { withDirectives as superWithDirectives, resolveDirective as superResolveDirective, - VNode + VNode, + VNodeChild, + VNodeArrayChildren } from 'vue'; @@ -133,10 +135,12 @@ export const * * @param vnode * @param [parent] - the parent component + * @param [group] - the name of the async group within which rendering takes place */ export function render( vnode: VNode, - parent?: ComponentInterface + parent?: ComponentInterface, + group?: string ): Node; /** @@ -144,13 +148,15 @@ export function render( * * @param vnodes * @param [parent] - the parent component + * @param [group] - the name of the async group within which rendering takes place */ export function render( vnodes: VNode[], - parent?: ComponentInterface + parent?: ComponentInterface, + group?: string ): Node[]; -export function render(vnode: CanArray, parent?: ComponentInterface): CanArray { +export function render(vnode: CanArray, parent?: ComponentInterface, group?: string): CanArray { const vue = new Vue({ render: () => vnode, @@ -171,6 +177,11 @@ export function render(vnode: CanArray, parent?: ComponentInterface): Can writable: true, value: root }); + + // Register a worker to clean up memory upon component destruction + parent.unsafe.async.worker(() => { + vue.unmount(); + }, {group}); } } }); @@ -201,3 +212,65 @@ export function render(vnode: CanArray, parent?: ComponentInterface): Can return node?.nodeType === 3 && node.textContent === ''; } } + +/** + * Deletes the specified node and frees up memory + * @param node + */ +export function destroy(node: VNode | Node): void { + if (node instanceof Node) { + if (('__vnode' in node)) { + removeVNode(node['__vnode']); + } + + node.parentNode?.removeChild(node); + + if (node instanceof Element) { + node.innerHTML = ''; + } + + } else { + removeVNode(node); + } + + function removeVNode(vnode: Nullable) { + if (vnode == null || Object.isPrimitive(vnode)) { + return; + } + + if (Object.isArray(vnode)) { + vnode.forEach(removeVNode); + return; + } + + if (Object.isArray(vnode.children)) { + vnode.children.forEach(removeVNode); + } + + if (Object.isArray(vnode['dynamicChildren'])) { + vnode['dynamicChildren'].forEach((vnode) => removeVNode(Object.cast(vnode))); + } + + if (Object.isArray(vnode.dirs)) { + vnode.dirs.forEach((binding) => { + binding.dir.beforeUnmount?.(vnode.el, binding, vnode, null); + binding.dir.unmounted?.(vnode.el, binding, vnode, null); + }); + } + + if (vnode.component != null) { + vnode.component.effect.stop(); + vnode.component = null; + } + + vnode.props = {}; + + ['dirs', 'children', 'dynamicChildren', 'dynamicProps'].forEach((key) => { + vnode[key] = []; + }); + + ['el', 'ctx', 'ref', 'virtualComponent', 'virtualContext'].forEach((key) => { + vnode[key] = null; + }); + } +} diff --git a/src/core/component/event/component.ts b/src/core/component/event/component.ts index da3f085137..8534530dba 100644 --- a/src/core/component/event/component.ts +++ b/src/core/component/event/component.ts @@ -6,6 +6,8 @@ * https://github.com/V4Fire/Client/blob/master/LICENSE */ +import type { EventId } from 'core/async'; + import { EventEmitter2 as EventEmitter } from 'eventemitter2'; import type { UnsafeComponentInterface } from 'core/component/interface'; @@ -55,8 +57,6 @@ export function resetComponents(type?: ComponentResetType): void { * @param component */ export function implementEventEmitterAPI(component: object): void { - /* eslint-disable @typescript-eslint/typedef */ - const ctx = Object.cast(component); @@ -74,7 +74,7 @@ export function implementEventEmitterAPI(component: object): void { enumerable: false, writable: false, - value(event: string, ...args) { + value(event: string, ...args: unknown[]) { if (!event.startsWith('[[')) { nativeEmit?.(event, ...args); } @@ -106,16 +106,28 @@ export function implementEventEmitterAPI(component: object): void { }); function getMethod(method: 'on' | 'once' | 'off') { - return function wrapper(this: unknown, event, cb) { + return function wrapper(this: unknown, event: CanArray, cb?: Function) { + const + links: EventId[] = [], + isOnLike = method !== 'off'; + Array.concat([], event).forEach((event) => { if (method === 'off' && cb == null) { $e.removeAllListeners(event); } else { - $e[method](Object.cast(event), Object.cast(cb)); + const link = $e[method](Object.cast(event), Object.cast(cb)); + + if (isOnLike) { + links.push(Object.cast(link)); + } } }); + if (isOnLike) { + return Object.isArray(event) ? links : links[0]; + } + return this; }; } diff --git a/src/core/component/functional/context.ts b/src/core/component/functional/context.ts index 1117c438a4..afb1759915 100644 --- a/src/core/component/functional/context.ts +++ b/src/core/component/functional/context.ts @@ -8,6 +8,7 @@ import * as init from 'core/component/init'; +import { saveRawComponentContext } from 'core/component/context'; import { forkMeta, ComponentMeta } from 'core/component/meta'; import { initProps } from 'core/component/prop'; @@ -132,6 +133,12 @@ export function createVirtualContext( } }); + // When extending the context of the original component (e.g., Vue), to avoid conflicts, + // we create an object with the original context in the prototype: `V4Context<__proto__: OriginalContext>`. + // However, for functional components, this approach is redundant and can lead to memory leaks. + // Instead, we simply assign a reference to the raw context, which points to the original context. + saveRawComponentContext(virtualCtx, virtualCtx); + initProps(virtualCtx, { from: $props, store: virtualCtx, diff --git a/src/core/component/hook/index.ts b/src/core/component/hook/index.ts index b962de6da2..ece057744f 100644 --- a/src/core/component/hook/index.ts +++ b/src/core/component/hook/index.ts @@ -16,9 +16,6 @@ import QueueEmitter from 'core/component/queue-emitter'; import type { Hook, ComponentHook, ComponentInterface } from 'core/component/interface'; -const - resolvedPromise = SyncPromise.resolve(); - /** * Runs a hook on the specified component instance. * The function returns a promise that is resolved when all hook handlers are executed. @@ -114,5 +111,5 @@ export function runHook(hook: Hook, component: ComponentInterface, ...args: unkn } } - return resolvedPromise; + return SyncPromise.resolve(); } diff --git a/src/core/component/init/CHANGELOG.md b/src/core/component/init/CHANGELOG.md index 95acd5c002..7cc94a4548 100644 --- a/src/core/component/init/CHANGELOG.md +++ b/src/core/component/init/CHANGELOG.md @@ -9,6 +9,12 @@ Changelog > - :house: [Internal] > - :nail_care: [Polish] +## v4.0.0-beta.49 (2024-01-17) + +#### :bug: Bug Fix + +* Fixed memory leaks when removing components + ## v4.0.0-alpha.1 (2022-12-14) #### :boom: Breaking Change diff --git a/src/core/component/init/states/activated.ts b/src/core/component/init/states/activated.ts index d834216124..993bd91a3c 100644 --- a/src/core/component/init/states/activated.ts +++ b/src/core/component/init/states/activated.ts @@ -16,6 +16,10 @@ import type { ComponentInterface } from 'core/component/interface'; * @param component */ export function activatedState(component: ComponentInterface): void { + if (component.hook === 'activated') { + return; + } + runHook('activated', component).catch(stderr); callMethodFromComponent(component, 'activated'); } diff --git a/src/core/component/init/states/before-destroy.ts b/src/core/component/init/states/before-destroy.ts index 97a57f37cf..572280b922 100644 --- a/src/core/component/init/states/before-destroy.ts +++ b/src/core/component/init/states/before-destroy.ts @@ -6,6 +6,7 @@ * https://github.com/V4Fire/Client/blob/master/LICENSE */ +import { dropRawComponentContext } from 'core/component/context'; import { callMethodFromComponent } from 'core/component/method'; import { runHook } from 'core/component/hook'; @@ -16,14 +17,43 @@ import type { ComponentInterface } from 'core/component/interface'; * @param component */ export function beforeDestroyState(component: ComponentInterface): void { + if (component.hook === 'beforeDestroy' || component.hook === 'destroyed') { + return; + } + runHook('beforeDestroy', component).catch(stderr); callMethodFromComponent(component, 'beforeDestroy'); - const - {unsafe} = component; + const { + unsafe, + unsafe: {$el} + } = component; unsafe.async.clearAll().locked = true; unsafe.$async.clearAll().locked = true; - delete unsafe.$el?.component; + if ($el != null) { + delete $el.component; + } + + setTimeout(() => { + if ($el != null && unsafe.meta.params.functional === true) { + unsafe.$renderEngine.r.destroy($el); + } + + const destroyedComponent = { + componentId: unsafe.componentId, + componentName: unsafe.componentName, + hook: unsafe.hook + }; + + Object.getOwnPropertyNames(unsafe).forEach((key) => { + delete unsafe[key]; + }); + + Object.assign(unsafe, destroyedComponent); + Object.setPrototypeOf(unsafe, destroyedComponent); + + dropRawComponentContext(unsafe); + }, 0); } diff --git a/src/core/component/init/states/created.ts b/src/core/component/init/states/created.ts index e419a96bfc..c7f9185738 100644 --- a/src/core/component/init/states/created.ts +++ b/src/core/component/init/states/created.ts @@ -21,6 +21,10 @@ const * @param component */ export function createdState(component: ComponentInterface): void { + if (component.hook !== 'beforeDataCreate') { + return; + } + const { unsafe, unsafe: { diff --git a/src/core/component/init/states/deactivated.ts b/src/core/component/init/states/deactivated.ts index e86e26a462..39957d33f8 100644 --- a/src/core/component/init/states/deactivated.ts +++ b/src/core/component/init/states/deactivated.ts @@ -16,6 +16,10 @@ import type { ComponentInterface } from 'core/component/interface'; * @param component */ export function deactivatedState(component: ComponentInterface): void { + if (component.hook === 'deactivated') { + return; + } + runHook('deactivated', component).catch(stderr); callMethodFromComponent(component, 'deactivated'); } diff --git a/src/core/component/init/states/destroyed.ts b/src/core/component/init/states/destroyed.ts index ec0ebc9840..313e52461b 100644 --- a/src/core/component/init/states/destroyed.ts +++ b/src/core/component/init/states/destroyed.ts @@ -16,6 +16,10 @@ import type { ComponentInterface } from 'core/component/interface'; * @param component */ export function destroyedState(component: ComponentInterface): void { + if (component.hook === 'destroyed') { + return; + } + runHook('destroyed', component).then(() => { callMethodFromComponent(component, 'destroyed'); }).catch(stderr); diff --git a/src/core/component/interface/component/component.ts b/src/core/component/interface/component/component.ts index 4861ce47a3..1e68228472 100644 --- a/src/core/component/interface/component/component.ts +++ b/src/core/component/interface/component/component.ts @@ -9,7 +9,7 @@ /* eslint-disable @typescript-eslint/unified-signatures */ import type Async from 'core/async'; -import type { BoundFn, ProxyCb } from 'core/async'; +import type { BoundFn, ProxyCb, EventId } from 'core/async'; import type { State } from 'core/component/state'; import type { HydrationStore } from 'core/component/hydration'; @@ -438,30 +438,61 @@ export abstract class ComponentInterface { /** * Attaches a listener to the specified component's event * - * @param _event - * @param _handler + * @param event + * @param handler */ - protected $on(_event: CanArray, _handler: ProxyCb): this { + protected $on(event: string, handler: ProxyCb): EventId; + + /** + * Attaches a listener to the specified component's events + * + * @param events + * @param handler + */ + protected $on(events: string[], handler: ProxyCb): EventId[]; + + protected $on(_event: CanArray, _handler: ProxyCb): CanArray { return Object.throw(); } /** * Attaches a disposable listener to the specified component's event * - * @param _event - * @param _handler + * @param event + * @param handler + */ + protected $once(event: string, handler: ProxyCb): EventId; + + /** + * Attaches a disposable listener to the specified component's event + * + * @param events + * @param handler */ - protected $once(_event: string, _handler: ProxyCb): this { + protected $once(events: string[], handler: ProxyCb): EventId[]; + + protected $once( + _event: CanArray, + _handler: ProxyCb + ): CanArray { return Object.throw(); } + /** + * Detaches the specified event listeners from the component + * @param [link] + */ + protected $off(link: CanArray): this; + /** * Detaches the specified event listeners from the component * - * @param [_event] - * @param [_handler] + * @param [event] + * @param [handler] */ - protected $off(_event?: CanArray, _handler?: Function): this { + protected $off(event?: CanArray, handler?: Function): this; + + protected $off(_event?: CanArray, _handler?: Function): this { return Object.throw(); } diff --git a/src/core/component/interface/engine.ts b/src/core/component/interface/engine.ts index bc001caa37..eb55c63f48 100644 --- a/src/core/component/interface/engine.ts +++ b/src/core/component/interface/engine.ts @@ -99,8 +99,9 @@ export type ProxyGetterType = 'mounted'; export interface RenderAPI { - render(vnode: VNode, parent?: ComponentInterface): Node; - render(vnode: VNode[], parent?: ComponentInterface): Node[]; + render(vnode: VNode, parent?: ComponentInterface, group?: string): Node; + render(vnode: VNode[], parent?: ComponentInterface, group?: string): Node[]; + destroy(vnode: Node | VNode): void; getCurrentInstance: typeof getCurrentInstance; diff --git a/src/core/component/meta/create.ts b/src/core/component/meta/create.ts index afbcc8b2ff..dae411f87c 100644 --- a/src/core/component/meta/create.ts +++ b/src/core/component/meta/create.ts @@ -78,7 +78,8 @@ export function createMeta(component: ComponentConstructorInfo): ComponentMeta { }; const - cache = new WeakMap(); + label = Symbol('Render cache'), + cache = new Map(); meta.component[SSR ? 'ssrRender' : 'render'] = Object.cast((ctx: object, ...args: unknown[]) => { const @@ -95,7 +96,14 @@ export function createMeta(component: ComponentConstructorInfo): ComponentMeta { return render; } - cache.set(ctx, render); + if (unsafe.meta.params.functional !== true) { + cache.set(ctx, render); + + unsafe.$async.worker(() => { + cache.delete(ctx); + }, {label}); + } + return render(); }); diff --git a/src/core/component/reflect/mod.ts b/src/core/component/reflect/mod.ts index 3f1481fc85..732351b65f 100644 --- a/src/core/component/reflect/mod.ts +++ b/src/core/component/reflect/mod.ts @@ -64,7 +64,7 @@ export function getComponentMods(component: ComponentConstructorInfo): ModsDecl cache = new Map(); let - active; + active: CanUndef; modDecl.forEach((modVal) => { if (Object.isArray(modVal)) { diff --git a/src/core/component/watch/create.ts b/src/core/component/watch/create.ts index 692d90aa7c..581f9b1578 100644 --- a/src/core/component/watch/create.ts +++ b/src/core/component/watch/create.ts @@ -519,10 +519,13 @@ export function createWatchFn(component: ComponentInterface): ComponentInterface function wrapDestructor(destructor: T): T { if (Object.isFunction(destructor)) { - // Every worker that passed to Async have a counter with a number of consumers of this worker, + // Every worker passed to Async has a counter with a number of consumers of this worker, // but in this case this behaviour is redundant and can produce an error, - // that why we wrap original destructor with a new function - component.unsafe.$async.worker(() => destructor()); + // that's why we wrap original destructor with a new function + component.unsafe.$async.worker(() => { + watchCache.clear(); + return destructor(); + }); } return destructor; diff --git a/src/routes/index.ts b/src/routes/index.ts index 806f4be9d6..df78e824b2 100644 --- a/src/routes/index.ts +++ b/src/routes/index.ts @@ -6,6 +6,8 @@ * https://github.com/V4Fire/Client/blob/master/LICENSE */ -export default { +import type { StaticRoutes } from 'components/base/b-router/b-router'; + +export default { }; diff --git a/yarn.lock b/yarn.lock index 0f89e468fc..b4084fca41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -61,13 +61,13 @@ __metadata: languageName: node linkType: hard -"@babel/code-frame@npm:^7.23.4": - version: 7.23.4 - resolution: "@babel/code-frame@npm:7.23.4" +"@babel/code-frame@npm:^7.23.5": + version: 7.23.5 + resolution: "@babel/code-frame@npm:7.23.5" dependencies: "@babel/highlight": "npm:^7.23.4" chalk: "npm:^2.4.2" - checksum: 5a210e42b0c3138f3870e452c7b6d06ddcfc43cba824231ef3023fffd1cb0613d00ea07c7d87d0718e14e830f891b86de56aac5cd034d41128383919c84ff4f6 + checksum: 44e58529c9d93083288dc9e649c553c5ba997475a7b0758cc3ddc4d77b8a7d985dbe78cc39c9bbc61f26d50af6da1ddf0a3427eae8cc222a9370619b671ed8f5 languageName: node linkType: hard @@ -159,15 +159,15 @@ __metadata: languageName: node linkType: hard -"@babel/generator@npm:^7.23.4": - version: 7.23.4 - resolution: "@babel/generator@npm:7.23.4" +"@babel/generator@npm:^7.23.6": + version: 7.23.6 + resolution: "@babel/generator@npm:7.23.6" dependencies: - "@babel/types": "npm:^7.23.4" + "@babel/types": "npm:^7.23.6" "@jridgewell/gen-mapping": "npm:^0.3.2" "@jridgewell/trace-mapping": "npm:^0.3.17" jsesc: "npm:^2.5.1" - checksum: 7b45b64505bfb3ddbdeaae01288d2814e0e8d1299b3485983f4abc6563d6c10837979f00021308c78c33564d33e6d715e63aed64ac407ed8440b76f6eeb79019 + checksum: 864090d5122c0aa3074471fd7b79d8a880c1468480cbd28925020a3dcc7eb6e98bedcdb38983df299c12b44b166e30915b8085a7bc126e68fa7e2aadc7bd1ac5 languageName: node linkType: hard @@ -530,12 +530,12 @@ __metadata: languageName: node linkType: hard -"@babel/parser@npm:^7.16.7, @babel/parser@npm:^7.23.4": - version: 7.23.4 - resolution: "@babel/parser@npm:7.23.4" +"@babel/parser@npm:^7.16.7, @babel/parser@npm:^7.23.6": + version: 7.23.6 + resolution: "@babel/parser@npm:7.23.6" bin: parser: ./bin/babel-parser.js - checksum: 73c0172d2784c93455cb72a4669af5711a8f0421812d0c93e3be46bc7aee50e9215f61df90f94daf0555736ca2236f284462218f6bbc6bc804ebd94a59324f72 + checksum: 6be3a63d3c9d07b035b5a79c022327cb7e16cbd530140ecb731f19a650c794c315a72c699a22413ebeafaff14aa8f53435111898d59e01a393d741b85629fa7d languageName: node linkType: hard @@ -2068,20 +2068,20 @@ __metadata: linkType: hard "@babel/traverse@npm:^7.16.7": - version: 7.23.4 - resolution: "@babel/traverse@npm:7.23.4" + version: 7.23.7 + resolution: "@babel/traverse@npm:7.23.7" dependencies: - "@babel/code-frame": "npm:^7.23.4" - "@babel/generator": "npm:^7.23.4" + "@babel/code-frame": "npm:^7.23.5" + "@babel/generator": "npm:^7.23.6" "@babel/helper-environment-visitor": "npm:^7.22.20" "@babel/helper-function-name": "npm:^7.23.0" "@babel/helper-hoist-variables": "npm:^7.22.5" "@babel/helper-split-export-declaration": "npm:^7.22.6" - "@babel/parser": "npm:^7.23.4" - "@babel/types": "npm:^7.23.4" - debug: "npm:^4.1.0" + "@babel/parser": "npm:^7.23.6" + "@babel/types": "npm:^7.23.6" + debug: "npm:^4.3.1" globals: "npm:^11.1.0" - checksum: 0ff190a793d94c8ee3ff24bbe7d086c6401a84fa16f97d3c695c31aa42270916d937ae5994e315ba797e8f3728840e4d68866ad4d82a01132312d07ac45ca9d0 + checksum: 3215e59429963c8dac85c26933372cdd322952aa9930e4bc5ef2d0e4bd7a1510d1ecf8f8fd860ace5d4d9fe496d23805a1ea019a86410aee4111de5f63ee84f9 languageName: node linkType: hard @@ -2132,14 +2132,14 @@ __metadata: languageName: node linkType: hard -"@babel/types@npm:^7.16.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.4": - version: 7.23.4 - resolution: "@babel/types@npm:7.23.4" +"@babel/types@npm:^7.16.7, @babel/types@npm:^7.23.0, @babel/types@npm:^7.23.6": + version: 7.23.6 + resolution: "@babel/types@npm:7.23.6" dependencies: "@babel/helper-string-parser": "npm:^7.23.4" "@babel/helper-validator-identifier": "npm:^7.22.20" to-fast-properties: "npm:^2.0.0" - checksum: acf791ead82bb220f35cc0cd53c852d96f3fbad14b20964719bae884737b6bb227bfe28c4d16274bee0c8cf0cf3c4c1882d20d894ffc9667dda6eb197ccb4262 + checksum: 07e70bb94d30b0231396b5e9a7726e6d9227a0a62e0a6830c0bd3232f33b024092e3d5a7d1b096a65bbf2bb43a9ab4c721bf618e115bfbb87b454fa060f88cbf languageName: node linkType: hard @@ -5640,8 +5640,8 @@ __metadata: linkType: hard "@v4fire/core@github:V4Fire/Core#v4": - version: 4.0.0-alpha.14 - resolution: "@v4fire/core@https://github.com/V4Fire/Core.git#commit=4496d6e0b33ff872556ebb4730a88eb3069eafa7" + version: 4.0.0-alpha.19 + resolution: "@v4fire/core@https://github.com/V4Fire/Core.git#commit=2ab18049421b9b78811f1ecae7a3f8f712b8e99b" dependencies: "@babel/core": "npm:7.17.5" "@babel/helper-module-transforms": "npm:7.16.7" @@ -5785,7 +5785,7 @@ __metadata: optional: true xhr2: optional: true - checksum: 57c1789417a0dca9289dda4465352727fdd656ab62b70c4b938a233dba61a714ee3d43bd3c929a44d554b581338eab2ffd8179019377688c480c31b29af7484d + checksum: 0588d359c980d39de770f832742395faf50ec2c36566acfd537cdf9e524300020c0d47bb9356866d0fabd20bebcb01143ca4b844a7068171f30854c48011cf9f languageName: node linkType: hard @@ -6282,9 +6282,9 @@ __metadata: linkType: hard "acorn-walk@npm:^8.1.1": - version: 8.3.0 - resolution: "acorn-walk@npm:8.3.0" - checksum: 7673f342db939adc16ac3596c374a56be33e6ef84e01dfb3a0b50cc87cf9b8e46d84c337dcd7d5644f75bf219ad5a36bf33795e9f1af15298e6bceacf46c5f1f + version: 8.3.2 + resolution: "acorn-walk@npm:8.3.2" + checksum: 57dbe2fd8cf744f562431775741c5c087196cd7a65ce4ccb3f3981cdfad25cd24ad2bad404997b88464ac01e789a0a61e5e355b2a84876f13deef39fb39686ca languageName: node linkType: hard @@ -6316,11 +6316,11 @@ __metadata: linkType: hard "acorn@npm:^8.4.1, acorn@npm:^8.5.0": - version: 8.11.2 - resolution: "acorn@npm:8.11.2" + version: 8.11.3 + resolution: "acorn@npm:8.11.3" bin: acorn: bin/acorn - checksum: ff559b891382ad4cd34cc3c493511d0a7075a51f5f9f02a03440e92be3705679367238338566c5fbd3521ecadd565d29301bc8e16cb48379206bffbff3d72500 + checksum: b688e7e3c64d9bfb17b596e1b35e4da9d50553713b3b3630cf5690f2b023a84eac90c56851e6912b483fe60e8b4ea28b254c07e92f17ef83d72d78745a8352dd languageName: node linkType: hard @@ -9428,9 +9428,9 @@ __metadata: linkType: hard "core-js@npm:^3.4": - version: 3.33.3 - resolution: "core-js@npm:3.33.3" - checksum: 77b4c9abaf22ae9c60966121b4b2a4a388cebd067d4cf6ae0f22762b2e8060f301eaacebb781e598ba5f43fe2e53fc88489b013faefdfcecadbf12e242263a50 + version: 3.35.0 + resolution: "core-js@npm:3.35.0" + checksum: 0815fce6bcc91d79d4b28885975453b0faa4d17fc2230635102b4f3832cd621035e4032aa3307e1dbe0ee14d5e34bcb64b507fd89bd8f567aedaf29538522e6a languageName: node linkType: hard @@ -9959,7 +9959,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:*, debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": +"debug@npm:*, debug@npm:4, debug@npm:^4.1.0, debug@npm:^4.1.1, debug@npm:^4.3.1, debug@npm:^4.3.2, debug@npm:^4.3.3, debug@npm:^4.3.4": version: 4.3.4 resolution: "debug@npm:4.3.4" dependencies: