diff --git a/.changeset/fluffy-masks-mate.md b/.changeset/fluffy-masks-mate.md new file mode 100644 index 0000000000..6203641d49 --- /dev/null +++ b/.changeset/fluffy-masks-mate.md @@ -0,0 +1,9 @@ +--- +"@lynx-js/web-mainthread-apis": patch +"@lynx-js/web-worker-runtime": patch +"@lynx-js/web-core-server": patch +"@lynx-js/web-constants": patch +"@lynx-js/web-core": patch +--- + +feat: supports lazy bundle. (This feature requires `@lynx-js/lynx-core >= 0.1.3`) diff --git a/packages/web-platform/offscreen-document/src/webworker/OffscreenElement.ts b/packages/web-platform/offscreen-document/src/webworker/OffscreenElement.ts index 5577ad9c05..a71774a9b2 100644 --- a/packages/web-platform/offscreen-document/src/webworker/OffscreenElement.ts +++ b/packages/web-platform/offscreen-document/src/webworker/OffscreenElement.ts @@ -275,6 +275,10 @@ export class OffscreenElement extends EventTarget { super.addEventListener(type, callback, options); } + get textContent() { + return this[textContent]; + } + set textContent(text: string) { this[ancestorDocument][operations].push( OperationType.SetTextContent, diff --git a/packages/web-platform/web-constants/src/endpoints.ts b/packages/web-platform/web-constants/src/endpoints.ts index 72c613c6eb..c7c5e88742 100644 --- a/packages/web-platform/web-constants/src/endpoints.ts +++ b/packages/web-platform/web-constants/src/endpoints.ts @@ -11,7 +11,11 @@ import type { Cloneable, CloneableObject } from './types/Cloneable.js'; import type { StartMainThreadContextConfig } from './types/MainThreadStartConfigs.js'; import type { IdentifierType, InvokeCallbackRes } from './types/NativeApp.js'; import type { ElementAnimationOptions } from './types/Element.js'; -import type { BackMainThreadContextConfig, MarkTiming } from './types/index.js'; +import type { + BackMainThreadContextConfig, + LynxTemplate, + MarkTiming, +} from './types/index.js'; export const postExposureEndpoint = createRpcEndpoint< [{ exposures: ExposureWorkerEvent[]; disExposures: ExposureWorkerEvent[] }], @@ -240,3 +244,18 @@ export const dispatchI18nResourceEndpoint = createRpcEndpoint< [Cloneable], void >('dispatchI18nResource', false, false); + +export const queryComponentEndpoint = createRpcEndpoint< + [string], + { code: number; detail: { schema: string } } +>('queryComponent', false, true); + +export const updateBTSTemplateCacheEndpoint = createRpcEndpoint< + [/** url */ string, LynxTemplate], + void +>('updateBTSTemplateCacheEndpoint', false, true); + +export const loadTemplateMultiThread = createRpcEndpoint< + [string], + LynxTemplate +>('loadTemplateMultiThread', false, true); diff --git a/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts b/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts index 990f55a418..8512b56029 100644 --- a/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts +++ b/packages/web-platform/web-constants/src/types/MainThreadGlobalThis.ts @@ -270,6 +270,7 @@ export type SetInlineStylesPAPI = ( export type SetCSSIdPAPI = ( elements: WebFiberElementImpl[], cssId: number | null, + entryName: string | undefined, ) => void; export type GetPageElementPAPI = () => WebFiberElementImpl | undefined; @@ -301,6 +302,17 @@ export type GetAttributeByNamePAPI = ( name: string, ) => string | null; +export type QueryComponentPAPI = ( + source: string, + resultCallback?: (result: { + code: number; + data?: { + url: string; + evalResult: unknown; + }; + }) => void, +) => null; + export interface MainThreadGlobalThis { __ElementFromBinary: ElementFromBinaryPAPI; @@ -381,6 +393,12 @@ export interface MainThreadGlobalThis { ) => unknown | undefined; // This is an empty implementation, just to avoid business call errors _AddEventListener: (...args: unknown[]) => void; + __QueryComponent: QueryComponentPAPI; + // DSL runtime binding + processEvalResult?: ( + exports: unknown, + schema: string, + ) => unknown; // the following methods is assigned by the main thread user code renderPage: ((data: unknown) => void) | undefined; updatePage?: (data: Cloneable, options?: Record) => void; diff --git a/packages/web-platform/web-constants/src/types/MarkTiming.ts b/packages/web-platform/web-constants/src/types/MarkTiming.ts index 62e4177ace..96d3266cc6 100644 --- a/packages/web-platform/web-constants/src/types/MarkTiming.ts +++ b/packages/web-platform/web-constants/src/types/MarkTiming.ts @@ -7,3 +7,9 @@ export interface MarkTiming { pipelineId?: string; timeStamp: number; } + +export type MarkTimingInternal = ( + timingKey: string, + pipelineId?: string, + timeStamp?: number, +) => void; diff --git a/packages/web-platform/web-constants/src/types/NativeApp.ts b/packages/web-platform/web-constants/src/types/NativeApp.ts index 6eba140132..b8da11ed7d 100644 --- a/packages/web-platform/web-constants/src/types/NativeApp.ts +++ b/packages/web-platform/web-constants/src/types/NativeApp.ts @@ -123,7 +123,7 @@ export interface NativeApp { cancelAnimationFrame: (id: number) => void; - loadScript: (sourceURL: string) => BundleInitReturnObj; + loadScript: (sourceURL: string, entryName?: string) => BundleInitReturnObj; loadScriptAsync( sourceURL: string, @@ -219,4 +219,15 @@ export interface NativeApp { reportException: (error: Error, _: unknown) => void; __SetSourceMapRelease: (err: Error) => void; + + queryComponent: ( + source: string, + callback: ( + ret: { __hasReady: boolean } | { + code: number; + detail?: { schema: string }; + }, + ) => void, + ) => void; + tt: NativeTTObject | null; } diff --git a/packages/web-platform/web-constants/src/types/TemplateLoader.ts b/packages/web-platform/web-constants/src/types/TemplateLoader.ts new file mode 100644 index 0000000000..f7a9736d61 --- /dev/null +++ b/packages/web-platform/web-constants/src/types/TemplateLoader.ts @@ -0,0 +1,3 @@ +import type { LynxTemplate } from './LynxModule.js'; + +export type TemplateLoader = (url: string) => Promise; diff --git a/packages/web-platform/web-constants/src/types/index.ts b/packages/web-platform/web-constants/src/types/index.ts index 5d62658b2e..14e07f04c7 100644 --- a/packages/web-platform/web-constants/src/types/index.ts +++ b/packages/web-platform/web-constants/src/types/index.ts @@ -20,3 +20,4 @@ export * from './BackThreadStartConfigs.js'; export * from './MarkTiming.js'; export * from './SSR.js'; export * from './JSRealm.js'; +export * from './TemplateLoader.js'; diff --git a/packages/web-platform/web-constants/src/utils/generateTemplate.ts b/packages/web-platform/web-constants/src/utils/generateTemplate.ts index 900bf96a98..780048a7b1 100644 --- a/packages/web-platform/web-constants/src/utils/generateTemplate.ts +++ b/packages/web-platform/web-constants/src/utils/generateTemplate.ts @@ -59,15 +59,9 @@ const templateUpgraders: templateUpgrader[] = [ template.manifest = Object.fromEntries( Object.entries(template.manifest).map(([key, value]) => [ key, - `module.exports={init: (lynxCoreInject) => { var {${defaultInjectStr}} = lynxCoreInject.tt; var module = {exports:null}; ${value}\n return module.exports; } }`, + `module.exports={init: (lynxCoreInject) => { var {${defaultInjectStr}} = lynxCoreInject.tt; var module = {exports:{}}; var exports=module.exports; ${value}\n return module.exports; } }`, ]), ) as typeof template.manifest; - template.lepusCode = Object.fromEntries( - Object.entries(template.lepusCode).map(([key, value]) => [ - key, - `(()=>{${value}\n})();`, - ]), - ) as typeof template.lepusCode; template.version = 2; return template; }, @@ -76,6 +70,7 @@ const templateUpgraders: templateUpgrader[] = [ const generateModuleContent = ( content: string, eager: boolean, + appType: 'card' | 'lazy', ) => /** * About the `allFunctionsCalledOnLoad` directive: @@ -92,6 +87,7 @@ const generateModuleContent = ( '\n(function() { "use strict"; const ', globalDisallowedVars.join('=void 0,'), '=void 0;\n', + appType === 'lazy' ? 'module.exports=\n' : '', content, '\n})()', ].join(''); @@ -100,6 +96,7 @@ async function generateJavascriptUrl>( obj: T, createJsModuleUrl: (content: string, name: string) => Promise, eager: boolean, + appType: 'card' | 'lazy', templateName?: string, ): Promise { const processEntry = async ([name, content]: [string, string]) => [ @@ -108,6 +105,7 @@ async function generateJavascriptUrl>( generateModuleContent( content, eager, + appType, ), `${templateName}-${name.replaceAll('/', '')}.js`, ), @@ -147,12 +145,14 @@ export async function generateTemplate( template.lepusCode, createJsModuleUrl as (content: string, name: string) => Promise, true, + template.appType!, templateName, ), manifest: await generateJavascriptUrl( template.manifest, createJsModuleUrl as (content: string, name: string) => Promise, false, + template.appType!, templateName, ), }; diff --git a/packages/web-platform/web-core-server/src/createLynxView.ts b/packages/web-platform/web-core-server/src/createLynxView.ts index 69d33d3636..23635e608f 100644 --- a/packages/web-platform/web-core-server/src/createLynxView.ts +++ b/packages/web-platform/web-core-server/src/createLynxView.ts @@ -190,6 +190,7 @@ export async function createLynxView( i18nResources.setData(initI18nResources); return i18nResources; }, + (() => {}) as any, { __AddEvent(element, eventName, eventData, eventOptions) { events.push([ diff --git a/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts b/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts index 8941be3ff2..93c5f3b228 100644 --- a/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts +++ b/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts @@ -16,10 +16,12 @@ import { lynxUniqueIdAttribute, type SSRDumpInfo, type JSRealm, + type TemplateLoader, } from '@lynx-js/web-constants'; import { Rpc } from '@lynx-js/web-worker-rpc'; import { dispatchLynxViewEvent } from '../utils/dispatchLynxViewEvent.js'; import { createExposureMonitor } from './crossThreadHandlers/createExposureMonitor.js'; +import type { StartUIThreadCallbacks } from './startUIThread.js'; const { prepareMainThreadAPIs, @@ -93,15 +95,14 @@ function createIFrameRealm(parent: Node): JSRealm { export function createRenderAllOnUI( mainToBackgroundRpc: Rpc, shadowRoot: ShadowRoot, + loadTemplate: TemplateLoader, markTimingInternal: ( timingKey: string, pipelineId?: string, timeStamp?: number, ) => void, flushMarkTimingInternal: () => void, - callbacks: { - onError?: (err: Error, release: string, fileName: string) => void; - }, + callbacks: StartUIThreadCallbacks, ssrDumpInfo: SSRDumpInfo | undefined, ) { if (!globalThis.module) { @@ -138,6 +139,7 @@ export function createRenderAllOnUI( i18nResources.setData(initI18nResources); return i18nResources; }, + loadTemplate, ); const pendingUpdateCalls: Parameters< RpcCallType diff --git a/packages/web-platform/web-core/src/uiThread/createRenderMultiThread.ts b/packages/web-platform/web-core/src/uiThread/createRenderMultiThread.ts index 3a6b1ab21e..f42fd8b11c 100644 --- a/packages/web-platform/web-core/src/uiThread/createRenderMultiThread.ts +++ b/packages/web-platform/web-core/src/uiThread/createRenderMultiThread.ts @@ -3,27 +3,30 @@ // LICENSE file in the root directory of this source tree. import { + loadTemplateMultiThread, mainThreadStartEndpoint, updateDataEndpoint, updateI18nResourcesEndpoint, + type TemplateLoader, } from '@lynx-js/web-constants'; import type { Rpc } from '@lynx-js/web-worker-rpc'; import { registerReportErrorHandler } from './crossThreadHandlers/registerReportErrorHandler.js'; import { registerFlushElementTreeHandler } from './crossThreadHandlers/registerFlushElementTreeHandler.js'; import { registerDispatchLynxViewEventHandler } from './crossThreadHandlers/registerDispatchLynxViewEventHandler.js'; import { createExposureMonitorForMultiThread } from './crossThreadHandlers/createExposureMonitor.js'; +import type { StartUIThreadCallbacks } from './startUIThread.js'; export function createRenderMultiThread( mainThreadRpc: Rpc, shadowRoot: ShadowRoot, - callbacks: { - onError?: (err: Error, release: string, fileName: string) => void; - }, + loadTemplate: TemplateLoader, + callbacks: StartUIThreadCallbacks, ) { registerReportErrorHandler(mainThreadRpc, 'lepus.js', callbacks.onError); registerFlushElementTreeHandler(mainThreadRpc, { shadowRoot }); registerDispatchLynxViewEventHandler(mainThreadRpc, shadowRoot); createExposureMonitorForMultiThread(mainThreadRpc, shadowRoot); + mainThreadRpc.registerHandler(loadTemplateMultiThread, loadTemplate); const start = mainThreadRpc.createCall(mainThreadStartEndpoint); const updateDataMainThread = mainThreadRpc.createCall(updateDataEndpoint); const updateI18nResourcesMainThread = mainThreadRpc.createCall( diff --git a/packages/web-platform/web-core/src/uiThread/startUIThread.ts b/packages/web-platform/web-core/src/uiThread/startUIThread.ts index af203875d5..76314e5106 100644 --- a/packages/web-platform/web-core/src/uiThread/startUIThread.ts +++ b/packages/web-platform/web-core/src/uiThread/startUIThread.ts @@ -6,7 +6,6 @@ import type { LynxView } from '../apis/createLynxView.js'; import { bootWorkers } from './bootWorkers.js'; import { createDispose } from './crossThreadHandlers/createDispose.js'; import { - type LynxTemplate, type StartMainThreadContextConfig, type NapiModulesCall, type NativeModulesCall, @@ -15,8 +14,10 @@ import { dispatchMarkTiming, flushMarkTiming, type SSRDumpInfo, + type TemplateLoader, + type MarkTimingInternal, } from '@lynx-js/web-constants'; -import { loadTemplate } from '../utils/loadTemplate.js'; +import { createTemplateLoader } from '../utils/loadTemplate.js'; import { createUpdateData } from './crossThreadHandlers/createUpdateData.js'; import { startBackground } from './startBackground.js'; import { createRenderMultiThread } from './createRenderMultiThread.js'; @@ -26,7 +27,7 @@ export type StartUIThreadCallbacks = { nativeModulesCall: NativeModulesCall; napiModulesCall: NapiModulesCall; onError?: (err: Error, release: string, fileName: string) => void; - customTemplateLoader?: (url: string) => Promise; + customTemplateLoader?: TemplateLoader; }; export function startUIThread( @@ -59,10 +60,10 @@ export function startUIThread( records: [], timeout: null, }; - const markTimingInternal = ( - timingKey: string, - pipelineId?: string, - timeStamp?: number, + const markTimingInternal: MarkTimingInternal = ( + timingKey, + pipelineId, + timeStamp, ) => { dispatchMarkTiming({ timingKey, @@ -74,10 +75,15 @@ export function startUIThread( }; const flushMarkTimingInternal = () => flushMarkTiming(markTiming, cacheMarkTimings); + const templateLoader = createTemplateLoader( + callbacks.customTemplateLoader, + markTimingInternal, + ); const { start, updateDataMainThread, updateI18nResourcesMainThread } = allOnUI ? createRenderAllOnUI( /* main-to-bg rpc*/ mainThreadRpc, shadowRoot, + templateLoader, markTimingInternal, flushMarkTimingInternal, callbacks, @@ -86,12 +92,11 @@ export function startUIThread( : createRenderMultiThread( /* main-to-ui rpc*/ mainThreadRpc, shadowRoot, + templateLoader, callbacks, ); markTimingInternal('create_lynx_start', undefined, createLynxStartTiming); - markTimingInternal('load_template_start'); - loadTemplate(templateUrl, callbacks.customTemplateLoader).then((template) => { - markTimingInternal('load_template_end'); + templateLoader(templateUrl).then((template) => { flushMarkTimingInternal(); start({ ...configs, diff --git a/packages/web-platform/web-core/src/utils/loadTemplate.ts b/packages/web-platform/web-core/src/utils/loadTemplate.ts index 47195cb8a0..91f37de2aa 100644 --- a/packages/web-platform/web-core/src/utils/loadTemplate.ts +++ b/packages/web-platform/web-core/src/utils/loadTemplate.ts @@ -1,4 +1,9 @@ -import { generateTemplate, type LynxTemplate } from '@lynx-js/web-constants'; +import { + generateTemplate, + type LynxTemplate, + type MarkTimingInternal, + type TemplateLoader, +} from '@lynx-js/web-constants'; const templateCache: Record = {}; @@ -6,22 +11,35 @@ function createJsModuleUrl(content: string): string { return URL.createObjectURL(new Blob([content], { type: 'text/javascript' })); } -export async function loadTemplate( - url: string, - customTemplateLoader?: (url: string) => Promise, -): Promise { - const cachedTemplate = templateCache[url]; - if (cachedTemplate) return cachedTemplate; - const template = customTemplateLoader - ? await customTemplateLoader(url) - : (await (await fetch(url, { - method: 'GET', - })).json()) as LynxTemplate; - const decodedTemplate = await generateTemplate(template, createJsModuleUrl); - templateCache[url] = decodedTemplate; - /** - * This will cause a memory leak, which is expected. - * We cannot ensure that the `URL.createObjectURL` created url will never be used, therefore here we keep it for the entire lifetime of this page. - */ - return decodedTemplate; +export function createTemplateLoader( + customTemplateLoader: TemplateLoader | undefined, + markTimingInternal: MarkTimingInternal, +) { + const loadTemplate: TemplateLoader = async ( + url: string, + ) => { + markTimingInternal('load_template_start'); + const cachedTemplate = templateCache[url]; + if (cachedTemplate) { + markTimingInternal('load_template_end'); + return cachedTemplate; + } + const template = customTemplateLoader + ? await customTemplateLoader(url) + : (await (await fetch(url, { + method: 'GET', + })).json()) as LynxTemplate; + const decodedTemplate = await generateTemplate( + template, + createJsModuleUrl, + ); + templateCache[url] = decodedTemplate; + /** + * This will cause a memory leak, which is expected. + * We cannot ensure that the `URL.createObjectURL` created url will never be used, therefore here we keep it for the entire lifetime of this page. + */ + markTimingInternal('load_template_end'); + return decodedTemplate; + }; + return loadTemplate; } diff --git a/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts b/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts index 1cde4b04c4..301c3e5830 100644 --- a/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts +++ b/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts @@ -59,6 +59,7 @@ import { type ElementTemplateData, type ElementFromBinaryPAPI, type JSRealm, + type QueryComponentPAPI, } from '@lynx-js/web-constants'; import { createMainThreadLynx } from './createMainThreadLynx.js'; import { @@ -134,6 +135,7 @@ export interface MainThreadRuntimeCallbacks { newClassName: string, cssID: string | null, ) => void; + __QueryComponent: QueryComponentPAPI; } export interface MainThreadRuntimeConfig { @@ -775,6 +777,7 @@ export function createMainThreadGlobalThis( __LoadLepusChunk, __GetPageElement, __globalProps: globalProps, + __QueryComponent: callbacks.__QueryComponent, SystemInfo, lynx: createMainThreadLynx(config, SystemInfo), _ReportError: (err, _) => callbacks._ReportError(err, _, release), diff --git a/packages/web-platform/web-mainthread-apis/src/crossThreadHandlers/createQueryComponent.ts b/packages/web-platform/web-mainthread-apis/src/crossThreadHandlers/createQueryComponent.ts new file mode 100644 index 0000000000..cb458b7aa2 --- /dev/null +++ b/packages/web-platform/web-mainthread-apis/src/crossThreadHandlers/createQueryComponent.ts @@ -0,0 +1,76 @@ +import { + queryComponentEndpoint, + updateBTSTemplateCacheEndpoint, + type JSRealm, + type LynxCrossThreadContext, + type MainThreadGlobalThis, + type QueryComponentPAPI, + type Rpc, + type RpcCallType, + type StyleInfo, + type TemplateLoader, +} from '@lynx-js/web-constants'; + +export function createQueryComponent( + loadTemplate: TemplateLoader, + updateLazyComponentStyle: (styleInfo: StyleInfo, entryName: string) => void, + backgroundThreadRpc: Rpc, + mtsGlobalThisRef: { + mtsGlobalThis: MainThreadGlobalThis; + }, + jsContext: LynxCrossThreadContext, + mtsRealm: JSRealm, +): QueryComponentPAPI { + const updateBTSTemplateCache = backgroundThreadRpc.createCall( + updateBTSTemplateCacheEndpoint, + ); + const __QueryComponentImpl: QueryComponentPAPI = (url, callback) => { + loadTemplate(url).then(async (template) => { + const updateBTSCachePromise = updateBTSTemplateCache(url, template); + let lepusRootChunkExport = await mtsRealm.loadScript( + template.lepusCode.root, + ); + if (mtsGlobalThisRef.mtsGlobalThis.processEvalResult) { + lepusRootChunkExport = mtsGlobalThisRef.mtsGlobalThis.processEvalResult( + lepusRootChunkExport, + url, + ); + } + updateLazyComponentStyle(template.styleInfo, url); + await updateBTSCachePromise; + jsContext.dispatchEvent({ + type: '__OnDynamicJSSourcePrepared', + data: url, + }); + callback?.({ + code: 0, + data: { + url, + evalResult: lepusRootChunkExport, + }, + }); + }).catch((error) => { + console.error(`lynx web: lazy bundle load failed:`, error); + callback?.({ + code: -1, + data: undefined, + }); + }); + return null; + }; + backgroundThreadRpc.registerHandler(queryComponentEndpoint, (url: string) => { + const ret: ReturnType> = + new Promise(resolve => { + __QueryComponentImpl(url, (result) => { + resolve({ + code: result.code, + detail: { + schema: url, + }, + }); + }); + }); + return ret; + }); + return __QueryComponentImpl; +} diff --git a/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts b/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts index 84c26eae44..a960bc09fc 100644 --- a/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts +++ b/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts @@ -25,6 +25,8 @@ import { type SSRHydrateInfo, type SSRDehydrateHooks, type JSRealm, + type MainThreadGlobalThis, + type TemplateLoader, } from '@lynx-js/web-constants'; import { registerCallLepusMethodHandler } from './crossThreadHandlers/registerCallLepusMethodHandler.js'; import { registerGetCustomSectionHandler } from './crossThreadHandlers/registerGetCustomSectionHandler.js'; @@ -32,6 +34,7 @@ import { createMainThreadGlobalThis } from './createMainThreadGlobalThis.js'; import { createExposureService } from './utils/createExposureService.js'; import { initWasm } from '@lynx-js/web-style-transformer'; import { appendStyleElement } from './utils/processStyleInfo.js'; +import { createQueryComponent } from './crossThreadHandlers/createQueryComponent.js'; const initWasmPromise = initWasm(); export function prepareMainThreadAPIs( @@ -49,6 +52,7 @@ export function prepareMainThreadAPIs( options: I18nResourceTranslationOptions, ) => void, initialI18nResources: (data: InitI18nResources) => I18nResources, + loadTemplate: TemplateLoader, ssrHooks?: SSRDehydrateHooks, ) { const postTimingFlags = backgroundThreadRpc.createCall( @@ -97,7 +101,7 @@ export function prepareMainThreadAPIs( }); const i18nResources = initialI18nResources(initI18nResources); - const { updateCssOGStyle } = appendStyleElement( + const { updateCssOGStyle, updateLazyComponentStyle } = appendStyleElement( styleInfo, pageConfig, rootDom as unknown as Node, @@ -105,6 +109,17 @@ export function prepareMainThreadAPIs( undefined, ssrHydrateInfo, ); + const mtsGlobalThisRef: { mtsGlobalThis: MainThreadGlobalThis } = { + mtsGlobalThis: undefined as unknown as MainThreadGlobalThis, + }; + const __QueryComponent = createQueryComponent( + loadTemplate, + updateLazyComponentStyle, + backgroundThreadRpc, + mtsGlobalThisRef, + jsContext, + mtsRealm, + ); const mtsGlobalThis = createMainThreadGlobalThis({ lynxTemplate: template, mtsRealm, @@ -227,8 +242,10 @@ export function prepareMainThreadAPIs( } return triggerI18nResourceFallback(options); }, + __QueryComponent, }, }); + mtsGlobalThisRef.mtsGlobalThis = mtsGlobalThis; markTimingInternal('decode_end'); await mtsRealm.loadScript(template.lepusCode.root); jsContext.__start(); // start the jsContext after the runtime is created diff --git a/packages/web-platform/web-mainthread-apis/src/pureElementPAPIs.ts b/packages/web-platform/web-mainthread-apis/src/pureElementPAPIs.ts index a0f563b7f1..46cf4238c1 100644 --- a/packages/web-platform/web-mainthread-apis/src/pureElementPAPIs.ts +++ b/packages/web-platform/web-mainthread-apis/src/pureElementPAPIs.ts @@ -8,6 +8,7 @@ import { lynxComponentConfigAttribute, lynxDatasetAttribute, lynxElementTemplateMarkerAttribute, + lynxEntryNameAttribute, lynxPartIdAttribute, lynxTagAttribute, lynxUniqueIdAttribute, @@ -268,9 +269,11 @@ export const __UpdateComponentInfo: UpdateComponentInfoPAPI = /*#__PURE__*/ ( export const __SetCSSId: SetCSSIdPAPI = /*#__PURE__*/ ( elements, cssId, + entryName, ) => { for (const element of elements) { element.setAttribute(cssIdAttribute, cssId + ''); + entryName && element.setAttribute(lynxEntryNameAttribute, entryName); } }; diff --git a/packages/web-platform/web-mainthread-apis/src/utils/processStyleInfo.ts b/packages/web-platform/web-mainthread-apis/src/utils/processStyleInfo.ts index 9c1c858181..6588cb025e 100644 --- a/packages/web-platform/web-mainthread-apis/src/utils/processStyleInfo.ts +++ b/packages/web-platform/web-mainthread-apis/src/utils/processStyleInfo.ts @@ -103,7 +103,9 @@ export function genCssContent( suffix = `[${lynxTagAttribute}]`; } if (entryName) { - suffix = `${suffix}[${lynxEntryNameAttribute}="${entryName}"]`; + suffix = `${suffix}[${lynxEntryNameAttribute}=${ + JSON.stringify(entryName) + }]`; } else { suffix = `${suffix}:not([${lynxEntryNameAttribute}])`; } @@ -234,5 +236,17 @@ export function appendStyleElement( lynxUniqueIdToStyleRulesIndex[uniqueId] = index; } }; - return { updateCssOGStyle }; + const updateLazyComponentStyle = ( + styleInfo: StyleInfo, + entryName: string, + ) => { + flattenStyleInfo( + styleInfo, + pageConfig.enableCSSSelector, + ); + transformToWebCss(styleInfo); + const newStyleSheet = genCssContent(styleInfo, pageConfig, entryName); + cardStyleElement.textContent += newStyleSheet; + }; + return { updateCssOGStyle, updateLazyComponentStyle }; } diff --git a/packages/web-platform/web-tests/rspack.config.js b/packages/web-platform/web-tests/rspack.config.js index 2e9804ec57..cd29df4c2b 100644 --- a/packages/web-platform/web-tests/rspack.config.js +++ b/packages/web-platform/web-tests/rspack.config.js @@ -17,6 +17,7 @@ const isCI = !!process.env.CI; const port = process.env.PORT ?? 3080; /** @type {import('@rspack/cli').Configuration} */ const config = { + cache: false, entry: { main: './shell-project/index.ts', 'web-elements': './shell-project/web-elements.ts', diff --git a/packages/web-platform/web-tests/tests/react.spec.ts b/packages/web-platform/web-tests/tests/react.spec.ts index d911be398d..c3819388fc 100644 --- a/packages/web-platform/web-tests/tests/react.spec.ts +++ b/packages/web-platform/web-tests/tests/react.spec.ts @@ -438,6 +438,277 @@ test.describe('reactlynx3 tests', () => { await expect(target).toHaveCSS('background-color', 'rgb(0, 128, 0)'); // green }, ); + + // lazy component + test( + 'basic-lazy-component', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').click(); + await wait(100); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await page.locator('#target2').click(); + await wait(100); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + }, + ); + // lazy component with relative path + test( + 'basic-lazy-component-relative-path', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').click(); + await wait(100); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await page.locator('#target2').click(); + await wait(100); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + }, + ); + test( + 'basic-lazy-component-fail', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + const result = await page.locator('#fallback').first().innerText(); + expect(result).toBe('Loading...'); + }, + ); + test( + 'basic-lazy-component-effect', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await expect(page.locator('#target')).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + }, + ); + // use the same lazy component multiple times + test( + 'basic-lazy-component-multi', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await expect(page.locator('#target1').nth(1)).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').nth(0).click(); + await wait(100); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await expect(page.locator('#target1').nth(1)).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').nth(1).click(); + await wait(100); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await expect(page.locator('#target1').nth(1)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + }, + ); + // import the same lazy component multiple times and use it multiple times + test( + 'basic-lazy-component-multi-import', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await expect(page.locator('#target1').nth(1)).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').nth(0).click(); + await wait(100); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await expect(page.locator('#target1').nth(1)).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').nth(1).click(); + await wait(100); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await expect(page.locator('#target1').nth(1)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + }, + ); + // the card's style and lazy component are displayed correctly. + test( + 'basic-lazy-component-css', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await expect(page.locator('.container').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 0, 0)', + ); // red + await expect(page.locator('.container').nth(1)).toHaveCSS( + 'background-color', + 'rgb(255, 165, 0)', + ); // orange + }, + ); + // the card's style should not affect the lazy component. + test( + 'basic-lazy-component-css-blank', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await expect(page.locator('.container').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 0, 0)', + ); // red + await expect(page.locator('.container').nth(1)).not.toHaveCSS( + 'background-color', + 'rgb(255, 165, 0)', + ); // orange + }, + ); + // two different lazy component + // the styles between lazy components need to be independent + test( + 'basic-lazy-component-css-multi', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await expect(page.locator('.container').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 0, 0)', + ); // red + await expect(page.locator('.container').nth(1)).toHaveCSS( + 'background-color', + 'rgb(255, 165, 0)', + ); // orange + await expect(page.locator('.container').nth(2)).toHaveCSS( + 'background-color', + 'rgb(128, 128, 128)', + ); // gray + }, + ); + // load lazy component when needed + test( + 'basic-lazy-component-when-needed', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await page.locator('#target').click(); + await wait(300); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').click(); + await wait(100); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await page.locator('#target2').click(); + await wait(100); + await expect(page.locator('#target1')).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + }, + ); + // load the same lazy component twice: use it directly, use it when needed + test( + 'basic-lazy-component-when-need-with-itself', + async ({ page }, { title }) => { + test.skip(isSSR, 'Lazy Component not support on SSR'); + await goto(page, title); + await wait(500); + await page.locator('#target').click(); + await wait(300); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').nth(0).click(); + await wait(100); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await page.locator('#target').click(); + await wait(100); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await expect(page.locator('#target1').nth(1)).toHaveCSS( + 'background-color', + 'rgb(0, 128, 0)', + ); // green + await page.locator('#target2').nth(1).click(); + await wait(100); + await expect(page.locator('#target1').nth(0)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + await expect(page.locator('#target1').nth(1)).toHaveCSS( + 'background-color', + 'rgb(255, 192, 203)', + ); // pink + }, + ); }); test.describe('basic-css', () => { test('basic-css-asset-in-css', async ({ page }, { title }) => { diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.css b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.css new file mode 100644 index 0000000000..9ae05f70f7 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.css @@ -0,0 +1,5 @@ +.container { + width: 100px; + height: 100px; + background-color: red; +} diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.jsx new file mode 100644 index 0000000000..e873d3daeb --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-blank/index.jsx @@ -0,0 +1,29 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; +import './index.css'; + +const importPath = `/dist/config-lazy-component-css-blank/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); + +export default function App() { + return ( + + + Loading...}> + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.css b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.css new file mode 100644 index 0000000000..9ae05f70f7 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.css @@ -0,0 +1,5 @@ +.container { + width: 100px; + height: 100px; + background-color: red; +} diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.jsx new file mode 100644 index 0000000000..53d37607a6 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css-multi/index.jsx @@ -0,0 +1,40 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; +import './index.css'; + +const importPath = `/dist/config-lazy-component-css/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); +const importPath2 = `/dist/config-lazy-component-css-other/index.web.json`; +const LazyComponent2 = lazy( + () => + import( + importPath2, + { + with: { type: 'component' }, + } + ), +); + +export default function App() { + return ( + + + Loading...}> + + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.css b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.css new file mode 100644 index 0000000000..9ae05f70f7 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.css @@ -0,0 +1,5 @@ +.container { + width: 100px; + height: 100px; + background-color: red; +} diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.jsx new file mode 100644 index 0000000000..b4d554578c --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-css/index.jsx @@ -0,0 +1,29 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; +import './index.css'; + +const importPath = `/dist/config-lazy-component-css/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); + +export default function App() { + return ( + + + Loading...}> + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-effect/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-effect/index.jsx new file mode 100644 index 0000000000..75d8f28bff --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-effect/index.jsx @@ -0,0 +1,27 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; + +const importPath = `/dist/config-lazy-component-use-effect/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); + +export default function App() { + return ( + + Loading...}> + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-fail/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-fail/index.jsx new file mode 100644 index 0000000000..68b5c7ec0a --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-fail/index.jsx @@ -0,0 +1,28 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; + +// nonexistent url +const importPath = `/dist/nonexistent/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); + +export default function App() { + return ( + + Loading...}> + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi-import/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi-import/index.jsx new file mode 100644 index 0000000000..b03b66b37d --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi-import/index.jsx @@ -0,0 +1,39 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; + +const importPath = `/dist/config-lazy-component-bindtap/index.web.json`; +const LazyComponent = lazy( + () => { + return import( + importPath, + { + with: { type: 'component' }, + } + ); + }, +); + +const importPath2 = `/dist/config-lazy-component-bindtap/index.web.json`; +const LazyComponent2 = lazy( + () => { + return import( + importPath2, + { + with: { type: 'component' }, + } + ); + }, +); + +export default function App() { + return ( + + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi/index.jsx new file mode 100644 index 0000000000..45a239a141 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-multi/index.jsx @@ -0,0 +1,27 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; + +const importPath = `/dist/config-lazy-component-bindtap/index.web.json`; +const LazyComponent = lazy( + () => { + return import( + importPath, + { + with: { type: 'component' }, + } + ); + }, +); + +export default function App() { + return ( + + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-relative-path/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-relative-path/index.jsx new file mode 100644 index 0000000000..29b44ba34c --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-relative-path/index.jsx @@ -0,0 +1,27 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; + +const importPath = `./dist/config-lazy-component-bindtap/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); + +export default function App() { + return ( + + Loading...}> + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-need-with-itself/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-need-with-itself/index.jsx new file mode 100644 index 0000000000..8c7e67c2cd --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-need-with-itself/index.jsx @@ -0,0 +1,43 @@ +// 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 { useState, root, lazy, Suspense } from '@lynx-js/react'; + +const importPath = `/dist/config-lazy-component-bindtap/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); + +export function App() { + const [shouldDisplay, setShouldDisplay] = useState(false); + const handleClick = () => { + setShouldDisplay(true); + }; + return ( + + Loading...}> + + + + Load Component + + {shouldDisplay && ( + Loading...}> + + + )} + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-needed/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-needed/index.jsx new file mode 100644 index 0000000000..b301e1703d --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component-when-needed/index.jsx @@ -0,0 +1,40 @@ +// 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 { useState, root, lazy, Suspense } from '@lynx-js/react'; + +const importPath = `/dist/config-lazy-component-bindtap/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); + +export function App() { + const [shouldDisplay, setShouldDisplay] = useState(false); + const handleClick = () => { + setShouldDisplay(true); + }; + return ( + + + Load Component + + {shouldDisplay && ( + Loading...}> + + + )} + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/basic-lazy-component/index.jsx b/packages/web-platform/web-tests/tests/react/basic-lazy-component/index.jsx new file mode 100644 index 0000000000..8bc6d1bdab --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/basic-lazy-component/index.jsx @@ -0,0 +1,27 @@ +// 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 { root, lazy, Suspense } from '@lynx-js/react'; + +const importPath = `/dist/config-lazy-component-bindtap/index.web.json`; +const LazyComponent = lazy( + () => + import( + importPath, + { + with: { type: 'component' }, + } + ), +); + +export default function App() { + return ( + + Loading...}> + + + + ); +} + +root.render(); diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-bindtap/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-bindtap/index.jsx new file mode 100644 index 0000000000..c9d6f284c9 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-bindtap/index.jsx @@ -0,0 +1,27 @@ +// 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 { root, useState } from '@lynx-js/react'; + +export default function App() { + const [color, setColor] = useState('green'); + const handleTap = () => { + setColor(color === 'green' ? 'pink' : 'green'); + }; + + return ( + + + + + + + ); +} diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.css b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.css new file mode 100644 index 0000000000..eaba052cb9 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.css @@ -0,0 +1,4 @@ +.container { + width: 100px; + height: 100px; +} diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.jsx new file mode 100644 index 0000000000..77294e3faa --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-blank/index.jsx @@ -0,0 +1,9 @@ +// 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 { root, useState } from '@lynx-js/react'; +import './index.css'; + +export default function App() { + return ; +} diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.css b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.css new file mode 100644 index 0000000000..c0ebd21bbd --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.css @@ -0,0 +1,5 @@ +.container { + width: 100px; + height: 100px; + background-color: gray; +} diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.jsx new file mode 100644 index 0000000000..77294e3faa --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css-other/index.jsx @@ -0,0 +1,9 @@ +// 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 { root, useState } from '@lynx-js/react'; +import './index.css'; + +export default function App() { + return ; +} diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.css b/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.css new file mode 100644 index 0000000000..144430b8bb --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.css @@ -0,0 +1,5 @@ +.container { + width: 100px; + height: 100px; + background-color: orange; +} diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.jsx new file mode 100644 index 0000000000..77294e3faa --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-css/index.jsx @@ -0,0 +1,9 @@ +// 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 { root, useState } from '@lynx-js/react'; +import './index.css'; + +export default function App() { + return ; +} diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component-use-effect/index.jsx b/packages/web-platform/web-tests/tests/react/config-lazy-component-use-effect/index.jsx new file mode 100644 index 0000000000..539a20343d --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component-use-effect/index.jsx @@ -0,0 +1,19 @@ +// 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 { root, useState, useEffect } from '@lynx-js/react'; + +export default function App() { + const [color, setColor] = useState('green'); + useEffect(() => { + setColor('pink'); + }, []); + + return ( + + + ); +} diff --git a/packages/web-platform/web-tests/tests/react/config-lazy-component.config.ts b/packages/web-platform/web-tests/tests/react/config-lazy-component.config.ts new file mode 100644 index 0000000000..efae9871b8 --- /dev/null +++ b/packages/web-platform/web-tests/tests/react/config-lazy-component.config.ts @@ -0,0 +1,32 @@ +// 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 { glob } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { mergeRspeedyConfig, type Config } from '@lynx-js/rspeedy'; +import { commonConfig } from './commonConfig.js'; +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const reactBasicCases = await Array.fromAsync(glob( + path.join( + __dirname, + 'config-lazy-component-*', + '*.jsx', + ), +)); +const config: Config = mergeRspeedyConfig( + commonConfig({ + experimental_isLazyBundle: true, + }), + { + source: { + entry: Object.fromEntries(reactBasicCases.map((reactBasicEntry) => { + return [path.basename(path.dirname(reactBasicEntry)), reactBasicEntry]; + })), + }, + }, +); + +export default config; diff --git a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createBackgroundLynx.ts b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createBackgroundLynx.ts index 59e87cec64..c574734e8c 100644 --- a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createBackgroundLynx.ts +++ b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createBackgroundLynx.ts @@ -48,5 +48,14 @@ export function createBackgroundLynx( return createElement(id, uiThreadRpc); }, getI18nResource: () => nativeApp.i18nResource.data, + QueryComponent: ( + source: string, + callback: ( + ret: { __hasReady: boolean } | { + code: number; + detail?: { schema: string }; + }, + ) => void, + ) => nativeApp.queryComponent(source, callback), }; } diff --git a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createNativeApp.ts b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createNativeApp.ts index 11d6ae88d4..f961f20ea6 100644 --- a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createNativeApp.ts +++ b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/createNativeApp.ts @@ -13,6 +13,9 @@ import { type BackMainThreadContextConfig, I18nResource, reportErrorEndpoint, + type LynxTemplate, + queryComponentEndpoint, + updateBTSTemplateCacheEndpoint, } from '@lynx-js/web-constants'; import { createInvokeUIMethod } from './crossThreadHandlers/createInvokeUIMethod.js'; import { registerPublicComponentEventHandler } from './crossThreadHandlers/registerPublicComponentEventHandler.js'; @@ -60,6 +63,9 @@ export async function createNativeApp( selectComponentEndpoint, 3, ); + const queryComponent = mainThreadRpc.createCall( + queryComponentEndpoint, + ); const reportError = uiThreadRpc.createCall(reportErrorEndpoint); const createBundleInitReturnObj = (): BundleInitReturnObj => { const ret = globalThis.module.exports ?? globalThis.__bundle__holder; @@ -67,6 +73,13 @@ export async function createNativeApp( globalThis.__bundle__holder = null; return ret as unknown as BundleInitReturnObj; }; + const templateCache = new Map([['__Card__', template]]); + mainThreadRpc.registerHandler( + updateBTSTemplateCacheEndpoint, + (url, template) => { + templateCache.set(url, template); + }, + ); const i18nResource = new I18nResource(); let release = ''; const nativeApp: NativeApp = { @@ -84,8 +97,11 @@ export async function createNativeApp( loadScriptAsync: function( sourceURL: string, callback: (message: string | null, exports?: BundleInitReturnObj) => void, + entryName?: string, ): void { - const manifestUrl = template.manifest[`/${sourceURL}`]; + entryName = entryName ?? '__Card__'; + const manifestUrl = templateCache.get(entryName!) + ?.manifest[`/${sourceURL}`]; if (manifestUrl) sourceURL = manifestUrl; globalThis.module.exports = null; globalThis.__bundle__holder = null; @@ -96,8 +112,10 @@ export async function createNativeApp( callback(null, createBundleInitReturnObj()); }); }, - loadScript: (sourceURL: string) => { - const manifestUrl = template.manifest[`/${sourceURL}`]; + loadScript: (sourceURL: string, entryName?: string) => { + entryName = entryName ?? '__Card__'; + const manifestUrl = templateCache.get(entryName!) + ?.manifest[`/${sourceURL}`]; if (manifestUrl) sourceURL = manifestUrl; globalThis.module.exports = null; globalThis.__bundle__holder = null; @@ -114,6 +132,7 @@ export async function createNativeApp( setNativeProps, getPathInfo: createGetPathInfo(uiThreadRpc), invokeUIMethod: createInvokeUIMethod(uiThreadRpc), + tt: null, setCard(tt) { registerPublicComponentEventHandler( mainThreadRpc, @@ -139,6 +158,7 @@ export async function createNativeApp( registerUpdateI18nResource(uiThreadRpc, mainThreadRpc, i18nResource, tt); timingSystem.registerGlobalEmitter(tt.GlobalEventEmitter); (tt.lynx.getCoreContext() as LynxCrossThreadContext).__start(); + nativeApp.tt = tt; }, triggerComponentEvent, selectComponent, @@ -152,6 +172,15 @@ export async function createNativeApp( i18nResource, reportException: (err: Error, _: unknown) => reportError(err, _, release), __SetSourceMapRelease: (err: Error) => release = err.message, + queryComponent: (source, callback) => { + if (templateCache.has(source)) { + callback({ __hasReady: true }); + } else { + queryComponent(source).then(res => { + callback?.(res); + }); + } + }, }; return nativeApp; } diff --git a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/startBackgroundThread.ts b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/startBackgroundThread.ts index 939bfe54f0..32085b94d7 100644 --- a/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/startBackgroundThread.ts +++ b/packages/web-platform/web-worker-runtime/src/backgroundThread/background-apis/startBackgroundThread.ts @@ -45,7 +45,19 @@ export function startBackgroundThread( uiThreadRpc, ); lynxCore.then( - ({ loadCard, destroyCard, callDestroyLifetimeFun }) => { + ( + { + loadCard, + destroyCard, + callDestroyLifetimeFun, + nativeGlobal, + loadDynamicComponent, + }, + ) => { + // @lynx-js/lynx-core >= 0.1.3 will export nativeGlobal and loadDynamicComponent + if (nativeGlobal && loadDynamicComponent) { + nativeGlobal.loadDynamicComponent = loadDynamicComponent; + } loadCard(nativeApp, { ...config, // @ts-ignore diff --git a/packages/web-platform/web-worker-runtime/src/mainThread/startMainThread.ts b/packages/web-platform/web-worker-runtime/src/mainThread/startMainThread.ts index dcaa8f1004..58c5b52c24 100644 --- a/packages/web-platform/web-worker-runtime/src/mainThread/startMainThread.ts +++ b/packages/web-platform/web-worker-runtime/src/mainThread/startMainThread.ts @@ -18,6 +18,7 @@ import { lynxUniqueIdAttribute, type JSRealm, type MainThreadGlobalThis, + loadTemplateMultiThread, } from '@lynx-js/web-constants'; import { Rpc } from '@lynx-js/web-worker-rpc'; import { createMarkTimingInternal } from './crossThreadHandlers/createMainthreadMarkTimingInternal.js'; @@ -90,6 +91,7 @@ export async function startMainThreadWorker( const sendMultiThreadExposureChangedEndpoint = uiThreadRpc.createCall( multiThreadExposureChangedEndpoint, ); + const loadTemplate = uiThreadRpc.createCall(loadTemplateMultiThread); const { startMainThread } = prepareMainThreadAPIs( backgroundThreadRpc, document, // rootDom @@ -111,6 +113,7 @@ export async function startMainThreadWorker( i18nResources.setData(initI18nResources); return i18nResources; }, + loadTemplate, ); uiThreadRpc.registerHandler( mainThreadStartEndpoint, @@ -122,7 +125,7 @@ export async function startMainThreadWorker( ); }, ); - uiThreadRpc?.registerHandler(updateI18nResourcesEndpoint, data => { + uiThreadRpc.registerHandler(updateI18nResourcesEndpoint, data => { i18nResources.setData(data as InitI18nResources); }); }