From a0e84cf831f5fe441ca7bb8b2fe95cc4318b6550 Mon Sep 17 00:00:00 2001 From: yradex <11014207+Yradex@users.noreply.github.com> Date: Thu, 30 Apr 2026 14:52:47 +0800 Subject: [PATCH] feat(react): add element template alog diagnostics --- .../element-template-alog-diagnostics.md | 5 + .../element-template/debug/alog.test.ts | 108 +++++++++++++ .../runtime/background/commit-hook.test.ts | 38 ++++- .../hydration/hydration-listener.test.ts | 69 ++++++++ .../runtime/patch/patch-listener-alog.test.ts | 82 ++++++++++ .../background/commit-hook.ts | 16 ++ .../background/hydration-listener.ts | 32 ++++ .../src/element-template/debug/alog.ts | 153 ++++++++++++++++++ .../element-template/native/patch-listener.ts | 15 ++ 9 files changed, 517 insertions(+), 1 deletion(-) create mode 100644 .changeset/element-template-alog-diagnostics.md create mode 100644 packages/react/runtime/__test__/element-template/debug/alog.test.ts create mode 100644 packages/react/runtime/__test__/element-template/runtime/patch/patch-listener-alog.test.ts create mode 100644 packages/react/runtime/src/element-template/debug/alog.ts diff --git a/.changeset/element-template-alog-diagnostics.md b/.changeset/element-template-alog-diagnostics.md new file mode 100644 index 0000000000..36c2ca3da3 --- /dev/null +++ b/.changeset/element-template-alog-diagnostics.md @@ -0,0 +1,5 @@ +--- + +--- + +No package release is required because this change only adds Element Template diagnostic ALog output behind the existing debug logging path, without changing public APIs, package exports, runtime/native contracts, or default production behavior. diff --git a/packages/react/runtime/__test__/element-template/debug/alog.test.ts b/packages/react/runtime/__test__/element-template/debug/alog.test.ts new file mode 100644 index 0000000000..722ea460c9 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/debug/alog.test.ts @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, it } from 'vitest'; + +import { BackgroundElementTemplateInstance } from '../../../src/element-template/background/instance.js'; +import { backgroundElementTemplateInstanceManager } from '../../../src/element-template/background/manager.js'; +import { + formatElementTemplateUpdateCommands, + printElementTemplateTreeToString, +} from '../../../src/element-template/debug/alog.js'; +import { ElementTemplateUpdateOps } from '../../../src/element-template/protocol/opcodes.js'; + +describe('ElementTemplate alog helpers', () => { + beforeEach(() => { + backgroundElementTemplateInstanceManager.clear(); + backgroundElementTemplateInstanceManager.nextId = 0; + }); + + it('formats update command streams into readable operations', () => { + expect(formatElementTemplateUpdateCommands([ + ElementTemplateUpdateOps.createTemplate, + 11, + '_et_card', + 'main.js', + ['title'], + [[12]], + ElementTemplateUpdateOps.setAttribute, + 11, + 0, + 'updated', + ElementTemplateUpdateOps.insertNode, + 11, + 1, + 12, + 0, + ElementTemplateUpdateOps.removeNode, + 11, + 1, + 12, + ])).toEqual([ + { + op: 'createTemplate', + handleId: 11, + templateKey: '_et_card', + bundleUrl: 'main.js', + attributeSlots: ['title'], + elementSlots: [[12]], + }, + { + op: 'setAttribute', + targetId: 11, + attrSlotIndex: 0, + value: 'updated', + }, + { + op: 'insertNode', + targetId: 11, + elementSlotIndex: 1, + childId: 12, + referenceId: 0, + }, + { + op: 'removeNode', + targetId: 11, + elementSlotIndex: 1, + childId: 12, + }, + ]); + }); + + it('keeps unknown opcodes readable without throwing', () => { + expect(formatElementTemplateUpdateCommands([99, 'leftover'])).toEqual([ + { + op: 'unknown', + opcode: 99, + index: 0, + remaining: ['leftover'], + }, + ]); + }); + + it('formats missing command streams as an empty list', () => { + expect(formatElementTemplateUpdateCommands(undefined)).toEqual([]); + }); + + it('prints a compact background tree summary', () => { + const root = new BackgroundElementTemplateInstance('root'); + const card = new BackgroundElementTemplateInstance('_et_card', ['title']); + const text = new BackgroundElementTemplateInstance('__et_builtin_raw_text__', ['hello']); + + root.appendChild(card); + card.appendChild(text); + card.elementSlots[0] = [text]; + card.elementSlots[1] = []; + + const output = printElementTemplateTreeToString(root); + + expect(output).toContain('root#1'); + expect(output).toContain('_et_card#2'); + expect(output).toContain('attributeSlots: ["title"]'); + expect(output).toContain('elementSlots[0]: [3]'); + expect(output).not.toContain('elementSlots[1]'); + expect(output).toContain('__et_builtin_raw_text__#3'); + expect(output).toContain('attributeSlots: ["hello"]'); + }); + + it('prints an empty marker for missing roots', () => { + expect(printElementTemplateTreeToString(null)).toBe(''); + }); +}); diff --git a/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts b/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts index 2522b5da9a..7917f243ff 100644 --- a/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/background/commit-hook.test.ts @@ -1,7 +1,8 @@ -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { options } from 'preact'; +import * as elementTemplateAlog from '../../../../src/element-template/debug/alog.js'; import { installElementTemplateCommitHook, markElementTemplateHydrated, @@ -48,6 +49,7 @@ describe('ElementTemplate commit hook', () => { }); afterEach(() => { + globalThis.__ALOG__ = true; envManager.switchToMainThread(); lynx.getJSContext().removeEventListener(ElementTemplateLifecycleConstant.update, onUpdate); envManager.switchToBackground(); @@ -117,6 +119,40 @@ describe('ElementTemplate commit hook', () => { envManager.switchToBackground(); }); + it('logs post-hydration update commits when alog is enabled', () => { + const alog = console.alog as unknown as { mock: { calls: unknown[][] }; mockClear(): void }; + alog.mockClear(); + + markElementTemplateHydrated(); + GlobalCommitContext.ops = createRawTextOps(1, 'hello'); + GlobalCommitContext.flushOptions = { nativeUpdateDataOrder: 7 }; + GlobalCommitContext.flowIds = [101, 202]; + + options.__c?.({} as unknown as object, []); + + const output = alog.mock.calls.map(args => String(args[0])).join('\n'); + expect(output).toContain('[ReactLynxDebug] ElementTemplate BTS -> MTS update'); + expect(output).toContain('createTemplate'); + expect(output).toContain('nativeUpdateDataOrder'); + expect(output).toContain('101'); + }); + + it('does not format update commit alog when alog is disabled', () => { + globalThis.__ALOG__ = false; + const alog = console.alog as unknown as { mock: { calls: unknown[][] }; mockClear(): void }; + alog.mockClear(); + const formatSpy = vi.spyOn(elementTemplateAlog, 'formatElementTemplateUpdateCommands'); + + markElementTemplateHydrated(); + GlobalCommitContext.ops = createRawTextOps(1, 'hello'); + GlobalCommitContext.flushOptions = { nativeUpdateDataOrder: 7 }; + + options.__c?.({} as unknown as object, []); + + expect(formatSpy).not.toHaveBeenCalled(); + expect(alog.mock.calls).toHaveLength(0); + }); + it('is idempotent', () => { installElementTemplateCommitHook(); installElementTemplateCommitHook(); diff --git a/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts b/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts index 4a17e032b2..1271a45651 100644 --- a/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts +++ b/packages/react/runtime/__test__/element-template/runtime/hydration/hydration-listener.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as elementTemplateAlog from '../../../../src/element-template/debug/alog.js'; import { installElementTemplateHydrationListener, resetElementTemplateHydrationListener, @@ -43,6 +44,7 @@ describe('ElementTemplate hydration listener', () => { }); afterEach(() => { + globalThis.__ALOG__ = true; resetElementTemplateHydrationListener(); }); @@ -169,6 +171,73 @@ describe('ElementTemplate hydration listener', () => { ]); }); + it('logs hydrate payload and background tree states when alog is enabled', () => { + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const alog = console.alog as unknown as { mock: { calls: unknown[][] }; mockClear(): void }; + alog.mockClear(); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_test', ['before']); + backgroundRoot.appendChild(after); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_test', + attributeSlots: ['after'], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + + const output = alog.mock.calls.map(args => String(args[0])).join('\n'); + expect(output).toContain('[ReactLynxDebug] ElementTemplate MTS -> BTS hydrate'); + expect(output).toContain('BackgroundElementTemplate tree before hydration'); + expect(output).toContain('BackgroundElementTemplate tree after hydration'); + expect(output).toContain('setAttribute'); + }); + + it('does not format hydrate alog when alog is disabled', () => { + globalThis.__ALOG__ = false; + envManager.switchToBackground(); + installElementTemplateHydrationListener(); + + const alog = console.alog as unknown as { mock: { calls: unknown[][] }; mockClear(): void }; + alog.mockClear(); + const formatSpy = vi.spyOn(elementTemplateAlog, 'formatElementTemplateUpdateCommands'); + const printSpy = vi.spyOn(elementTemplateAlog, 'printElementTemplateTreeToString'); + + const backgroundRoot = __root as BackgroundElementTemplateInstance; + const after = new BackgroundElementTemplateInstance('_et_test', ['before']); + backgroundRoot.appendChild(after); + + envManager.switchToMainThread(); + lynx.getJSContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.hydrate, + data: [ + { + templateKey: '_et_test', + attributeSlots: ['after'], + elementSlots: [], + uid: -1, + } satisfies SerializedElementTemplate, + ], + }); + + envManager.switchToBackground(); + + expect(formatSpy).not.toHaveBeenCalled(); + expect(printSpy).not.toHaveBeenCalled(); + expect(alog.mock.calls).toHaveLength(0); + }); + it('reports illegal handleId 0 during hydrate', () => { envManager.switchToBackground(); installElementTemplateHydrationListener(); diff --git a/packages/react/runtime/__test__/element-template/runtime/patch/patch-listener-alog.test.ts b/packages/react/runtime/__test__/element-template/runtime/patch/patch-listener-alog.test.ts new file mode 100644 index 0000000000..41430d0314 --- /dev/null +++ b/packages/react/runtime/__test__/element-template/runtime/patch/patch-listener-alog.test.ts @@ -0,0 +1,82 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import * as elementTemplateAlog from '../../../../src/element-template/debug/alog.js'; +import { + installElementTemplatePatchListener, + resetElementTemplatePatchListener, +} from '../../../../src/element-template/native/patch-listener.js'; +import { ElementTemplateLifecycleConstant } from '../../../../src/element-template/protocol/lifecycle-constant.js'; +import { ElementTemplateUpdateOps } from '../../../../src/element-template/protocol/opcodes.js'; +import { ElementTemplateEnvManager } from '../../test-utils/debug/envManager.js'; +import { registerBuiltinRawTextTemplate } from '../../test-utils/debug/registry.js'; + +function createRawTextOps(id: number, text: string) { + return [ + ElementTemplateUpdateOps.createTemplate, + id, + '__et_builtin_raw_text__', + null, + [text], + [], + ]; +} + +describe('ElementTemplate patch listener alog', () => { + const envManager = new ElementTemplateEnvManager(); + + beforeEach(() => { + vi.clearAllMocks(); + envManager.resetEnv('main'); + registerBuiltinRawTextTemplate(); + installElementTemplatePatchListener(); + }); + + afterEach(() => { + globalThis.__ALOG__ = true; + resetElementTemplatePatchListener(); + }); + + it('logs decoded update payloads before applying patches', () => { + const alog = console.alog as unknown as { mock: { calls: unknown[][] }; mockClear(): void }; + alog.mockClear(); + + envManager.switchToBackground(() => { + lynx.getCoreContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.update, + data: { + ops: createRawTextOps(1, 'hello'), + flushOptions: { nativeUpdateDataOrder: 7 }, + flowIds: [101, 202], + }, + }); + }); + envManager.switchToMainThread(); + + const output = alog.mock.calls.map(args => String(args[0])).join('\n'); + expect(output).toContain('[ReactLynxDebug] ElementTemplate main-thread patch'); + expect(output).toContain('createTemplate'); + expect(output).toContain('nativeUpdateDataOrder'); + expect(output).toContain('101'); + }); + + it('does not format patch alog when alog is disabled', () => { + globalThis.__ALOG__ = false; + const alog = console.alog as unknown as { mock: { calls: unknown[][] }; mockClear(): void }; + alog.mockClear(); + const formatSpy = vi.spyOn(elementTemplateAlog, 'formatElementTemplateUpdateCommands'); + + envManager.switchToBackground(() => { + lynx.getCoreContext().dispatchEvent({ + type: ElementTemplateLifecycleConstant.update, + data: { + ops: createRawTextOps(1, 'hello'), + flushOptions: { nativeUpdateDataOrder: 7 }, + }, + }); + }); + envManager.switchToMainThread(); + + expect(formatSpy).not.toHaveBeenCalled(); + expect(alog.mock.calls).toHaveLength(0); + }); +}); diff --git a/packages/react/runtime/src/element-template/background/commit-hook.ts b/packages/react/runtime/src/element-template/background/commit-hook.ts index aa96cc6c3b..d28377b8b8 100644 --- a/packages/react/runtime/src/element-template/background/commit-hook.ts +++ b/packages/react/runtime/src/element-template/background/commit-hook.ts @@ -7,6 +7,7 @@ import { options } from 'preact'; import { GlobalCommitContext, resetGlobalCommitContext } from './commit-context.js'; import { COMMIT } from '../../shared/render-constants.js'; import { hook } from '../../utils.js'; +import { formatElementTemplateUpdateCommands } from '../debug/alog.js'; import { profileEnd, profileStart } from '../debug/profile.js'; import { globalPipelineOptions, markTiming, markTimingLegacy, setPipeline } from '../lynx/performance.js'; import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant.js'; @@ -53,6 +54,21 @@ export function installElementTemplateCommitHook(): void { profileEnd(); } + if (typeof __ALOG__ !== 'undefined' && __ALOG__) { + console.alog?.( + '[ReactLynxDebug] ElementTemplate BTS -> MTS update:\n' + + JSON.stringify( + { + ops: formatElementTemplateUpdateCommands(GlobalCommitContext.ops), + flushOptions: GlobalCommitContext.flushOptions, + flowIds: GlobalCommitContext.flowIds, + }, + null, + 2, + ), + ); + } + lynx.getCoreContext().dispatchEvent({ type: ElementTemplateLifecycleConstant.update, data: { diff --git a/packages/react/runtime/src/element-template/background/hydration-listener.ts b/packages/react/runtime/src/element-template/background/hydration-listener.ts index ded4d1a7f8..6da1ba03d7 100644 --- a/packages/react/runtime/src/element-template/background/hydration-listener.ts +++ b/packages/react/runtime/src/element-template/background/hydration-listener.ts @@ -5,6 +5,7 @@ import { GlobalCommitContext, resetGlobalCommitContext } from './commit-context. import { markElementTemplateHydrated, resetElementTemplateCommitState } from './commit-hook.js'; import { hydrateIntoContext } from './hydrate.js'; import { BackgroundElementTemplateInstance } from './instance.js'; +import { formatElementTemplateUpdateCommands, printElementTemplateTreeToString } from '../debug/alog.js'; import { profileEnd, profileStart } from '../debug/profile.js'; import { PerformanceTimingFlags, PipelineOrigins, beginPipeline, markTiming } from '../lynx/performance.js'; import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant.js'; @@ -32,6 +33,17 @@ export function installElementTemplateHydrationListener(): void { const root = __root as BackgroundElementTemplateInstance; resetGlobalCommitContext(); + if (typeof __ALOG__ !== 'undefined' && __ALOG__) { + console.alog?.( + '[ReactLynxDebug] ElementTemplate MTS -> BTS hydrate:\n' + + JSON.stringify({ data: instances }, null, 2), + ); + console.alog?.( + '[ReactLynxDebug] BackgroundElementTemplate tree before hydration:\n' + + printElementTemplateTreeToString(root), + ); + } + let after = root.firstChild; for (const before of instances) { if (!after) { @@ -40,6 +52,12 @@ export function installElementTemplateHydrationListener(): void { hydrateIntoContext(before, after); after = after.nextSibling; } + if (typeof __ALOG__ !== 'undefined' && __ALOG__) { + console.alog?.( + '[ReactLynxDebug] BackgroundElementTemplate tree after hydration:\n' + + printElementTemplateTreeToString(root), + ); + } if (__PROFILE__) { profileEnd(); @@ -49,6 +67,20 @@ export function installElementTemplateHydrationListener(): void { markElementTemplateHydrated(); if (GlobalCommitContext.ops.length > 0) { + if (typeof __ALOG__ !== 'undefined' && __ALOG__) { + console.alog?.( + '[ReactLynxDebug] ElementTemplate hydrate update commands:\n' + + JSON.stringify( + { + ops: formatElementTemplateUpdateCommands(GlobalCommitContext.ops), + flushOptions: GlobalCommitContext.flushOptions, + flowIds: GlobalCommitContext.flowIds, + }, + null, + 2, + ), + ); + } lynx.getCoreContext().dispatchEvent({ type: ElementTemplateLifecycleConstant.update, data: { diff --git a/packages/react/runtime/src/element-template/debug/alog.ts b/packages/react/runtime/src/element-template/debug/alog.ts new file mode 100644 index 0000000000..bcfb087fe4 --- /dev/null +++ b/packages/react/runtime/src/element-template/debug/alog.ts @@ -0,0 +1,153 @@ +// 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 type { BackgroundElementTemplateInstance } from '../background/instance.js'; +import { ElementTemplateUpdateOps } from '../protocol/opcodes.js'; +import type { ElementTemplateUpdateOp } from '../protocol/opcodes.js'; +import type { ElementTemplateUpdateCommandStream } from '../protocol/types.js'; + +export type FormattedElementTemplateUpdateCommand = + | { + op: 'createTemplate'; + handleId: number; + templateKey: string; + bundleUrl: string | null | undefined; + attributeSlots: unknown; + elementSlots: unknown; + } + | { + op: 'setAttribute'; + targetId: number; + attrSlotIndex: number; + value: unknown; + } + | { + op: 'insertNode'; + targetId: number; + elementSlotIndex: number; + childId: number; + referenceId: number; + } + | { + op: 'removeNode'; + targetId: number; + elementSlotIndex: number; + childId: number; + } + | { + op: 'unknown'; + opcode: unknown; + index: number; + remaining: unknown[]; + }; + +export function formatElementTemplateUpdateCommands( + stream: ElementTemplateUpdateCommandStream | undefined, +): FormattedElementTemplateUpdateCommand[] { + if (!Array.isArray(stream)) { + return []; + } + + const result: FormattedElementTemplateUpdateCommand[] = []; + for (let index = 0; index < stream.length;) { + const opIndex = index; + const op = stream[index++] as ElementTemplateUpdateOp; + + switch (op) { + case ElementTemplateUpdateOps.createTemplate: + result.push({ + op: 'createTemplate', + handleId: stream[index++] as number, + templateKey: stream[index++] as string, + bundleUrl: stream[index++] as string | null | undefined, + attributeSlots: stream[index++], + elementSlots: stream[index++], + }); + break; + + case ElementTemplateUpdateOps.setAttribute: + result.push({ + op: 'setAttribute', + targetId: stream[index++] as number, + attrSlotIndex: stream[index++] as number, + value: stream[index++], + }); + break; + + case ElementTemplateUpdateOps.insertNode: + result.push({ + op: 'insertNode', + targetId: stream[index++] as number, + elementSlotIndex: stream[index++] as number, + childId: stream[index++] as number, + referenceId: stream[index++] as number, + }); + break; + + case ElementTemplateUpdateOps.removeNode: + result.push({ + op: 'removeNode', + targetId: stream[index++] as number, + elementSlotIndex: stream[index++] as number, + childId: stream[index++] as number, + }); + break; + + default: + result.push({ + op: 'unknown', + opcode: op, + index: opIndex, + remaining: stream.slice(index), + }); + index = stream.length; + break; + } + } + return result; +} + +export function printElementTemplateTreeToString( + root: BackgroundElementTemplateInstance | null | undefined, +): string { + if (!root) { + return ''; + } + + const lines: string[] = []; + appendInstance(lines, root, 0); + return lines.join('\n'); +} + +function appendInstance( + lines: string[], + instance: BackgroundElementTemplateInstance, + depth: number, +): void { + const indent = ' '.repeat(depth); + const type = instance.type ?? ''; + const instanceId = instance.instanceId ?? ''; + lines.push(`${indent}${type}#${instanceId}`); + + if (Array.isArray(instance.attributeSlots) && instance.attributeSlots.length > 0) { + lines.push(`${indent} attributeSlots: ${JSON.stringify(instance.attributeSlots)}`); + } + + const elementSlots = Array.isArray(instance.elementSlots) ? instance.elementSlots : []; + for (let slotIndex = 0; slotIndex < elementSlots.length; slotIndex += 1) { + const children = elementSlots[slotIndex]; + if (!children || children.length === 0) { + continue; + } + lines.push( + `${indent} elementSlots[${slotIndex}]: [${children.map(child => child.instanceId).join(', ')}]`, + ); + } + + let child = instance.firstChild; + while (child) { + appendInstance(lines, child, depth + 1); + child = child.nextSibling; + } +} diff --git a/packages/react/runtime/src/element-template/native/patch-listener.ts b/packages/react/runtime/src/element-template/native/patch-listener.ts index 6874f30c8d..384fa101a5 100644 --- a/packages/react/runtime/src/element-template/native/patch-listener.ts +++ b/packages/react/runtime/src/element-template/native/patch-listener.ts @@ -2,6 +2,7 @@ // 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 { formatElementTemplateUpdateCommands } from '../debug/alog.js'; import { markTiming, setPipeline } from '../lynx/performance.js'; import { ElementTemplateLifecycleConstant } from '../protocol/lifecycle-constant.js'; import type { ElementTemplateUpdateCommitContext } from '../protocol/types.js'; @@ -38,6 +39,20 @@ export function installElementTemplatePatchListener(): void { } if (hasOps) { + if (typeof __ALOG__ !== 'undefined' && __ALOG__) { + console.alog?.( + '[ReactLynxDebug] ElementTemplate main-thread patch:\n' + + JSON.stringify( + { + ops: formatElementTemplateUpdateCommands(payload.ops), + flushOptions, + flowIds, + }, + null, + 2, + ), + ); + } markTiming('mtsRenderStart'); markTiming('parseChangesStart'); markTiming('parseChangesEnd');