diff --git a/packages/web-platform/web-core-wasm/AGENTS.md b/packages/web-platform/web-core-wasm/AGENTS.md index 4f1d214a4a..92745bb033 100644 --- a/packages/web-platform/web-core-wasm/AGENTS.md +++ b/packages/web-platform/web-core-wasm/AGENTS.md @@ -131,6 +131,9 @@ This package uses `pnpm` for dependency management and scripts. - **Install Dependencies**: `pnpm install` - **Build WASM**: `pnpm build` (This runs the `scripts/build.js` script, which builds both `client` and `encode` features) - **Test**: `pnpm test` (Uses `vitest` to run tests in `tests/`) + - `tests/encode.spec.ts`: Verifies CSS encoding (build-time). + - `tests/element-apis.spec.ts`: comprehensive tests for `createElementAPI` and DOM interactions. + - `tests/lazy-load.spec.ts`: Verifies the lazy loading mechanism for elements. ### Build Script (`scripts/build.js`) @@ -150,6 +153,15 @@ The `scripts/build.js` script handles the complex build process: - **`wasm.ts`**: Handles the loading of the WASM module. It supports `WebAssembly.compileStreaming` in browsers and falls back for other environments. It exports `wasmInstance` and `wasmModule`. - **`DecodedStyle`**: A wrapper class around the WASM `DecodedStyleData` struct. It provides a convenient API for accessing decoded style information in TypeScript, handling `Uint8Array` decoding and string caching. +### Main Thread Runtime (`ts/client/mainthread`) + +The main thread runtime creates the environment for Lynx views to run in the browser. + +- **`TemplateManager.ts`**: Manages the loading, decoding, and caching of templates. It offloads CPU-intensive decoding tasks to a Web Worker (`decode.worker.js`) to keep the main thread responsive. It handles various template sections (configurations, style info, custom sections) and orchestrates the worker lifecycle. +- **`LynxView.ts`**: Defines the `` custom element. It serves as the entry point for embedding Lynx cards, managing properties like `url`, `initData`, and `globalProps`. It uses `TemplateManager` to fetch bundles and creates `LynxViewInstance` to render content. +- **`LynxViewInstance.ts`**: Represents a single instance of a Lynx view. It manages the lifecycle of the view, handles data updates, and interacts with the Wasm core to render the component tree. +- **`elementAPIs/createElementAPI.ts`**: Bridges the JavaScript runtime with the Wasm `MainThreadWasmContext`. It provides a comprehensive set of APIs (`__CreateView`, `__SetAttribute`, `__AppendElement`, etc.) for direct DOM manipulation and interaction with the Wasm core. It also handles the integration with the lazy loading mechanism ensuring elements are loaded on demand. + ### Encode (`ts/encode`) - **`encodeCSS.ts`**: Provides the `encodeCSS` function, which takes a map of CSS ASTs (from `@lynx-js/css-serializer`) and encodes them into a `Uint8Array` using the `encode` feature of the WASM module. This is used by build tools to serialize CSS. diff --git a/packages/web-platform/web-core-wasm/css/in_shadow.css b/packages/web-platform/web-core-wasm/css/in_shadow.css new file mode 100644 index 0000000000..2bf8528d8a --- /dev/null +++ b/packages/web-platform/web-core-wasm/css/in_shadow.css @@ -0,0 +1,9 @@ +@import url("./linear.css"); +@import url("@lynx-js/web-elements/elements.css"); +[lynx-default-display-linear="false"] * { + --lynx-display: flex; + --lynx-display-toggle: var(--lynx-display-flex); +} +[lynx-default-overflow-visible="true"] x-view { + overflow: visible; +} diff --git a/packages/web-platform/web-core-wasm/css/index.css b/packages/web-platform/web-core-wasm/css/index.css new file mode 100644 index 0000000000..7ca316de77 --- /dev/null +++ b/packages/web-platform/web-core-wasm/css/index.css @@ -0,0 +1,119 @@ +/* +// Copyright 2023 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. +*/ + +lynx-view { + contain: strict; + display: none; +} + +lynx-view[width="auto"] { + --lynx-view-width: 100%; + width: var(--lynx-view-width); + inline-size: var(--lynx-view-width); +} + +lynx-view[height="auto"], lynx-view[width="auto"] { + contain: content; +} + +lynx-view::part(page) { + height: 100%; + width: 100%; +} + +@property --lynx-display { + syntax: "linear | flex"; + inherits: false; + initial-value: linear; +} +@property --lynx-linear-weight-sum { + syntax: ""; + inherits: false; + initial-value: 1; +} +@property --lynx-linear-weight { + syntax: ""; + inherits: false; + initial-value: 0; +} +@property --justify-content-column { + syntax: "flex-start|flex-end|center|space-between|space-around"; + inherits: false; + initial-value: flex-start; +} +@property --justify-content-column-reverse { + syntax: "flex-start|flex-end|center|space-between|space-around"; + inherits: false; + initial-value: flex-start; +} +@property --justify-content-row { + syntax: "flex-start|flex-end|center|space-between|space-around"; + inherits: false; + initial-value: flex-start; +} +@property --justify-content-row-reverse { + syntax: "flex-start|flex-end|center|space-between|space-around"; + inherits: false; + initial-value: flex-start; +} +@property --align-self-row { + syntax: "start|end|center|stretch|auto"; + inherits: false; + initial-value: auto; +} +@property --align-self-column { + syntax: "start|end|center|stretch|auto"; + inherits: false; + initial-value: auto; +} +@property --lynx-linear-weight-basis { + syntax: "auto||"; + inherits: false; + initial-value: auto; +} +@property --lynx-linear-orientation { + syntax: ""; + inherits: false; + initial-value: vertical; +} + +@property --flex-direction { + syntax: "*"; + inherits: false; +} +@property --flex-wrap { + syntax: "*"; + inherits: false; +} +@property --flex-grow { + syntax: ""; + inherits: false; + initial-value: 0; +} +@property --flex-shrink { + syntax: ""; + inherits: false; + initial-value: 1; +} +@property --flex-basis { + syntax: "*"; + inherits: false; + initial-value: auto; +} +@property --flex-value { + syntax: "*"; + inherits: false; +} +@property --flex { + syntax: "*"; + inherits: false; +} + +@property --linear-justify-content { + syntax: "flex-start|flex-end|center|space-between|space-around"; + inherits: false; + initial-value: flex-start; +} diff --git a/packages/web-platform/web-core-wasm/css/linear.css b/packages/web-platform/web-core-wasm/css/linear.css new file mode 100644 index 0000000000..e6716721b8 --- /dev/null +++ b/packages/web-platform/web-core-wasm/css/linear.css @@ -0,0 +1,167 @@ +div * { + display: flex; + box-sizing: border-box; + border-width: 0px; + position: relative; + overflow: clip; + min-width: 0; + min-height: 0; + border-style: solid; + scrollbar-width: none; +} + +x-view::--webkit-scrollbar { + display: none; +} + +/** + * only enable this toggle logic for those container elements + */ +div * { + /* + --lynx-display-toggle is compile-time generated. + */ + --lynx-display-toggle: var(--lynx-display-linear); + --lynx-display-linear: var(--lynx-display-toggle,); + --lynx-display-flex: var(--lynx-display-toggle,); + /* + --lynx-linear-orientation-toggle is compile-time generated. + */ + --lynx-linear-orientation-toggle: var(--lynx-linear-orientation-vertical); + --lynx-linear-orientation-horizontal: var(--lynx-linear-orientation-toggle,); + --lynx-linear-orientation-vertical: var(--lynx-linear-orientation-toggle,); + --lynx-linear-orientation-horizontal-reverse: var( + --lynx-linear-orientation-toggle, + ); + --lynx-linear-orientation-vertical-reverse: var( + --lynx-linear-orientation-toggle, + ); + + --linear-flex-direction: var(--lynx-linear-orientation-horizontal, row) var( + --lynx-linear-orientation-vertical, + column + ) var(--lynx-linear-orientation-horizontal-reverse, row-reverse) var( + --lynx-linear-orientation-vertical-reverse, + column-reverse + ); + --linear-justify-content: var( + --lynx-linear-orientation-horizontal, + var(--justify-content-row) + ) var(--lynx-linear-orientation-vertical, var(--justify-content-column)) var( + --lynx-linear-orientation-horizontal-reverse, + var(--justify-content-row-reverse) + ) var( + --lynx-linear-orientation-vertical-reverse, + var(--justify-content-column-reverse) + ); +} +div * { + flex-wrap: var(--lynx-display-linear, nowrap) + var( + --lynx-display-flex, + var(--flex-wrap) + ); + flex-direction: var(--lynx-display-linear, var(--linear-flex-direction)) + var( + --lynx-display-flex, + var(--flex-direction) + ); + justify-content: var(--lynx-display-linear, var(--linear-justify-content)); +} + +/** For @container + * + * when the chromuim version is less than 116.0.5806.0, the following code will crash: + * ``` + * +
+
+
+ + * ``` + * it fixed in 116.0.5806.0, detail: https://issues.chromium.org/issues/40270007 + * + * so we limit this feature to chrome 117, safari 18, firefox no: + * rex unit: chrome 111, safari 17.2, firefox no + * https://developer.mozilla.org/en-US/docs/Web/CSS/length + * transition-behavior:allow-discrete: chrome 117, safari 18, firefox 125 + * https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior + * https://caniuse.com/mdn-css_properties_display_is_transitionable + * + * update this once firefox supports this. + * + * If you want to be fully compatible with chrome below 117, you need to use a plugin @lynx-js/web-elements-compat. + */ +@supports (content-visibility: auto) and + (transition-behavior: allow-discrete) and (width: 1rex) { + @container style(--lynx-display: linear) { + div * { + /* + `--lynx-linear-weight-sum` + 0 -> 1 + -> + */ + flex-shrink: 0; + /* The following `calc` and `clamp` logic ensures that if + `--lynx-linear-weight-sum` is zero, it defaults to 1. This prevents + division by zero and ensures consistent behavior. */ + flex-grow: calc( + var(--lynx-linear-weight) / + calc( + var(--lynx-linear-weight-sum) + + ( + 1 - clamp(0, var(--lynx-linear-weight-sum) * 999999, 1) + ) + ) + ); + flex-basis: var(--lynx-linear-weight-basis); + } + } + + @container not style(--lynx-display: linear) { + div * { + flex: var( + --flex, + var(--flex-grow) var(--flex-shrink) var(--flex-basis) + ); + } + } + + @container style(--lynx-display: linear) and + (style(--lynx-linear-orientation: vertical) or + style(--lynx-linear-orientation: vertical-reverse)) { + div * { + align-self: var(--align-self-column); + } + } + + @container style(--lynx-display: linear) and + (style(--lynx-linear-orientation: horizontal) or + style(--lynx-linear-orientation: horizontal-reverse)) { + div * { + align-self: var(--align-self-row); + } + } +} diff --git a/packages/web-platform/web-core-wasm/package.json b/packages/web-platform/web-core-wasm/package.json index 8797ced47a..8f373876e2 100644 --- a/packages/web-platform/web-core-wasm/package.json +++ b/packages/web-platform/web-core-wasm/package.json @@ -43,6 +43,11 @@ "last 1 chrome version" ] }, + "dependencies": { + "@lynx-js/web-elements": "workspace:*", + "@lynx-js/web-worker-rpc": "workspace:*", + "hyphenate-style-name": "^1.1.0" + }, "devDependencies": { "@lynx-js/css-serializer": "workspace:*", "@lynx-js/lynx-core": "0.1.3", @@ -51,7 +56,6 @@ "fb-dotslash": "^0.5.8", "jsdom": "^27.4.0", "tslib": "^2.8.1", - "vite-plugin-wasm": "^3.5.0", "wasm-feature-detect": "^1.8.0" }, "peerDependencies": { diff --git a/packages/web-platform/web-core-wasm/tests/I18n.spec.ts b/packages/web-platform/web-core-wasm/tests/I18n.spec.ts new file mode 100644 index 0000000000..8d6b87efa3 --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/I18n.spec.ts @@ -0,0 +1,72 @@ +/* + * Copyright 2025 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 './jsdom.js'; +import { describe, expect, test, vi } from 'vitest'; +import { I18nManager } from '../ts/client/mainthread/I18n.js'; +import { BackgroundThread } from '../ts/client/mainthread/Background.js'; +import { i18nResourceMissedEventName } from '../ts/constants.js'; + +describe('I18nManager', () => { + const mockBackground = { + dispatchI18nResource: vi.fn(), + } as unknown as BackgroundThread; + + const rootDom = document.createElement('div').attachShadow({ mode: 'open' }); + + test('should return matched resource and dispatch to background', () => { + const initData = [ + { + options: { locale: 'en', channel: 'default' }, + resource: { key: 'value' }, + }, + ]; + const i18nManager = new I18nManager(mockBackground, rootDom, initData); + + const result = i18nManager._I18nResourceTranslation({ + locale: 'en', + channel: 'default', + }); + + expect(result).toEqual({ key: 'value' }); + expect(mockBackground.dispatchI18nResource).toHaveBeenCalledWith({ + key: 'value', + }); + }); + + test('should return undefined and trigger fallback when resource not found', () => { + const i18nManager = new I18nManager(mockBackground, rootDom, []); + const dispatchEventSpy = vi.spyOn(rootDom, 'dispatchEvent'); + + const result = i18nManager._I18nResourceTranslation({ + locale: 'fr', + channel: 'default', + }); + + expect(result).toBeUndefined(); + expect(mockBackground.dispatchI18nResource).toHaveBeenCalledWith(undefined); + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + const event = dispatchEventSpy.mock.calls[0]![0] as CustomEvent; + expect(event.type).toBe(i18nResourceMissedEventName); + expect(event.detail).toEqual({ locale: 'fr', channel: 'default' }); + }); + + test('should update data using setData', () => { + const i18nManager = new I18nManager(mockBackground, rootDom, []); + i18nManager.updateData([ + { + options: { locale: 'es', channel: 'default' }, + resource: { key: 'valor' }, + }, + ], { locale: 'es', channel: 'default' }); + + const result = i18nManager._I18nResourceTranslation({ + locale: 'es', + channel: 'default', + }); + + expect(result).toEqual({ key: 'valor' }); + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/StyleManager.spec.ts b/packages/web-platform/web-core-wasm/tests/StyleManager.spec.ts new file mode 100644 index 0000000000..610a1a74cd --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/StyleManager.spec.ts @@ -0,0 +1,118 @@ +// Copyright 2025 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 './jsdom.js'; +import { describe, it, expect, beforeEach, vi, beforeAll } from 'vitest'; +import { encodeCSS } from '../ts/encode/encodeCSS.js'; +import { DecodedStyle } from '../ts/client/wasm.js'; +import * as CSS from '@lynx-js/css-serializer'; + +vi.mock('wasm-feature-detect', () => ({ + referenceTypes: async () => true, +})); + +describe('StyleManager', () => { + let rootNode: HTMLElement; + let styleManager: any; + let StyleManager: any; + + beforeAll(async () => { + const module = await import('../ts/client/mainthread/StyleManager.js'); + StyleManager = module.StyleManager; + }); + + beforeEach(() => { + rootNode = document.createElement('div'); + document.body.appendChild(rootNode); + }); + + it('should create StyleManager', () => { + styleManager = new StyleManager(rootNode); + expect(styleManager).toBeDefined(); + }); + + it('should push style sheet in CSS Selector mode', () => { + styleManager = new StyleManager(rootNode); + const encoded = encodeCSS({ + '0': CSS.parse(` + .test { + color: red; + } + `).root, + }); + const decodedStyle = new DecodedStyle( + DecodedStyle.webWorkerDecode(encoded, true, undefined), + ); + styleManager.pushStyleSheet(decodedStyle); + + const styleElement = rootNode.querySelector('style'); + expect(styleElement).not.toBeNull(); + expect(styleElement?.textContent).toContain('.test'); + expect(styleElement?.textContent).toContain('color:red'); + }); + + it('should push style sheet and populate map in Non-CSS Selector mode', () => { + styleManager = new StyleManager(rootNode); + const encoded = encodeCSS({ + '0': CSS.parse(` + .test-class { + color: red; + width: 100px; + } + `).root, + }); + + const decodedStyle = new DecodedStyle( + DecodedStyle.webWorkerDecode(encoded, false), + ); + + styleManager.pushStyleSheet(decodedStyle, 'entry1'); + }); + + it('should update styles in Non-CSS Selector mode', () => { + styleManager = new StyleManager(rootNode); + + // Setup style info + const encoded = encodeCSS({ + '0': CSS.parse(` + .test-class { + color: red; + width: 100px; + } + `).root, + }); + + const decodedStyle = new DecodedStyle( + DecodedStyle.webWorkerDecode(encoded, false, 'entry1'), + ); + + styleManager.pushStyleSheet(decodedStyle, 'entry1'); + + // updateCssOgStyle + // Signature: updateCssOgStyle(uniqueId, cssId, classNames, entryName) + // We pass array as classNames, which has forEach. + styleManager.updateCssOgStyle(1, 0, ['test-class'] as any, 'entry1'); + + // Check if rule was inserted + const styleElements = rootNode.querySelectorAll('style'); + const styleElement = styleElements[styleElements.length - 1]; + const sheet = styleElement?.sheet; + expect(sheet).toBeDefined(); + if (sheet) { + expect(sheet.cssRules.length).toBe(1); + const rule = sheet.cssRules[0] as CSSStyleRule; + expect(rule.selectorText).toBe('[l-uid="1"]'); + // The implementation joins declarations: finalDeclarations.join('') + // 'color: red; width: 100px;' + expect(rule.style.color).toBe('red'); + expect(rule.style.width).toBe('100px'); + } + + // Update again + styleManager.updateCssOgStyle(1, 0, ['test-class'] as any, 'entry1'); + // Should update existing rule + if (sheet) { + expect(sheet.cssRules.length).toBe(1); + } + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/element-apis.spec.ts b/packages/web-platform/web-core-wasm/tests/element-apis.spec.ts new file mode 100644 index 0000000000..67422ec71a --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/element-apis.spec.ts @@ -0,0 +1,1342 @@ +import './jsdom.js'; +import { describe, test, expect, beforeEach, beforeAll } from 'vitest'; +import { createElementAPI } from '../ts/client/mainthread/elementAPIs/createElementAPI.js'; +import { WASMJSBinding } from '../ts/client/mainthread/elementAPIs/WASMJSBinding.js'; +import { vi } from 'vitest'; +import { cssIdAttribute } from '../ts/constants.js'; +import { templateManager } from '../ts/client/mainthread/TemplateManager.js'; +import { wasmInstance } from '../ts/client/wasm.js'; +import { encodeElementTemplates } from '../ts/encode/encodeElementTemplate.js'; +describe('Element APIs', () => { + let lynxViewDom: HTMLElement; + let rootDom: ShadowRoot; + let mtsGlobalThis: ReturnType; + let mtsBinding: WASMJSBinding; + beforeEach(() => { + vi.resetAllMocks(); + lynxViewDom = document.createElement('div') as unknown as HTMLElement; + rootDom = lynxViewDom.attachShadow({ mode: 'open' }); + + mtsBinding = new WASMJSBinding( + vi.mockObject({ + rootDom, + backgroundThread: vi.mockObject({ + publicComponentEvent: vi.fn(), + publishEvent: vi.fn(), + postTimingFlags: vi.fn(), + markTiming: vi.fn(), + flushTimingInfo: vi.fn(), + jsContext: vi.mockObject({ + dispatchEvent: vi.fn(), + }), + } as any), + exposureServices: vi.mockObject({ + updateExposureStatus: vi.fn(), + }) as any, + loadWebElement: vi.fn(), + loadUnknownElement: vi.fn(), + mainThreadGlobalThis: vi.mockObject({}) as any, + }), + ); + mtsGlobalThis = createElementAPI( + 'test', + rootDom, + mtsBinding, + true, + true, + true, + ); + }); + test('createElementView', () => { + const element = mtsGlobalThis.__CreateElement('view', 0); + expect(mtsGlobalThis.__GetTag(element)).toBe('view'); + }); + test('__CreateComponent', () => { + const ret = mtsGlobalThis.__CreateComponent( + 0, + 'id', + 0, + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + mtsGlobalThis.__UpdateComponentID(ret, 'id'); + expect(mtsGlobalThis.__GetComponentID(ret)).toBe('id'); + expect(mtsGlobalThis.__GetAttributeByName(ret, 'name')).toBe('name'); + }); + + test('__CreateView', () => { + const ret = mtsGlobalThis.__CreateView(0); + expect(mtsGlobalThis.__GetTag(ret)).toBe('view'); + }); + + test('__CreateScrollView', () => { + const ret = mtsGlobalThis.__CreateScrollView(0); + expect(mtsGlobalThis.__GetTag(ret)).toBe('scroll-view'); + }); + + test('create-scroll-view-with-set-attribute', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let ret = mtsGlobalThis.__CreateScrollView(0); + mtsGlobalThis.__SetAttribute(ret, 'scroll-x', true); + mtsGlobalThis.__AppendElement(root, ret); + mtsGlobalThis.__FlushElementTree(); + expect(mtsGlobalThis.__GetAttributeByName(ret, 'scroll-x')).toBe('true'); + expect(rootDom.querySelector('scroll-view')?.getAttribute('scroll-x')).toBe( + 'true', + ); + }); + + test('__SetID', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let ret = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__SetID(ret, 'target'); + mtsGlobalThis.__AppendElement(root, ret); + mtsGlobalThis.__FlushElementTree(); + expect(rootDom.querySelector('#target')).not.toBeNull(); + }); + + test('__SetID to remove id', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let ret = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__SetID(ret, 'target'); + mtsGlobalThis.__AppendElement(root, ret); + mtsGlobalThis.__FlushElementTree(); + expect(mtsGlobalThis.__GetAttributeByName(ret, 'id')).toBe('target'); + expect(rootDom.querySelector('#target')).not.toBeNull(); + mtsGlobalThis.__SetID(ret, null); + expect(mtsGlobalThis.__GetAttributeByName(ret, 'id')).toBe(null); + expect(rootDom.querySelector('#target')).toBeNull(); + }); + + test('__CreateText', () => { + const ret = mtsGlobalThis.__CreateText(0); + expect(mtsGlobalThis.__GetTag(ret)).toBe('text'); + }); + + test('__CreateImage', () => { + const ret = mtsGlobalThis.__CreateImage(0); + expect(mtsGlobalThis.__GetTag(ret)).toBe('image'); + }); + + test('__CreateRawText', () => { + const ret = mtsGlobalThis.__CreateRawText('content'); + expect(mtsGlobalThis.__GetTag(ret)).toBe('raw-text'); + expect(mtsGlobalThis.__GetAttributeByName(ret, 'text')).toBe('content'); + }); + + test('__CreateWrapperElement', () => { + const ret = mtsGlobalThis.__CreateWrapperElement(0); + expect(mtsGlobalThis.__GetTag(ret)).toBe('wrapper'); + }); + + test('__AppendElement-children-count', () => { + let ret = mtsGlobalThis.__CreateView(0); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AppendElement(ret, child_0); + mtsGlobalThis.__AppendElement(ret, child_1); + expect(mtsGlobalThis.__GetChildren(ret).length).toBe(2); + }); + + test('__AppendElement-__RemoveElement', () => { + let ret = mtsGlobalThis.__CreateView(0); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AppendElement(ret, child_0); + mtsGlobalThis.__AppendElement(ret, child_1); + mtsGlobalThis.__RemoveElement(ret, child_0); + expect(mtsGlobalThis.__GetChildren(ret).length).toBe(1); + }); + + test('__InsertElementBefore', () => { + let ret = mtsGlobalThis.__CreateView(0); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__InsertElementBefore(ret, child_0, null); + mtsGlobalThis.__InsertElementBefore(ret, child_1, child_0); + mtsGlobalThis.__InsertElementBefore(ret, child_2, child_1); + const children = mtsGlobalThis.__GetChildren(ret); + expect(children.length).toBe(3); + expect(mtsGlobalThis.__GetTag(children[0]!)).toBe('text'); + expect(mtsGlobalThis.__GetTag(children[1]!)).toBe('image'); + }); + + test('__FirstElement', () => { + let root = mtsGlobalThis.__CreateView(0); + let ret0 = mtsGlobalThis.__FirstElement(root); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__InsertElementBefore(root, child_0, null); + mtsGlobalThis.__InsertElementBefore(root, child_1, child_0); + mtsGlobalThis.__InsertElementBefore(root, child_2, child_1); + let ret1 = mtsGlobalThis.__FirstElement(root); + expect(ret0).toBeFalsy(); + expect(mtsGlobalThis.__GetTag(ret1!)).toBe('text'); + }); + + test('__LastElement', () => { + let root = mtsGlobalThis.__CreateView(0); + let ret0 = mtsGlobalThis.__LastElement(root); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__InsertElementBefore(root, child_0, null); + mtsGlobalThis.__InsertElementBefore(root, child_1, child_0); + mtsGlobalThis.__InsertElementBefore(root, child_2, child_1); + let ret1 = mtsGlobalThis.__LastElement(root); + expect(ret0).toBeFalsy(); + expect(mtsGlobalThis.__GetTag(ret1!)).toBe('view'); + }); + + test('__NextElement', () => { + let root = mtsGlobalThis.__CreateView(0); + let ret0 = mtsGlobalThis.__NextElement(root); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__InsertElementBefore(root, child_0, null); + mtsGlobalThis.__InsertElementBefore(root, child_1, child_0); + mtsGlobalThis.__InsertElementBefore(root, child_2, child_1); + let ret1 = mtsGlobalThis.__NextElement(mtsGlobalThis.__FirstElement(root)!); + expect(ret0).toBeFalsy(); + expect(mtsGlobalThis.__GetTag(ret1!)).toBe('image'); + }); + + test('__ReplaceElement', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let ret0 = mtsGlobalThis.__NextElement(root); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + let child_3 = mtsGlobalThis.__CreateScrollView(0); + mtsGlobalThis.__InsertElementBefore(root, child_0, null); + mtsGlobalThis.__InsertElementBefore(root, child_1, child_0); + mtsGlobalThis.__InsertElementBefore(root, child_2, child_1); + mtsGlobalThis.__ReplaceElement(child_3, child_1); + let ret1 = mtsGlobalThis.__NextElement(mtsGlobalThis.__FirstElement(root)!); + mtsGlobalThis.__FlushElementTree(); + expect(ret0).toBeFalsy(); + expect(mtsGlobalThis.__GetTag(ret1!)).toBe('scroll-view'); + }); + + test('__SwapElement', () => { + let root = mtsGlobalThis.__CreateView(0); + let ret = root; + let ret0 = mtsGlobalThis.__NextElement(root); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__AppendElement(root, child_0); + mtsGlobalThis.__AppendElement(root, child_1); + mtsGlobalThis.__AppendElement(root, child_2); + mtsGlobalThis.__SwapElement(child_0, child_1); + const children = mtsGlobalThis.__GetChildren(ret); + expect(ret0).toBeFalsy(); + expect(mtsGlobalThis.__GetTag(children[0]!)).toBe('image'); + expect(mtsGlobalThis.__GetTag(children[1]!)).toBe('view'); + }); + + test('__GetParent', () => { + let root = mtsGlobalThis.__CreateView(0); + let ret0 = mtsGlobalThis.__NextElement(root); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__AppendElement(root, child_0); + mtsGlobalThis.__AppendElement(root, child_1); + mtsGlobalThis.__AppendElement(root, child_2); + let ret1 = mtsGlobalThis.__GetParent(child_0); + expect(ret1).toBeTruthy(); + }); + + test('__GetChildren', () => { + let root = mtsGlobalThis.__CreateView(0); + let ret0 = mtsGlobalThis.__NextElement(root); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__AppendElement(root, child_0); + mtsGlobalThis.__AppendElement(root, child_1); + mtsGlobalThis.__AppendElement(root, child_2); + let ret1 = mtsGlobalThis.__GetChildren(root); + expect(ret0).toBeFalsy(); + expect(Array.isArray(ret1)).toBe(true); + expect(ret1?.length).toBe(3); + }); + + test('__ElementIsEqual', () => { + let node1 = mtsGlobalThis.__CreateView(0); + let node2 = mtsGlobalThis.__CreateView(0); + let node3 = node1; + let ret0 = mtsGlobalThis.__ElementIsEqual(node1, node2); + let ret1 = mtsGlobalThis.__ElementIsEqual(node1, node3); + let ret2 = mtsGlobalThis.__ElementIsEqual(node1, null); + expect(ret0).toBe(false); + expect(ret1).toBe(true); + expect(ret2).toBe(false); + }); + + test('__GetElementUniqueID', () => { + let node1 = mtsGlobalThis.__CreateView(0); + let node2 = mtsGlobalThis.__CreateView(0); + let ret0 = mtsGlobalThis.__GetElementUniqueID(node1); + let ret1 = mtsGlobalThis.__GetElementUniqueID(node2); + expect(ret0 + 1).toBe(ret1); + }); + + test('__GetAttributes', () => { + let node1 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__SetAttribute(node1, 'test', 'test-value'); + let attr_map = mtsGlobalThis.__GetAttributes(node1); + expect(attr_map['test']).toEqual('test-value'); + let page = mtsGlobalThis.__CreatePage('page', 0); + mtsGlobalThis.__AppendElement(page, node1); + mtsGlobalThis.__FlushElementTree(); + expect(rootDom.querySelector('[test="test-value"]')).not.toBeNull(); + }); + + test('__GetAttributeByName', () => { + const page = mtsGlobalThis.__CreatePage('page', 0); + mtsGlobalThis.__SetAttribute(page, 'test-attr', 'val'); + mtsGlobalThis.__FlushElementTree(); + expect(mtsGlobalThis.__GetAttributeByName(page, 'test-attr')).toBe('val'); + expect( + rootDom.querySelector('[test-attr="val"]'), + ).not.toBeNull(); + }); + + test('__SetDataset', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let node1 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__SetDataset(node1, { 'test': 'test-value' }); + let ret_0 = mtsGlobalThis.__GetDataset(node1); + mtsGlobalThis.__AddDataset(node1, 'test1', 'test-value1'); + let ret_2 = mtsGlobalThis.__GetDataByKey(node1, 'test1'); + mtsGlobalThis.__AppendElement(root, node1); + mtsGlobalThis.__AppendElement(root, node1); + mtsGlobalThis.__FlushElementTree(); + expect(ret_0).toEqual({ 'test': 'test-value' }); + expect(ret_2).toBe('test-value1'); + expect(rootDom.querySelector('[data-test="test-value"]')).not.toBeNull(); + expect(rootDom.querySelector('[data-test1="test-value1"]')).not.toBeNull(); + }); + + test('__GetClasses', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let node1 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__AddClass(node1, 'a'); + mtsGlobalThis.__AddClass(node1, 'b'); + mtsGlobalThis.__AddClass(node1, 'c'); + let class_1 = mtsGlobalThis.__GetClasses(node1); + expect(class_1.length).toBe(3); + expect(class_1).toStrictEqual(['a', 'b', 'c']); + mtsGlobalThis.__AppendElement(root, node1); + mtsGlobalThis.__FlushElementTree(); + expect(rootDom.querySelector('[class="a b c"]')).not.toBeNull(); + mtsGlobalThis.__SetClasses(node1, 'c b a'); + let class_2 = mtsGlobalThis.__GetClasses(node1); + mtsGlobalThis.__FlushElementTree(); + expect(class_2.length).toBe(3); + expect(class_2).toStrictEqual(['c', 'b', 'a']); + }); + + test('__UpdateComponentID', () => { + let e1 = mtsGlobalThis.__CreateComponent( + 0, + 'id1', + 0, + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + let e2 = mtsGlobalThis.__CreateComponent( + 0, + 'id2', + 0, + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + mtsGlobalThis.__UpdateComponentID(e1, 'id2'); + mtsGlobalThis.__UpdateComponentID(e2, 'id1'); + expect(mtsGlobalThis.__GetComponentID(e1)).toBe('id2'); + expect(mtsGlobalThis.__GetComponentID(e2)).toBe('id1'); + }); + + test('__SetInlineStyles', () => { + const root = mtsGlobalThis.__CreatePage('page', 0); + let target = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__SetID(target, 'target'); + mtsGlobalThis.__SetInlineStyles(target, undefined); + mtsGlobalThis.__SetInlineStyles(target, { + 'margin': '10px', + 'marginTop': '20px', + 'marginLeft': '30px', + 'marginRight': '20px', + 'marginBottom': '10px', + }); + mtsGlobalThis.__AppendElement(root, target); + mtsGlobalThis.__FlushElementTree(); + const targetDom = rootDom.querySelector('#target') as HTMLElement; + const targetStyle = targetDom.getAttribute('style'); + expect(targetStyle).toContain('20px'); + expect(targetStyle).toContain('30px'); + expect(targetStyle).toContain('10px'); + }); + + test('__GetConfig__AddConfig', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + mtsGlobalThis.__AddConfig(root, 'key1', 'value1'); + mtsGlobalThis.__AddConfig(root, 'key2', 'value2'); + mtsGlobalThis.__AddConfig(root, 'key3', 'value3'); + mtsGlobalThis.__FlushElementTree(); + let config = mtsGlobalThis.__GetConfig(root); + expect(config['key1']).toBe('value1'); + expect(config['key2']).toBe('value2'); + expect(config['key3']).toBe('value3'); + }); + + test('__AddInlineStyle', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + mtsGlobalThis.__AddInlineStyle(root, 26, '80px'); + mtsGlobalThis.__FlushElementTree(); + const rootDomElement = rootDom.firstElementChild as HTMLElement; + expect(rootDomElement.style.height).toBe('80px'); + }); + + test('__AddInlineStyle_key_is_name', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + mtsGlobalThis.__AddInlineStyle(root, 'height', '80px'); + mtsGlobalThis.__FlushElementTree(); + const rootDomElement = rootDom.firstElementChild as HTMLElement; + expect(rootDomElement.style.height).toBe('80px'); + }); + + test('__AddInlineStyle_raw_string', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + mtsGlobalThis.__SetInlineStyles(root, 'height:80px'); + mtsGlobalThis.__FlushElementTree(); + const rootDomElement = rootDom.firstElementChild as HTMLElement; + expect(rootDomElement.style.height).toBe('80px'); + }); + + test('complicated_dom_tree_opt', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + + let view_0 = mtsGlobalThis.__CreateView(0); + let view_1 = mtsGlobalThis.__CreateView(0); + let view_2 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_2]!, null); + + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_2, + ), + ).toBe(true); + let view_3 = mtsGlobalThis.__CreateView(0); + let view_4 = mtsGlobalThis.__CreateView(0); + let view_5 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements(root, [view_3, view_4, view_5]!, [ + view_0, + view_1, + view_2, + ]); + + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_3, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_4, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_5, + ), + ).toBe(true); + mtsGlobalThis.__FlushElementTree(); + + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_2]!, [ + view_3, + view_4, + view_5, + ]); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_2, + ), + ).toBe(true); + expect(mtsGlobalThis.__GetChildren(root)!.length).toBe(3); + + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_2]!, [ + view_0, + view_1, + view_2, + ]); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_2, + ), + ).toBe(true); + expect(mtsGlobalThis.__GetChildren(root)!.length).toBe(3); + + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_2]!, [ + view_0, + view_1, + view_2, + ]); + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_2]!, [ + view_0, + view_1, + view_2, + ]); + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_2]!, [ + view_0, + view_1, + view_2, + ]); + + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_2, + ), + ).toBe(true); + mtsGlobalThis.__FlushElementTree(); + }); + + test('__ReplaceElements', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let view_0 = mtsGlobalThis.__CreateView(0); + let view_1 = mtsGlobalThis.__CreateView(0); + let view_2 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_2]!, null); + expect(mtsGlobalThis.__GetChildren(root)!.length).toBe(3); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_2, + ), + ).toBe(true); + mtsGlobalThis.__ReplaceElements(root, [view_2, view_1, view_0]!, [ + view_0, + view_1, + view_2, + ]); + expect(mtsGlobalThis.__GetChildren(root)!.length).toBe(3); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_2, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_0, + ), + ).toBe(true); + mtsGlobalThis.__FlushElementTree(); + }); + + test('__ReplaceElements_2', () => { + let res = true; + let root = mtsGlobalThis.__CreatePage('page', 0); + let view_0 = mtsGlobalThis.__CreateView(0); + let view_1 = mtsGlobalThis.__CreateView(0); + let view_2 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_2]!, null); + expect(mtsGlobalThis.__GetChildren(root)!.length).toBe(3); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_2, + ), + ).toBe(true); + mtsGlobalThis.__FlushElementTree(); + + let view_3 = mtsGlobalThis.__CreateView(0); + let view_4 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements(root, [view_0, view_1, view_3, view_4]!, [ + view_0, + view_1, + ]); + expect(mtsGlobalThis.__GetChildren(root)!.length).toBe(5); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_3, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![3]!, + view_4, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![4]!, + view_2, + ), + ).toBe(true); + mtsGlobalThis.__FlushElementTree(); + + let view_5 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements(root, [view_5]!, null); + mtsGlobalThis.__FlushElementTree(); + expect(mtsGlobalThis.__GetChildren(root)!.length).toBe(6); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![5]!, + view_5, + ), + ).toBe(true); + + let view_6 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements(root, [view_6], [view_3]); + mtsGlobalThis.__FlushElementTree(); + + expect(mtsGlobalThis.__GetChildren(root)!.length).toBe(6); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_6, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![3]!, + view_4, + ), + ).toBe(true); + }); + + test('__ReplaceElements_3', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let view_0 = mtsGlobalThis.__CreateView(0); + let view_1 = mtsGlobalThis.__CreateView(0); + let view_2 = mtsGlobalThis.__CreateView(0); + let view_3 = mtsGlobalThis.__CreateView(0); + let view_4 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements( + root, + [view_0, view_1, view_2, view_3, view_4]!, + null, + ); + mtsGlobalThis.__FlushElementTree(); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_2, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![3]!, + view_3, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![4]!, + view_4, + ), + ).toBe(true); + + mtsGlobalThis.__ReplaceElements(root, [view_1, view_0, view_2]!, [ + view_0, + view_1, + view_2, + ]); + mtsGlobalThis.__FlushElementTree(); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_2, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![3]!, + view_3, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![4]!, + view_4, + ), + ).toBe(true); + + mtsGlobalThis.__ReplaceElements(root, [view_1, view_0, view_3, view_2]!, [ + view_1, + view_0, + view_2, + view_3, + ]); + mtsGlobalThis.__FlushElementTree(); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![0]!, + view_1, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![1]!, + view_0, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![2]!, + view_3, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![3]!, + view_2, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![4]!, + view_4, + ), + ).toBe(true); + + let view_5 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__ReplaceElements(root, [view_5]!, null); + mtsGlobalThis.__FlushElementTree(); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)![5]!, + view_5, + ), + ).toBe(true); + + mtsGlobalThis.__ReplaceElements( + root, + [view_1, view_3, view_2, view_0, view_4]!, + [view_1, view_0, view_3, view_2, view_4]!, + ); + expect( + mtsGlobalThis.__GetChildren(root), + ).toStrictEqual([ + view_1, + view_3, + view_2, + view_0, + view_4, + view_5, + ]); + }); + + test('with_querySelector', () => { + let page = mtsGlobalThis.__CreatePage('0', 0); + let parent = mtsGlobalThis.__CreateComponent( + 0, + 'id1', + 0, + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + mtsGlobalThis.__AppendElement(page, parent); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateView(0); + let child_component = mtsGlobalThis.__CreateComponent( + mtsGlobalThis.__GetElementUniqueID(parent), + 'id2', + 0, + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + let child_2 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AppendElement(parent, child_0); + mtsGlobalThis.__AppendElement(parent, child_1); + mtsGlobalThis.__AppendElement(parent, child_component); + mtsGlobalThis.__AppendElement(child_component, child_2); + mtsGlobalThis.__SetID(child_1, 'node_id'); + mtsGlobalThis.__SetID(child_2, 'node_id_2'); + + mtsGlobalThis.__FlushElementTree(); + let ret_node = rootDom.querySelector('#node_id'); + let ret_id = ret_node?.getAttribute('id'); + + let ret_u = rootDom.querySelector('#node_id_u'); + + let ret_child = rootDom.querySelector('#node_id_2'); + let ret_child_id = ret_child?.getAttribute('id'); + + expect(ret_id).toBe('node_id'); + expect(ret_u).toBe(null); + expect(ret_child_id).toBe('node_id_2'); + }); + + test('__setAttribute_null_value', () => { + const ret = mtsGlobalThis.__CreatePage('page', 0); + mtsGlobalThis.__SetAttribute(ret, 'test-attr', 'val'); + mtsGlobalThis.__SetAttribute(ret, 'test-attr', null); + mtsGlobalThis.__FlushElementTree(); + expect(rootDom.querySelector('[test-attr="val"]')).toBeNull(); + }); + + test('__ReplaceElements should accept not array', () => { + let root = mtsGlobalThis.__CreatePage('page', 0); + let ret0 = mtsGlobalThis.__NextElement(root); + let child_0 = mtsGlobalThis.__CreateView(0); + let child_1 = mtsGlobalThis.__CreateImage(0); + let child_2 = mtsGlobalThis.__CreateText(0); + let child_3 = mtsGlobalThis.__CreateScrollView(0); + mtsGlobalThis.__InsertElementBefore(root, child_0, null); + mtsGlobalThis.__InsertElementBefore(root, child_1, child_0); + mtsGlobalThis.__InsertElementBefore(root, child_2, child_1); + mtsGlobalThis.__AppendElement(root, child_3); + mtsGlobalThis.__ReplaceElements( + mtsGlobalThis.__GetParent(child_3)!, + child_3, + child_1, + ); + mtsGlobalThis.__FlushElementTree(); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)[0]!, + child_2, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)[1]!, + child_3, + ), + ).toBe(true); + expect( + mtsGlobalThis.__ElementIsEqual( + mtsGlobalThis.__GetChildren(root)[2]!, + child_0, + ), + ).toBe(true); + let ret1 = mtsGlobalThis.__NextElement(mtsGlobalThis.__FirstElement(root)!); + mtsGlobalThis.__ReplaceElements( + mtsGlobalThis.__GetParent(child_1)!, + child_1, + child_1, + ); + mtsGlobalThis.__ReplaceElements( + mtsGlobalThis.__GetParent(child_1)!, + child_1, + child_1, + ); + expect(ret0).toBeFalsy(); + expect(mtsGlobalThis.__GetTag(ret1!)).toBe('scroll-view'); + }); + + test('create element infer css id from parent component id', () => { + const root = mtsGlobalThis.__CreatePage('page', 0); + const parentComponent = mtsGlobalThis.__CreateComponent( + 0, + 'id', + 100, // cssid + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + const parentComponentUniqueId = mtsGlobalThis.__GetElementUniqueID( + parentComponent, + ); + const view = mtsGlobalThis.__CreateText(parentComponentUniqueId); + + mtsGlobalThis.__AppendElement(root, view); + mtsGlobalThis.__SetID(view, 'target'); + mtsGlobalThis.__AppendElement(root, parentComponent); + mtsGlobalThis.__FlushElementTree(); + expect(rootDom.querySelector('#target')?.getAttribute(cssIdAttribute)).toBe( + '100', + ); + }); + + test('create element wont infer for cssid 0', () => { + const root = mtsGlobalThis.__CreatePage('page', 0); + const parentComponent = mtsGlobalThis.__CreateComponent( + 0, + 'id', + 0, // cssid + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + const parentComponentUniqueId = mtsGlobalThis.__GetElementUniqueID( + parentComponent, + ); + const view = mtsGlobalThis.__CreateText(parentComponentUniqueId); + + mtsGlobalThis.__AppendElement(root, view); + mtsGlobalThis.__SetID(view, 'target'); + mtsGlobalThis.__AppendElement(root, parentComponent); + mtsGlobalThis.__FlushElementTree(); + expect(rootDom.querySelector('#target')?.hasAttribute(cssIdAttribute)).toBe( + false, + ); + }); + + test('__GetElementUniqueID for incorrect fiber object', () => { + const root = mtsGlobalThis.__CreatePage('page', 0); + const parentComponent = mtsGlobalThis.__CreateComponent( + 0, + 'id', + 0, // cssid + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + const list = mtsGlobalThis.__CreateList( + 0, + () => {}, + () => {}, + ); + mtsGlobalThis.__FlushElementTree(); + const rootId = mtsGlobalThis.__GetElementUniqueID(root); + const parentComponentId = mtsGlobalThis.__GetElementUniqueID( + parentComponent, + ); + const listId = mtsGlobalThis.__GetElementUniqueID(list); + // @ts-expect-error + const nul = mtsGlobalThis.__GetElementUniqueID(null); + // @ts-expect-error + const undef = mtsGlobalThis.__GetElementUniqueID(undefined); + const randomObject = mtsGlobalThis.__GetElementUniqueID({} as any); + expect(rootId).toBeGreaterThanOrEqual(0); + expect(parentComponentId).toBeGreaterThanOrEqual(0); + expect(listId).toBeGreaterThanOrEqual(0); + expect(nul).toBe(-1); + expect(undef).toBe(-1); + expect(randomObject).toBe(-1); + }); + + test('__AddInlineStyle_value_number_0', () => { + const root = mtsGlobalThis.__CreatePage('page', 0); + const view = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AddInlineStyle(root, 24, 'flex'); // display: flex + mtsGlobalThis.__AddInlineStyle(view, 51, 0); // flex-shrink:0; + mtsGlobalThis.__SetID(view, 'target'); + mtsGlobalThis.__AppendElement(root, view); + mtsGlobalThis.__FlushElementTree(); + const inlineStyle = rootDom.querySelector('#target')?.getAttribute('style'); + expect(inlineStyle).toContain('flex-shrink'); + }); + + test('event upper case `Tap` works', () => { + vi.spyOn(mtsBinding, 'addEventListener'); + vi.spyOn(mtsBinding, 'publishEvent'); + let page = mtsGlobalThis.__CreatePage('0', 0); + let parent = mtsGlobalThis.__CreateComponent( + 0, + 'id1', + 0, + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + let parentUid = mtsGlobalThis.__GetElementUniqueID(parent); + let child = mtsGlobalThis.__CreateView(parentUid); + mtsGlobalThis.__AppendElement(page, parent); + mtsGlobalThis.__AppendElement(parent, child); + mtsGlobalThis.__SetID(parent, 'parent_id'); + mtsGlobalThis.__SetID(child, 'child_id'); + mtsGlobalThis.__AddEvent(child, 'bindEvent', 'Tap', 'hname'); + mtsGlobalThis.__FlushElementTree(); + rootDom.querySelector('#child_id')?.dispatchEvent( + new window.Event('click'), + ); + expect(mtsBinding.addEventListener).toBeCalledTimes(1); + expect(mtsBinding.addEventListener).toBeCalledWith('tap'); + expect(mtsBinding.publishEvent).toBeCalledTimes(1); + }); + + test('publicComponentEvent', () => { + vi.spyOn(mtsBinding, 'addEventListener'); + vi.spyOn(mtsBinding, 'publishEvent'); + let page = mtsGlobalThis.__CreatePage('0', 0); + let parent = mtsGlobalThis.__CreateComponent( + 0, + 'id1', + 0, + 'test_entry', + 'name', + 'path', + {}, + {}, + ); + let parentUid = mtsGlobalThis.__GetElementUniqueID(parent); + let child = mtsGlobalThis.__CreateView(parentUid); + mtsGlobalThis.__AppendElement(page, parent); + mtsGlobalThis.__AppendElement(parent, child); + mtsGlobalThis.__SetID(parent, 'parent_id'); + mtsGlobalThis.__SetID(child, 'child_id'); + mtsGlobalThis.__AddEvent(child, 'bindEvent', 'tap', 'hname'); + mtsGlobalThis.__SetInlineStyles(parent, { + 'display': 'flex', + }); + mtsGlobalThis.__SetInlineStyles(child, { + 'width': '100px', + 'height': '100px', + }); + mtsGlobalThis.__FlushElementTree(); + rootDom.querySelector('#child_id')?.dispatchEvent( + new window.Event('click'), + ); + expect(mtsBinding.addEventListener).toBeCalledTimes(1); + expect(mtsBinding.addEventListener).toBeCalledWith('tap'); + expect(mtsBinding.publishEvent).toBeCalledTimes(1); + expect(mtsBinding.publishEvent).toBeCalledWith( + 'hname', + 'id1', + expect.objectContaining({ + currentTarget: expect.objectContaining({ + id: 'child_id', + dataset: expect.any(Object), + uniqueId: expect.any(Number), + }), + target: expect.objectContaining({ + id: 'child_id', + dataset: expect.any(Object), + uniqueId: expect.any(Number), + }), + }), + expect.any(Number), + undefined, + expect.any(Number), + undefined, + ); + }); + + test('__UpdateComponentInfo', () => { + let ele = mtsGlobalThis.__CreateComponent( + 0, + 'id1', + 0, + 'test_entry', + 'name1', + 'path', + {}, + {}, + ); + mtsGlobalThis.__UpdateComponentInfo(ele, { + componentID: 'id2', + cssID: 8, + name: 'name2', + }); + const id = mtsGlobalThis.__GetComponentID(ele); + const cssID = mtsGlobalThis.__GetAttributeByName(ele, cssIdAttribute); + const name = mtsGlobalThis.__GetAttributeByName(ele, 'name'); + expect(id).toBe('id2'); + expect(cssID).toBe('8'); + expect(name).toBe('name2'); + }); + + test('__MarkTemplate_and_Get_Parts', () => { + /* + * + * + * + * + * + * + * + * + * + * + * + * + * + */ + const root = mtsGlobalThis.__CreatePage('page', 0); + const grandParentTemplate = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__MarkTemplateElement(grandParentTemplate); + let view = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__MarkPartElement(view, 'grandParentPart'); + mtsGlobalThis.__AppendElement(grandParentTemplate, view); + const targetTemplate = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__MarkTemplateElement(targetTemplate); + mtsGlobalThis.__AppendElement(view, targetTemplate); + view = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AppendElement(targetTemplate, view); + const targetPart = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__MarkPartElement(targetPart, 'targetPart'); + mtsGlobalThis.__AppendElement(view, targetPart); + const subTemplate = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__MarkTemplateElement(subTemplate); + mtsGlobalThis.__AppendElement(targetPart, subTemplate); + const subPart = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__MarkPartElement(subPart, 'subPart'); + mtsGlobalThis.__AppendElement(subTemplate, subPart); + mtsGlobalThis.__FlushElementTree(); + const targetPartLength = Object.keys( + mtsGlobalThis.__GetTemplateParts(targetTemplate)!, + ).length; + const targetPartExist = + mtsGlobalThis.__GetTemplateParts(targetTemplate)!['targetPart'] + === targetPart; + expect(targetPartLength).toBe(1); + expect(targetPartExist).toBe(true); + }); + + describe('__ElementFromBinary', () => { + beforeAll(() => { + templateManager.createTemplate('test'); + const encoded = encodeElementTemplates({ + 'test-template': { + 'type': 'view', + 'class': [ + 'class1', + 'class2', + ], + 'idSelector': 'template-view', + 'attributes': { + 'attr1': 'value1', + }, + 'builtinAttributes': {}, + 'children': [ + { + 'type': 'text', + 'class': [], + 'idSelector': 'id-2', + 'attributes': { + 'value': 'Hello from template', + }, + 'builtinAttributes': { + 'dirtyID': 'id-2', + }, + 'children': [], + 'events': [], + }, + ], + 'events': [ + { + 'type': 'bindEvent', + 'name': 'tap', + 'value': 'templateTap', + }, + ], + 'dataset': { + 'customdata': 'customdata', + }, + }, + }); + const section = wasmInstance.ElementTemplateSection.from_encoded(encoded); + templateManager.setElementTemplateSection( + 'test', + section, + ); + }); + + test('should create a basic element from template', () => { + const element = mtsGlobalThis.__ElementFromBinary('test-template', 0); + expect(mtsGlobalThis.__GetTag(element)).toBe('view'); + }); + + test('should apply attributes from template', () => { + const element = mtsGlobalThis.__ElementFromBinary('test-template', 0); + const attrs = Object.entries(mtsGlobalThis.__GetAttributes(element)); + expect(attrs).toContainEqual(['attr1', 'value1']); + }); + + test('should apply classes from template', () => { + const element = mtsGlobalThis.__ElementFromBinary('test-template', 0); + const classes = mtsGlobalThis.__GetClasses(element); + expect(classes).toEqual(['class1', 'class2']); + }); + + test('should apply id from template', () => { + const element = mtsGlobalThis.__ElementFromBinary('test-template', 0); + const id = mtsGlobalThis.__GetID(element); + expect(id).toBe('template-view'); + }); + + test('should create child elements from template', () => { + const element = mtsGlobalThis.__ElementFromBinary('test-template', 0); + const child = mtsGlobalThis.__FirstElement(element); + expect(mtsGlobalThis.__GetTag(child!)).toBe('text'); + expect(mtsGlobalThis.__GetAttributeByName(child!, 'value')).toBe( + 'Hello from template', + ); + }); + + test('should apply events from template', () => { + const element = mtsGlobalThis.__ElementFromBinary('test-template', 0); + const events = mtsGlobalThis.__GetEvents(element); + expect(events!.length).toBe(1); + expect(events![0]!.name).toBe('tap'); + expect(events![0]!.type.toLowerCase()).toBe('bindevent'); + }); + + test('should mark part element', () => { + const element = mtsGlobalThis.__ElementFromBinary('test-template', 0); + const child = mtsGlobalThis.__FirstElement(element); + const parts = mtsGlobalThis.__GetTemplateParts(element); + expect(Object.keys(parts!).length).toBe(1); + expect(parts!['id-2']).toBe(child); + }); + + test('should apply dataset from template', () => { + const element = mtsGlobalThis.__ElementFromBinary('test-template', 0); + expect(mtsGlobalThis.__GetDataByKey(element, 'customdata')).toBe( + 'customdata', + ); + }); + + test('should throw error if template not found', () => { + expect(() => { + mtsGlobalThis.__ElementFromBinary('non-existent-template', 0); + }).toThrow(); + }); + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/encode.spec.ts b/packages/web-platform/web-core-wasm/tests/encode.spec.ts index 756cc88fce..2fae003636 100644 --- a/packages/web-platform/web-core-wasm/tests/encode.spec.ts +++ b/packages/web-platform/web-core-wasm/tests/encode.spec.ts @@ -8,8 +8,7 @@ import { RulePrelude, } from '../ts/encode/encodeCSS.js'; import * as CSS from '@lynx-js/css-serializer'; -import { DecodedStyle, wasmReadyPromise } from '../ts/client/wasm.js'; -await wasmReadyPromise; +import { DecodedStyle } from '../ts/client/wasm.js'; describe('RawStyleInfo', () => { test('should encode StyleRule correctly', () => { diff --git a/packages/web-platform/web-core-wasm/tests/lazy-load.spec.ts b/packages/web-platform/web-core-wasm/tests/lazy-load.spec.ts new file mode 100644 index 0000000000..c85f89d8c8 --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/lazy-load.spec.ts @@ -0,0 +1,95 @@ +import './jsdom.js'; +import { describe, test, expect, beforeEach, beforeAll, vi } from 'vitest'; +import { createElementAPI } from '../ts/client/mainthread/elementAPIs/createElementAPI.js'; +import { WASMJSBinding } from '../ts/client/mainthread/elementAPIs/WASMJSBinding.js'; +import { templateManager } from '../ts/client/mainthread/TemplateManager.js'; +import { ElementTemplateSection, wasmInstance } from '../ts/client/wasm.js'; +import { encodeElementTemplates } from '../ts/encode/encodeElementTemplate.js'; + +describe('Lazy Load Web Elements', () => { + let lynxViewDom: HTMLElement; + let rootDom: ShadowRoot; + let mtsGlobalThis: ReturnType; + let mtsBinding: WASMJSBinding; + let loadUnknownElementSpy: any; + let loadWebElementSpy: any; + + beforeAll(() => { + templateManager.createTemplate('test-lazy-load'); + const encoded = encodeElementTemplates({ + 'lazy-load-template': { + 'type': 'custom-element', + 'attributes': {}, + 'builtinAttributes': {}, + 'children': [], + 'events': [], + }, + 'normal-template': { + 'type': 'view', + 'attributes': {}, + 'builtinAttributes': {}, + 'children': [], + 'events': [], + }, + 'list-template': { + 'type': 'list', + 'attributes': {}, + 'builtinAttributes': {}, + 'children': [], + 'events': [], + }, + }); + const section = wasmInstance.ElementTemplateSection.from_encoded(encoded); + templateManager.setElementTemplateSection( + 'test-lazy-load', + section, + ); + }); + + beforeEach(() => { + vi.resetAllMocks(); + lynxViewDom = document.createElement('div') as unknown as HTMLElement; + rootDom = lynxViewDom.attachShadow({ mode: 'open' }); + + loadUnknownElementSpy = vi.fn(); + loadWebElementSpy = vi.fn(); + + mtsBinding = new WASMJSBinding( + vi.mockObject({ + rootDom, + backgroundThread: vi.mockObject({ + publicComponentEvent: vi.fn(), + publishEvent: vi.fn(), + postTimingFlags: vi.fn(), + } as any), + exposureServices: vi.mockObject({ + updateExposureStatus: vi.fn(), + }) as any, + loadWebElement: loadWebElementSpy, + loadUnknownElement: loadUnknownElementSpy, + mainThreadGlobalThis: {} as any, + }), + ); + mtsGlobalThis = createElementAPI( + 'test-lazy-load', + rootDom, + mtsBinding, + true, + true, + true, + ); + }); + + test('should trigger loadUnknownElement for unknown tags', () => { + const element = mtsGlobalThis.__ElementFromBinary('lazy-load-template', 0); + expect(loadUnknownElementSpy).toHaveBeenCalledWith('custom-element'); + expect(element.tagName.toLowerCase()).toBe('custom-element'); + }); + + test('should trigger loadWebElement for dynamic load tags (list)', () => { + const element = mtsGlobalThis.__ElementFromBinary('list-template', 0); + expect(loadWebElementSpy).toHaveBeenCalledWith(0); // list -> 0 + expect(loadUnknownElementSpy).not.toHaveBeenCalled(); + expect(element.tagName.toLowerCase()).toBe('x-list'); + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/template-manager.spec.ts b/packages/web-platform/web-core-wasm/tests/template-manager.spec.ts new file mode 100644 index 0000000000..d7702261cf --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/template-manager.spec.ts @@ -0,0 +1,256 @@ +import './jsdom.js'; +import { describe, test, expect, vi, beforeEach } from 'vitest'; +import { encode, type TasmJSONInfo } from '../ts/encode/index.js'; +import { MagicHeader } from '../ts/constants.js'; +import type { LynxViewInstance } from '../ts/client/mainthread/LynxViewInstance.js'; + +// Import the worker script to execute it and register the handler +await import('../ts/client/decodeWorker/decode.worker.js'); +// ------------------------------------- + +// Mock wasm-feature-detect to ensure we load the standard WASM +vi.mock('wasm-feature-detect', () => ({ + referenceTypes: async () => true, +})); + +// Import TemplateManager after mocks are set up +const { templateManager } = await import( + '../ts/client/mainthread/TemplateManager.js' +); + +const sampleTasm: TasmJSONInfo = { + styleInfo: {}, + manifest: {}, + cardType: 'card', + appType: 'react', + pageConfig: { + foo: 'bar', + enableCSSSelector: true, + isLazyComponentTemplate: false, + }, + lepusCode: { root: 'console.log("hello")' }, + customSections: { + 'my-section': { + type: 'lazy', + content: 'some content', + }, + }, + elementTemplates: {}, +}; + +const mockLynxViewInstance = { + onPageConfigReady: vi.fn(), + onStyleInfoReady: vi.fn(), + onMTSScriptsLoaded: vi.fn(), + onBTSScriptsLoaded: vi.fn(), +} as unknown as LynxViewInstance; + +describe('Template Manager', () => { + beforeEach(() => { + vi.clearAllMocks(); + globalThis.fetch = vi.fn(); + }); + + test('should encode and decode correctly with version 1', async () => { + const templateUrl = 'http://example.com/template_version_test'; + const encoded = encode(sampleTasm); + + // Verify version in encoded buffer + const view = new DataView(encoded.buffer); + const magic = view.getBigUint64(0, true); + expect(magic).toBe(BigInt(MagicHeader)); + const version = view.getUint32(8, true); + expect(version).toBe(1); + + // Mock fetch + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoded); + controller.close(); + }, + }); + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body: stream, + }); + + await templateManager.fetchBundle( + templateUrl, + Promise.resolve(mockLynxViewInstance), + ); + + // Verify data using getCustomSection + const customSections = templateManager.getTemplate(templateUrl) + ?.customSections; + const decoder = new TextDecoder('utf-16le'); + const decodedCustomSections = JSON.parse( + decoder.decode(customSections as unknown as Uint8Array), + ); + expect(decodedCustomSections).toEqual(sampleTasm.customSections); + }); + + test('should throw error for unsupported version', async () => { + const templateUrl = 'http://example.com/template_unsupported_version'; + const encoded = encode(sampleTasm); + const buffer = new Uint8Array(encoded); + const view = new DataView(buffer.buffer); + view.setUint32(8, 2, true); // Set version to 2 + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(buffer); + controller.close(); + }, + }); + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body: stream, + }); + + await expect( + templateManager.fetchBundle( + templateUrl, + Promise.resolve(mockLynxViewInstance), + ), + ) + .rejects.toThrow('Unsupported version: 2'); + + // Verify template is removed + expect(templateManager.getTemplate(templateUrl)?.customSections) + .toBeUndefined(); + }); + + /* + test('should throw error for create same template twice', () => { + const templateUrl = 'http://example.com/template_duplicate_url_test'; + templateManager.createTemplate(templateUrl); + expect(() => { + templateManager.createTemplate(templateUrl); + }).toThrow(); + }); + */ + + test('should handle streaming', async () => { + const encoded = encode(sampleTasm); + + const stream = new ReadableStream({ + async start(controller) { + const chunkSize = 10; + for (let i = 0; i < encoded.length; i += chunkSize) { + controller.enqueue(encoded.slice(i, i + chunkSize)); + await new Promise(resolve => setTimeout(resolve, 1)); + } + controller.close(); + }, + }); + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body: stream, + }); + + await templateManager.fetchBundle( + 'http://example.com/template', + Promise.resolve(mockLynxViewInstance), + ); + + // Verify data using getCustomSection + const customSections = templateManager.getTemplate( + 'http://example.com/template', + )?.customSections; + const decoder = new TextDecoder('utf-16le'); + const decodedCustomSections = JSON.parse( + decoder.decode(customSections as unknown as Uint8Array), + ); + expect(decodedCustomSections).toEqual(sampleTasm.customSections); + }); + + /* + test('should remove template correctly', () => { + const templateUrl = 'http://example.com/template_to_remove'; + templateManager.createTemplate(templateUrl); + + // Manually set a custom section to verify existence + templateManager.setCustomSection(templateUrl, { test: 'data' }); + expect(templateManager.getTemplate(templateUrl)?.customSections).toEqual({ + test: 'data', + }); + + templateManager.removeTemplate(templateUrl); + + expect(templateManager.getTemplate(templateUrl)?.customSections) + .toBeUndefined(); + }); + */ + + test('should clean up template on stream error', async () => { + const templateUrl = 'http://example.com/template_stream_error'; + const encoded = encode(sampleTasm); + // Get valid header (8 bytes magic + 4 bytes version) + const header = encoded.slice(0, 12); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(header); + controller.error(new Error('Stream failed')); + }, + }); + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body: stream, + }); + + await expect( + templateManager.fetchBundle( + templateUrl, + Promise.resolve(mockLynxViewInstance), + ), + ).rejects.toThrow('Stream failed'); + + expect(templateManager.getTemplate(templateUrl)?.customSections) + .toBeUndefined(); + }); + + test('should handle overrideConfig correctly', async () => { + const templateUrl = 'http://example.com/template_override_test'; + const encoded = encode(sampleTasm); + + const stream = new ReadableStream({ + start(controller) { + controller.enqueue(encoded); + controller.close(); + }, + }); + (globalThis.fetch as any).mockResolvedValue({ + ok: true, + status: 200, + statusText: 'OK', + body: stream, + }); + + const overrideConfig = { + cardType: 'override-card', + }; + + await templateManager.fetchBundle( + templateUrl, + Promise.resolve(mockLynxViewInstance), + overrideConfig as any, + ); + + // Verify config was merged and passed to instance + expect(mockLynxViewInstance.onPageConfigReady).toHaveBeenCalledWith( + expect.objectContaining({ + cardType: 'override-card', + foo: 'bar', + }), + ); + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/testing-library-port.spec.ts b/packages/web-platform/web-core-wasm/tests/testing-library-port.spec.ts new file mode 100644 index 0000000000..6d4296ccc8 --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/testing-library-port.spec.ts @@ -0,0 +1,313 @@ +import './jsdom.js'; +import { describe, test, expect, beforeEach, vi } from 'vitest'; +import { createElementAPI } from '../ts/client/mainthread/elementAPIs/createElementAPI.js'; +import { WASMJSBinding } from '../ts/client/mainthread/elementAPIs/WASMJSBinding.js'; + +describe('Testing Library Port', () => { + let lynxViewDom: HTMLElement; + let rootDom: ShadowRoot; + let mtsGlobalThis: ReturnType; + let mtsBinding: WASMJSBinding; + const mockedBackground = vi.mockObject({ + publishEvent: vi.fn(), + postTimingFlags: vi.fn(), + }); + beforeEach(() => { + vi.resetAllMocks(); + lynxViewDom = document.createElement('div') as unknown as HTMLElement; + rootDom = lynxViewDom.attachShadow({ mode: 'open' }); + + mtsBinding = new WASMJSBinding( + vi.mockObject({ + rootDom, + backgroundThread: vi.mockObject({ + publicComponentEvent: vi.fn(), + publishEvent: vi.fn(), + postTimingFlags: vi.fn(), + markTiming: vi.fn(), + flushTimingInfo: vi.fn(), + jsContext: vi.mockObject({ + dispatchEvent: vi.fn(), + }), + } as any), + exposureServices: vi.mockObject({ + updateExposureStatus: vi.fn(), + } as any), + loadWebElement: vi.fn(), + loadUnknownElement: vi.fn(), + mainThreadGlobalThis: globalThis as any, + }), + ); + mtsGlobalThis = createElementAPI( + 'test', + rootDom, + mtsBinding as any, + true, + true, + true, + ); + }); + + // Helper to simulate elementTree.root.querySelector + const querySelector = (selector: string) => rootDom.querySelector(selector); + + describe('basic.test.js', () => { + test('should create page and view', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + expect(rootDom.innerHTML).toBe(''); + + const view0 = mtsGlobalThis.__CreateView(0); + expect(mtsGlobalThis.__GetTag(view0)).toBe('view'); + + mtsGlobalThis.__AppendElement(page, view0); + mtsGlobalThis.__FlushElementTree(); + + // Verify view is in DOM + const el = rootDom.querySelector('x-view'); + expect(el).not.toBeNull(); + expect(el?.tagName.toLowerCase()).toBe('x-view'); + }); + + test('should add dataset to view', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const view0 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AppendElement(page, view0); + + mtsGlobalThis.__AddDataset(view0, 'testid', 'view-element'); + mtsGlobalThis.__FlushElementTree(); + + const el = rootDom.querySelector('[data-testid="view-element"]'); + expect(el).not.toBeNull(); + expect(el?.tagName.toLowerCase()).toBe('x-view'); + expect(mtsGlobalThis.__GetTag(view0)).toBe('view'); + }); + + test('should create and append svg element', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const view1 = mtsGlobalThis.__CreateElement('svg', 0); + mtsGlobalThis.__AddDataset(view1, 'testid', 'svg-element'); + mtsGlobalThis.__AppendElement(page, view1); + mtsGlobalThis.__FlushElementTree(); + + const el = rootDom.querySelector('[data-testid="svg-element"]'); + expect(el).not.toBeNull(); + expect(el?.tagName.toLowerCase()).toBe('svg'); + expect(mtsGlobalThis.__GetTag(view1)).toBe('svg'); + }); + + test('should create and append custom element', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const element0 = mtsGlobalThis.__CreateElement('custom-element', 0); + mtsGlobalThis.__AddDataset(element0, 'testid', 'custom-element'); + mtsGlobalThis.__AppendElement(page, element0); + mtsGlobalThis.__FlushElementTree(); + + const el = rootDom.querySelector('[data-testid="custom-element"]'); + expect(el).not.toBeNull(); + expect(el?.tagName.toLowerCase()).toBe('custom-element'); + expect(mtsGlobalThis.__GetTag(element0)).toBe('custom-element'); + }); + + test('should create and append text with raw text', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const view0 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AppendElement(page, view0); + + const text0 = mtsGlobalThis.__CreateText(0); + const rawText0 = mtsGlobalThis.__CreateRawText('Text Element'); + + mtsGlobalThis.__AppendElement(text0, rawText0); + mtsGlobalThis.__AppendElement(view0, text0); + mtsGlobalThis.__FlushElementTree(); + + const textElement = rootDom.querySelector('x-text'); + expect(textElement).not.toBeNull(); + expect(mtsGlobalThis.__GetTag(text0)).toBe('text'); + + const rawText = textElement?.querySelector('raw-text'); + expect(rawText?.getAttribute('text')).toBe('Text Element'); + }); + + test('should handle detached elements', () => { + const detachedElement = mtsGlobalThis.__CreateElement( + 'custom-element', + 0, + ); + expect(detachedElement).not.toBeNull(); + expect(mtsGlobalThis.__GetTag(detachedElement)).toBe('custom-element'); + + // Should not be in DOM + expect(rootDom.querySelectorAll('custom-element').length).toBe(0); + }); + + test('should add event listener', () => { + // Spy on mtsBinding methods + vi.spyOn(mtsBinding, 'addEventListener'); + vi.spyOn(mtsBinding, 'publishEvent'); + + const page = mtsGlobalThis.__CreatePage('0', 0); + const view0 = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AppendElement(page, view0); + + // Add event listener + mtsGlobalThis.__AddEvent(view0, 'bindEvent', 'tap', '2:0:bindtap'); + mtsGlobalThis.__FlushElementTree(); + + const viewElement = rootDom.querySelector('x-view'); + expect(viewElement).not.toBeNull(); + + // Dispatch event + viewElement?.dispatchEvent(new window.Event('click')); + + // Verify spies + expect(mtsBinding.addEventListener).toBeCalledWith('tap'); + + expect(mtsBinding.publishEvent).toBeCalled(); + }); + + test('text should work with SetAttribute', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const text0 = mtsGlobalThis.__CreateText(0); + const rawText0 = mtsGlobalThis.__CreateRawText('raw-text'); + + mtsGlobalThis.__AppendElement(text0, rawText0); + mtsGlobalThis.__SetAttribute(rawText0, 'text', 'Hello World'); + + mtsGlobalThis.__AppendElement(page, text0); + mtsGlobalThis.__FlushElementTree(); + + const textElement = rootDom.querySelector('x-text'); + expect(textElement).not.toBeNull(); + + const rawText = textElement?.querySelector('raw-text'); + expect(rawText?.getAttribute('text')).toBe('Hello World'); + }); + + test('should handle empty text content', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const text0 = mtsGlobalThis.__CreateText(0); + mtsGlobalThis.__AppendElement(page, text0); + mtsGlobalThis.__FlushElementTree(); + + const textElement = rootDom.querySelector('x-text'); + expect(textElement).not.toBeNull(); + expect(textElement?.children.length).toBe(0); + expect(textElement?.textContent).toBe(''); + }); + + test('should be case sensitive', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const text0 = mtsGlobalThis.__CreateText(0); + const rawText0 = mtsGlobalThis.__CreateRawText('Sensitive text'); + mtsGlobalThis.__AppendElement(text0, rawText0); + mtsGlobalThis.__AppendElement(page, text0); + mtsGlobalThis.__FlushElementTree(); + + const textElement = rootDom.querySelector('x-text'); + const rawText = textElement?.querySelector('raw-text'); + expect(rawText?.getAttribute('text')).toBe('Sensitive text'); + expect(rawText?.getAttribute('text')).not.toBe('sensitive text'); + }); + }); + + describe('element-papi.test.js', () => { + test('__RemoveElement should work', () => { + const view = mtsGlobalThis.__CreateView(0); + const childViews: any[] = []; + for (let i = 0; i < 6; i++) { + const child = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AppendElement(view, child); + mtsGlobalThis.__SetID(child, `child-${i}`); + childViews.push(child); + } + + // We need to append view to a page to flush it to DOM? + // Or just flush? + // If view is not in rootDom, flush might not update rootDom, but the elements themselves might update. + // But we want to check structure. + const page = mtsGlobalThis.__CreatePage('0', 0); + mtsGlobalThis.__AppendElement(page, view); + mtsGlobalThis.__FlushElementTree(); + + expect(rootDom.querySelectorAll('x-view[id^="child-"]').length).toBe(6); + + mtsGlobalThis.__RemoveElement(view, childViews[0]); + mtsGlobalThis.__RemoveElement(view, childViews[4]); + mtsGlobalThis.__FlushElementTree(); + + expect(rootDom.querySelectorAll('x-view[id^="child-"]').length).toBe(4); + expect(rootDom.querySelector('#child-0')).toBeNull(); + expect(rootDom.querySelector('#child-4')).toBeNull(); + expect(rootDom.querySelector('#child-1')).not.toBeNull(); + }); + }); + + describe('to-have-text-content.test.js', () => { + test('handles positive test cases', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const text0 = mtsGlobalThis.__CreateText(0); + const rawText0 = mtsGlobalThis.__CreateRawText('2'); + mtsGlobalThis.__AppendElement(text0, rawText0); + mtsGlobalThis.__AppendElement(page, text0); + mtsGlobalThis.__AddDataset(text0, 'testid', 'count-value'); + mtsGlobalThis.__FlushElementTree(); + + const el = rootDom.querySelector('[data-testid="count-value"]'); + expect(el).not.toBeNull(); + // expect(el?.textContent).toBe('2'); + const rawText = el?.querySelector('raw-text'); + expect(rawText?.getAttribute('text')).toBe('2'); + }); + + test('normalizes whitespace (attribute check)', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const text0 = mtsGlobalThis.__CreateText(0); + const rawText0 = mtsGlobalThis.__CreateRawText(' Step 1 of 4'); + mtsGlobalThis.__AppendElement(text0, rawText0); + mtsGlobalThis.__AppendElement(page, text0); + mtsGlobalThis.__FlushElementTree(); + + const textElement = rootDom.querySelector('x-text'); + const rawText = textElement?.querySelector('raw-text'); + // Attributes preserve whitespace + expect(rawText?.getAttribute('text')).toBe(' Step 1 of 4'); + }); + + test('can handle multiple levels', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const text0 = mtsGlobalThis.__CreateText(0); + const rawText0 = mtsGlobalThis.__CreateRawText('Step 1 of 4'); + mtsGlobalThis.__AppendElement(text0, rawText0); + mtsGlobalThis.__AppendElement(page, text0); + mtsGlobalThis.__SetID(text0, 'parent'); + mtsGlobalThis.__FlushElementTree(); + + const parent = rootDom.querySelector('#parent'); + const rawText = parent?.querySelector('raw-text'); + expect(rawText?.getAttribute('text')).toBe('Step 1 of 4'); + }); + + test('can handle multiple levels with content spread across descendants', () => { + const page = mtsGlobalThis.__CreatePage('0', 0); + const view = mtsGlobalThis.__CreateView(0); + const text0 = mtsGlobalThis.__CreateText(0); + const rawText0 = mtsGlobalThis.__CreateRawText('Step'); + const text1 = mtsGlobalThis.__CreateText(0); + const rawText1 = mtsGlobalThis.__CreateRawText('1'); + + mtsGlobalThis.__AppendElement(text0, rawText0); + mtsGlobalThis.__AppendElement(text1, rawText1); + mtsGlobalThis.__AppendElement(view, text0); + mtsGlobalThis.__AppendElement(view, text1); + mtsGlobalThis.__AppendElement(page, view); + mtsGlobalThis.__SetID(view, 'parent'); + mtsGlobalThis.__FlushElementTree(); + + const parent = rootDom.querySelector('#parent'); + const rawTexts = parent?.querySelectorAll('raw-text'); + expect(rawTexts?.length).toBe(2); + expect(rawTexts?.[0]!.getAttribute('text')).toBe('Step'); + expect(rawTexts?.[1]!.getAttribute('text')).toBe('1'); + }); + }); +}); diff --git a/packages/web-platform/web-core-wasm/ts/client/LynxCrossThreadContext.ts b/packages/web-platform/web-core-wasm/ts/client/LynxCrossThreadContext.ts new file mode 100644 index 0000000000..07b08134ee --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/LynxCrossThreadContext.ts @@ -0,0 +1,58 @@ +// Copyright 2023 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 { dispatchCoreContextOnBackgroundEndpoint } from './endpoints.js'; +import type { LynxContextEventTarget } from '../types/index.js'; +import type { Rpc } from '@lynx-js/web-worker-rpc'; + +export const DispatchEventResult = { + // Event was not canceled by event handler or default event handler. + NotCanceled: 0, + // Event was canceled by event handler; i.e. a script handler calling + // preventDefault. + CanceledByEventHandler: 1, + // Event was canceled by the default event handler; i.e. executing the default + // action. This result should be used sparingly as it deviates from the DOM + // Event Dispatch model. Default event handlers really shouldn't be invoked + // inside of dispatch. + CanceledByDefaultEventHandler: 2, + // Event was canceled but suppressed before dispatched to event handler. This + // result should be used sparingly; and its usage likely indicates there is + // potential for a bug. Trusted events may return this code; but untrusted + // events likely should always execute the event handler the developer intends + // to execute. + CanceledBeforeDispatch: 3, +} as const; + +type LynxCrossThreadContextConfig = { + rpc: Rpc; + receiveEventEndpoint: typeof dispatchCoreContextOnBackgroundEndpoint; + sendEventEndpoint: typeof dispatchCoreContextOnBackgroundEndpoint; +}; +export class LynxCrossThreadContext extends EventTarget + implements LynxContextEventTarget +{ + #config: LynxCrossThreadContextConfig; + constructor( + config: LynxCrossThreadContextConfig, + ) { + super(); + this.#config = config; + } + postMessage(...args: any[]) { + console.error('[lynx-web] postMessage not implemented, args:', ...args); + } + // @ts-expect-error + override dispatchEvent(event: ContextCrossThreadEvent) { + const { rpc, sendEventEndpoint } = this.#config; + rpc.invoke(sendEventEndpoint, [event]); + return DispatchEventResult.CanceledBeforeDispatch; + } + __start() { + const { rpc, receiveEventEndpoint } = this.#config; + rpc.registerHandler(receiveEventEndpoint, ({ type, data }) => { + super.dispatchEvent(new MessageEvent(type, { data: data ?? {} })); + }); + } +} diff --git a/packages/web-platform/web-core-wasm/ts/client/decodeWorker/decode.worker.ts b/packages/web-platform/web-core-wasm/ts/client/decodeWorker/decode.worker.ts new file mode 100644 index 0000000000..d98851c982 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/decodeWorker/decode.worker.ts @@ -0,0 +1,306 @@ +import { TemplateSectionLabel, MagicHeader } from '../../constants.js'; +import type { InitMessage, LoadTemplateMessage, MainMessage } from './types.js'; +import { DecodedStyle, wasmInstance } from '../wasm.js'; +import type { PageConfig } from '../../types/PageConfig.js'; + +let wasmModuleLoadedResolve: () => void; +const wasmModuleLoadedPromise: Promise = new Promise((resolve) => { + wasmModuleLoadedResolve = resolve; +}); + +class StreamReader { + #reader: ReadableStreamDefaultReader; + #buffer: Uint8Array = new Uint8Array(0); + + constructor(reader: ReadableStreamDefaultReader) { + this.#reader = reader; + } + + async read(size: number): Promise { + if (this.#buffer.length >= size) { + const result = this.#buffer.slice(0, size); + this.#buffer = this.#buffer.slice(size); + return result; + } + + while (this.#buffer.length < size) { + const { done, value } = await this.#reader.read(); + + if (value) { + const newBuffer = new Uint8Array(this.#buffer.length + value.length); + newBuffer.set(this.#buffer); + newBuffer.set(value, this.#buffer.length); + this.#buffer = newBuffer; + } + + if (done) { + break; + } + } + + if (this.#buffer.length < size) { + if (this.#buffer.length === 0) { + return null; + } + throw new Error( + `Unexpected end of stream. Expected ${size} bytes, got ${this.#buffer.length}`, + ); + } + + const result = this.#buffer.slice(0, size); + this.#buffer = this.#buffer.slice(size); + return result; + } +} + +function decodeJSONMap(buffer: Uint8Array): Record { + const utf16Array = new Uint16Array( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength / 2, + ); + let jsonString = ''; + const CHUNK_SIZE = 8192; + for (let i = 0; i < utf16Array.length; i += CHUNK_SIZE) { + jsonString += String.fromCharCode.apply( + null, + utf16Array.subarray(i, i + CHUNK_SIZE) as unknown as number[], + ); + } + + return JSON.parse(jsonString); +} + +function decodeBinaryMap(buffer: Uint8Array): Record { + const view = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ); + let offset = 0; + if (buffer.byteLength < 4) { + throw new Error('Buffer too short for count'); + } + const count = view.getUint32(offset, true); + offset += 4; + + const result: Record = {}; + const decoder = new TextDecoder(); + + for (let i = 0; i < count; i++) { + if (buffer.byteLength < offset + 4) { + throw new Error('Buffer too short for key length'); + } + const keyLen = view.getUint32(offset, true); + offset += 4; + + if (buffer.byteLength < offset + keyLen) { + throw new Error('Buffer too short for key'); + } + const key = decoder.decode(buffer.subarray(offset, offset + keyLen)); + offset += keyLen; + + if (buffer.byteLength < offset + 4) { + throw new Error('Buffer too short for value length'); + } + const valLen = view.getUint32(offset, true); + offset += 4; + + if (buffer.byteLength < offset + valLen) { + throw new Error('Buffer too short for value'); + } + const val = buffer.subarray(offset, offset + valLen); + offset += valLen; + + result[key] = val; + } + return result; +} + +self.onmessage = async ( + event: MessageEvent | MessageEvent, +) => { + const data = event.data; + if (data.type === 'init') { + const { wasmModule } = data; + wasmInstance.initSync({ module: wasmModule }); + wasmModuleLoadedResolve(); + } else if (data.type === 'load') { + const { url, fetchUrl, overrideConfig } = data; + try { + const response = await fetch(fetchUrl, { + headers: { + 'Content-Type': 'octet-stream', + }, + }); + if (!response.body || response.status !== 200) { + throw new Error(`Failed to fetch template: ${response.statusText}`); + } + const reader = response.body.getReader(); + await handleStream(url, reader, overrideConfig); + postMessage({ type: 'done', url } as MainMessage); + } catch (error) { + postMessage( + { type: 'error', url, error: (error as Error).message } as MainMessage, + ); + } + } +}; +async function handleStream( + url: string, + reader: ReadableStreamDefaultReader, + overrideConfig?: Partial, +) { + const streamReader = new StreamReader(reader); + let config: Partial = {}; + + // 1. Check MagicHeader + const headerBytes = await streamReader.read(8); + if (!headerBytes) { + throw new Error('Empty stream'); + } + const view = new DataView( + headerBytes.buffer, + headerBytes.byteOffset, + headerBytes.byteLength, + ); + const magic = view.getBigUint64(0, true); // Little Endian + if (magic !== BigInt(MagicHeader)) { + throw new Error('Invalid Magic Header'); + } + + // 2. Check Version + const versionBytes = await streamReader.read(4); + if (!versionBytes) { + throw new Error('Unexpected EOF reading version'); + } + const versionView = new DataView( + versionBytes.buffer, + versionBytes.byteOffset, + versionBytes.byteLength, + ); + const version = versionView.getUint32(0, true); + if (version > 1) { + throw new Error(`Unsupported version: ${version}`); + } + + // 3. Read Sections + while (true) { + const labelBytes = await streamReader.read(4); + if (!labelBytes) { + break; // EOF + } + const labelView = new DataView( + labelBytes.buffer, + labelBytes.byteOffset, + labelBytes.byteLength, + ); + const label = labelView.getUint32(0, true); + + const lengthBytes = await streamReader.read(4); + if (!lengthBytes) { + throw new Error('Unexpected EOF reading section length'); + } + const lengthView = new DataView( + lengthBytes.buffer, + lengthBytes.byteOffset, + lengthBytes.byteLength, + ); + const length = lengthView.getUint32(0, true); + + const content = await streamReader.read(length); + if (!content) { + throw new Error( + `Unexpected EOF reading section content. Expected ${length} bytes.`, + ); + } + + switch (label) { + case TemplateSectionLabel.Configurations: { + config = overrideConfig + ? { ...decodeJSONMap(content), ...overrideConfig } + : decodeJSONMap(content); + postMessage( + { type: 'section', label, url, data: config } as MainMessage, + ); + break; + } + case TemplateSectionLabel.StyleInfo: { + await wasmModuleLoadedPromise; + const buffer = DecodedStyle.webWorkerDecode( + content, + config['enableCSSSelector'] === 'true', + config['isLazy'] === 'true' ? url : undefined, + ); + postMessage( + { + type: 'section', + label, + url, + data: buffer.buffer, + config, + } as MainMessage, + { + transfer: [buffer.buffer], + }, + ); + break; + } + case TemplateSectionLabel.LepusCode: { + const codeMap = decodeBinaryMap(content); + const isLazy = config['isLazy'] === 'true'; + const blobMap: Record = {}; + for (const [key, code] of Object.entries(codeMap)) { + const prefix = + `(function(){ "use strict"; const navigator=void 0,postMessage=void 0,window=void 0; ${ + isLazy ? 'module.exports=' : '' + } `; + const suffix = ` \n })()\n//# sourceURL=${url}\n`; + const blob = new Blob([prefix, code as unknown as BlobPart, suffix], { + type: 'text/javascript; charset=utf-8', + }); + blobMap[key] = URL.createObjectURL(blob); + } + postMessage( + { type: 'section', label, url, data: blobMap, config } as MainMessage, + ); + break; + } + case TemplateSectionLabel.ElementTemplates: { + postMessage( + { type: 'section', label, url, data: content } as MainMessage, + [content.buffer], + ); + break; + } + case TemplateSectionLabel.CustomSections: { + postMessage( + { type: 'section', label, url, data: content.buffer } as MainMessage, + { + transfer: [content.buffer], + }, + ); + break; + } + case TemplateSectionLabel.Manifest: { + const codeMap = decodeBinaryMap(content); + const blobMap: Record = {}; + for (const [key, code] of Object.entries(codeMap)) { + const suffix = `//# sourceURL=${url}/${key}`; + const blob = new Blob([code as unknown as BlobPart, suffix], { + type: 'text/javascript; charset=utf-8', + }); + blobMap[key] = URL.createObjectURL(blob); + } + postMessage( + { type: 'section', label, url, data: blobMap } as MainMessage, + ); + break; + } + default: + throw new Error(`Unknown section label: ${label}`); + } + } +} + +postMessage({ type: 'ready' } as MainMessage); diff --git a/packages/web-platform/web-core-wasm/ts/client/decodeWorker/types.ts b/packages/web-platform/web-core-wasm/ts/client/decodeWorker/types.ts new file mode 100644 index 0000000000..89fc957d21 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/decodeWorker/types.ts @@ -0,0 +1,44 @@ +import type { PageConfig } from '../../types/PageConfig.js'; + +export interface DecodeWorkerMessage { + type: string; + url: string; +} + +export interface InitMessage extends DecodeWorkerMessage { + type: 'init'; + wasmModule: WebAssembly.Module; +} + +export interface LoadTemplateMessage extends DecodeWorkerMessage { + type: 'load'; + fetchUrl: string; + overrideConfig?: Record; +} + +export interface SectionMessage extends DecodeWorkerMessage { + type: 'section'; + label: number; + data: any; + config?: PageConfig; +} + +export interface ErrorMessage extends DecodeWorkerMessage { + type: 'error'; + error: string; +} + +export interface DoneMessage extends DecodeWorkerMessage { + type: 'done'; +} + +export interface ReadyMessage { + type: 'ready'; +} + +export type WorkerMessage = LoadTemplateMessage; +export type MainMessage = + | SectionMessage + | ErrorMessage + | DoneMessage + | ReadyMessage; diff --git a/packages/web-platform/web-core-wasm/ts/client/endpoints.ts b/packages/web-platform/web-core-wasm/ts/client/endpoints.ts new file mode 100644 index 0000000000..e73d23a986 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/endpoints.ts @@ -0,0 +1,214 @@ +// Copyright 2023 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 { createRpcEndpoint } from '@lynx-js/web-worker-rpc'; +import type { + Cloneable, + CloneableObject, + LynxCrossThreadEvent, + InvokeCallbackRes, + ElementAnimationOptions, + UpdateDataOptions, + TimingEntry, +} from '../types/index.js'; +import type { IdentifierType } from '../constants.js'; + +export const publicComponentEventEndpoint = createRpcEndpoint< + [componentId: string, hname: string, LynxCrossThreadEvent], + void +>('publicComponentEvent', false, false); + +export const publishEventEndpoint = createRpcEndpoint< + [string, LynxCrossThreadEvent], + void +>('publishEvent', false, false); + +export const switchExposureServiceEndpoint = createRpcEndpoint< + [boolean, boolean], + void +>( + 'switchExposureServiceEndpoint', + false, + false, +); + +export const updateDataEndpoint = createRpcEndpoint< + [Cloneable, UpdateDataOptions | undefined], + void +>('updateData', false, true); + +export const sendGlobalEventEndpoint = createRpcEndpoint< + [string, Cloneable[] | undefined], + void +>('sendGlobalEventEndpoint', false, false); + +export const disposeEndpoint = createRpcEndpoint< + [], + void +>('dispose', false, true); + +export const BackgroundThreadStartEndpoint = createRpcEndpoint<[], void>( + 'start', + false, + true, +); + +/** + * Error message, info + */ +export const reportErrorEndpoint = createRpcEndpoint< + [Error, unknown, string], + void +>('reportError', false, false); + +export const callLepusMethodEndpoint = createRpcEndpoint< + [name: string, data: unknown], + void +>('callLepusMethod', false, true); + +export const invokeUIMethodEndpoint = createRpcEndpoint< + [ + type: IdentifierType, + identifier: string, + component_id: string, + method: string, + params: object, + root_unique_id: number | undefined, + ], + InvokeCallbackRes +>('__invokeUIMethod', false, true); + +export const setNativePropsEndpoint = createRpcEndpoint< + [ + type: IdentifierType, + identifier: string, + component_id: string, + first_only: boolean, + native_props: object, + root_unique_id: number | undefined, + ], + void +>('__setNativeProps', false, true); + +export const getPathInfoEndpoint = createRpcEndpoint< + [ + type: IdentifierType, + identifier: string, + component_id: string, + first_only: boolean, + root_unique_id?: number | undefined, + ], + InvokeCallbackRes +>('__getPathInfo', false, true); + +export const nativeModulesCallEndpoint = createRpcEndpoint< + [name: string, data: Cloneable, moduleName: string], + any +>('nativeModulesCall', false, true); + +export const napiModulesCallEndpoint = createRpcEndpoint< + [name: string, data: Cloneable, moduleName: string], + any +>('napiModulesCall', false, true, true); + +export const getCustomSectionsEndpoint = createRpcEndpoint< + [string], + Cloneable +>('getCustomSections', false, true); + +export const markTimingEndpoint = createRpcEndpoint< + [TimingEntry[]], + void +>('markTiming', false, false); + +export const postTimingFlagsEndpoint = createRpcEndpoint< + [ + timingFlags: string[], + pipelineId: string | undefined, + ], + void +>('postTimingFlags', false, false); + +export const triggerComponentEventEndpoint = createRpcEndpoint< + [ + id: string, + params: { + eventDetail: CloneableObject; + eventOption: CloneableObject; + componentId: string; + }, + ], + void +>('__triggerComponentEvent', false, false); + +export const selectComponentEndpoint = createRpcEndpoint< + [ + componentId: string, + idSelector: string, + single: boolean, + ], + void +>('__selectComponent', false, true); + +export const dispatchLynxViewEventEndpoint = createRpcEndpoint< + [ + eventType: string, + detail: CloneableObject, + ], + void +>('dispatchLynxViewEvent', false, false); + +export const dispatchNapiModuleEndpoint = createRpcEndpoint< + [data: Cloneable], + void +>('dispatchNapiModule', false, false); +export const dispatchCoreContextOnBackgroundEndpoint = createRpcEndpoint< + [{ + type: string; + data: Cloneable; + }], + void +>('dispatchCoreContextOnBackground', false, false); + +export const dispatchJSContextOnMainThreadEndpoint = createRpcEndpoint< + [{ + type: string; + data: Cloneable; + }], + void +>('dispatchJSContextOnMainThread', false, false); + +export const triggerElementMethodEndpoint = createRpcEndpoint< + [ + method: string, + id: string, + options: ElementAnimationOptions, + ], + void +>('__triggerElementMethod', false, false); + +export const updateGlobalPropsEndpoint = createRpcEndpoint< + [Cloneable], + void +>('updateGlobalProps', false, false); + +export const updateI18nResourcesEndpoint = createRpcEndpoint< + [Cloneable], + void +>('updateI18nResources', false, false); + +export const dispatchI18nResourceEndpoint = createRpcEndpoint< + [Cloneable], + void +>('dispatchI18nResource', false, false); + +export const queryComponentEndpoint = createRpcEndpoint< + [string], + { code: number; detail: { schema: string } } +>('queryComponent', false, true); + +export const updateBTSChunkEndpoint = createRpcEndpoint< + [/** url */ string, Record], + void +>('updateBTSChunkEndpoint', false, true); diff --git a/packages/web-platform/web-core-wasm/ts/client/global.d.ts b/packages/web-platform/web-core-wasm/ts/client/global.d.ts new file mode 100644 index 0000000000..4d36088cd9 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/global.d.ts @@ -0,0 +1,7 @@ +declare global { + var SystemInfo: Record | undefined; + var module: { exports: any }; + var __bundle__holder: unknown; +} + +export {}; diff --git a/packages/web-platform/web-core-wasm/ts/client/index.ts b/packages/web-platform/web-core-wasm/ts/client/index.ts new file mode 100644 index 0000000000..3462c37238 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/index.ts @@ -0,0 +1,3 @@ +import './mainthread/LynxView.js'; +import '../../css/index.css'; +export type { LynxViewElement } from './mainthread/LynxView.js'; diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/Background.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/Background.ts new file mode 100644 index 0000000000..9668c3b60e --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/Background.ts @@ -0,0 +1,295 @@ +/* + * Copyright (C) 2025 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 { Rpc, type RpcCallType } from '@lynx-js/web-worker-rpc'; +import { + dispatchCoreContextOnBackgroundEndpoint, + dispatchJSContextOnMainThreadEndpoint, + disposeEndpoint, + markTimingEndpoint, + postTimingFlagsEndpoint, + publicComponentEventEndpoint, + publishEventEndpoint, + sendGlobalEventEndpoint, + dispatchI18nResourceEndpoint, + updateDataEndpoint, + updateGlobalPropsEndpoint, + BackgroundThreadStartEndpoint, + callLepusMethodEndpoint, + switchExposureServiceEndpoint, + reportErrorEndpoint, + dispatchLynxViewEventEndpoint, + updateBTSChunkEndpoint, + queryComponentEndpoint, +} from '../endpoints.js'; +import type { + Cloneable, + NapiModulesMap, + NativeModulesMap, + TimingEntry, + WorkerStartMessage, +} from '../../types/index.js'; +import { LynxCrossThreadContext } from '../LynxCrossThreadContext.js'; +import { systemInfo, type LynxViewInstance } from './LynxViewInstance.js'; +import { registerInvokeUIMethodHandler } from './crossThreadHandlers/registerInvokeUIMethodHandler.js'; +import { registerNativePropsHandler } from './crossThreadHandlers/registerSetNativePropsHandler.js'; +import { registerGetPathInfoHandler } from './crossThreadHandlers/registerGetPathInfoHandler.js'; +import { registerSelectComponentHandler } from './crossThreadHandlers/registerSelectComponentHandler.js'; +import { registerTriggerComponentEventHandler } from './crossThreadHandlers/registerTriggerComponentEventHandler.js'; +import { registerTriggerElementMethodEndpointHandler } from './crossThreadHandlers/registerTriggerElementMethodEndpointHandler.js'; +import { registerNapiModulesCallHandler } from './crossThreadHandlers/registerNapiModulesCallHandler.js'; +import { registerNativeModulesCallHandler } from './crossThreadHandlers/registerNativeModulesCallHandler.js'; + +function createWebWorker(): Worker { + return new Worker( + /* webpackFetchPriority: "high" */ + /* webpackChunkName: "web-core-worker-chunk" */ + /* webpackPrefetch: true */ + /* webpackPreload: true */ + new URL('../background/index.js', import.meta.url), + { + type: 'module', + name: 'lynx-bg', + }, + ); +} +export class BackgroundThread implements AsyncDisposable { + static contextIdToBackgroundWorker: ({ + worker: Worker; + runningCards: number; + } | undefined)[] = []; + + #rpc: Rpc; + #webWorker?: Worker; + #nextMacroTask: ReturnType | null = null; + #caughtTimingInfo: TimingEntry[] = []; + #batchSendTimingInfo: RpcCallType; + + readonly jsContext: LynxCrossThreadContext; + + readonly postTimingFlags: RpcCallType; + readonly sendGlobalEvent: RpcCallType; + readonly publicComponentEvent: RpcCallType< + typeof publicComponentEventEndpoint + >; + readonly publishEvent: RpcCallType; + readonly dispatchI18nResource: RpcCallType< + typeof dispatchI18nResourceEndpoint + >; + readonly updateData: RpcCallType; + readonly updateGlobalProps: RpcCallType; + readonly updateBTSChunk: RpcCallType; + + readonly #lynxGroupId: number | undefined; + readonly #lynxViewInstance: LynxViewInstance; + + #btsStarted = false; + + constructor( + lynxGroupId: number | undefined, + lynxViewInstance: LynxViewInstance, + ) { + this.#lynxGroupId = lynxGroupId; + this.#lynxViewInstance = lynxViewInstance; + const btsRpc = new Rpc(undefined, 'main-to-bg'); + this.#rpc = btsRpc; + this.jsContext = new LynxCrossThreadContext({ + rpc: this.#rpc, + receiveEventEndpoint: dispatchJSContextOnMainThreadEndpoint, + sendEventEndpoint: dispatchCoreContextOnBackgroundEndpoint, + }); + this.jsContext.__start(); + this.#batchSendTimingInfo = this.#rpc.createCall(markTimingEndpoint); + this.postTimingFlags = this.#rpc.createCall(postTimingFlagsEndpoint); + this.sendGlobalEvent = this.#rpc.createCall(sendGlobalEventEndpoint); + this.publicComponentEvent = this.#rpc.createCall( + publicComponentEventEndpoint, + ); + this.publishEvent = this.#rpc.createCall(publishEventEndpoint); + this.dispatchI18nResource = this.#rpc.createCall( + dispatchI18nResourceEndpoint, + ); + this.updateData = this.#rpc.createCall(updateDataEndpoint); + this.updateGlobalProps = this.#rpc.createCall(updateGlobalPropsEndpoint); + this.updateBTSChunk = this.#rpc.createCall(updateBTSChunkEndpoint); + } + + startWebWorker( + initData: Cloneable, + globalProps: Cloneable, + cardType: string, + customSections: Record, + nativeModulesMap: NativeModulesMap, + napiModulesMap: NapiModulesMap, + ) { + if (this.#webWorker) return; + // now start the background worker + if (this.#lynxGroupId !== undefined) { + const group = + BackgroundThread.contextIdToBackgroundWorker[this.#lynxGroupId]; + if (group) { + group.runningCards += 1; + } else { + BackgroundThread.contextIdToBackgroundWorker[this.#lynxGroupId] = { + worker: createWebWorker(), + runningCards: 1, + }; + } + this.#webWorker = BackgroundThread.contextIdToBackgroundWorker[ + this.#lynxGroupId + ]!.worker; + } else { + this.#webWorker = createWebWorker(); + } + const messageChannel = new MessageChannel(); + this.#webWorker.postMessage( + { + mainThreadMessagePort: messageChannel.port2, + systemInfo, + initData, + globalProps, + cardType, + customSections, + nativeModulesMap, + napiModulesMap, + entryTemplateUrl: this.#lynxViewInstance.templateUrl, + } as WorkerStartMessage, + [messageChannel.port2], + ); + this.#rpc.setMessagePort(messageChannel.port1); + } + + startBTS() { + if (this.#btsStarted) return; + this.#btsStarted = true; + // prepare bts rpc handlers + this.#rpc.registerHandler( + callLepusMethodEndpoint, + (methodName: string, data: unknown) => { + const method = ( + this.#lynxViewInstance.mainThreadGlobalThis as any + )[methodName]; + if (typeof method === 'function') { + method.call(this.#lynxViewInstance.mainThreadGlobalThis, data); + } else { + console.error( + `Method ${methodName} not found on mainThreadGlobalThis`, + ); + } + }, + ); + + this.#rpc.registerHandler( + switchExposureServiceEndpoint, + this.#lynxViewInstance.exposureServices.switchExposureService.bind( + this.#lynxViewInstance.exposureServices, + ), + ); + + this.#rpc.registerHandler( + reportErrorEndpoint, + (e, _, release) => { + this.#lynxViewInstance.reportError( + e, + release, + this.#lynxViewInstance.templateUrl, + ); + }, + ); + this.#rpc.registerHandler( + dispatchLynxViewEventEndpoint, + (eventType, detail) => { + this.#lynxViewInstance.rootDom.dispatchEvent( + new CustomEvent(eventType, { + detail, + bubbles: true, + cancelable: true, + composed: true, + }), + ); + }, + ); + this.#rpc.registerHandler( + queryComponentEndpoint, + (url: string) => { + return this.#lynxViewInstance.queryComponent(url).then(() => { + this.jsContext.dispatchEvent({ + type: '__OnDynamicJSSourcePrepared', + data: url, + }); + return { + code: 0, + detail: { + schema: url, + }, + }; + }); + }, + ); + registerGetPathInfoHandler(this.#rpc, this.#lynxViewInstance); + registerInvokeUIMethodHandler(this.#rpc, this.#lynxViewInstance); + registerNapiModulesCallHandler(this.#rpc, this.#lynxViewInstance); + registerNativeModulesCallHandler(this.#rpc, this.#lynxViewInstance); + registerSelectComponentHandler(this.#rpc, this.#lynxViewInstance); + registerNativePropsHandler(this.#rpc, this.#lynxViewInstance); + registerTriggerComponentEventHandler(this.#rpc, this.#lynxViewInstance); + registerTriggerElementMethodEndpointHandler( + this.#rpc, + this.#lynxViewInstance, + ); + + this.#rpc.invoke(BackgroundThreadStartEndpoint, []); + } + + markTiming( + timingKey: string, + pipelineId?: string, + timeStamp?: number, + ): void { + this.#caughtTimingInfo.push({ + timingKey, + pipelineId, + timeStamp: timeStamp ?? performance.now(), + }); + if (this.#nextMacroTask === null) { + this.#nextMacroTask = setTimeout(() => { + this.flushTimingInfo(); + }, 500); + } + } + + /** + * Flush the timing info immediately. + */ + flushTimingInfo(): void { + this.#batchSendTimingInfo(this.#caughtTimingInfo); + this.#caughtTimingInfo = []; + if (this.#nextMacroTask !== null) { + clearTimeout(this.#nextMacroTask); + this.#nextMacroTask = null; + } + } + + [Symbol.asyncDispose](): Promise { + if (this.#lynxGroupId !== undefined) { + const group = + BackgroundThread.contextIdToBackgroundWorker[this.#lynxGroupId]; + if (group) { + group.runningCards -= 1; + if (group.runningCards === 0) { + group.worker.terminate(); + BackgroundThread.contextIdToBackgroundWorker[ + this.#lynxGroupId + ] = undefined; + } + } + } else { + this.#webWorker?.terminate(); + } + this.#nextMacroTask && clearTimeout(this.#nextMacroTask); + return this.#rpc.invoke(disposeEndpoint, []); + } +} diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/ExposureServices.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/ExposureServices.ts new file mode 100644 index 0000000000..eef3ea951e --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/ExposureServices.ts @@ -0,0 +1,295 @@ +/* + * Copyright 2021-2024 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 { + CloneableObject, + DecoratedHTMLElement, + ExposureEventDetail, + GlobalExposureEvent, +} from '../../types/index.js'; +import { scrollContainerDom } from '../../constants.js'; +import { convertLengthToPx } from './utils/convertLengthToPx.js'; +import type { LynxViewInstance } from './LynxViewInstance.js'; + +export class ExposureServices { + #exposureEnabledElementsToIntersectionObserver: Map< + HTMLElement, + IntersectionObserver + > = new Map(); + #exposureEnabledElementsToOldExposureIdAttributeValue: Map< + HTMLElement, + string + > = new Map(); + #globalExposureEventCache: GlobalExposureEvent[] = []; + #globalDisexposureEventCache: GlobalExposureEvent[] = []; + #globalExposureEventBatchTimer: ReturnType | null = null; + /** + * The elements that are currently exposed + * We only send the event when the element enters or leaves the exposed state + */ + #exposedElements: Set = new Set(); + /** + * note that this flag only affects the global exposure events. + * The uiappear/uidisappear events are always dispatched when the element enters or leaves the viewport + */ + #isExposureServiceOn: boolean = true; + readonly #lynxViewInstance: LynxViewInstance; + + constructor( + lynxViewInstance: LynxViewInstance, + ) { + this.#lynxViewInstance = lynxViewInstance; + } + + /** + * diff the current exposure enabled elements with the previous ones, and start/stop IntersectionObserver accordingly + * If an element's exposure-id attribute has changed, we also need to send a new disexposure event with the old one + */ + updateExposureStatus( + elementsToBeEnabled: HTMLElement[], + ) { + const elementsToBeEnabledSet = new Set(elementsToBeEnabled); + for ( + const element of this.#exposureEnabledElementsToIntersectionObserver + .keys() + ) { + if (!elementsToBeEnabledSet.has(element)) { + // stop observing elements that are no longer enabled + this.#exposureEnabledElementsToIntersectionObserver.get(element) + ?.disconnect(); + this.#exposureEnabledElementsToIntersectionObserver.delete(element); + this.#exposureEnabledElementsToOldExposureIdAttributeValue.delete( + element, + ); + } + } + // start observing newly enabled elements + elementsToBeEnabledSet.forEach((element) => { + const currentExposureId = element.getAttribute('exposure-id') || ''; + if (!this.#exposureEnabledElementsToIntersectionObserver.has(element)) { + this.#exposureEnabledElementsToOldExposureIdAttributeValue.set( + element, + currentExposureId, + ); + this.#startIntersectionObserver(element); + } else { + // check if exposure-id attribute has changed + const oldExposureId = this + .#exposureEnabledElementsToOldExposureIdAttributeValue.get(element); + if (oldExposureId !== currentExposureId) { + this.#exposureEnabledElementsToOldExposureIdAttributeValue.set( + element, + currentExposureId, + ); + if (oldExposureId != null) { + // send disexposure event with old exposure id + this.#sendExposureEvent(element, false, oldExposureId, false); + } + } + } + }); + } + + #IntersectionObserverEventHandler = ( + entries: IntersectionObserverEntry[], + ) => { + entries.forEach(({ target, isIntersecting }) => { + if (isIntersecting && !this.#exposedElements.has(target as HTMLElement)) { + this.#sendExposureEvent(target as HTMLElement, true, null, true); + this.#exposedElements.add(target as HTMLElement); + } else if ( + !isIntersecting && this.#exposedElements.has(target as HTMLElement) + ) { + this.#sendExposureEvent(target as HTMLElement, false, null, true); + this.#exposedElements.delete(target as HTMLElement); + } + }); + }; + + #startIntersectionObserver(target: HTMLElement) { + const threshold = parseFloat(target.getAttribute('exposure-area') ?? '0') + / 100; + const screenMarginTop = convertLengthToPx( + target, + target.getAttribute('exposure-screen-margin-top'), + ); + const screenMarginRight = convertLengthToPx( + target, + target.getAttribute('exposure-screen-margin-right'), + ); + const screenMarginBottom = convertLengthToPx( + target, + target.getAttribute('exposure-screen-margin-bottom'), + ); + const screenMarginLeft = convertLengthToPx( + target, + target.getAttribute('exposure-screen-margin-left'), + ); + const uiMarginTop = convertLengthToPx( + target, + target.getAttribute('exposure-ui-margin-top'), + ); + const uiMarginRight = convertLengthToPx( + target, + target.getAttribute('exposure-ui-margin-right'), + ); + const uiMarginBottom = convertLengthToPx( + target, + target.getAttribute('exposure-ui-margin-bottom'), + ); + const uiMarginLeft = convertLengthToPx( + target, + target.getAttribute('exposure-ui-margin-left'), + ); + /** + * TODO: @haoyang.wang support the switch `enableExposureUIMargin` + */ + const calcedRootMarginTop = (uiMarginBottom ? -1 : 1) + * (screenMarginTop - uiMarginBottom); + const calcedRootMarginRight = (uiMarginLeft ? -1 : 1) + * (screenMarginRight - uiMarginLeft); + const calcedRootMarginBottom = (uiMarginTop ? -1 : 1) + * (screenMarginBottom - uiMarginTop); + const calcedRootMarginLeft = (uiMarginRight ? -1 : 1) + * (screenMarginLeft - uiMarginRight); + // get the parent scroll container + let root: HTMLElement | null = target.parentElement; + while (root) { + // @ts-expect-error + if (root[scrollContainerDom]) { + // @ts-expect-error + root = root[scrollContainerDom]; + break; + } else { + root = root.parentElement; + } + } + const rootContainer = root ?? this.#lynxViewInstance.rootDom.parentElement!; + const intersectionObserver = new IntersectionObserver( + this.#IntersectionObserverEventHandler, + { + rootMargin: + `${calcedRootMarginTop}px ${calcedRootMarginRight}px ${calcedRootMarginBottom}px ${calcedRootMarginLeft}px`, + root: rootContainer, + threshold, + }, + ); + intersectionObserver.observe(target); + this.#exposureEnabledElementsToIntersectionObserver.set( + target, + intersectionObserver, + ); + } + + #sendExposureEvent( + target: HTMLElement, + isIntersecting: boolean, + exposureId: string | null, + /** + * Whether to send the uiappear/uidisappear event + * If the exposure service is turned from off to on, we may not want to send the appear events for all currently exposed elements + */ + sendAppearEvent: boolean, + ) { + exposureId = exposureId ?? target.getAttribute('exposure-id')!; + const exposureScene = target.getAttribute('exposure-scene') ?? ''; + const uniqueId = this.#lynxViewInstance.mainThreadGlobalThis + .__GetElementUniqueID(target); + const detail: ExposureEventDetail = { + 'unique-id': uniqueId, + exposureID: exposureId, + exposureScene, + 'exposure-id': exposureId, + 'exposure-scene': exposureScene, + }; + if (sendAppearEvent) { + const appearEvent = new CustomEvent( + isIntersecting ? 'uiappear' : 'uidisappear', + { + bubbles: false, + composed: false, + cancelable: true, + detail, + }, + ); + target.dispatchEvent(appearEvent); + } + const serializedTargetInfo = this.#lynxViewInstance.mtsWasmBinding + .generateTargetObject( + target as DecoratedHTMLElement, + this.#lynxViewInstance.mainThreadGlobalThis.__GetDataset( + target, + ) as CloneableObject + ?? ({} as CloneableObject), + ); + const globalEvent: GlobalExposureEvent = { + ...serializedTargetInfo.dataset, + ...detail, + type: isIntersecting ? 'exposure' : 'disexposure', + target: serializedTargetInfo, + currentTarget: serializedTargetInfo, + detail: { + ...detail, + 'unique-id': 0, + }, + timestamp: Date.now(), + }; + if (isIntersecting) { + this.#globalExposureEventCache.push(globalEvent); + } else { + this.#globalDisexposureEventCache.push(globalEvent); + } + if (!this.#globalExposureEventBatchTimer) { + this.#globalExposureEventBatchTimer = setTimeout(() => { + if ( + this.#globalExposureEventCache.length > 0 + || this.#globalDisexposureEventCache.length > 0 + ) { + const currentExposureEvents = this.#globalExposureEventCache; + const currentDisexposureEvents = this.#globalDisexposureEventCache; + this.#globalExposureEventCache = []; + this.#globalDisexposureEventCache = []; + this.#lynxViewInstance.backgroundThread?.sendGlobalEvent('exposure', [ + currentExposureEvents, + ]); + this.#lynxViewInstance.backgroundThread?.sendGlobalEvent( + 'disexposure', + [ + currentDisexposureEvents, + ], + ); + } + this.#globalExposureEventBatchTimer = null; + }, 1000 / 20); + } + } + + switchExposureService(toEnable: boolean, sendEvent: boolean) { + if (toEnable && !this.#isExposureServiceOn) { + // send all onScreen info + this.#exposedElements.forEach((element) => { + this.#sendExposureEvent( + element as HTMLElement, + true, + element.getAttribute('exposure-id'), + false, + ); + }); + } else if (!toEnable && this.#isExposureServiceOn) { + if (sendEvent) { + this.#exposedElements.forEach((element) => { + this.#sendExposureEvent( + element as HTMLElement, + false, + element.getAttribute('exposure-id'), + false, + ); + }); + } + } + this.#isExposureServiceOn = toEnable; + } +} diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/I18n.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/I18n.ts new file mode 100644 index 0000000000..011807d708 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/I18n.ts @@ -0,0 +1,75 @@ +/* + * Copyright 2025 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 { + Cloneable, + CloneableObject, + I18nResourceTranslationOptions, + InitI18nResources, +} from '../../types/index.js'; +import type { BackgroundThread } from './Background.js'; +import { i18nResourceMissedEventName } from '../../constants.js'; + +export const getCacheI18nResourcesKey = ( + options: I18nResourceTranslationOptions, +) => { + return `${options.locale}_${options.channel}_${options.fallback_url}`; +}; + +export class I18nManager { + readonly #background: BackgroundThread; + readonly #rootDom: ShadowRoot; + #i18nResources: InitI18nResources; + constructor( + background: BackgroundThread, + rootDom: ShadowRoot, + i18nResources: InitI18nResources = [], + ) { + this.#background = background; + this.#rootDom = rootDom; + this.#i18nResources = i18nResources; + } + + updateData(data: InitI18nResources, options: I18nResourceTranslationOptions) { + this.#i18nResources = this.#i18nResources.concat(data); + const matchedInitI18nResources = data.find(i => + getCacheI18nResourcesKey(i.options) + === getCacheI18nResourcesKey(options) + ); + this.#background.dispatchI18nResource( + matchedInitI18nResources?.resource as Cloneable, + ); + } + + _I18nResourceTranslation = ( + options: I18nResourceTranslationOptions, + ): unknown | undefined => { + const matchedInitI18nResources = this.#i18nResources?.find((i) => + getCacheI18nResourcesKey(i.options) + === getCacheI18nResourcesKey(options) + ); + + this.#background.dispatchI18nResource( + matchedInitI18nResources?.resource as Cloneable, + ); + + if (matchedInitI18nResources) { + return matchedInitI18nResources.resource; + } + + this.#triggerI18nResourceFallback(options); + return undefined; + }; + + #triggerI18nResourceFallback( + options: I18nResourceTranslationOptions, + ) { + const event = new CustomEvent(i18nResourceMissedEventName, { + detail: options as CloneableObject, + }); + this.#rootDom.dispatchEvent(event); + } +} diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxView.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxView.ts new file mode 100644 index 0000000000..cf646ca487 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxView.ts @@ -0,0 +1,429 @@ +// Copyright 2023 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 { + Cloneable, + I18nResourceTranslationOptions, + InitI18nResources, + NapiModulesCall, + NapiModulesMap, + NativeModulesCall, + NativeModulesMap, +} from '../../types/index.js'; +import { lynxDisposedAttribute } from '../../constants.js'; +import { createIFrameRealm } from './createIFrameRealm.js'; +import type { LynxViewInstance } from './LynxViewInstance.js'; +import { templateManager } from './TemplateManager.js'; +import( + /* webpackChunkName: "web-core-main-chunk" */ + /* webpackFetchPriority: "high" */ + './LynxViewInstance.js' +); +export type INapiModulesCall = ( + name: string, + data: any, + moduleName: string, + lynxView: LynxViewElement, + dispatchNapiModules: (data: Cloneable) => void, +) => + | Promise<{ data: unknown; transfer?: Transferable[] } | undefined> + | { + data: unknown; + transfer?: Transferable[]; + } + | undefined + | Promise; + +/** + * Based on our experiences, these elements are almost used in all lynx cards. + */ + +/** + * @property {string} url [required] (attribute: "url") The url of the entry of your Lynx card + * @property {Cloneable} globalProps [optional] (attribute: "global-props") The globalProps value of this Lynx card + * @property {Cloneable} initData [optional] (attribute: "init-data") The initial data of this Lynx card + * @property {Record} overrideLynxTagToHTMLTagMap [optional] use this property/attribute to override the lynx tag -> html tag map + * @property {NativeModulesMap} nativeModulesMap [optional] use to customize NativeModules. key is module-name, value is esm url. + * @property {NativeModulesCall} onNativeModulesCall [optional] the NativeModules value handler. Arguments will be cached before this property is assigned. + * @property {"auto" | null} height [optional] (attribute: "height") set it to "auto" for height auto-sizing + * @property {"auto" | null} width [optional] (attribute: "width") set it to "auto" for width auto-sizing + * @property {NapiModulesMap} napiModulesMap [optional] the napiModule which is called in lynx-core. key is module-name, value is esm url. + * @property {INapiModulesCall} onNapiModulesCall [optional] the NapiModule value handler. + * @property {string[]} injectStyleRules [optional] the css rules which will be injected into shadowroot. Each items will be inserted by `insertRule` method. @see https://developer.mozilla.org/docs/Web/API/CSSStyleSheet/insertRule + * @property {number} lynxGroupId [optional] (attribute: "lynx-group-id") the background shared context id, which is used to share webworker between different lynx cards + * @property {(string)=>Promise} customTemplateLoader [optional] the custom template loader, which is used to load the template + * @property {InitI18nResources} initI18nResources [optional] (attribute: "init-i18n-resources") the complete set of i18nResources that on the container side, which can be obtained synchronously by _I18nResourceTranslation + * + * @event error lynx card fired an error + * @event i18nResourceMissed i18n resource cache miss + * + * @example + * HTML Example + * + * Note that you should declarae the size of lynx-view + * + * ```html + * + * + * ``` + * + * React 19 Example + * ```jsx + * + * + * ``` + */ +export class LynxViewElement extends HTMLElement { + static lynxViewCount = 0; + static tag = 'lynx-view' as const; + static observedAttributeAsProperties = [ + 'url', + 'global-props', + 'init-data', + ]; + /** + * @private + */ + static observedAttributes = LynxViewElement.observedAttributeAsProperties.map( + nm => nm.toLowerCase(), + ); + #instance?: LynxViewInstance; + + #connected = false; + #url?: string; + /** + * @public + * @property the url of lynx view output entry file + */ + get url(): string | undefined { + return this.#url; + } + set url(val: string) { + this.#url = val; + this.#render(); + } + + #globalProps: Cloneable = {}; + /** + * @public + * @property globalProps + * @default {} + */ + get globalProps(): Cloneable { + return this.#globalProps; + } + set globalProps(val: string | Cloneable) { + if (typeof val === 'string') { + this.#globalProps = JSON.parse(val); + } else { + this.#globalProps = val; + } + } + + #initData: Cloneable = {}; + /** + * @public + * @property initData + * @default {} + */ + get initData(): Cloneable { + return this.#initData; + } + set initData(val: string | Cloneable) { + if (typeof val === 'string') { + this.#initData = JSON.parse(val); + } else { + this.#initData = val; + } + } + + #initI18nResources: InitI18nResources = []; + /** + * @public + * @property initI18nResources + * @default {} + */ + get initI18nResources(): InitI18nResources { + return this.#initI18nResources; + } + set initI18nResources(val: string | InitI18nResources) { + if (typeof val === 'string') { + this.#initI18nResources = JSON.parse(val); + } else { + this.#initI18nResources = val; + } + } + + /** + * @public + * @method + * update the `__initData` and trigger essential flow + */ + updateI18nResources( + data: InitI18nResources, + options: I18nResourceTranslationOptions, + ) { + this.#instance?.i18nManager.updateData(data, options); + } + + #overrideLynxTagToHTMLTagMap: Record = { + 'page': 'div', + }; + /** + * @public + * @property + * @default {page: 'div'} + */ + get overrideLynxTagToHTMLTagMap(): Record { + return this.#overrideLynxTagToHTMLTagMap; + } + set overrideLynxTagToHTMLTagMap(val: string | Record) { + if (typeof val === 'string') { + this.#overrideLynxTagToHTMLTagMap = JSON.parse(val); + } else { + this.#overrideLynxTagToHTMLTagMap = val; + } + } + + #cachedNativeModulesCall: Array< + { + args: [name: string, data: any, moduleName: string]; + resolve: (ret: unknown) => void; + } + > = []; + #onNativeModulesCall?: NativeModulesCall; + /** + * @param + * @property + */ + get onNativeModulesCall(): NativeModulesCall | undefined { + return this.#onNativeModulesCall; + } + set onNativeModulesCall(handler: NativeModulesCall) { + this.#onNativeModulesCall = handler; + for (const callInfo of this.#cachedNativeModulesCall) { + callInfo.resolve(handler.apply(undefined, callInfo.args)); + } + this.#cachedNativeModulesCall = []; + } + + #nativeModulesMap: NativeModulesMap = {}; + /** + * @public + * @property nativeModulesMap + * @default {} + */ + get nativeModulesMap(): NativeModulesMap | undefined { + return this.#nativeModulesMap; + } + set nativeModulesMap(map: NativeModulesMap) { + this.#nativeModulesMap = map; + } + + #napiModulesMap: NapiModulesMap = {}; + /** + * @param + * @property napiModulesMap + * @default {} + */ + get napiModulesMap(): NapiModulesMap | undefined { + return this.#napiModulesMap; + } + set napiModulesMap(map: NapiModulesMap) { + this.#napiModulesMap = map; + } + + #onNapiModulesCall?: NapiModulesCall; + /** + * @param + * @property + */ + get onNapiModulesCall(): NapiModulesCall | undefined { + return this.#onNapiModulesCall; + } + set onNapiModulesCall(handler: INapiModulesCall) { + this.#onNapiModulesCall = (name, data, moduleName, dispatchNapiModules) => { + return handler(name, data, moduleName, this, dispatchNapiModules); + }; + } + + /** + * @param + * @property + */ + get lynxGroupId(): number | undefined { + return this.getAttribute('lynx-group-id') + ? Number(this.getAttribute('lynx-group-id')!) + : undefined; + } + set lynxGroupId(val: number | undefined) { + if (val) { + this.setAttribute('lynx-group-id', val.toString()); + } else { + this.removeAttribute('lynx-group-id'); + } + } + + /** + * @public + * @method + * update the `__initData` and trigger essential flow + */ + updateData( + data: Cloneable, + processorName?: string, + callback?: () => void, + ) { + this.#instance?.updateData(data, processorName).then(() => { + callback?.(); + }); + } + + /** + * @public + * @method + * update the `__globalProps` + */ + updateGlobalProps(data: Cloneable) { + this.#instance?.updateGlobalProps(data); + this.globalProps = data; + } + + /** + * @public + * @method + * send global events, which can be listened to using the GlobalEventEmitter + */ + sendGlobalEvent(eventName: string, params: Cloneable[]) { + this.#instance?.backgroundThread.sendGlobalEvent(eventName, params); + } + + /** + * @public + * @method + * reload the current page + */ + reload() { + this.removeAttribute('ssr'); + this.#render(); + } + + /** + * @override + * "false" value will be omitted + * + * {@inheritdoc HTMLElement.setAttribute} + */ + override setAttribute(qualifiedName: string, value: string): void { + if (value === 'false') { + this.removeAttribute(qualifiedName); + } else { + super.setAttribute(qualifiedName, value); + } + } + + /** + * @private + */ + attributeChangedCallback(name: string, oldValue: string, newValue: string) { + if (oldValue !== newValue) { + switch (name) { + case 'url': + this.#url = newValue; + break; + case 'global-props': + this.#globalProps = JSON.parse(newValue); + break; + case 'init-data': + this.#initData = JSON.parse(newValue); + break; + } + } + } + + public injectStyleRules: string[] = []; + + /** + * @private + */ + disconnectedCallback() { + this.#instance?.[Symbol.asyncDispose](); + this.#instance = undefined; + // under the all-on-ui strategy, when reload() triggers dsl flush, the previously removed pageElement will be used in __FlushElementTree. + // This attribute is added to filter this issue. + this.shadowRoot?.querySelector('[part="page"]') + ?.setAttribute( + lynxDisposedAttribute, + '', + ); + if (this.shadowRoot) { + this.shadowRoot.innerHTML = ''; + } + } + + /** + * @#the flag to group all changes into one render operation + */ + #rendering = false; + + /** + * @private + */ + #render() { + if (!this.#rendering && this.#connected) { + this.#rendering = true; + if (!this.shadowRoot) { + this.attachShadow({ mode: 'open' }); + } + if (this.#instance) { + this.disconnectedCallback(); + } + const mtsRealmPromise = createIFrameRealm(this.shadowRoot!); + queueMicrotask(async () => { + const mtsRealm = await mtsRealmPromise; + if (this.#url) { + const lynxViewInstance = import( + /* webpackChunkName: "web-core-main-chunk" */ + /* webpackFetchPriority: "high" */ + './LynxViewInstance.js' + ).then(({ LynxViewInstance }) => { + return new LynxViewInstance( + this, + this.initData, + this.globalProps, + this.#url!, + this.shadowRoot!, + mtsRealm, + lynxGroupId, + this.#nativeModulesMap, + this.#napiModulesMap, + this.#initI18nResources, + ); + }); + templateManager.fetchBundle(this.#url, lynxViewInstance); + + const lynxGroupId = this.lynxGroupId; + this.#instance = await lynxViewInstance; + this.#rendering = false; + } + }); + } + } + /** + * @private + */ + connectedCallback() { + if (this.url) { + this.#url = this.url; + } + this.#connected = true; + this.#render(); + } +} + +if (customElements.get(LynxViewElement.tag)) { + console.error(`[${LynxViewElement.tag}] has already been defined`); +} else { + customElements.define(LynxViewElement.tag, LynxViewElement); +} diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxViewInstance.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxViewInstance.ts new file mode 100644 index 0000000000..b01a454200 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxViewInstance.ts @@ -0,0 +1,295 @@ +/* + * Copyright 2025 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 { + Cloneable, + InitI18nResources, + JSRealm, + MainThreadGlobalThis, + NapiModulesMap, + NativeModulesMap, + PageConfig, +} from '../../types/index.js'; +import { + loadUnknownElementEventName, + systemInfoBase, +} from '../../constants.js'; +import type { DecodedStyle } from '../wasm.js'; +import { BackgroundThread } from './Background.js'; +import { I18nManager } from './I18n.js'; +import { WASMJSBinding } from './elementAPIs/WASMJSBinding.js'; +import { ExposureServices } from './ExposureServices.js'; +import { createElementAPI } from './elementAPIs/createElementAPI.js'; +import { createMainThreadGlobalAPIs } from './createMainThreadGlobalAPIs.js'; +import { templateManager } from './TemplateManager.js'; +import { loadWebElement } from '../webElementsDynamicLoader.js'; +import type { LynxViewElement } from './LynxView.js'; +import { StyleManager } from './StyleManager.js'; + +const pixelRatio = window.devicePixelRatio; +const screenWidth = window.screen.availWidth * pixelRatio; +const screenHeight = window.screen.availHeight * pixelRatio; +export const systemInfo = Object.freeze({ + ...systemInfoBase, + // some information only available on main thread, we should read and pass to worker + pixelRatio, + screenWidth, + screenHeight, +}); + +export interface LynxViewConfigs { + templateUrl: string; + initData: Cloneable; + globalProps: Cloneable; + shadowRoot: ShadowRoot; + nativeModulesMap: NativeModulesMap; + napiModulesMap: NapiModulesMap; + tagMap: Record; + lynxGroupId: number | undefined; + initI18nResources: InitI18nResources; +} + +export class LynxViewInstance implements AsyncDisposable { + readonly mainThreadGlobalThis: MainThreadGlobalThis; + readonly mtsWasmBinding: WASMJSBinding; + readonly backgroundThread: BackgroundThread; + readonly i18nManager: I18nManager; + readonly exposureServices: ExposureServices; + readonly webElementsLoadingPromises: Promise[] = []; + readonly styleReadyPromise: Promise; + readonly styleReadyResolve: () => void; + styleManager?: StyleManager; + + #renderPageFunction: ((data: Cloneable) => void) | null = null; + #queryComponentCache: Map> = new Map(); + #pageConfig?: PageConfig; + #nativeModulesMap: NativeModulesMap; + #napiModulesMap: NapiModulesMap; + + lepusCodeUrls = new Map>(); + + constructor( + public readonly parentDom: LynxViewElement, + public readonly initData: Cloneable, + public readonly globalprops: Cloneable, + public readonly templateUrl: string, + public readonly rootDom: ShadowRoot, + public readonly mtsRealm: JSRealm, + lynxGroupId: number | undefined, + nativeModulesMap: NativeModulesMap = {}, + napiModulesMap: NapiModulesMap = {}, + initI18nResources?: InitI18nResources, + ) { + this.#nativeModulesMap = nativeModulesMap; + this.#napiModulesMap = napiModulesMap; + let resolve!: () => void; + const promise = new Promise((res) => { + resolve = res; + }); + this.styleReadyPromise = promise; + this.styleReadyResolve = resolve; + this.parentDom.style.display = 'none'; + this.mainThreadGlobalThis = mtsRealm.globalWindow as + & typeof globalThis + & MainThreadGlobalThis; + + this.backgroundThread = new BackgroundThread(lynxGroupId, this); + this.i18nManager = new I18nManager( + this.backgroundThread, + this.rootDom, + initI18nResources, + ); + this.mtsWasmBinding = new WASMJSBinding( + this, + ); + this.exposureServices = new ExposureServices( + this, + ); + } + + onPageConfigReady(config: PageConfig) { + if (this.#pageConfig) { + return; + } + // create element APIs + this.#pageConfig = config; + const enableCSSSelector = config['enableCSSSelector'] == 'true'; + const defaultDisplayLinear = config['defaultDisplayLinear'] == 'true'; + const defaultOverflowVisible = config['defaultOverflowVisible'] == 'true'; + this.styleManager = new StyleManager( + this.rootDom, + ); + Object.assign( + this.mtsRealm.globalWindow, + createElementAPI( + this.templateUrl, + this.rootDom, + this.mtsWasmBinding, + enableCSSSelector, + defaultDisplayLinear, + defaultOverflowVisible, + ), + createMainThreadGlobalAPIs( + this, + ), + ); + Object.defineProperty(this.mainThreadGlobalThis, 'renderPage', { + get: () => { + return this.#renderPageFunction; + }, + set: (v) => { + this.#renderPageFunction = v; + this.onMTSScriptsExecuted(); + }, + configurable: true, + enumerable: true, + }); + } + + onStyleInfoReady(styleInfo: DecodedStyle, currentUrl: string) { + this.styleManager?.pushStyleSheet( + styleInfo, + currentUrl === this.templateUrl ? undefined : currentUrl, + ); + this.parentDom.style.display = 'flex'; + this.styleReadyResolve(); + } + + onMTSScriptsLoaded(currentUrl: string, isLazy: boolean) { + const urlMap = templateManager.getTemplate(currentUrl) + ?.lepusCode as Record; + this.lepusCodeUrls.set( + currentUrl, + urlMap, + ); + if (!isLazy) { + this.mtsRealm.loadScript( + urlMap['root']!, + ); + } + } + + async onMTSScriptsExecuted() { + await Promise.all([ + ...this.webElementsLoadingPromises, + this.styleReadyPromise, + ]); + this.webElementsLoadingPromises.length = 0; + const processedData = this.mainThreadGlobalThis.processData + ? this.mainThreadGlobalThis.processData(this.initData) + : this.initData; + this.backgroundThread.startWebWorker( + processedData, + this.globalprops, + templateManager.getTemplate(this.templateUrl)!.config!.cardType, + templateManager.getTemplate(this.templateUrl)?.customSections as Record< + string, + Cloneable + >, + this.#nativeModulesMap, + this.#napiModulesMap, + ); + this.#renderPageFunction?.(processedData); + this.mainThreadGlobalThis.__FlushElementTree(); + } + + async onBTSScriptsLoaded(url: string) { + const btsUrls = templateManager.getTemplate(url) + ?.backgroundCode as Record< + string, + string + >; + await this.backgroundThread.updateBTSChunk( + url, + btsUrls, + ); + this.backgroundThread.startBTS(); + } + + loadWebElement(id: number) { + const loadPromise = loadWebElement(id); + if (loadPromise) { + this.webElementsLoadingPromises.push(loadPromise); + } + } + + loadUnknownElement(tagName: string) { + if (tagName.includes('-') && !customElements.get(tagName)) { + this.rootDom.dispatchEvent( + new CustomEvent(loadUnknownElementEventName, { + detail: { + tagName, + }, + }), + ); + this.webElementsLoadingPromises.push( + customElements.whenDefined(tagName).then(() => {}), + ); + } + } + + queryComponent(url: string): Promise { + if (this.#queryComponentCache.has(url)) { + return this.#queryComponentCache.get(url)!; + } + const promise = templateManager.fetchBundle(url, Promise.resolve(this), { + enableCSSSelector: this.#pageConfig!['enableCSSSelector'], + }) + .then(async () => { + const urlMap = this.lepusCodeUrls.get(url); + const rootUrl = urlMap?.['root']; + if (!rootUrl) { + throw new Error(`[lynx-web] Missing root URL for component: ${url}`); + } + let lepusRootChunkExport = await this.mtsRealm.loadScript( + rootUrl, + ); + lepusRootChunkExport = this.mainThreadGlobalThis.processEvalResult?.( + lepusRootChunkExport, + url, + ); + return lepusRootChunkExport; + }); + this.#queryComponentCache.set(url, promise); + return promise; + } + + async updateData( + data: Cloneable, + processorName?: string, + ): Promise { + const processedData = this.mainThreadGlobalThis.processData + ? this.mainThreadGlobalThis.processData(data, processorName) + : data; + this.mainThreadGlobalThis.updatePage?.(processedData, { processorName }); + await this.backgroundThread.updateData(processedData, { processorName }); + } + + async updateGlobalProps(data: Cloneable) { + await this.backgroundThread.updateGlobalProps(data); + } + + reportError(error: Error, release: string, fileName: string) { + this.rootDom.dispatchEvent( + new CustomEvent('error', { + detail: { + sourceMap: { + offset: { + line: 2, + col: 0, + }, + }, + error, + release, + fileName, + }, + }), + ); + } + + async [Symbol.asyncDispose]() { + await this.backgroundThread[Symbol.asyncDispose](); + } +} diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/StyleManager.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/StyleManager.ts new file mode 100644 index 0000000000..e179ffe1c1 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/StyleManager.ts @@ -0,0 +1,100 @@ +// Copyright 2025 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 { lynxUniqueIdAttribute } from '../../constants.js'; +import type { DecodedStyle } from '../wasm.js'; +// @ts-expect-error +import IN_SHADOW_CSS_MODERN from '../../../css/in_shadow.css?inline'; + +const IN_SHADOW_CSS = URL.createObjectURL( + new Blob([IN_SHADOW_CSS_MODERN], { type: 'text/css' }), +); + +const IMPORT_CSS_STMT = `@import url("${IN_SHADOW_CSS}");\n`; + +/** + * There are two modes to manage styles: + * 1. CSS Selector mode: styles are injected into a