import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { increaseReloadVersion } from '../../../src/core/reload-version.js'; import { setupBackgroundElementTemplateDocument } from '../../../src/element-template/background/document.js'; import { destroyElementTemplateBackgroundRuntime } from '../../../src/element-template/background/destroy.js'; import { installElementTemplateHydrationListener } from '../../../src/element-template/background/hydration-listener.js'; import { BackgroundPageRootInstance } from '../../../src/element-template/background/instance.js'; import { profileEnd, profileStart } from '../../../src/element-template/debug/profile.js'; import { reloadBackground } from '../../../src/element-template/native/reload-background.js'; import { onMtsDestruction } from '../../../src/element-template/native/mts-destroy.js'; import { completeMainThreadReload, reloadMainThread } from '../../../src/element-template/native/reload-main-thread.js'; import { resetEventStateForRuntime } from '../../../src/element-template/prop-adapters/event.js'; import { destroyAllElementTemplateListStates } from '../../../src/element-template/runtime/list/list.js'; import { setupPage } from '../../../src/element-template/runtime/page/page.js'; import { __root, setRoot } from '../../../src/element-template/runtime/page/root-instance.js'; import { elementTemplateRegistry } from '../../../src/element-template/runtime/template/registry.js'; import { resetTemplateId } from '../../../src/element-template/runtime/template/handle.js'; import { attachMainThreadDynamicAttrRefsForSubtree, clearMainThreadDynamicAttrState, getMainThreadDynamicAttrState, initializeMainThreadDynamicAttrSlots, } from '../../../src/element-template/runtime/template/main-thread-dynamic-attr-state.js'; import { __etAttrPlanMap, adaptMTEventAttrSlot, adaptMTRefAttrSlot, clearEtAttrPlanMap, } from '../../../src/element-template/runtime/template/attr-slot-plan.js'; import { renderMainThread } from '../../../src/element-template/runtime/render/render-main-thread.js'; import { renderToElementTemplate as mockRender } from '../../../src/element-template/runtime/render/render-direct.js'; import { render as preactRender } from 'preact'; const mockedState = vi.hoisted(() => ({ page: undefined as unknown, root: {} as { __jsx?: unknown; stale?: boolean }, })); vi.mock('../../../src/core/reload-version.js', () => ({ getReloadVersion: vi.fn(() => 1), increaseReloadVersion: vi.fn(), })); vi.mock('../../../src/element-template/runtime/page/page.js', () => ({ get __page() { return mockedState.page; }, setupPage: vi.fn((page: unknown) => { mockedState.page = page; }), })); vi.mock('../../../src/element-template/runtime/page/root-instance.js', () => ({ get __root() { return mockedState.root; }, setRoot: vi.fn((root: typeof mockedState.root) => { mockedState.root = root; }), })); vi.mock('../../../src/element-template/runtime/render/render-direct.js', () => ({ renderToElementTemplate: vi.fn(), })); vi.mock('../../../src/element-template/runtime/template/registry.js', () => ({ elementTemplateRegistry: { clear: vi.fn(), delete: vi.fn(), get: vi.fn(), }, })); vi.mock('../../../src/element-template/runtime/template/handle.js', async importOriginal => ({ ...await importOriginal(), resetTemplateId: vi.fn(), })); vi.mock('../../../src/element-template/runtime/list/list.js', () => ({ destroyAllElementTemplateListStates: vi.fn(), flushInitialElementTemplateListUpdates: vi.fn(() => []), })); vi.mock('../../../src/element-template/background/destroy.js', () => ({ destroyElementTemplateBackgroundRuntime: vi.fn(), })); vi.mock('../../../src/element-template/background/document.js', () => ({ setupBackgroundElementTemplateDocument: vi.fn(), })); vi.mock('../../../src/element-template/background/hydration-listener.js', () => ({ installElementTemplateHydrationListener: vi.fn(), })); vi.mock('../../../src/element-template/prop-adapters/event.js', () => ({ resetEventStateForRuntime: vi.fn(), })); vi.mock('../../../src/element-template/background/instance.js', () => { class BackgroundElementTemplateInstance { constructor(public type: string) {} } return { BackgroundElementTemplateInstance, BackgroundPageRootInstance: class BackgroundPageRootInstance extends BackgroundElementTemplateInstance { constructor() { super('root'); } }, }; }); vi.mock('../../../src/element-template/native/mts-destroy.js', () => ({ onMtsDestruction: vi.fn(), })); vi.mock('../../../src/element-template/debug/profile.js', () => ({ profileEnd: vi.fn(), profileStart: vi.fn(), })); vi.mock('preact', () => ({ render: vi.fn(), })); describe('ElementTemplate reloadMainThread', () => { beforeEach(() => { vi.clearAllMocks(); mockedState.page = { type: 'page', id: '0', children: [] }; mockedState.root = {}; clearMainThreadDynamicAttrState(); clearEtAttrPlanMap(); vi.stubGlobal('__PROFILE__', false); vi.stubGlobal('__FlushElementTree', vi.fn()); vi.stubGlobal('__InsertNodeToElementTemplate', vi.fn()); vi.stubGlobal('__RemoveNodeFromElementTemplate', vi.fn()); vi.stubGlobal('__SerializeElementTemplate', vi.fn()); vi.stubGlobal('__SetAttributeOfElementTemplate', vi.fn()); vi.mocked(__SerializeElementTemplate).mockReturnValue({ tag: 'page', attributes: null, childSlots: [[]], uid: 0, } as ReturnType); globalThis.lynx = { ...(globalThis.lynx ?? {}), __initData: {}, reportError: vi.fn(), getJSContext: vi.fn(() => ({ dispatchEvent: vi.fn(), })), } as typeof lynx; }); afterEach(() => { vi.unstubAllGlobals(); }); it('rebuilds main-thread ET state and flushes at the root', () => { const jsx = { type: 'App' }; const oldRoot = { __jsx: jsx, stale: true }; mockedState.root = oldRoot; const initData = { msg: 'init', stable: true }; lynx.__initData = initData; const data = { msg: 'reload' }; const options = { reloadTemplate: true, pipelineOptions: { pipelineID: 'reload-1' } }; const page = { type: 'page', id: '0', children: [] }; mockedState.page = page; const oldRootRef = { type: 'old-ref' } as unknown as ElementTemplateHandle; const oldSerializedRoot = { templateKey: '_et_old', attributeSlots: [], childSlots: [], uid: -1, }; const oldSerializedPage = { tag: 'page', attributes: { id: 'background' }, childSlots: [[oldSerializedRoot]], uid: 0, }; const rootRef = { type: 'ref-a' } as unknown as ElementTemplateHandle; const serializedRoot = { templateKey: '_et_reload', attributeSlots: [], childSlots: [], uid: -1, }; const serializedPage = { tag: 'page', attributes: null, childSlots: [[serializedRoot]], uid: 0, }; const dispatchEvent = vi.fn(); const mtRef = { _wvid: 7 }; const updateWorkletRef = vi.fn(); const previousWorkletImpl = globalThis.lynxWorkletImpl; globalThis.lynxWorkletImpl = { ...previousWorkletImpl, _refImpl: { updateWorkletRef, }, } as typeof globalThis.lynxWorkletImpl; try { __etAttrPlanMap._et_old = [0, adaptMTEventAttrSlot, 1, adaptMTRefAttrSlot]; initializeMainThreadDynamicAttrSlots( -1, '_et_old', [ { type: 'worklet', value: { _wkltId: 'old' }, }, { type: 'main-thread-ref', value: mtRef, }, ], ); attachMainThreadDynamicAttrRefsForSubtree([{ uid: -1, ref: oldRootRef }]); expect(getMainThreadDynamicAttrState(-1, 0)).toBeDefined(); expect(getMainThreadDynamicAttrState(-1, 1)).toBeDefined(); vi.mocked(mockRender).mockReturnValueOnce({ pageAttributes: { id: 'background' }, rootRefs: [oldRootRef], rootSubtreeHandles: [[]], }); vi.mocked(__SerializeElementTemplate).mockReturnValueOnce( oldSerializedPage as ReturnType, ); (globalThis.lynx as typeof lynx & { getJSContext?: () => { dispatchEvent: typeof dispatchEvent } }) .getJSContext = vi.fn(() => ({ dispatchEvent, })); renderMainThread(); vi.mocked(__InsertNodeToElementTemplate).mockClear(); vi.mocked(__SetAttributeOfElementTemplate).mockClear(); vi.mocked(__SerializeElementTemplate).mockClear(); vi.mocked(mockRender).mockClear(); dispatchEvent.mockClear(); vi.mocked(mockRender).mockReturnValue({ pageAttributes: null, rootRefs: [rootRef], rootSubtreeHandles: [[]], }); vi.mocked(elementTemplateRegistry.get).mockReturnValue(oldRootRef); vi.mocked(__SerializeElementTemplate) .mockReturnValueOnce(oldSerializedPage as ReturnType) .mockReturnValueOnce(serializedPage as ReturnType); reloadMainThread(data, options); expect(increaseReloadVersion).toHaveBeenCalledTimes(1); expect(lynx.__initData).toBe(initData); expect(lynx.__initData).toEqual({ msg: 'reload', stable: true }); expect(destroyAllElementTemplateListStates).toHaveBeenCalledTimes(1); expect(vi.mocked(destroyAllElementTemplateListStates).mock.invocationCallOrder[0]!).toBeLessThan( vi.mocked(elementTemplateRegistry.clear).mock.invocationCallOrder[0]!, ); expect(elementTemplateRegistry.clear).toHaveBeenCalledTimes(1); expect(getMainThreadDynamicAttrState(-1, 0)).toBeUndefined(); expect(getMainThreadDynamicAttrState(-1, 1)).toBeUndefined(); expect(updateWorkletRef).toHaveBeenCalledWith(mtRef, null); expect(resetTemplateId).toHaveBeenCalledTimes(1); expect(vi.mocked(setupPage)).not.toHaveBeenCalled(); expect(elementTemplateRegistry.get).toHaveBeenCalledWith(-1); expect(__RemoveNodeFromElementTemplate).toHaveBeenCalledWith(page, 0, oldRootRef); expect(__SetAttributeOfElementTemplate).toHaveBeenCalledWith(page, 0, null); expect(vi.mocked(setRoot)).toHaveBeenCalledTimes(1); expect(__root).not.toBe(oldRoot); expect(__root.__jsx).toBe(jsx); expect(__root).not.toHaveProperty('stale'); expect(mockRender).toHaveBeenCalledWith(jsx, undefined); expect(__InsertNodeToElementTemplate).toHaveBeenCalledWith(page, 0, rootRef, null); expect(__SerializeElementTemplate).toHaveBeenCalledTimes(2); expect(__SerializeElementTemplate).toHaveBeenNthCalledWith(1, page); expect(__SerializeElementTemplate).toHaveBeenNthCalledWith(2, page); expect(vi.mocked(__SerializeElementTemplate).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(__RemoveNodeFromElementTemplate).mock.invocationCallOrder[0]!, ); expect(vi.mocked(__RemoveNodeFromElementTemplate).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(elementTemplateRegistry.clear).mock.invocationCallOrder[0]!, ); expect(vi.mocked(__InsertNodeToElementTemplate).mock.invocationCallOrder[0]).toBeLessThan( vi.mocked(__SerializeElementTemplate).mock.invocationCallOrder[1]!, ); expect(dispatchEvent).toHaveBeenCalledWith({ type: 'rLynxElementTemplateHydrate', data: { page: serializedPage, reloadVersion: expect.any(Number), }, }); expect(__FlushElementTree).toHaveBeenCalledWith(undefined, options); } finally { globalThis.lynxWorkletImpl = previousWorkletImpl; } }); it('evaluates the entry again and lets it render into the retained page', () => { vi.mocked(mockRender).mockReturnValue({ pageAttributes: null, rootRefs: [], rootSubtreeHandles: [] }); const supportsReEvalKey = Symbol.for('__LYNX_ENTRY_SUPPORTS_RE_EVAL__'); const reloaderKey = Symbol.for('__LYNX_MAIN_THREAD_ENTRY_RELOADER__'); const completerKey = Symbol.for('__LYNX_MAIN_THREAD_RELOAD_COMPLETER__'); const host = lynx as unknown as Record; vi.stubGlobal('__EXPERIMENTAL_RE_EVAL_JS_ON_RELOAD__', true); const page = mockedState.page; const newJsx = { type: 'NewApp' }; const events: string[] = []; lynx.__initData = { kept: 'yes', count: 1 }; vi.mocked(onMtsDestruction).mockImplementation(() => events.push('retire')); vi.mocked(increaseReloadVersion).mockImplementationOnce(() => { events.push('advance-version'); return 2; }); host[supportsReEvalKey] = true; host[reloaderKey] = () => { events.push('evaluate'); // What evaluating the entry does: reset init data, install its completer, render into a fresh root. lynx.__initData = {}; host[completerKey] = completeMainThreadReload; setRoot({ __jsx: newJsx }); }; const options = { reloadTemplate: true }; try { reloadMainThread({ count: 2 }, options); } finally { delete host[supportsReEvalKey]; delete host[reloaderKey]; delete host[completerKey]; } expect(events).toEqual(['retire', 'advance-version', 'evaluate']); expect(increaseReloadVersion).toHaveBeenCalledOnce(); expect(lynx.__initData).toEqual({ kept: 'yes', count: 2 }); expect(setupPage).toHaveBeenLastCalledWith(page); expect(mockRender).toHaveBeenCalledWith(newJsx, undefined); expect(__FlushElementTree).toHaveBeenLastCalledWith(undefined, options); // The retained page is not rebuilt by the previous entry. expect(elementTemplateRegistry.clear).not.toHaveBeenCalled(); }); it('rebuilds when the physical page has no root slot', () => { mockedState.root = { __jsx: null }; vi.mocked(mockRender).mockReturnValue({ pageAttributes: null, rootRefs: [], rootSubtreeHandles: [], }); vi.mocked(__SerializeElementTemplate) .mockReturnValueOnce({ tag: 'page', attributes: null, childSlots: null, uid: 0, } as ReturnType) .mockReturnValueOnce({ tag: 'page', attributes: null, childSlots: [[]], uid: 0, } as ReturnType); reloadMainThread(undefined, { reloadTemplate: true }); expect(elementTemplateRegistry.get).not.toHaveBeenCalled(); expect(__RemoveNodeFromElementTemplate).not.toHaveBeenCalled(); expect(__SerializeElementTemplate).toHaveBeenCalledTimes(2); }); it('clears delayed runOnBackground tasks during main-thread reload', () => { const delayedBackgroundFunctionArray = [{ task: vi.fn() }]; globalThis.lynxWorkletImpl = { ...(globalThis.lynxWorkletImpl ?? {}), _runOnBackgroundDelayImpl: { delayedBackgroundFunctionArray, clearDelayedBackgroundFunctions: vi.fn(() => { delayedBackgroundFunctionArray.length = 0; }), }, } as typeof globalThis.lynxWorkletImpl; mockedState.root = { __jsx: null }; vi.mocked(mockRender).mockReturnValue({ pageAttributes: null, rootRefs: [], rootSubtreeHandles: [], }); reloadMainThread(undefined, { reloadTemplate: true }); expect(globalThis.lynxWorkletImpl._runOnBackgroundDelayImpl.clearDelayedBackgroundFunctions).toHaveBeenCalledTimes( 1, ); expect(delayedBackgroundFunctionArray).toHaveLength(0); }); it('keeps newly rendered main-thread dynamic attr state when reload flush throws after create succeeds', () => { const jsx = { type: 'App' }; const oldRoot = { __jsx: jsx }; mockedState.root = oldRoot; mockedState.page = { type: 'page', id: '0', children: [] }; const ctx = { _wkltId: 'new' }; const rootRef = { type: 'ref-a' } as unknown as ElementTemplateHandle; vi.mocked(mockRender).mockImplementationOnce(() => { __etAttrPlanMap._et_reload = [0, adaptMTEventAttrSlot]; initializeMainThreadDynamicAttrSlots( -2, '_et_reload', [{ type: 'worklet', value: ctx, }], ); return { pageAttributes: null, rootRefs: [rootRef], rootSubtreeHandles: [[]] }; }); vi.mocked(__FlushElementTree).mockImplementationOnce(() => { throw new Error('flush failed'); }); expect(() => reloadMainThread({ msg: 'reload' }, { reloadTemplate: true })).toThrow('flush failed'); expect(getMainThreadDynamicAttrState(-2, 0)?.nativeHeldValue).toBe(ctx); }); it('cleans each removed root before a later root removal fails', () => { const firstRootRef = { type: 'first-root' } as unknown as ElementTemplateHandle; const firstListRef = { type: 'first-list' } as unknown as ElementTemplateHandle; const firstChildRef = { type: 'first-child' } as unknown as ElementTemplateHandle; const secondRootRef = { type: 'second-root' } as unknown as ElementTemplateHandle; const firstMTRef = { _wvid: 1 }; const firstChildMTRef = { _wvid: 2 }; const secondMTRef = { _wvid: 3 }; const updateWorkletRef = vi.fn(); const previousWorkletImpl = globalThis.lynxWorkletImpl; globalThis.lynxWorkletImpl = { ...previousWorkletImpl, _refImpl: { updateWorkletRef }, } as typeof globalThis.lynxWorkletImpl; try { __etAttrPlanMap._et_first_root = [0, adaptMTRefAttrSlot]; __etAttrPlanMap._et_first_child = [0, adaptMTRefAttrSlot]; __etAttrPlanMap._et_second_root = [0, adaptMTRefAttrSlot]; initializeMainThreadDynamicAttrSlots( -1, '_et_first_root', [{ type: 'main-thread-ref', value: firstMTRef }], ); initializeMainThreadDynamicAttrSlots( -2, '_et_first_child', [{ type: 'main-thread-ref', value: firstChildMTRef }], ); initializeMainThreadDynamicAttrSlots( -3, '_et_second_root', [{ type: 'main-thread-ref', value: secondMTRef }], ); attachMainThreadDynamicAttrRefsForSubtree([ { uid: -1, ref: firstRootRef }, { uid: -2, ref: firstChildRef }, { uid: -3, ref: secondRootRef }, ]); const nativeRefs = new Map([ [-1, firstRootRef], [-2, firstChildRef], [-3, secondRootRef], [-4, firstListRef], ]); vi.mocked(elementTemplateRegistry.get).mockImplementation(id => nativeRefs.get(id)); vi.mocked(__SerializeElementTemplate).mockReturnValueOnce({ tag: 'page', attributes: null, childSlots: [[ { templateKey: '_et_first_root', uid: -1, childSlots: [null, [{ tag: 'list', uid: -4, options: { listChildren: [{ templateKey: '_et_first_child', uid: -2, }], }, }]], }, { templateKey: '_et_second_root', uid: -3, }, ]], uid: 0, } as ReturnType); vi.mocked(__RemoveNodeFromElementTemplate).mockImplementation((_parent, _slot, child) => { if (child === secondRootRef) { throw new Error('remove second root failed'); } }); expect(() => reloadMainThread(undefined, { reloadTemplate: true })).toThrow('remove second root failed'); expect(elementTemplateRegistry.delete).toHaveBeenCalledWith(-1); expect(elementTemplateRegistry.delete).toHaveBeenCalledWith(-4); expect(elementTemplateRegistry.delete).toHaveBeenCalledWith(-2); expect(elementTemplateRegistry.delete).not.toHaveBeenCalledWith(-3); expect(elementTemplateRegistry.clear).not.toHaveBeenCalled(); expect(getMainThreadDynamicAttrState(-1, 0)).toBeUndefined(); expect(getMainThreadDynamicAttrState(-2, 0)).toBeUndefined(); expect(getMainThreadDynamicAttrState(-3, 0)).toEqual({ kind: 'mt-ref', value: secondMTRef, }); expect(updateWorkletRef).toHaveBeenCalledWith(firstMTRef, null); expect(updateWorkletRef).toHaveBeenCalledWith(firstChildMTRef, null); expect(updateWorkletRef).not.toHaveBeenCalledWith(secondMTRef, null); } finally { clearMainThreadDynamicAttrState(); globalThis.lynxWorkletImpl = previousWorkletImpl; } }); it('clears initData before resetPageData main-thread reloads', () => { mockedState.root = { __jsx: { type: 'App' } }; lynx.__initData = { stale: true, msg: 'init' }; mockedState.page = { type: 'page', id: '0', children: [] }; vi.mocked(mockRender).mockReturnValue({ pageAttributes: null, rootRefs: [], rootSubtreeHandles: [], }); reloadMainThread({ msg: 'reset' }, { reloadTemplate: true, resetPageData: true }); expect(lynx.__initData).toEqual({ msg: 'reset' }); }); it('profiles main-thread reload when profiling is enabled', () => { vi.stubGlobal('__PROFILE__', true); mockedState.root = { __jsx: null }; vi.mocked(mockRender).mockReturnValue({ pageAttributes: null, rootRefs: [], rootSubtreeHandles: [], }); reloadMainThread(undefined, { reloadTemplate: true }); expect(profileStart).toHaveBeenCalledWith('ReactLynx::reloadMainThread'); expect(__FlushElementTree).toHaveBeenCalledTimes(1); }); }); describe('ElementTemplate reloadBackground', () => { beforeEach(() => { vi.clearAllMocks(); mockedState.root = {}; globalThis.lynx = { ...(globalThis.lynx ?? {}), __initData: {}, reportError: vi.fn(), getJSContext: vi.fn(), } as typeof lynx; vi.stubGlobal('__PROFILE__', false); }); afterEach(() => { vi.unstubAllGlobals(); }); it('destroys old background state, rebuilds root state and renders saved JSX', () => { const jsx = { type: 'App' }; const oldRoot = { __jsx: jsx, stale: true }; mockedState.root = oldRoot; const initData = { msg: 'init', stable: true }; lynx.__initData = initData; const updateData = { msg: 'reload' }; reloadBackground(updateData); expect(destroyElementTemplateBackgroundRuntime).toHaveBeenCalledTimes(1); expect(increaseReloadVersion).toHaveBeenCalledTimes(1); expect(lynx.__initData).not.toBe(initData); expect(lynx.__initData).toEqual({ msg: 'reload', stable: true }); expect(vi.mocked(setRoot)).toHaveBeenCalledWith(expect.any(BackgroundPageRootInstance)); expect(__root).toBeInstanceOf(BackgroundPageRootInstance); expect(__root).not.toBe(oldRoot); expect(__root.__jsx).toBe(jsx); expect(__root).not.toHaveProperty('stale'); expect(setupBackgroundElementTemplateDocument).toHaveBeenCalledTimes(1); expect(installElementTemplateHydrationListener).toHaveBeenCalledTimes(1); expect(resetEventStateForRuntime).toHaveBeenCalledTimes(1); expect(preactRender).toHaveBeenCalledWith(jsx, __root); }); it('profiles background reload with the Snapshot reload label', () => { vi.stubGlobal('__PROFILE__', true); mockedState.root = { __jsx: null }; reloadBackground(undefined); expect(profileStart).toHaveBeenCalledWith('ReactLynx::reloadBackground'); expect(profileEnd).toHaveBeenCalledTimes(1); }); it('keeps background reload initData object fresh without merging non-object update data', () => { mockedState.root = { __jsx: null }; const initData = { stable: true }; lynx.__initData = initData; reloadBackground('ignored'); expect(lynx.__initData).not.toBe(initData); expect(lynx.__initData).toEqual({ stable: true }); }); });