From c3fa234445bd621db8968e02e4028c3c73b2fc47 Mon Sep 17 00:00:00 2001 From: pupiltong <12288479+PupilTong@users.noreply.github.com> Date: Mon, 11 May 2026 20:01:25 +0800 Subject: [PATCH] feat(web-core): support frame element --- .changeset/frame-web-core.md | 5 ++ .../web-core-e2e/tests/reactlynx.spec.ts | 65 ++++++++++++++++ .../reactlynx/api-frame-auto-height/index.jsx | 19 +++++ .../reactlynx/api-frame-auto-width/index.jsx | 19 +++++ .../reactlynx/api-frame-bindload/index.jsx | 27 +++++++ .../reactlynx/api-frame-data-update/index.jsx | 23 ++++++ .../tests/reactlynx/api-frame-data/index.jsx | 18 +++++ .../reactlynx/api-frame-element-map/index.jsx | 18 +++++ .../api-frame-global-props/index.jsx | 18 +++++ .../tests/reactlynx/api-frame-inner/index.jsx | 19 +++++ .../tests/reactlynx/api-frame-src/index.jsx | 18 +++++ .../web-platform/web-core/src/constants.rs | 1 + .../style_info/style_info_decoder.rs | 38 +++++++++ .../web-core/ts/client/mainthread/LynxView.ts | 77 ++++++++++++++----- .../elementAPIs/createElementAPI.ts | 29 +++++++ .../web-platform/web-core/ts/constants.ts | 1 + .../ts/server/elementAPIs/createElementAPI.ts | 10 +++ .../web-core/ts/types/IElementPAPI.ts | 3 + 18 files changed, 390 insertions(+), 18 deletions(-) create mode 100644 .changeset/frame-web-core.md create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-auto-height/index.jsx create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-auto-width/index.jsx create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-bindload/index.jsx create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-data-update/index.jsx create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-data/index.jsx create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-element-map/index.jsx create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-global-props/index.jsx create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-inner/index.jsx create mode 100644 packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-src/index.jsx diff --git a/.changeset/frame-web-core.md b/.changeset/frame-web-core.md new file mode 100644 index 0000000000..59945c9170 --- /dev/null +++ b/.changeset/frame-web-core.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/web-core": patch +--- + +Add web support for the `` element by mapping it to ``. diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts b/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts index c93d3eae21..4874396030 100644 --- a/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts +++ b/packages/web-platform/web-core-e2e/tests/reactlynx.spec.ts @@ -261,6 +261,71 @@ test.describe('reactlynx3 tests', () => { await expect(height).toHaveText('5678'); }); + test('api-frame-element-map', async ({ page }, { title }) => { + await goto(page, title); + await expect(page.locator('#target')).toHaveJSProperty( + 'tagName', + 'LYNX-VIEW', + ); + }); + + test('api-frame-src', async ({ page }, { title }) => { + await goto(page, title); + await expect(page.locator('#frame-ready')).toHaveText('frame:ready'); + }); + + test('api-frame-data', async ({ page }, { title }) => { + await goto(page, title); + await expect(page.locator('#frame-data')).toHaveText('data:from-data'); + }); + + test('api-frame-data-update', async ({ page }, { title }) => { + await goto(page, title); + await expect(page.locator('#frame-data')).toHaveText('data:before'); + await page.locator('#update-frame-data').click(); + await expect(page.locator('#frame-data')).toHaveText('data:after'); + }); + + test('api-frame-global-props', async ({ page }, { title }) => { + await goto(page, title); + await expect(page.locator('#frame-global-props')).toHaveText( + 'global:from-global-props', + ); + }); + + test('api-frame-bindload', async ({ page }, { title }) => { + await goto(page, title); + await expect(page.locator('#frame-load-status')).toHaveText('0'); + await expect(page.locator('#frame-load-message')).toHaveText('success'); + await expect(page.locator('#frame-load-url')).toContainText( + '/dist/api-frame-inner.web.bundle', + ); + }); + + test('api-frame-auto-height', async ({ page }, { title }) => { + await goto(page, title); + await expect(page.locator('#target')).toHaveAttribute( + 'auto-height', + 'true', + ); + await expect(page.locator('#target')).not.toHaveAttribute( + 'height', + 'auto', + ); + }); + + test('api-frame-auto-width', async ({ page }, { title }) => { + await goto(page, title); + await expect(page.locator('#target')).toHaveAttribute( + 'auto-width', + 'true', + ); + await expect(page.locator('#target')).not.toHaveAttribute( + 'width', + 'auto', + ); + }); + test('basic-bindtap-simultaneous', async ({ page }, { title }) => { await goto(page, title); await wait(100); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-auto-height/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-auto-height/index.jsx new file mode 100644 index 0000000000..369ac24666 --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-auto-height/index.jsx @@ -0,0 +1,19 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root } from '@lynx-js/react'; + +function App() { + return ( + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-auto-width/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-auto-width/index.jsx new file mode 100644 index 0000000000..c5313ea6ad --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-auto-width/index.jsx @@ -0,0 +1,19 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root } from '@lynx-js/react'; + +function App() { + return ( + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-bindload/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-bindload/index.jsx new file mode 100644 index 0000000000..098c811a80 --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-bindload/index.jsx @@ -0,0 +1,27 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root, useState } from '@lynx-js/react'; + +function App() { + const [detail, setDetail] = useState({ + statusCode: -1, + statusMessage: '', + url: '', + }); + + return ( + + setDetail(event.detail)} + style={{ width: '300px', height: '120px' }} + /> + {detail.statusCode} + {detail.statusMessage} + {detail.url} + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-data-update/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-data-update/index.jsx new file mode 100644 index 0000000000..684ddee557 --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-data-update/index.jsx @@ -0,0 +1,23 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root, useState } from '@lynx-js/react'; + +function App() { + const [label, setLabel] = useState('before'); + + return ( + + + setLabel('after')}> + update + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-data/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-data/index.jsx new file mode 100644 index 0000000000..56c8eb93f6 --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-data/index.jsx @@ -0,0 +1,18 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root } from '@lynx-js/react'; + +function App() { + return ( + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-element-map/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-element-map/index.jsx new file mode 100644 index 0000000000..a3c402d363 --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-element-map/index.jsx @@ -0,0 +1,18 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root } from '@lynx-js/react'; + +function App() { + return ( + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-global-props/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-global-props/index.jsx new file mode 100644 index 0000000000..8895c8fe3b --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-global-props/index.jsx @@ -0,0 +1,18 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root } from '@lynx-js/react'; + +function App() { + return ( + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-inner/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-inner/index.jsx new file mode 100644 index 0000000000..1ff85d8f6d --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-inner/index.jsx @@ -0,0 +1,19 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root, useInitData } from '@lynx-js/react'; + +function App() { + const initData = useInitData(); + const globalProps = lynx.__globalProps; + + return ( + + frame:ready + data:{initData.label} + global:{globalProps.message} + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-src/index.jsx b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-src/index.jsx new file mode 100644 index 0000000000..a3c402d363 --- /dev/null +++ b/packages/web-platform/web-core-e2e/tests/reactlynx/api-frame-src/index.jsx @@ -0,0 +1,18 @@ +// Copyright 2026 The Lynx Authors. All rights reserved. +// Licensed under the Apache License Version 2.0 that can be found in the +// LICENSE file in the root directory of this source tree. +import { root } from '@lynx-js/react'; + +function App() { + return ( + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-core/src/constants.rs b/packages/web-platform/web-core/src/constants.rs index 32521fc388..d058f921f1 100644 --- a/packages/web-platform/web-core/src/constants.rs +++ b/packages/web-platform/web-core/src/constants.rs @@ -32,6 +32,7 @@ lazy_static::lazy_static! { ("list", "x-list"), ("page", "div"), ("svg", "x-svg"), + ("frame", "lynx-view"), ]); pub static ref HTML_TAG_TO_LYNX_TAG_MAP: FnvHashMap<&'static str, &'static str> = FnvHashMap::from_iter(LYNX_TAG_TO_HTML_TAG_MAP diff --git a/packages/web-platform/web-core/src/template/template_sections/style_info/style_info_decoder.rs b/packages/web-platform/web-core/src/template/template_sections/style_info/style_info_decoder.rs index 418e829364..4df1840de1 100644 --- a/packages/web-platform/web-core/src/template/template_sections/style_info/style_info_decoder.rs +++ b/packages/web-platform/web-core/src/template/template_sections/style_info/style_info_decoder.rs @@ -703,6 +703,44 @@ mod test { assert_eq!(result.style_content, expected); } + #[test] + fn test_frame_type_selector() { + let raw_style_info = RawStyleInfo { + css_id_to_style_sheet: FnvHashMap::from_iter(vec![( + 0, + StyleSheet { + imports: vec![], + rules: vec![Rule { + nested_rules: vec![], + rule_type: RuleType::Declaration, + prelude: RulePrelude { + selector_list: vec![Selector { + simple_selectors: vec![OneSimpleSelector { + selector_type: OneSimpleSelectorType::TypeSelector, + value: "frame".to_string(), + }], + }], + }, + declaration_block: DeclarationBlock { + declarations: vec![ParsedDeclaration { + property_id: CSSPropertyEnum::Height.into(), + is_important: false, + value_token_list: vec![ValueToken { + token_type: crate::css_tokenizer::token_types::DIMENSION_TOKEN, + value: "200px".to_string(), + }], + }], + }, + }], + }, + )]), + style_content_str_size_hint: 0, + }; + let result = generate_string_buf(raw_style_info, true, None); + let expected = "lynx-view:not([l-e-name]){height:200px;}"; + assert_eq!(result.style_content, expected); + } + #[test] fn test_multiple_selectors() { let raw_style_info = RawStyleInfo { diff --git a/packages/web-platform/web-core/ts/client/mainthread/LynxView.ts b/packages/web-platform/web-core/ts/client/mainthread/LynxView.ts index 2b8270058c..ca4c4f4eab 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/LynxView.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/LynxView.ts @@ -70,8 +70,10 @@ export class LynxViewElement extends HTMLElement { static tag = 'lynx-view' as const; static observedAttributeAsProperties = [ 'url', + 'src', 'global-props', 'init-data', + 'data', 'browser-config', 'transform-vw', 'transform-vh', @@ -203,11 +205,21 @@ export class LynxViewElement extends HTMLElement { get url(): string | undefined { return this.#url; } - set url(val: string) { + set url(val: string | undefined) { + if (this.#url === val) { + return; + } this.#url = val; this.#render(); } + get src(): string | undefined { + return this.url; + } + set src(val: string | undefined) { + this.url = val; + } + #globalProps: Cloneable = {}; /** * @public @@ -218,11 +230,16 @@ export class LynxViewElement extends HTMLElement { return this.#globalProps; } set globalProps(val: string | Cloneable) { - if (typeof val === 'string') { - this.#globalProps = JSON.parse(val); - } else { - this.#globalProps = val; - } + const nextGlobalProps = typeof val === 'string' ? JSON.parse(val) : val; + this.#globalProps = nextGlobalProps; + this.#instance?.updateGlobalProps(nextGlobalProps); + } + + get ['global-props'](): Cloneable { + return this.globalProps; + } + set ['global-props'](val: string | Cloneable) { + this.globalProps = val; } #initData: Cloneable = {}; @@ -235,11 +252,22 @@ export class LynxViewElement extends HTMLElement { return this.#initData; } set initData(val: string | Cloneable) { - if (typeof val === 'string') { - this.#initData = JSON.parse(val); - } else { - this.#initData = val; - } + const nextInitData = typeof val === 'string' ? JSON.parse(val) : val; + this.updateData(nextInitData); + } + + get ['init-data'](): Cloneable { + return this.initData; + } + set ['init-data'](val: string | Cloneable) { + this.initData = val; + } + + get data(): Cloneable { + return this.initData; + } + set data(val: string | Cloneable) { + this.initData = val; } #initI18nResources: InitI18nResources = []; @@ -320,6 +348,7 @@ export class LynxViewElement extends HTMLElement { processorName?: string, callback?: () => void, ) { + this.#initData = data; this.#instance?.updateData(data, processorName).then(() => { callback?.(); }); @@ -331,7 +360,6 @@ export class LynxViewElement extends HTMLElement { * update the `__globalProps` */ updateGlobalProps(data: Cloneable) { - this.#instance?.updateGlobalProps(data); this.globalProps = data; } @@ -371,20 +399,26 @@ export class LynxViewElement extends HTMLElement { /** * @private */ - attributeChangedCallback(name: string, oldValue: string, newValue: string) { + attributeChangedCallback( + name: string, + oldValue: string | null, + newValue: string | null, + ) { if (oldValue !== newValue) { switch (name) { case 'url': - this.#url = newValue; + case 'src': + this.url = newValue ?? undefined; break; case 'global-props': - this.#globalProps = JSON.parse(newValue); + this.globalProps = newValue ? JSON.parse(newValue) : {}; break; case 'browser-config': - this.browserConfig = JSON.parse(newValue); + this.browserConfig = newValue ? JSON.parse(newValue) : undefined; break; case 'init-data': - this.#initData = JSON.parse(newValue); + case 'data': + this.initData = newValue ? JSON.parse(newValue) : {}; break; case 'transform-vw': this.transformVW = newValue !== 'false' && newValue !== null; @@ -446,7 +480,7 @@ export class LynxViewElement extends HTMLElement { * @private */ async #render() { - if (!this.#rendering && this.#connected) { + if (!this.#rendering && this.#connected && this.#url) { this.#rendering = true; if (!this.shadowRoot) { this.attachShadow({ mode: 'open' }); @@ -524,6 +558,13 @@ export class LynxViewElement extends HTMLElement { * @private */ connectedCallback() { + this.#upgradeProperty('url'); + this.#upgradeProperty('src'); + this.#upgradeProperty('globalProps'); + this.#upgradeProperty('global-props'); + this.#upgradeProperty('initData'); + this.#upgradeProperty('init-data'); + this.#upgradeProperty('data'); this.#upgradeProperty('browserConfig'); this.#upgradeProperty('transformVW'); this.#upgradeProperty('transformVH'); diff --git a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts index 7e81b79b0e..c4be2b0d2f 100644 --- a/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts +++ b/packages/web-platform/web-core/ts/client/mainthread/elementAPIs/createElementAPI.ts @@ -59,6 +59,21 @@ const { set_inline_styles_in_key_value_vec, } = wasmInstance; +function dispatchLynxViewLoadEvent(host: HTMLElement & { url?: string }) { + host.dispatchEvent( + new CustomEvent('load', { + detail: { + statusCode: 0, + statusMessage: 'success', + url: host.url ?? '', + }, + bubbles: true, + cancelable: true, + composed: true, + }), + ); +} + export function createElementAPI( rootDom: ShadowRoot, mtsBinding: WASMJSBinding, @@ -178,6 +193,17 @@ export function createElementAPI( ); return dom; }, + __CreateFrame(parentComponentUniqueId) { + const dom = document.createElement( + LYNX_TAG_TO_HTML_TAG_MAP['frame']!, + ) as DecoratedHTMLElement; + dom[uniqueIdSymbol] = wasmContext.create_element_common( + parentComponentUniqueId, + dom, + new WeakRef(dom), + ); + return dom; + }, __CreateRawText(text) { const dom = document.createElement('raw-text') as DecoratedHTMLElement; dom.setAttribute('text', text); @@ -610,6 +636,9 @@ export function createElementAPI( backgroundThread.markTiming('ui_operation_flush_start', pipelineId); rootDom.appendChild(page); (rootDom.host as HTMLElement).style.display = 'flex'; + dispatchLynxViewLoadEvent( + rootDom.host as HTMLElement & { url?: string }, + ); backgroundThread.markTiming('ui_operation_flush_end', pipelineId); backgroundThread.markTiming('layout_end', pipelineId); backgroundThread.markTiming('dispatch_end', pipelineId); diff --git a/packages/web-platform/web-core/ts/constants.ts b/packages/web-platform/web-core/ts/constants.ts index 621e30f607..24eeb69de3 100644 --- a/packages/web-platform/web-core/ts/constants.ts +++ b/packages/web-platform/web-core/ts/constants.ts @@ -87,6 +87,7 @@ export const LYNX_TAG_TO_HTML_TAG_MAP: Record = 'input': 'x-input', 'x-input-ng': 'x-input', 'svg': 'x-svg', + 'frame': 'lynx-view', }), ); diff --git a/packages/web-platform/web-core/ts/server/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core/ts/server/elementAPIs/createElementAPI.ts index ca438c38f8..98a0e174b6 100644 --- a/packages/web-platform/web-core/ts/server/elementAPIs/createElementAPI.ts +++ b/packages/web-platform/web-core/ts/server/elementAPIs/createElementAPI.ts @@ -20,6 +20,7 @@ import type { AppendElementPAPI, CreateComponentPAPI, CreateElementPAPI, + CreateFramePAPI, CreateImagePAPI, CreateListPAPI, CreatePagePAPI, @@ -303,6 +304,15 @@ export function createElementAPI( ); return { [uniqueIdSymbol]: id } as unknown as DecoratedHTMLElement; }) as CreateImagePAPI, + __CreateFrame: ((parentComponentUniqueId: number) => { + const htmlTag = LYNX_TAG_TO_HTML_TAG_MAP['frame']!; + const id = wasmContext.create_element(htmlTag, parentComponentUniqueId); + const el = { [uniqueIdSymbol]: id }; + if (!config.enableCSSSelector) { + wasmContext.set_attribute(id, lynxUniqueIdAttribute, id.toString()); + } + return el as unknown as DecoratedHTMLElement; + }) as CreateFramePAPI, __CreateRawText: ((text: string) => { const id = wasmContext.create_element('raw-text'); wasmContext.set_attribute(id, 'text', text); diff --git a/packages/web-platform/web-core/ts/types/IElementPAPI.ts b/packages/web-platform/web-core/ts/types/IElementPAPI.ts index d53d692fa4..7433095291 100644 --- a/packages/web-platform/web-core/ts/types/IElementPAPI.ts +++ b/packages/web-platform/web-core/ts/types/IElementPAPI.ts @@ -225,6 +225,8 @@ export type CreateRawTextPAPI = (text: string) => HTMLElement; export type CreateImagePAPI = CreateViewPAPI; +export type CreateFramePAPI = CreateViewPAPI; + export type CreateScrollViewPAPI = CreateViewPAPI; export type CreateWrapperElementPAPI = CreateViewPAPI; @@ -393,6 +395,7 @@ export interface ElementPAPIs { __CreateText: CreateTextPAPI; __CreateRawText: CreateRawTextPAPI; __CreateImage: CreateImagePAPI; + __CreateFrame: CreateFramePAPI; __CreateScrollView: CreateScrollViewPAPI; __CreateWrapperElement: CreateWrapperElementPAPI; __CreateComponent: CreateComponentPAPI;