From 59e648913cfd54f7278c8e6f2f230a80791ebf55 Mon Sep 17 00:00:00 2001 From: Matt Lewis Date: Sun, 19 Oct 2025 16:04:27 +0100 Subject: [PATCH] fix(core): Clear lView from IcuIteratorState when stack is empty to prevent memory leak If a component template contains an icu expression it is being retained until the next change detection cycle for that template. This results in a net retention of only ever a single copy of the given lView but that creates an opportunity for compounding leaks. Change the icu i18n_icu_container_visitor to free the IcuIteratorState retained lView when the stack is empty so that garbage collection can occur when the view is discarded. --- .../i18n/i18n_icu_container_visitor.ts | 6 ++++-- packages/core/test/render3/i18n/i18n_spec.ts | 21 +++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/packages/core/src/render3/i18n/i18n_icu_container_visitor.ts b/packages/core/src/render3/i18n/i18n_icu_container_visitor.ts index 62e786263f7..d1544000973 100644 --- a/packages/core/src/render3/i18n/i18n_icu_container_visitor.ts +++ b/packages/core/src/render3/i18n/i18n_icu_container_visitor.ts @@ -15,7 +15,7 @@ import {TIcuContainerNode} from '../interfaces/node'; import {RNode} from '../interfaces/renderer_dom'; import {LView, TVIEW} from '../interfaces/view'; -interface IcuIteratorState { +export interface IcuIteratorState { stack: any[]; index: number; lView?: LView; @@ -35,7 +35,7 @@ function enterIcu(state: IcuIteratorState, tIcu: TIcu, lView: LView) { } } -function icuContainerIteratorNext(state: IcuIteratorState): RNode | null { +export function icuContainerIteratorNext(state: IcuIteratorState): RNode | null { if (state.index < state.removes!.length) { const removeOpCode = state.removes![state.index++] as number; ngDevMode && assertNumber(removeOpCode, 'Expecting OpCode number'); @@ -54,6 +54,8 @@ function icuContainerIteratorNext(state: IcuIteratorState): RNode | null { } } else { if (state.stack.length === 0) { + // Clear the lView reference when iteration completes to allow garbage collection + state.lView = undefined; return null; } else { state.removes = state.stack.pop(); diff --git a/packages/core/test/render3/i18n/i18n_spec.ts b/packages/core/test/render3/i18n/i18n_spec.ts index affde113967..e8f529a1970 100644 --- a/packages/core/test/render3/i18n/i18n_spec.ts +++ b/packages/core/test/render3/i18n/i18n_spec.ts @@ -11,6 +11,10 @@ import { getTranslationForTemplate, i18nStartFirstCreatePass, } from '../../../src/render3/i18n/i18n_parse'; +import { + icuContainerIteratorNext, + IcuIteratorState, +} from '../../../src/render3/i18n/i18n_icu_container_visitor'; import {getTIcu} from '../../../src/render3/i18n/i18n_util'; import {TNodeType} from '../../../src/render3/interfaces/node'; @@ -584,6 +588,23 @@ describe('Runtime i18n', () => { ], }); }); + + it('should clear lView reference when ICU iteration completes', () => { + const state: IcuIteratorState = { + stack: [], + index: 0, + removes: [] as any, + lView: [] as any, // Mock lView + }; + + // Call icuContainerIteratorNext with an empty removes array and empty stack + // This simulates the end of iteration + const result = icuContainerIteratorNext(state); + + expect(result).toBeNull(); + // Verify that lView should be cleared to allow garbage collection + expect(state.lView).toBeUndefined(); + }); }); describe(`i18nAttribute`, () => {