Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions .changeset/empty-et-destroy-cleanup.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---

---

No package release is required because this change only updates the unpublished Element Template runtime path and its internal tests, without changing public APIs, package exports, or release-facing defaults.
Original file line number Diff line number Diff line change
@@ -1,17 +1,83 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

const resetElementTemplateHydrationListener = vi.fn();

vi.mock('../../../src/element-template/background/hydration-listener.js', () => ({
import {
GlobalCommitContext,
markRemovedSubtreeForCurrentCommit,
} from '../../../src/element-template/background/commit-context.js';
import { resetElementTemplateCommitState } from '../../../src/element-template/background/commit-hook.js';
import {
installElementTemplateHydrationListener,
resetElementTemplateHydrationListener,
}));
} from '../../../src/element-template/background/hydration-listener.js';
import { BackgroundElementTemplateInstance } from '../../../src/element-template/background/instance.js';
import { backgroundElementTemplateInstanceManager } from '../../../src/element-template/background/manager.js';
import { ElementTemplateLifecycleConstant } from '../../../src/element-template/protocol/lifecycle-constant.js';
import { ElementTemplateUpdateOps } from '../../../src/element-template/protocol/opcodes.js';
import type { SerializedElementTemplate } from '../../../src/element-template/protocol/types.js';
import { callDestroyLifetimeFun } from '../../../src/element-template/native/callDestroyLifetimeFun.js';
import { __root } from '../../../src/element-template/runtime/page/root-instance.js';
import { ElementTemplateEnvManager } from '../test-utils/debug/envManager.js';

