diff --git a/.changeset/cool-geckos-exist.md b/.changeset/cool-geckos-exist.md new file mode 100644 index 0000000000..e59d245289 --- /dev/null +++ b/.changeset/cool-geckos-exist.md @@ -0,0 +1,7 @@ +--- +"@lynx-js/web-mainthread-apis": patch +"@lynx-js/web-core": patch +"@lynx-js/web-core-server": patch +--- + +feat: support SSR for all-on-ui diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 1e9405e5a8..72f13cbeae 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -95,6 +95,9 @@ jobs: env: - ENABLE_SSR=true - TEST_FP_ONLY=true + - name: SSR-ALL-ON-UI + env: + - ENABLE_SSR=true with: runs-on: lynx-ubuntu-24.04-xlarge is-web: true diff --git a/packages/web-platform/web-constants/src/types/SSR.ts b/packages/web-platform/web-constants/src/types/SSR.ts index cd6b73361d..beddea9aa1 100644 --- a/packages/web-platform/web-constants/src/types/SSR.ts +++ b/packages/web-platform/web-constants/src/types/SSR.ts @@ -1,3 +1,4 @@ +import type { WebFiberElementImpl } from './Element.js'; import type { AddEventPAPI } from './MainThreadGlobalThis.js'; export type SSREventReplayInfo = [ @@ -12,6 +13,15 @@ export type SSRDumpInfo = { events: SSREventReplayInfo[]; }; +export interface SSRHydrateInfo extends SSRDumpInfo { + /** WeakRef */ + lynxUniqueIdToElement: WeakRef[]; + /** for cssog */ + lynxUniqueIdToStyleRulesIndex: number[]; + // @ts-expect-error + cardStyleElement: HTMLStyleElement | null; +} + export type SSRDehydrateHooks = { __AddEvent: AddEventPAPI; }; diff --git a/packages/web-platform/web-constants/src/utils/generateTemplate.ts b/packages/web-platform/web-constants/src/utils/generateTemplate.ts index f79fcb5f41..48364679fc 100644 --- a/packages/web-platform/web-constants/src/utils/generateTemplate.ts +++ b/packages/web-platform/web-constants/src/utils/generateTemplate.ts @@ -68,6 +68,7 @@ const mainThreadInjectVars = [ '__GetTemplateParts', '__MarkPartElement', '__MarkTemplateElement', + '__GetPageElement', ]; const backgroundInjectVars = [ diff --git a/packages/web-platform/web-core/src/apis/LynxView.ts b/packages/web-platform/web-core/src/apis/LynxView.ts index b700984f81..b4bc0c93b3 100644 --- a/packages/web-platform/web-core/src/apis/LynxView.ts +++ b/packages/web-platform/web-core/src/apis/LynxView.ts @@ -11,6 +11,7 @@ import { lynxDisposedAttribute, lynxTagAttribute, type Cloneable, + type SSRDumpInfo, type I18nResourceTranslationOptions, type InitI18nResources, type LynxTemplate, @@ -300,6 +301,7 @@ export class LynxView extends HTMLElement { * reload the current page */ reload() { + this.removeAttribute('ssr'); this.#render(); } @@ -403,6 +405,7 @@ export class LynxView extends HTMLElement { this.#rendering = true; queueMicrotask(() => { this.#rendering = false; + const ssrData = this.getAttribute('ssr'); if (this.#instance) { this.disconnectedCallback(); } @@ -468,26 +471,31 @@ export class LynxView extends HTMLElement { }, customTemplateLoader: this.customTemplateLoader, }, + ssr: ssrData + ? JSON.parse(decodeURI(ssrData)) as SSRDumpInfo + : undefined, }); this.#instance = lynxView; - const styleElement = document.createElement('style'); - this.shadowRoot!.append(styleElement); - const styleSheet = styleElement.sheet!; - for (const rule of inShadowRootStyles) { - styleSheet.insertRule(rule); - } - for (const rule of this.injectStyleRules) { - styleSheet.insertRule(rule); - } - const injectHeadLinks = - this.getAttribute('inject-head-links') !== 'false'; - if (injectHeadLinks) { - document.head.querySelectorAll('link[rel="stylesheet"]').forEach( - (linkElement) => { - const href = (linkElement as HTMLLinkElement).href; - styleSheet.insertRule(`@import url("${href}");`); - }, - ); + if (!ssrData) { + const styleElement = document.createElement('style'); + this.shadowRoot!.append(styleElement); + const styleSheet = styleElement.sheet!; + for (const rule of inShadowRootStyles) { + styleSheet.insertRule(rule); + } + for (const rule of this.injectStyleRules) { + styleSheet.insertRule(rule); + } + const injectHeadLinks = + this.getAttribute('inject-head-links') !== 'false'; + if (injectHeadLinks) { + document.head.querySelectorAll('link[rel="stylesheet"]').forEach( + (linkElement) => { + const href = (linkElement as HTMLLinkElement).href; + styleSheet.insertRule(`@import url("${href}");`); + }, + ); + } } } }); diff --git a/packages/web-platform/web-core/src/apis/createLynxView.ts b/packages/web-platform/web-core/src/apis/createLynxView.ts index 480ef71edf..9865b6f067 100644 --- a/packages/web-platform/web-core/src/apis/createLynxView.ts +++ b/packages/web-platform/web-core/src/apis/createLynxView.ts @@ -4,6 +4,7 @@ import type { Cloneable, + SSRDumpInfo, I18nResourceTranslationOptions, InitI18nResources, NapiModulesMap, @@ -32,6 +33,7 @@ export interface LynxViewConfigs { lynxGroupId: number | undefined; threadStrategy: 'all-on-ui' | 'multi-thread'; initI18nResources: InitI18nResources; + ssr?: SSRDumpInfo; } export interface LynxView { @@ -62,6 +64,7 @@ export function createLynxView(configs: LynxViewConfigs): LynxView { lynxGroupId, threadStrategy = 'multi-thread', initI18nResources, + ssr, } = configs; return startUIThread( templateUrl, @@ -82,5 +85,6 @@ export function createLynxView(configs: LynxViewConfigs): LynxView { lynxGroupId, threadStrategy, callbacks, + ssr, ); } diff --git a/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts b/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts index d2c1305e44..3f986a112b 100644 --- a/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts +++ b/packages/web-platform/web-core/src/uiThread/createRenderAllOnUI.ts @@ -13,6 +13,8 @@ import { I18nResources, type InitI18nResources, type Cloneable, + lynxUniqueIdAttribute, + type SSRDumpInfo, } from '@lynx-js/web-constants'; import { Rpc } from '@lynx-js/web-worker-rpc'; import { dispatchLynxViewEvent } from '../utils/dispatchLynxViewEvent.js'; @@ -34,6 +36,7 @@ export function createRenderAllOnUI( callbacks: { onError?: (err: Error, release: string, fileName: string) => void; }, + ssrDumpInfo: SSRDumpInfo | undefined, ) { if (!globalThis.module) { Object.assign(globalThis, { module: {} }); @@ -67,8 +70,49 @@ export function createRenderAllOnUI( ); let mtsGlobalThis!: MainThreadGlobalThis; const start = async (configs: StartMainThreadContextConfig) => { - const mainThreadRuntime = startMainThread(configs); - mtsGlobalThis = await mainThreadRuntime; + if (ssrDumpInfo) { + const lynxUniqueIdToElement: WeakRef[] = []; + const allLynxElements = shadowRoot.querySelectorAll( + `[${lynxUniqueIdAttribute}]`, + ); + const length = allLynxElements.length; + for (let ii = 0; ii < length; ii++) { + const element = allLynxElements[ii]! as HTMLElement; + const lynxUniqueId = Number( + element.getAttribute(lynxUniqueIdAttribute)!, + ); + lynxUniqueIdToElement[lynxUniqueId] = new WeakRef(element); + } + const hydrateStyleElement = shadowRoot.querySelector( + `style:nth-of-type(2)`, + ) as HTMLStyleElement | null; + const styleSheet = hydrateStyleElement?.sheet; + const lynxUniqueIdToStyleRulesIndex: number[] = []; + const cssRulesLength = styleSheet?.cssRules.length ?? 0; + for (let ii = 0; ii < cssRulesLength; ii++) { + const cssRule = styleSheet?.cssRules[ii]; + if (cssRule?.constructor.name === 'CSSStyleRule') { + const lynxUniqueId = parseFloat( + (cssRule as CSSStyleRule).selectorText.substring( + lynxUniqueIdAttribute.length + 3, // skip `[`, `="` + ), + ); + if (lynxUniqueId !== undefined && !isNaN(lynxUniqueId)) { + lynxUniqueIdToStyleRulesIndex[lynxUniqueId] = ii; + } + } + } + + mtsGlobalThis = await startMainThread(configs, { + // @ts-expect-error + lynxUniqueIdToElement: lynxUniqueIdToElement, + lynxUniqueIdToStyleRulesIndex, + ...ssrDumpInfo, + cardStyleElement: hydrateStyleElement, + }); + } else { + mtsGlobalThis = await startMainThread(configs); + } }; const updateDataMainThread: RpcCallType = async ( ...args diff --git a/packages/web-platform/web-core/src/uiThread/startUIThread.ts b/packages/web-platform/web-core/src/uiThread/startUIThread.ts index f96d74edb0..692e684241 100644 --- a/packages/web-platform/web-core/src/uiThread/startUIThread.ts +++ b/packages/web-platform/web-core/src/uiThread/startUIThread.ts @@ -14,6 +14,7 @@ import { type Cloneable, dispatchMarkTiming, flushMarkTiming, + type SSRDumpInfo, } from '@lynx-js/web-constants'; import { loadTemplate } from '../utils/loadTemplate.js'; import { createUpdateData } from './crossThreadHandlers/createUpdateData.js'; @@ -35,6 +36,7 @@ export function startUIThread( lynxGroupId: number | undefined, threadStrategy: 'all-on-ui' | 'multi-thread', callbacks: StartUIThreadCallbacks, + ssr?: SSRDumpInfo, ): LynxView { const createLynxStartTiming = performance.now() + performance.timeOrigin; const allOnUI = threadStrategy === 'all-on-ui'; @@ -79,6 +81,7 @@ export function startUIThread( markTimingInternal, flushMarkTimingInternal, callbacks, + ssr, ) : createRenderMultiThread( /* main-to-ui rpc*/ mainThreadRpc, diff --git a/packages/web-platform/web-core/tsconfig.json b/packages/web-platform/web-core/tsconfig.json index 028f76e3e5..b93748e96d 100644 --- a/packages/web-platform/web-core/tsconfig.json +++ b/packages/web-platform/web-core/tsconfig.json @@ -5,7 +5,7 @@ "rootDir": "./src", "outDir": "./dist", "lib": ["DOM", "ESNext", "WebWorker"], - "noUnusedParameters": false, + "noUnusedParameters": true, "noImplicitReturns": false, }, "include": ["src"], diff --git a/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts b/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts index 49853e2a40..2b11e82ead 100644 --- a/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts +++ b/packages/web-platform/web-mainthread-apis/src/createMainThreadGlobalThis.ts @@ -57,6 +57,7 @@ import { type MinimalRawEventObject, type I18nResourceTranslationOptions, lynxDisposedAttribute, + type SSRHydrateInfo, type SSRDehydrateHooks, } from '@lynx-js/web-constants'; import { globalMuteableVars } from '@lynx-js/web-constants'; @@ -150,14 +151,13 @@ export interface MainThreadRuntimeConfig { & Pick & Partial>; jsContext: LynxContextEventTarget; + ssrHydrateInfo?: SSRHydrateInfo; ssrHooks?: SSRDehydrateHooks; } export function createMainThreadGlobalThis( config: MainThreadRuntimeConfig, ): MainThreadGlobalThis { - let pageElement!: WebFiberElementImpl; - let uniqueIdInc = 1; let timingFlags: string[] = []; let renderPage: MainThreadGlobalThis['renderPage']; const { @@ -168,12 +168,19 @@ export function createMainThreadGlobalThis( rootDom, globalProps, styleInfo, + ssrHydrateInfo, ssrHooks, } = config; - const lynxUniqueIdToElement: WeakRef[] = []; + const lynxUniqueIdToElement: WeakRef[] = + ssrHydrateInfo?.lynxUniqueIdToElement ?? []; + const lynxUniqueIdToStyleRulesIndex: number[] = + ssrHydrateInfo?.lynxUniqueIdToStyleRulesIndex ?? []; const elementToRuntimeInfoMap: WeakMap = new WeakMap(); - const lynxUniqueIdToStyleRulesIndex: number[] = []; + + let pageElement: WebFiberElementImpl | undefined = lynxUniqueIdToElement[1] + ?.deref(); + let uniqueIdInc = lynxUniqueIdToElement.length || 1; /** * for "update" the globalThis.val in the main thread */ @@ -197,13 +204,19 @@ export function createMainThreadGlobalThis( const cssOGInfo: CssOGInfo = pageConfig.enableCSSSelector ? {} : genCssOGInfo(styleInfo); - const cardStyleElement = callbacks.createElement('style'); - cardStyleElement.innerHTML = genCssContent( - styleInfo, - pageConfig, - ); - // @ts-expect-error - rootDom.append(cardStyleElement); + let cardStyleElement: HTMLStyleElement; + if (ssrHydrateInfo?.cardStyleElement) { + cardStyleElement = ssrHydrateInfo.cardStyleElement; + } else { + cardStyleElement = callbacks.createElement( + 'style', + ) as unknown as HTMLStyleElement; + cardStyleElement.innerHTML = genCssContent( + styleInfo, + pageConfig, + ); + rootDom.append(cardStyleElement); + } const cardStyleElementSheet = (cardStyleElement as unknown as HTMLStyleElement).sheet!; const updateCssOGStyle: ( diff --git a/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts b/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts index a071cdb819..f243f7d514 100644 --- a/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts +++ b/packages/web-platform/web-mainthread-apis/src/prepareMainThreadAPIs.ts @@ -24,6 +24,7 @@ import { type I18nResources, dispatchI18nResourceEndpoint, type Cloneable, + type SSRHydrateInfo, type SSRDehydrateHooks, } from '@lynx-js/web-constants'; import { registerCallLepusMethodHandler } from './crossThreadHandlers/registerCallLepusMethodHandler.js'; @@ -69,6 +70,7 @@ export function prepareMainThreadAPIs( markTimingInternal('lepus_execute_start'); async function startMainThread( config: StartMainThreadContextConfig, + ssrHydrateInfo?: SSRHydrateInfo, ): Promise { let isFp = true; const { @@ -117,6 +119,7 @@ export function prepareMainThreadAPIs( styleInfo, lepusCode: lepusCodeLoaded, rootDom, + ssrHydrateInfo, ssrHooks, callbacks: { mainChunkReady: () => { @@ -159,8 +162,21 @@ export function prepareMainThreadAPIs( napiModulesMap, browserConfig, }); - mtsGlobalThis.renderPage!(initData); - mtsGlobalThis.__FlushElementTree(undefined, {}); + if (!ssrHydrateInfo) { + mtsGlobalThis.renderPage!(initData); + mtsGlobalThis.__FlushElementTree(undefined, {}); + } else { + // replay the hydrate event + for (const event of ssrHydrateInfo.events) { + const uniqueId = event[0]; + const element = ssrHydrateInfo.lynxUniqueIdToElement[uniqueId] + ?.deref(); + if (element) { + mtsGlobalThis.__AddEvent(element, event[1], event[2], event[3]); + } + } + mtsGlobalThis.ssrHydrate?.(ssrHydrateInfo.ssrEncodeData); + } }, flushElementTree: async ( options, diff --git a/packages/web-platform/web-tests/playwright.config.ts b/packages/web-platform/web-tests/playwright.config.ts index 2939c76e0a..558b8c4f39 100644 --- a/packages/web-platform/web-tests/playwright.config.ts +++ b/packages/web-platform/web-tests/playwright.config.ts @@ -19,7 +19,10 @@ const testMatch: string | undefined = (() => { if (testFPOnly) { return '**/fp-only.spec.ts'; } - if (enableMultiThread || enableSSR) { + if (enableSSR) { + return '**/react.{test,spec}.ts'; + } + if (enableMultiThread) { return '**/{react,web-core,main-thread-apis}.{test,spec}.ts'; } return undefined; diff --git a/packages/web-platform/web-tests/rspack.config.js b/packages/web-platform/web-tests/rspack.config.js index ebd455ac86..80936eac7d 100644 --- a/packages/web-platform/web-tests/rspack.config.js +++ b/packages/web-platform/web-tests/rspack.config.js @@ -58,6 +58,21 @@ const config = { scriptLoading: 'module', filename: 'index.html', }), + new rspack.HtmlRspackPlugin({ + title: 'lynx-for-web-test', + meta: { + viewport: + 'width=device-width,initial-scale=1.0,maximum-scale=1.0,user-scalable=no', + 'apple-mobile-web-app-capable': 'yes', + 'apple-mobile-web-app-status-bar-style': 'default', + 'screen-orientation': 'portrait', + 'format-detection': 'telephone=no', + 'x5-orientation': 'portrait', + }, + chunks: ['main'], + scriptLoading: 'module', + filename: 'ssr.html', + }), new rspack.HtmlRspackPlugin({ title: 'mainthread-test', meta: { @@ -159,6 +174,32 @@ const config = { next(); } }, + }, { + name: 'ssr', + path: '/ssr', + middleware: async (req, res, next) => { + try { + const html = await readFile( + path.join(__dirname, 'www', 'index.html'), + 'utf-8', + ); + const casename = req.query.casename; + if (!casename) { + res.statusCode = 400; + res.send('casename is required'); + next(); + return; + } + + res.send(await genHtml(html, casename, 'main')); + next(); + } catch (e) { + res.statusCode = 500; + console.error(e); + res.send(e.toString() + '\n' + e.stack?.toString()); + next(); + } + }, }); return middlewares; }, diff --git a/packages/web-platform/web-tests/server.js b/packages/web-platform/web-tests/server.js index eaa5266d04..c138a33c9c 100644 --- a/packages/web-platform/web-tests/server.js +++ b/packages/web-platform/web-tests/server.js @@ -24,13 +24,15 @@ export async function SSR(rawTemplate, caseName, projectName = 'fp-only') { globalProps: {}, template: rawTemplate, templateName: caseName, - hydrateUrl: `/dist/${caseName}/index.web.json`, - injectStyles: `@import url("/${projectName}.css");`, + hydrateUrl: `/dist/ssr/${caseName}/index.web.json`, + injectStyles: + `@import url("/${projectName}.css"); .injected-style-rules{background:green}`, autoSize: true, lynxViewStyle: 'width:100vw; max-width: 500px;', + threadStrategy: 'all-on-ui', }); const ssrHtml = await lynxView.renderToString(); - return ssrHtml; + return ssrHtml.toString('utf-16le'); } export async function genTemplate(caseName, projectName = 'fp-only') { const ssrHtml = await SSR( diff --git a/packages/web-platform/web-tests/shell-project/index.ts b/packages/web-platform/web-tests/shell-project/index.ts index 6288c57a9c..5927e8de8e 100644 --- a/packages/web-platform/web-tests/shell-project/index.ts +++ b/packages/web-platform/web-tests/shell-project/index.ts @@ -11,10 +11,18 @@ const searchParams = new URLSearchParams(document.location.search); const casename = searchParams.get('casename'); const casename2 = searchParams.get('casename2'); const hasdir = searchParams.get('hasdir') === 'true'; +const isSSR = document.location.pathname.includes('ssr'); if (casename) { - const dir = `/dist/${casename}${hasdir ? `/${casename}` : ''}`; - const dir2 = `/dist/${casename2}${hasdir ? `/${casename2}` : ''}`; + const dir = `/dist/${isSSR ? 'ssr/' : ''}${casename}${ + hasdir ? `/${casename}` : '' + }`; + const dir2 = `/dist/${isSSR ? 'ssr/' : ''}${casename2}${ + hasdir ? `/${casename2}` : '' + }`; + const lynxView = isSSR + ? document.querySelector('lynx-view')! + : undefined; lynxViewTests(lynxView => { lynxView.setAttribute('url', `${dir}/index.web.json`); ENABLE_MULTI_THREAD @@ -42,6 +50,7 @@ if (casename) { enableRemoveCSSScope: true, defaultDisplayLinear: true, defaultOverflowVisible: true, + enableJSDataProcessor: false, }, customSections: {}, lepusCode: { @@ -62,13 +71,13 @@ if (casename) { return template; }; } - }); + }, lynxView); if (casename2) { lynxViewTests(lynxView2 => { lynxView2.id = 'lynxview2'; lynxView2.setAttribute('url', `${dir2}/index.web.json`); lynxView2.setAttribute('lynx-group-id', '2'); - }); + }, undefined); } } else { console.warn('cannot find casename'); diff --git a/packages/web-platform/web-tests/shell-project/lynx-view.ts b/packages/web-platform/web-tests/shell-project/lynx-view.ts index f473fe9a69..1eca884d71 100644 --- a/packages/web-platform/web-tests/shell-project/lynx-view.ts +++ b/packages/web-platform/web-tests/shell-project/lynx-view.ts @@ -2,15 +2,18 @@ // 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 { LynxView } from '@lynx-js/web-core'; +import '@lynx-js/web-elements-compat/LinearContainer'; import '@lynx-js/web-core'; import '@lynx-js/web-elements/all'; import '@lynx-js/web-elements/index.css'; -import '@lynx-js/web-elements-compat/LinearContainer'; import '@lynx-js/web-core/index.css'; import './index.css'; -export const lynxViewTests = (callback: (lynxView: LynxView) => void) => { - const lynxView = document.createElement('lynx-view') as LynxView; +export const lynxViewTests = ( + callback: (lynxView: LynxView) => void, + lynxView: LynxView | undefined, +) => { + if (!lynxView) lynxView = document.createElement('lynx-view') as LynxView; lynxView.initData = { mockData: 'mockData' }; lynxView.setAttribute('height', 'auto'); lynxView.globalProps = { backgroundColor: 'pink' }; @@ -24,7 +27,7 @@ export const lynxViewTests = (callback: (lynxView: LynxView) => void) => { globalThis.timing = Object.assign(globalThis.timing ?? {}, ev.detail); }); callback(lynxView); - document.body.append(lynxView); + if (!lynxView.parentElement) document.body.append(lynxView); Object.assign(globalThis, { lynxView }); }; diff --git a/packages/web-platform/web-tests/tests/__snapshots__/server.vitest.spec.ts.snap b/packages/web-platform/web-tests/tests/__snapshots__/server.vitest.spec.ts.snap index a39d078421..8b227cea3e 100644 --- a/packages/web-platform/web-tests/tests/__snapshots__/server.vitest.spec.ts.snap +++ b/packages/web-platform/web-tests/tests/__snapshots__/server.vitest.spec.ts.snap @@ -1,7 +1,7 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[`server-tests > basic-performance-div-10 1`] = ` -"