describe('callDestroyLifetimeFun', () => {
it('resets the hydration listener', async () => {
const { callDestroyLifetimeFun } = await import('../../../src/element-template/native/callDestroyLifetimeFun.js');
const envManager = new ElementTemplateEnvManager();

beforeEach(() => {
vi.clearAllMocks();
resetElementTemplateHydrationListener();
resetElementTemplateCommitState();
envManager.resetEnv('background');
});

afterEach(() => {
resetElementTemplateHydrationListener();
resetElementTemplateCommitState();
});

it('destroys background runtime state', () => {
const root = new BackgroundElementTemplateInstance('_et_test');
const child = new BackgroundElementTemplateInstance('_et_child');
root.appendChild(child);
markRemovedSubtreeForCurrentCommit(root);
GlobalCommitContext.ops = [
ElementTemplateUpdateOps.createTemplate,
child.instanceId,
'_et_child',
null,
[],
[],
];

callDestroyLifetimeFun();

expect(resetElementTemplateHydrationListener).toHaveBeenCalledTimes(1);
expect(GlobalCommitContext.nonPayload.removedSubtrees).toEqual([]);
expect(GlobalCommitContext.ops).toEqual([]);
expect(backgroundElementTemplateInstanceManager.values.size).toBe(0);
});

it('removes the hydration listener without processing later hydrate payloads', () => {
installElementTemplateHydrationListener();

const backgroundRoot = __root as BackgroundElementTemplateInstance;
const after = new BackgroundElementTemplateInstance('_et_test');
backgroundRoot.appendChild(after);

callDestroyLifetimeFun();

envManager.switchToMainThread();
lynx.getJSContext().dispatchEvent({
type: ElementTemplateLifecycleConstant.hydrate,
data: [
{
templateKey: '_et_test',
attributeSlots: [],
elementSlots: [],
uid: -1,
} satisfies SerializedElementTemplate,
],
});

envManager.switchToBackground();
expect(backgroundElementTemplateInstanceManager.get(-1)).toBeUndefined();
expect(backgroundElementTemplateInstanceManager.values.size).toBe(0);
});
});
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

import { installOnMtsDestruction, onMtsDestruction } from '../../../src/element-template/native/mts-destroy.js';
import { ElementTemplateRegistry } from '../../../src/element-template/runtime/template/registry.js';

type LynxWithNative = typeof globalThis & {
lynx: {
Expand All @@ -12,6 +13,12 @@ type LynxWithNative = typeof globalThis & {
};

describe('mts-destroy', () => {
afterEach(() => {
ElementTemplateRegistry.clear();
vi.doUnmock('../../../src/element-template/native/patch-listener.js');
vi.doUnmock('../../../src/element-template/runtime/template/registry.js');
});

it('registers and unregisters destruction listener when native exists', () => {
const g = globalThis as LynxWithNative;
const originalGetNative = g.lynx.getNative;
Expand Down Expand Up @@ -48,15 +55,65 @@ describe('mts-destroy', () => {
performance: Partial<typeof lynx.performance>;
};
};
const originalGetNative = g.lynx.getNative;
const originalPerformance = g.lynx.performance;
const removeEventListener = vi.fn();

g.lynx.getNative = () => ({ addEventListener: vi.fn(), removeEventListener });
g.lynx.performance = {};
try {
g.lynx.getNative = () => ({ addEventListener: vi.fn(), removeEventListener });
g.lynx.performance = {};

expect(() => onMtsDestruction()).not.toThrow();
expect(removeEventListener).toHaveBeenCalledWith('__DestroyLifetime', onMtsDestruction);
expect(() => onMtsDestruction()).not.toThrow();
expect(removeEventListener).toHaveBeenCalledWith('__DestroyLifetime', onMtsDestruction);
} finally {
g.lynx.getNative = originalGetNative;
g.lynx.performance = originalPerformance;
}
});

it('clears the element template registry on destruction', () => {
const registryRef = {} as ElementRef;
ElementTemplateRegistry.set(-1, registryRef);
expect(ElementTemplateRegistry.get(-1)).toBe(registryRef);

onMtsDestruction();

expect(ElementTemplateRegistry.get(-1)).toBeUndefined();
});

it('clears registry and removes native listener even when patch listener reset throws', async () => {
vi.resetModules();
const resetError = new Error('patch listener reset failed');
const clear = vi.fn();
const removeEventListener = vi.fn();
const g = globalThis as LynxWithNative;
const originalGetNative = g.lynx.getNative;

vi.doMock('../../../src/element-template/native/patch-listener.js', () => ({
resetElementTemplatePatchListener: vi.fn(() => {
throw resetError;
}),
}));
vi.doMock('../../../src/element-template/runtime/template/registry.js', () => ({
ElementTemplateRegistry: {
clear,
},
}));

try {
g.lynx.getNative = () => ({ addEventListener: vi.fn(), removeEventListener });
const { onMtsDestruction: onMtsDestructionWithThrowingReset } = await import(
'../../../src/element-template/native/mts-destroy.js'
);

g.lynx.performance = originalPerformance;
expect(() => onMtsDestructionWithThrowingReset()).toThrow(resetError);
expect(clear).toHaveBeenCalledTimes(1);
expect(removeEventListener).toHaveBeenCalledWith(
'__DestroyLifetime',
onMtsDestructionWithThrowingReset,
);
} finally {
g.lynx.getNative = originalGetNative;
}
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
installElementTemplateCommitHook,
markElementTemplateHydrated,
resetElementTemplateCommitState,
scheduleElementTemplateRemovedSubtreeCleanup,
} from '../../../../src/element-template/background/commit-hook.js';
import { destroyElementTemplateBackgroundRuntime } from '../../../../src/element-template/background/destroy.js';
import {
installElementTemplateHydrationListener,
resetElementTemplateHydrationListener,
Expand Down Expand Up @@ -181,6 +183,32 @@ describe('ElementTemplate commit hook', () => {
}
});

it('keeps pending removed subtrees when only the hydration listener is reset', () => {
const root = new BackgroundElementTemplateInstance('root');
markRemovedSubtreeForCurrentCommit(root);

resetElementTemplateHydrationListener();

expect(GlobalCommitContext.nonPayload.removedSubtrees).toEqual([root]);
});

it('cancels scheduled removed subtree cleanup on background destroy', () => {
vi.useFakeTimers();
try {
const root = new BackgroundElementTemplateInstance('root');
const tearDown = vi.spyOn(root, 'tearDown');
scheduleElementTemplateRemovedSubtreeCleanup([root]);

destroyElementTemplateBackgroundRuntime();
vi.advanceTimersByTime(10000);

expect(tearDown).not.toHaveBeenCalled();
expect(backgroundElementTemplateInstanceManager.get(root.instanceId)).toBeUndefined();
} finally {
vi.useRealTimers();
}
});

it('resets commit state when update dispatch throws', () => {
vi.useFakeTimers();
const dispatchError = new Error('update dispatch failed');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ describe('ElementTemplate hydration listener', () => {
});

envManager.switchToBackground();
expect(backgroundElementTemplateInstanceManager.get(oldId)).toBe(after);
expect(backgroundElementTemplateInstanceManager.get(oldId)).toBeUndefined();
expect(backgroundElementTemplateInstanceManager.get(-1)).toBeUndefined();
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant

let installed = false;
let hasHydrated = false;
const scheduledRemovedSubtreeCleanupTimers = new Set<ReturnType<typeof setTimeout>>();

export function markElementTemplateHydrated(): void {
hasHydrated = true;
Expand All @@ -39,11 +40,20 @@ export function scheduleElementTemplateRemovedSubtreeCleanup(
if (removedSubtrees.length === 0) {
return;
}
setTimeout(() => {
const timer = setTimeout(() => {
scheduledRemovedSubtreeCleanupTimers.delete(timer);
for (const root of removedSubtrees) {
root.tearDown();
}
}, 10000);
scheduledRemovedSubtreeCleanupTimers.add(timer);
}

export function cancelElementTemplateRemovedSubtreeCleanup(): void {
for (const timer of scheduledRemovedSubtreeCleanupTimers) {
clearTimeout(timer);
}
scheduledRemovedSubtreeCleanupTimers.clear();
}

export function installElementTemplateCommitHook(): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.

import { cancelElementTemplateRemovedSubtreeCleanup, resetElementTemplateCommitState } from './commit-hook.js';
import { resetElementTemplateHydrationListener } from './hydration-listener.js';
import { backgroundElementTemplateInstanceManager } from './manager.js';

export function destroyElementTemplateBackgroundRuntime(): void {
resetElementTemplateHydrationListener();
resetElementTemplateCommitState();
// Destroy is the only place that may discard delayed removed subtrees instead
// of letting the Snapshot-aligned timer tear them down later.
cancelElementTemplateRemovedSubtreeCleanup();
backgroundElementTemplateInstanceManager.clear();
}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ let listener:

export function installElementTemplateHydrationListener(): void {
resetElementTemplateHydrationListener();
resetElementTemplateCommitState();

listener = (event: { data: unknown }) => {
const { data } = event;
Expand Down Expand Up @@ -114,5 +115,4 @@ export function resetElementTemplateHydrationListener(): void {
lynx.getCoreContext().removeEventListener(ElementTemplateLifecycleConstant.hydrate, listener);
}
listener = undefined;
resetElementTemplateCommitState();
}
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
// Copyright 2026 The Lynx Authors. All rights reserved.
// Licensed under the Apache License Version 2.0 that can be found in the
// LICENSE file in the root directory of this source tree.
import { resetElementTemplateHydrationListener } from '../background/hydration-listener.js';
import { destroyElementTemplateBackgroundRuntime } from '../background/destroy.js';

export function callDestroyLifetimeFun(): void {
resetElementTemplateHydrationListener();
destroyElementTemplateBackgroundRuntime();
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// LICENSE file in the root directory of this source tree.

import { resetElementTemplatePatchListener } from './patch-listener.js';
import { ElementTemplateRegistry } from '../runtime/template/registry.js';

export function installOnMtsDestruction(): void {
lynx.getNative?.().addEventListener('__DestroyLifetime', onMtsDestruction);
Expand All @@ -12,9 +13,28 @@ export function onMtsDestruction(): void {
const performance = lynx.performance;
performance?.profileStart?.('ReactLynx::onMtsDestruction');
try {
resetElementTemplatePatchListener();
destroyElementTemplateMainThreadRuntime();
} finally {
performance?.profileEnd?.();
lynx.getNative?.().removeEventListener('__DestroyLifetime', onMtsDestruction);
}
}

export function destroyElementTemplateMainThreadRuntime(): void {
let patchListenerResetError: unknown;
let didPatchListenerResetThrow = false;
try {
resetElementTemplatePatchListener();
} catch (error) {
patchListenerResetError = error;
didPatchListenerResetThrow = true;
}

// The registry is the main-thread strong-reference owner for ET refs. Clear it
// even if listener reset fails so destroy does not leave removed pages retained.
ElementTemplateRegistry.clear();

if (didPatchListenerResetThrow) {
throw patchListenerResetError;
}
}
Loading