diff --git a/.changeset/lemon-pigs-teach.md b/.changeset/lemon-pigs-teach.md new file mode 100644 index 0000000000..81d5e8623b --- /dev/null +++ b/.changeset/lemon-pigs-teach.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/web-core-wasm": patch +--- + +Refactor web element templates and server-side rendering logic diff --git a/.changeset/nine-gifts-cheat.md b/.changeset/nine-gifts-cheat.md new file mode 100644 index 0000000000..e99df7bbf1 --- /dev/null +++ b/.changeset/nine-gifts-cheat.md @@ -0,0 +1,5 @@ +--- +"@lynx-js/web-elements": patch +--- + +fix: firefox 147+ layout issue diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 14ac6ad51e..02241357e7 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -48,7 +48,7 @@ jobs: env: CARGO_LLVM_COV_FLAGS_NO_RUNNER: --no-sparse run: | - cargo llvm-cov nextest --all-features --profile ci --config-file .cargo/nextest.toml --lcov --output-path lcov.info --release + cargo llvm-cov nextest --all-targets --all-features --profile ci --config-file .cargo/nextest.toml --lcov --output-path lcov.info --release - name: Upload coverage reports to Codecov uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5 with: diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d358546724..22c0506fb5 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -96,7 +96,7 @@ jobs: run: | export NODE_OPTIONS="--max-old-space-size=32768" export PLAYWRIGHT_JUNIT_OUTPUT_NAME=test-report.junit.xml - pnpm --filter @lynx-js/web-elements run test --reporter='github,dot,junit,html' --shard=${{ matrix.shard }}/4 + pnpm --filter @lynx-js/web-elements run test --reporter='github,dot,junit,html' --shard=${{ matrix.shard }}/2 pnpm --filter @lynx-js/web-elements run coverage:ci playwright-linux: needs: build @@ -127,7 +127,7 @@ jobs: fi export NODE_OPTIONS="--max-old-space-size=32768" export PLAYWRIGHT_JUNIT_OUTPUT_NAME=test-report.junit.xml - pnpm --filter @lynx-js/web-tests run test --reporter='github,dot,junit,html' --shard=${{ matrix.shard }}/4 + pnpm --filter @lynx-js/web-tests run test --reporter='github,dot,junit,html' --shard=${{ matrix.shard }}/3 pnpm --filter @lynx-js/web-tests run coverage:ci web-core-wasm-e2e: needs: build @@ -152,7 +152,7 @@ jobs: fi export NODE_OPTIONS="--max-old-space-size=32768" export PLAYWRIGHT_JUNIT_OUTPUT_NAME=test-report.junit.xml - pnpm --filter @lynx-js/web-core-wasm-e2e run test --reporter='github,dot,junit,html' --shard=${{ matrix.shard }}/4 + pnpm --filter @lynx-js/web-core-wasm-e2e run test --reporter='github,dot,junit,html' --shard=${{ matrix.shard }}/2 test-api: needs: build uses: ./.github/workflows/workflow-test.yml diff --git a/AGENTS.md b/AGENTS.md index 1747fec8da..b75e866a68 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -174,7 +174,7 @@ The CI runs these checks (replicate locally for confidence): 5. **TypeScript compilation**: Part of `pnpm turbo build` 6. **Unit tests**: `pnpm test` (vitest-based, requires build) 7. **E2E tests**: Web platform tests with Playwright -8. **Rust tests**: `cargo test --all-features` in Rust packages +8. **Rust tests**: `cargo test --all-targets --all-features` in Rust packages 9. **Type checking**: `pnpm -r run test:type` ### GitHub Workflows diff --git a/Cargo.lock b/Cargo.lock index 756ec0adce..27cc54838c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3925,6 +3925,7 @@ dependencies = [ "wasm-bindgen", "wasm-bindgen-test", "web-sys", + "web_elements", ] [[package]] @@ -3946,6 +3947,14 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_elements" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "winapi" version = "0.3.9" diff --git a/Cargo.toml b/Cargo.toml index aed6cd5506..7f220931de 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,6 +7,7 @@ members = [ "packages/react/transform/swc-plugin-reactlynx", "packages/react/transform/swc-plugin-reactlynx-compat", "packages/web-platform/web-core-wasm", + "packages/web-platform/web-elements", "packages/web-platform/web-mainthread-apis", ] diff --git a/packages/web-platform/playwright-fixtures/src/coverage-fixture.ts b/packages/web-platform/playwright-fixtures/src/coverage-fixture.ts index 58b17154a0..59828b77db 100644 --- a/packages/web-platform/playwright-fixtures/src/coverage-fixture.ts +++ b/packages/web-platform/playwright-fixtures/src/coverage-fixture.ts @@ -24,6 +24,9 @@ export const test: typeof base = base.extend({ const pages = new Set(); context.on('page', async (page) => { + if (testInfo.titlePath.join(' ').includes('SSR No JS')) { + return; + } await page.coverage.startJSCoverage({ reportAnonymousScripts: true, resetOnNavigation: true, diff --git a/packages/web-platform/playwright-fixtures/src/playwright.common.ts b/packages/web-platform/playwright-fixtures/src/playwright.common.ts index b4871646f6..fcdbdbf295 100644 --- a/packages/web-platform/playwright-fixtures/src/playwright.common.ts +++ b/packages/web-platform/playwright-fixtures/src/playwright.common.ts @@ -42,7 +42,7 @@ const workerLimit = Math.floor(((cpuCount, envCPULimit) => { export const playwrightConfigCommon: Parameters[0] = { /** global timeout https://playwright.dev/docs/test-timeouts#global-timeout */ globalTimeout: 20 * 60 * 1000, - // testMatch, + testMatch: '**/tests/*', /* Run tests in files in parallel */ fullyParallel: true, workers: isCI ? workerLimit : undefined, diff --git a/packages/web-platform/web-core-wasm-e2e/bench/server.bench.vitest.spec.ts b/packages/web-platform/web-core-wasm-e2e/bench/server.bench.vitest.spec.ts new file mode 100644 index 0000000000..ea0eab273d --- /dev/null +++ b/packages/web-platform/web-core-wasm-e2e/bench/server.bench.vitest.spec.ts @@ -0,0 +1,53 @@ +import { bench, describe } from 'vitest'; +import * as path from 'path'; +import * as fs from 'fs'; +import { executeTemplate } from '@lynx-js/web-core-wasm/server'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function loadTemplate(name: string) { + const bundlePath = path.resolve(__dirname, `../dist/${name}.web.bundle`); + return fs.readFileSync(bundlePath); +} + +const cases = { + 'basic-performance-div-10000': await loadTemplate( + 'basic-performance-div-10000', + ), + 'basic-performance-div-1000': await loadTemplate( + 'basic-performance-div-1000', + ), + 'basic-performance-div-100': await loadTemplate('basic-performance-div-100'), + 'basic-performance-nest-level-100': await loadTemplate( + 'basic-performance-nest-level-100', + ), + 'basic-performance-image-100': await loadTemplate( + 'basic-performance-image-100', + ), + 'basic-performance-scroll-view-100': await loadTemplate( + 'basic-performance-scroll-view-100', + ), + 'basic-performance-text-200': await loadTemplate( + 'basic-performance-text-200', + ), + 'basic-performance-large-css': await loadTemplate( + 'basic-performance-large-css', + ), + 'basic-performance-small-css': await loadTemplate( + 'basic-performance-small-css', + ), +}; + +describe('server-bench', () => { + for (const [testName, rawTemplate] of Object.entries(cases)) { + bench(testName, async () => { + await executeTemplate( + rawTemplate, + {}, // initData + {}, // globalProps + {}, // initI18nResources + ); + }); + } +}); diff --git a/packages/web-platform/web-core-wasm-e2e/package.json b/packages/web-platform/web-core-wasm-e2e/package.json index 4c64004120..61609007c5 100644 --- a/packages/web-platform/web-core-wasm-e2e/package.json +++ b/packages/web-platform/web-core-wasm-e2e/package.json @@ -5,6 +5,7 @@ "license": "Apache-2.0", "type": "module", "scripts": { + "bench": "vitest bench", "build": "pnpm dlx premove dist && pnpm run --stream \"/^build:.*/\"", "build:csr": "node ./scripts/generate-build-command.js", "coverage": "nyc report --cwd=$(realpath ../)", @@ -23,6 +24,8 @@ "@lynx-js/web-core-wasm": "workspace:*", "@playwright/test": "^1.58.2", "@rsbuild/core": "catalog:rsbuild", - "nyc": "^17.1.0" + "nyc": "^17.1.0", + "prettier": "^3.8.1", + "vitest": "^3.2.4" } } diff --git a/packages/web-platform/web-core-wasm-e2e/rsbuild.config.ts b/packages/web-platform/web-core-wasm-e2e/rsbuild.config.ts index ab5845c3ce..03221a952e 100644 --- a/packages/web-platform/web-core-wasm-e2e/rsbuild.config.ts +++ b/packages/web-platform/web-core-wasm-e2e/rsbuild.config.ts @@ -1,5 +1,6 @@ import { defineConfig } from '@rsbuild/core'; import { fileURLToPath } from 'node:url'; +import { ssrMiddleware } from './shell-project/devMiddleware.js'; const __filename = fileURLToPath(import.meta.url); const port = process.env.PORT ?? 3080; @@ -20,6 +21,12 @@ export default defineConfig({ hmr: false, liveReload: false, writeToDisk: true, + setupMiddlewares: [ + (middlewares: any) => { + middlewares.unshift(ssrMiddleware); + return middlewares; + }, + ], }, server: { port: Number(port), diff --git a/packages/web-platform/web-core-wasm-e2e/server-tests/__snapshots__/server-e2e.test.ts.snap b/packages/web-platform/web-core-wasm-e2e/server-tests/__snapshots__/server-e2e.test.ts.snap new file mode 100644 index 0000000000..cb62dfabaf --- /dev/null +++ b/packages/web-platform/web-core-wasm-e2e/server-tests/__snapshots__/server-e2e.test.ts.snap @@ -0,0 +1,1516 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`executeTemplate should run lepusCode.root from basic-element-image-src.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-list-basic.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-svg-with-css.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-text-baseline.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-text-color.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-x-audio-tt-play.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-x-input-value.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-x-overlay-ng-demo.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-x-refresh-view-demo.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-x-swiper-autoplay.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-x-textarea-placeholder.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-element-x-viewpager-ng-bindchange.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-pink-rect.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from basic-scroll-view.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from config-css-default-display-linear-false.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from config-css-remove-scope-false-display-linear.web.bundle 1`] = ` +" +" +`; + +exports[`executeTemplate should run lepusCode.root from config-css-selector-false-remove-css-and-style-collapsed.web.bundle 1`] = ` +" +" +`; diff --git a/packages/web-platform/web-core-wasm-e2e/server-tests/server-e2e.test.ts b/packages/web-platform/web-core-wasm-e2e/server-tests/server-e2e.test.ts new file mode 100644 index 0000000000..3896887637 --- /dev/null +++ b/packages/web-platform/web-core-wasm-e2e/server-tests/server-e2e.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from 'vitest'; +import * as path from 'path'; +import * as fs from 'fs'; +import { format } from 'prettier'; +import { executeTemplate } from '@lynx-js/web-core-wasm/server'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +async function runSnapshotTest(bundleName: string) { + const distDir = path.resolve(__dirname, '../dist'); + const bundlePath = path.join(distDir, bundleName); + const buffer = fs.readFileSync(bundlePath); + const result = await executeTemplate( + buffer, + {}, // initData + {}, // globalProps + {}, // initI18nResources + ); + + expect(result).toBeDefined(); + expect(typeof result).toBe('string'); + + const formatted = await format(result, { parser: 'html' }); + expect(formatted).toMatchSnapshot(); +} + +test('executeTemplate should run lepusCode.root from basic-pink-rect.web.bundle', async () => { + await runSnapshotTest('basic-pink-rect.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from config-css-default-display-linear-false.web.bundle', async () => { + await runSnapshotTest('config-css-default-display-linear-false.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from config-css-remove-scope-false-display-linear.web.bundle', async () => { + await runSnapshotTest( + 'config-css-remove-scope-false-display-linear.web.bundle', + ); +}); + +test('executeTemplate should run lepusCode.root from config-css-selector-false-remove-css-and-style-collapsed.web.bundle', async () => { + await runSnapshotTest( + 'config-css-selector-false-remove-css-and-style-collapsed.web.bundle', + ); +}); + +test('executeTemplate should run lepusCode.root from basic-element-text-baseline.web.bundle', async () => { + await runSnapshotTest('basic-element-text-baseline.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-scroll-view.web.bundle', async () => { + await runSnapshotTest('basic-scroll-view.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-x-audio-tt-play.web.bundle', async () => { + await runSnapshotTest('basic-element-x-audio-tt-play.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-image-src.web.bundle', async () => { + await runSnapshotTest('basic-element-image-src.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-x-input-value.web.bundle', async () => { + await runSnapshotTest('basic-element-x-input-value.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-list-basic.web.bundle', async () => { + await runSnapshotTest('basic-element-list-basic.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-x-overlay-ng-demo.web.bundle', async () => { + await runSnapshotTest('basic-element-x-overlay-ng-demo.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-x-refresh-view-demo.web.bundle', async () => { + await runSnapshotTest('basic-element-x-refresh-view-demo.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-svg-with-css.web.bundle', async () => { + await runSnapshotTest('basic-element-svg-with-css.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-x-swiper-autoplay.web.bundle', async () => { + await runSnapshotTest('basic-element-x-swiper-autoplay.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-text-color.web.bundle', async () => { + await runSnapshotTest('basic-element-text-color.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-x-textarea-placeholder.web.bundle', async () => { + await runSnapshotTest('basic-element-x-textarea-placeholder.web.bundle'); +}); + +test('executeTemplate should run lepusCode.root from basic-element-x-viewpager-ng-bindchange.web.bundle', async () => { + await runSnapshotTest('basic-element-x-viewpager-ng-bindchange.web.bundle'); +}); diff --git a/packages/web-platform/web-core-wasm-e2e/shell-project/devMiddleware.ts b/packages/web-platform/web-core-wasm-e2e/shell-project/devMiddleware.ts new file mode 100644 index 0000000000..6673345b97 --- /dev/null +++ b/packages/web-platform/web-core-wasm-e2e/shell-project/devMiddleware.ts @@ -0,0 +1,91 @@ +/* + * 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 * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; +import { executeTemplate } from '@lynx-js/web-core-wasm/server'; +import type { IncomingMessage, ServerResponse } from 'http'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +export async function ssrMiddleware( + req: IncomingMessage, + res: ServerResponse, + next: () => void, +) { + const url = new URL(req.url ?? '', `http://${req.headers.host}`); + if (url.pathname === '/ssr') { + const caseName = url.searchParams.get('casename'); + const hasdir = url.searchParams.get('hasdir') === 'true'; + const bundlePath = path.join( + __dirname, + '../dist', + hasdir ? caseName : '', + `${caseName}.web.bundle`, + ); + + try { + const buffer = fs.readFileSync(bundlePath); + + // Construct view attributes from other query params and defaults + const attributes = new Map(); + attributes.set('height', 'auto'); + attributes.set('id', 'lynxview1'); + attributes.set( + 'url', + `/dist/ssr/${ + hasdir + ? `${caseName}/${caseName}.web.bundle` + : `${caseName}.web.bundle` + }`, + ); + + url.searchParams.forEach((value, key) => { + if (key !== 'casename' && key !== 'hasdir') { + attributes.set(key, value); + } + }); + + let viewAttributes = ''; + for (const [key, value] of attributes) { + viewAttributes += `${key}="${value}" `; + } + viewAttributes = viewAttributes.trim(); + + // Execute Template + const ssrResult = await executeTemplate( + buffer, + {}, // initData + {}, // globalProps + {}, // initI18nResources + viewAttributes, + ); + + // Read template + const template = fs.readFileSync( + path.join(__dirname, 'ssr.html'), + 'utf-8', + ); + + // Inject result + const html = template.replace( + '', + ssrResult, + ); + + res.setHeader('Content-Type', 'text/html'); + res.end(html); + } catch (err: any) { + console.error('SSR Error:', err); + res.statusCode = 500; + res.end('Internal Server Error'); + } + } else { + next(); + } +} diff --git a/packages/web-platform/web-core-wasm-e2e/shell-project/ssr.html b/packages/web-platform/web-core-wasm-e2e/shell-project/ssr.html new file mode 100644 index 0000000000..72089be7c1 --- /dev/null +++ b/packages/web-platform/web-core-wasm-e2e/shell-project/ssr.html @@ -0,0 +1,18 @@ + + + + + + SSR Test + + + + +
+ +
+ + diff --git a/packages/web-platform/web-core-wasm-e2e/tests/reactlynx.spec.ts b/packages/web-platform/web-core-wasm-e2e/tests/reactlynx.spec.ts index 8d05f69dd9..74dae5d97f 100644 --- a/packages/web-platform/web-core-wasm-e2e/tests/reactlynx.spec.ts +++ b/packages/web-platform/web-core-wasm-e2e/tests/reactlynx.spec.ts @@ -3555,9 +3555,10 @@ test.describe('reactlynx3 tests', () => { }); test( 'basic-element-x-swiper-method-scroll-to', - async ({ page }, { title }) => { + async ({ page, browserName }, { title }) => { + test.skip(browserName == 'firefox'); await goto(page, title); - await wait(100); + await wait(400); await diffScreenShot(page, 'x-swiper', 'scroll-to', '1', { animations: 'allow', }); @@ -3919,6 +3920,7 @@ test.describe('reactlynx3 tests', () => { test( 'basic-element-x-swiper-circular-flat-coverflow', async ({ page, browserName }, { title }) => { + test.skip(browserName == 'firefox'); await goto(page, title); await wait(1000); await diffScreenShot( @@ -4032,6 +4034,7 @@ test.describe('reactlynx3 tests', () => { test( 'basic-element-x-swiper-bindchange', async ({ page, browserName, context }, { title }) => { + test.skip(browserName === 'firefox'); await goto(page, title); const autoplay = [null, false, false, false]; let manual = false; diff --git a/packages/web-platform/web-core-wasm-e2e/tests/ssr-no-js.spec.ts b/packages/web-platform/web-core-wasm-e2e/tests/ssr-no-js.spec.ts new file mode 100644 index 0000000000..7ab2d170e0 --- /dev/null +++ b/packages/web-platform/web-core-wasm-e2e/tests/ssr-no-js.spec.ts @@ -0,0 +1,21 @@ +import { test, expect } from '@lynx-js/playwright-fixtures'; + +test.describe('SSR No JS', () => { + test.use({ javaScriptEnabled: false }); + + test('basic-pink-rect', async ({ page }) => { + // 1. Navigate to SSR page + await page.goto('/ssr?casename=basic-pink-rect', { + waitUntil: 'load', + }); + + // 2. Verify Attributes + const lynxView = page.locator('lynx-view'); + await expect(lynxView).toHaveAttribute('id', 'lynxview1'); + await expect(lynxView).toHaveAttribute('height', 'auto'); + await expect(lynxView).toHaveAttribute( + 'url', + '/dist/ssr/basic-pink-rect.web.bundle', + ); + }); +}); diff --git a/packages/web-platform/web-core-wasm-e2e/tests/web-core.test.ts b/packages/web-platform/web-core-wasm-e2e/tests/web-core.test.ts index be31bc4b7e..edaa15c4ee 100644 --- a/packages/web-platform/web-core-wasm-e2e/tests/web-core.test.ts +++ b/packages/web-platform/web-core-wasm-e2e/tests/web-core.test.ts @@ -48,23 +48,22 @@ test.describe('web core tests', () => { test.skip(browserName === 'firefox'); await goto(page); await page.evaluate(() => { - globalThis.runtime.renderPage = () => { - const root = globalThis.runtime.__CreatePage('0', '0', {}); - const element = globalThis.runtime.__CreateElement('view', '0', {}); - globalThis.runtime.__AppendElement(root, element); - const component = globalThis.runtime.__CreateComponent( - '1', - '0-13826000', - '0', - '', - '', - '', - {}, - {}, - ); - globalThis.runtime.__AddClass(component, 'wrapper'); - globalThis.runtime.__AppendElement(element, component); - }; + const root = globalThis.runtime.__CreatePage('0', '0', {}); + const element = globalThis.runtime.__CreateElement('view', '0', {}); + globalThis.runtime.__AppendElement(root, element); + const component = globalThis.runtime.__CreateComponent( + '1', + '0-13826000', + '0', + '', + '', + '', + {}, + {}, + ); + globalThis.runtime.__AddClass(component, 'wrapper'); + globalThis.runtime.__AppendElement(element, component); + globalThis.runtime.__FlushElementTree(); }); await wait(200); const backWorker = await getBackgroundThreadWorker(page); @@ -91,9 +90,6 @@ test.describe('web core tests', () => { 'firefox flaky', ); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); const worker = await getBackgroundThreadWorker(page); const importedValue = await worker!.evaluate(async () => { const { promise, resolve } = Promise.withResolvers(); @@ -113,9 +109,6 @@ test.describe('web core tests', () => { 'firefox flaky', ); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); const worker = await getBackgroundThreadWorker(page); const [hello, world] = await worker!.evaluate(async () => { const chunk1 = Promise.withResolvers(); @@ -143,9 +136,7 @@ test.describe('web core tests', () => { 'firefox flaky', ); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + const worker = await getBackgroundThreadWorker(page); const [hello, world] = await worker!.evaluate(async () => { const chunk1 = Promise.withResolvers(); @@ -167,9 +158,7 @@ test.describe('web core tests', () => { test('loadLepusChunk', async ({ page, browserName }) => { await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + const [success, fail] = await page.evaluate(async () => { return [ globalThis.runtime.__LoadLepusChunk('manifest-chunk2.js'), @@ -184,9 +173,7 @@ test.describe('web core tests', () => { // firefox dose not support this. test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + await wait(3000); const backWorker = await getBackgroundThreadWorker(page); const jsonContent = await backWorker.evaluate(() => { @@ -209,9 +196,7 @@ test.describe('web core tests', () => { // firefox dose not support this. test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + const backgroundWorker = await getBackgroundThreadWorker(page); const ret = await backgroundWorker!.evaluate(async () => { const { promise, resolve } = Promise.withResolvers(); @@ -256,9 +241,7 @@ test.describe('web core tests', () => { // firefox dose not support this. test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + await wait(3000); const backWorker = await getBackgroundThreadWorker(page); let successCallback = false; @@ -287,9 +270,7 @@ test.describe('web core tests', () => { // firefox dose not support this. test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + await wait(3000); const backWorker = await getBackgroundThreadWorker(page); let successCallback = false; @@ -320,9 +301,7 @@ test.describe('web core tests', () => { test.skip(browserName === 'firefox'); await goto(page); await wait(200); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + await wait(3000); const backWorker = await getBackgroundThreadWorker(page); let successDispatchNapiModule = false; @@ -351,7 +330,6 @@ test.describe('web core tests', () => { test.skip(browserName === 'firefox'); await goto(page); const success = await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; if ( JSON.stringify(globalThis.runtime._I18nResourceTranslation({ locale: 'en', @@ -382,7 +360,6 @@ test.describe('web core tests', () => { ); }); await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; globalThis.runtime._I18nResourceTranslation({ locale: 'en', channel: '2', @@ -400,7 +377,6 @@ test.describe('web core tests', () => { test.skip(browserName === 'firefox'); await goto(page); const first = await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; if ( globalThis.runtime._I18nResourceTranslation({ locale: 'en', @@ -444,7 +420,6 @@ test.describe('web core tests', () => { }); await wait(500); const second = await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; if ( JSON.stringify(globalThis.runtime._I18nResourceTranslation({ locale: 'en', @@ -463,9 +438,7 @@ test.describe('web core tests', () => { // firefox dose not support this. test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + await wait(500); const backWorker = await getBackgroundThreadWorker(page); const first = await backWorker?.evaluate(() => @@ -490,9 +463,7 @@ test.describe('web core tests', () => { // firefox dose not support this. test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + await wait(500); const backWorker = await getBackgroundThreadWorker(page); const first = await backWorker?.evaluate(() => @@ -541,9 +512,7 @@ test.describe('web core tests', () => { // firefox dose not support this. test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + await wait(500); let success = false; await page.on('console', async (message) => { @@ -575,9 +544,7 @@ test.describe('web core tests', () => { // firefox dose not support this. test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + await wait(500); let success = false; await page.on('console', async (message) => { @@ -632,9 +599,7 @@ test.describe('web core tests', () => { // firefox not support test.skip(browserName === 'firefox'); await goto(page); - await page.evaluate(() => { - globalThis.runtime.renderPage = () => {}; - }); + const backWorker = await getBackgroundThreadWorker(page); const isSuccess = await backWorker.evaluate(() => { return new Promise(resolve => { diff --git a/packages/web-platform/web-core-wasm-e2e/tsconfig.json b/packages/web-platform/web-core-wasm-e2e/tsconfig.json index a410e92c8c..05e5fe0909 100644 --- a/packages/web-platform/web-core-wasm-e2e/tsconfig.json +++ b/packages/web-platform/web-core-wasm-e2e/tsconfig.json @@ -1,6 +1,7 @@ { "extends": "../../../tsconfig.json", "compilerOptions": { + "composite": true, "lib": ["DOM", "ESNext"], "isolatedDeclarations": false, }, diff --git a/packages/web-platform/web-core-wasm-e2e/vitest.config.ts b/packages/web-platform/web-core-wasm-e2e/vitest.config.ts new file mode 100644 index 0000000000..f7944266d4 --- /dev/null +++ b/packages/web-platform/web-core-wasm-e2e/vitest.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vitest/config'; +import codspeed from '@codspeed/vitest-plugin'; +import * as path from 'path'; + +export default defineConfig({ + resolve: { + alias: { + '@lynx-js/web-core-wasm/server': path.resolve( + __dirname, + '../web-core-wasm/ts/server/index.ts', + ), + '@lynx-js/web-core-wasm': path.resolve( + __dirname, + '../web-core-wasm/ts/client/index.ts', + ), + }, + }, + test: { + include: ['server-tests/**/*.test.ts'], + exclude: ['bench/**/*.bench.vitest.spec.ts'], + testTimeout: 10000, + benchmark: { + include: ['bench/**/*.bench.vitest.spec.ts'], + }, + }, + plugins: [ + process.env['CI'] ? codspeed() : undefined, + ], +}); diff --git a/packages/web-platform/web-core-wasm/AGENTS.md b/packages/web-platform/web-core-wasm/AGENTS.md index 66b59f9eb0..49eb41f866 100644 --- a/packages/web-platform/web-core-wasm/AGENTS.md +++ b/packages/web-platform/web-core-wasm/AGENTS.md @@ -58,7 +58,14 @@ Runs the application logic (BTS). - **`createNativeApp.ts`**: Creates the `NativeApp` interface, which mimics the native Lynx environment for the application code. It proxies commands (like `createElement`) to the main thread via RPC. - **`createBackgroundLynx.ts`**: Sets up the global `lynx` object in the worker. -### 3. Encoder (`ts/encode`) +### 3. Server Runtime (`ts/server`) + +TypeScript bindings for Server-Side Rendering (SSR). + +- **`wasm.ts`**: Loads the server-specific Wasm binary. +- **`elementAPIs/createElementAPI.ts`**: Implements `ElementPAPIs` and returns the `wasmContext` for the server, using `MainThreadServerContext` to generate HTML strings instead of manipulating DOM. + +### 4. Encoder (`ts/encode`) Build-time utilities. @@ -91,8 +98,8 @@ This package uses a hybrid build system involving `pnpm`, `rsbuild`, and `cargo` 1. Compiles Rust to Wasm (`wasm32-unknown-unknown`) using `cargo`. 2. Generates high-performance JS bindings with `wasm-bindgen`. 3. Optimizes the binary size with `wasm-opt`. - 4. Builds two variants: `client` (browser runtime) and `encode` (build tool). -- **`pnpm test`**: Runs `vitest`. + 4. Builds three variants: `client` (browser runtime), `server` (SSR), and `encode` (build tool). +- **`pnpm test`**: Runs `vitest`. Note: If you modify Rust code, you must run `pnpm build:wasm` first for the changes to take effect in the tests. ### Configuration @@ -105,6 +112,11 @@ This package uses a hybrid build system involving `pnpm`, `rsbuild`, and `cargo` - **`tests/encode.spec.ts`**: Verifies that the CSS encoder correctly serializes various CSS rules. - **`tests/lazy-load.spec.ts`**: Ensures that custom elements are loaded dynamically only when needed. - **Rust Tests**: run `cargo test --all-features` and `cargo test --target wasm32-unknown-unknown --all-features` separately. +- **Server E2E Tests (`packages/web-platform/web-core-wasm-e2e`)**: + - Located in `packages/web-platform/web-core-wasm-e2e`. + - Uses `vitest` to run tests against the **built artifacts** (e.g., `dist/api-globalThis.web.bundle`). + - Verifies server-side execution of templates using `executeTemplate` and isolated VM contexts. + - Run with `pnpm test` inside the `web-core-wasm-e2e` directory. ## Guidelines for LLMs diff --git a/packages/web-platform/web-core-wasm/Cargo.toml b/packages/web-platform/web-core-wasm/Cargo.toml index e2bafeaa58..d611f6377f 100644 --- a/packages/web-platform/web-core-wasm/Cargo.toml +++ b/packages/web-platform/web-core-wasm/Cargo.toml @@ -13,6 +13,7 @@ lazy_static = { workspace = true } rkyv = { version = "0.7" } wasm-bindgen = { workspace = true } web-sys = { workspace = true, optional = true, features = ["CssStyleRule", "HtmlCollection", "CssStyleDeclaration", "CssRule", "CssRuleList", "HtmlElement", "HtmlTemplateElement", "HtmlStyleElement", "DocumentFragment", "Document", "NodeList", "DomTokenList", "CssStyleSheet"] } +web_elements = { path = "../web-elements" } [features] default = [] encode = [] diff --git a/packages/web-platform/web-core-wasm/binary/client/client.d.ts b/packages/web-platform/web-core-wasm/binary/client/client.d.ts index b8f301fcc0..f7b49b5d0b 100644 --- a/packages/web-platform/web-core-wasm/binary/client/client.d.ts +++ b/packages/web-platform/web-core-wasm/binary/client/client.d.ts @@ -35,7 +35,7 @@ export class MainThreadWasmContext { get_events(unique_id: number): EventInfo[]; get_unique_id_by_component_id(component_id: string): number | undefined; constructor(root_node: Node, mts_binding: any, config_enable_css_selector: boolean); - push_style_sheet(template_manager: TemplateManager, entry_name: string, is_entry_template: boolean): void; + push_style_sheet(style_info: StyleSheetResource, entry_name?: string | null): void; /** * * * key: String @@ -146,11 +146,10 @@ export class Selector { push_one_selector_section(selector_type: string, value: string): void; } -export class TemplateManager { +export class StyleSheetResource { free(): void; [Symbol.dispose](): void; - add_style_info(template_name: string, buf: Uint8Array, document: Document): void; - constructor(); + constructor(buffer: Uint8Array, _document: any); } /** @@ -191,7 +190,7 @@ export interface InitOutput { readonly __wbg_set_eventinfo_event_handler: (a: number, b: any) => void; readonly __wbg_set_eventinfo_event_name: (a: number, b: number, c: number) => void; readonly __wbg_set_eventinfo_event_type: (a: number, b: number, c: number) => void; - readonly __wbg_templatemanager_free: (a: number, b: number) => void; + readonly __wbg_stylesheetresource_free: (a: number, b: number) => void; readonly add_inline_style_raw_string_key: (a: any, b: number, c: number, d: number, e: number) => void; readonly decode_style_info: (a: any, b: number, c: number, d: number) => [number, number, number]; readonly encode_legacy_json_generated_raw_style_info: (a: number, b: number, c: number, d: number) => [number, number, number]; @@ -215,7 +214,7 @@ export interface InitOutput { readonly mainthreadwasmcontext_get_events: (a: number, b: number) => [number, number]; readonly mainthreadwasmcontext_get_unique_id_by_component_id: (a: number, b: number, c: number) => number; readonly mainthreadwasmcontext_new: (a: any, b: any, c: number) => number; - readonly mainthreadwasmcontext_push_style_sheet: (a: number, b: number, c: number, d: number, e: number) => [number, number]; + readonly mainthreadwasmcontext_push_style_sheet: (a: number, b: number, c: number, d: number) => [number, number]; readonly mainthreadwasmcontext_set_config: (a: number, b: number, c: any) => [number, number]; readonly mainthreadwasmcontext_set_css_id: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; readonly mainthreadwasmcontext_set_dataset: (a: number, b: number, c: any, d: any) => [number, number]; @@ -235,8 +234,7 @@ export interface InitOutput { readonly selector_push_one_selector_section: (a: number, b: number, c: number, d: number, e: number) => [number, number]; readonly set_inline_styles_in_str: (a: any, b: number, c: number) => number; readonly set_inline_styles_number_key: (a: any, b: number, c: number, d: number) => void; - readonly templatemanager_add_style_info: (a: number, b: number, c: number, d: any, e: any) => [number, number]; - readonly templatemanager_new: () => number; + readonly stylesheetresource_new: (a: any, b: any) => [number, number, number]; readonly selector_new: () => number; readonly __wbindgen_malloc: (a: number, b: number) => number; readonly __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; diff --git a/packages/web-platform/web-core-wasm/binary/client/client_bg.wasm.d.ts b/packages/web-platform/web-core-wasm/binary/client/client_bg.wasm.d.ts index 44f922e7b2..cf988009f1 100644 --- a/packages/web-platform/web-core-wasm/binary/client/client_bg.wasm.d.ts +++ b/packages/web-platform/web-core-wasm/binary/client/client_bg.wasm.d.ts @@ -13,7 +13,7 @@ export const __wbg_selector_free: (a: number, b: number) => void; export const __wbg_set_eventinfo_event_handler: (a: number, b: any) => void; export const __wbg_set_eventinfo_event_name: (a: number, b: number, c: number) => void; export const __wbg_set_eventinfo_event_type: (a: number, b: number, c: number) => void; -export const __wbg_templatemanager_free: (a: number, b: number) => void; +export const __wbg_stylesheetresource_free: (a: number, b: number) => void; export const add_inline_style_raw_string_key: (a: any, b: number, c: number, d: number, e: number) => void; export const decode_style_info: (a: any, b: number, c: number, d: number) => [number, number, number]; export const encode_legacy_json_generated_raw_style_info: (a: number, b: number, c: number, d: number) => [number, number, number]; @@ -37,7 +37,7 @@ export const mainthreadwasmcontext_get_event: (a: number, b: number, c: number, export const mainthreadwasmcontext_get_events: (a: number, b: number) => [number, number]; export const mainthreadwasmcontext_get_unique_id_by_component_id: (a: number, b: number, c: number) => number; export const mainthreadwasmcontext_new: (a: any, b: any, c: number) => number; -export const mainthreadwasmcontext_push_style_sheet: (a: number, b: number, c: number, d: number, e: number) => [number, number]; +export const mainthreadwasmcontext_push_style_sheet: (a: number, b: number, c: number, d: number) => [number, number]; export const mainthreadwasmcontext_set_config: (a: number, b: number, c: any) => [number, number]; export const mainthreadwasmcontext_set_css_id: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; export const mainthreadwasmcontext_set_dataset: (a: number, b: number, c: any, d: any) => [number, number]; @@ -57,8 +57,7 @@ export const ruleprelude_push_selector: (a: number, b: number) => void; export const selector_push_one_selector_section: (a: number, b: number, c: number, d: number, e: number) => [number, number]; export const set_inline_styles_in_str: (a: any, b: number, c: number) => number; export const set_inline_styles_number_key: (a: any, b: number, c: number, d: number) => void; -export const templatemanager_add_style_info: (a: number, b: number, c: number, d: any, e: any) => [number, number]; -export const templatemanager_new: () => number; +export const stylesheetresource_new: (a: any, b: any) => [number, number, number]; export const selector_new: () => number; export const __wbindgen_malloc: (a: number, b: number) => number; export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; diff --git a/packages/web-platform/web-core-wasm/binary/server/server.d.ts b/packages/web-platform/web-core-wasm/binary/server/server.d.ts new file mode 100644 index 0000000000..df48da420f --- /dev/null +++ b/packages/web-platform/web-core-wasm/binary/server/server.d.ts @@ -0,0 +1,134 @@ +/* tslint:disable */ +/* eslint-disable */ + +export class MainThreadServerContext { + free(): void; + [Symbol.dispose](): void; + add_class(element_id: number, class_name: string): void; + add_inline_style_raw_string_key(element_id: number, key: string, value?: string | null): void; + append_child(parent_id: number, child_id: number): void; + create_element(tag_name: string, parent_component_unique_id?: number | null, css_id_opt?: number | null, component_id?: string | null): number; + generate_html(element_id: number): string; + get_attribute(element_id: number, key: string): string | undefined; + get_attributes(element_id: number): object; + get_inline_styles_in_key_value_vec(element_id: number, k_v_vec: string[]): void; + get_page_css(): string; + get_tag(element_id: number): string | undefined; + constructor(view_attributes: string, enable_css_selector: boolean); + push_style_sheet(resource: StyleSheetResource, entry_name?: string | null): void; + remove_attribute(element_id: number, key: string): void; + set_attribute(element_id: number, key: string, value: string): void; + set_css_id(elements_unique_id: Uint32Array, css_id: number, entry_name?: string | null): void; + set_inline_styles_in_str(element_id: number, styles: string): boolean; + set_inline_styles_number_key(element_id: number, key: number, value?: string | null): void; + update_css_og_style(unique_id: number, entry_name?: string | null): void; +} + +export class RawStyleInfo { + free(): void; + [Symbol.dispose](): void; + /** + * + * * Appends an import to the stylesheet identified by `css_id`. + * * If the stylesheet does not exist, it is created. + * * @param css_id - The ID of the CSS file. + * * @param import_css_id - The ID of the imported CSS file. + * + */ + append_import(css_id: number, import_css_id: number): void; + constructor(); + /** + * + * * Pushes a rule to the stylesheet identified by `css_id`. + * * If the stylesheet does not exist, it is created. + * * @param css_id - The ID of the CSS file. + * * @param rule - The rule to append. + * + */ + push_rule(css_id: number, rule: Rule): void; +} + +export class Rule { + free(): void; + [Symbol.dispose](): void; + /** + * + * * Creates a new Rule with the specified type. + * * @param rule_type - The type of the rule (e.g., "StyleRule", "FontFaceRule", "KeyframesRule"). + * + */ + constructor(rule_type: string); + /** + * + * * Pushes a declaration to the rule's declaration block. + * * LynxJS doesn't support !important + * * @param property_name - The property name. + * * @param value - The property value. + * + */ + push_declaration(property_name: string, value: string): void; + /** + * + * * Pushes a nested rule to the rule. + * * @param rule - The nested rule to add. + * + */ + push_rule_children(rule: Rule): void; + /** + * + * * Sets the prelude for the rule. + * * @param prelude - The prelude to set (SelectorList or KeyFramesPrelude). + * + */ + set_prelude(prelude: RulePrelude): void; +} + +/** + * + * * Either SelectorList or KeyFramesPrelude + * * Depending on the RuleType + * * If it is SelectorList, then selectors is a list of Selector + * * If it is KeyFramesPrelude, then selectors has only one selector which is Prelude text, its simple_selectors is empty + * * If the parent is FontFace, then selectors is empty + * + */ +export class RulePrelude { + free(): void; + [Symbol.dispose](): void; + constructor(); + /** + * + * * Pushes a selector to the list. + * * @param selector - The selector to add. + * + */ + push_selector(selector: Selector): void; +} + +export class Selector { + free(): void; + [Symbol.dispose](): void; + constructor(); + /** + * + * * Pushes a selector section to the selector. + * * @param selector_type - The type of the selector section (e.g., "ClassSelector", "IdSelector"). + * * @param value - The value of the selector section. + * + */ + push_one_selector_section(selector_type: string, value: string): void; +} + +export class StyleSheetResource { + free(): void; + [Symbol.dispose](): void; + constructor(buffer: Uint8Array, _document: any); +} + +export function decode_style_info(buffer: Uint8Array, entry_name: string | null | undefined, config_enable_css_selector: boolean): Uint8Array; + +export function encode_legacy_json_generated_raw_style_info(raw_style_info: RawStyleInfo, config_enable_css_selector: boolean, entry_name?: string | null): Uint8Array; + +export function get_font_face_content(buffer: Uint8Array): string; + +export function get_style_content(buffer: Uint8Array): string; diff --git a/packages/web-platform/web-core-wasm/binary/server/server_bg.wasm.d.ts b/packages/web-platform/web-core-wasm/binary/server/server_bg.wasm.d.ts new file mode 100644 index 0000000000..acf8521340 --- /dev/null +++ b/packages/web-platform/web-core-wasm/binary/server/server_bg.wasm.d.ts @@ -0,0 +1,51 @@ +/* tslint:disable */ +/* eslint-disable */ +export const memory: WebAssembly.Memory; +export const __wbg_mainthreadservercontext_free: (a: number, b: number) => void; +export const __wbg_rawstyleinfo_free: (a: number, b: number) => void; +export const __wbg_rule_free: (a: number, b: number) => void; +export const __wbg_ruleprelude_free: (a: number, b: number) => void; +export const __wbg_selector_free: (a: number, b: number) => void; +export const __wbg_stylesheetresource_free: (a: number, b: number) => void; +export const decode_style_info: (a: any, b: number, c: number, d: number) => [number, number, number]; +export const encode_legacy_json_generated_raw_style_info: (a: number, b: number, c: number, d: number) => [number, number, number]; +export const get_font_face_content: (a: any) => [number, number, number, number]; +export const get_style_content: (a: any) => [number, number, number, number]; +export const mainthreadservercontext_add_class: (a: number, b: number, c: number, d: number) => [number, number]; +export const mainthreadservercontext_add_inline_style_raw_string_key: (a: number, b: number, c: number, d: number, e: number, f: number) => void; +export const mainthreadservercontext_append_child: (a: number, b: number, c: number) => void; +export const mainthreadservercontext_create_element: (a: number, b: number, c: number, d: number, e: number, f: number, g: number) => number; +export const mainthreadservercontext_generate_html: (a: number, b: number) => [number, number]; +export const mainthreadservercontext_get_attribute: (a: number, b: number, c: number, d: number) => [number, number]; +export const mainthreadservercontext_get_attributes: (a: number, b: number) => [number, number, number]; +export const mainthreadservercontext_get_inline_styles_in_key_value_vec: (a: number, b: number, c: number, d: number) => void; +export const mainthreadservercontext_get_page_css: (a: number) => [number, number]; +export const mainthreadservercontext_get_tag: (a: number, b: number) => [number, number]; +export const mainthreadservercontext_new: (a: number, b: number, c: number) => number; +export const mainthreadservercontext_push_style_sheet: (a: number, b: number, c: number, d: number) => [number, number]; +export const mainthreadservercontext_remove_attribute: (a: number, b: number, c: number, d: number) => void; +export const mainthreadservercontext_set_attribute: (a: number, b: number, c: number, d: number, e: number, f: number) => void; +export const mainthreadservercontext_set_css_id: (a: number, b: number, c: number, d: number, e: number, f: number) => [number, number]; +export const mainthreadservercontext_set_inline_styles_in_str: (a: number, b: number, c: number, d: number) => number; +export const mainthreadservercontext_set_inline_styles_number_key: (a: number, b: number, c: number, d: number, e: number) => void; +export const mainthreadservercontext_update_css_og_style: (a: number, b: number, c: number, d: number) => [number, number]; +export const rawstyleinfo_append_import: (a: number, b: number, c: number) => void; +export const rawstyleinfo_new: () => number; +export const rawstyleinfo_push_rule: (a: number, b: number, c: number) => void; +export const rule_new: (a: number, b: number) => [number, number, number]; +export const rule_push_declaration: (a: number, b: number, c: number, d: number, e: number) => void; +export const rule_push_rule_children: (a: number, b: number) => void; +export const rule_set_prelude: (a: number, b: number) => void; +export const ruleprelude_new: () => number; +export const ruleprelude_push_selector: (a: number, b: number) => void; +export const selector_push_one_selector_section: (a: number, b: number, c: number, d: number, e: number) => [number, number]; +export const stylesheetresource_new: (a: any, b: any) => [number, number, number]; +export const selector_new: () => number; +export const __wbindgen_malloc: (a: number, b: number) => number; +export const __wbindgen_realloc: (a: number, b: number, c: number, d: number) => number; +export const __wbindgen_exn_store: (a: number) => void; +export const __externref_table_alloc: () => number; +export const __wbindgen_externrefs: WebAssembly.Table; +export const __externref_table_dealloc: (a: number) => void; +export const __wbindgen_free: (a: number, b: number, c: number) => void; +export const __wbindgen_start: () => void; diff --git a/packages/web-platform/web-core-wasm/package.json b/packages/web-platform/web-core-wasm/package.json index 9c838ec823..c4bb03ef5f 100644 --- a/packages/web-platform/web-core-wasm/package.json +++ b/packages/web-platform/web-core-wasm/package.json @@ -21,11 +21,16 @@ "import": "./dist/client/index.js", "default": "./dist/client/index.js" }, + "./server": { + "types": "./dist/server/index.d.ts", + "import": "./dist/server/index.js", + "default": "./dist/server/index.js" + }, "./client.prod.js": { - "default": "./dist/client_prod/client.js" + "default": "./dist/client_prod/static/js/client.js" }, - "./index.css": { - "default": "./css/index.css" + "./client.prod.css": { + "default": "./dist/client_prod/static/css/client.css" } }, "main": "./dist/client/index.js", diff --git a/packages/web-platform/web-core-wasm/scripts/build.js b/packages/web-platform/web-core-wasm/scripts/build.js index a2fb82a9ee..e32e9524e8 100644 --- a/packages/web-platform/web-core-wasm/scripts/build.js +++ b/packages/web-platform/web-core-wasm/scripts/build.js @@ -94,4 +94,11 @@ build( '', '--enable-bulk-memory-opt --enable-sign-ext --enable-simd --enable-reference-types --enable-nontrapping-float-to-int --enable-mutable-globals', ); +build( + 'server', + '-C target_feature=+bulk-memory,+sign-ext,+simd128,+reference-types,+nontrapping-fptoint,+mutable-globals', + '', + '--enable-bulk-memory-opt --enable-sign-ext --enable-simd --enable-reference-types --enable-nontrapping-float-to-int --enable-mutable-globals', + true, +); build('encode', '', '', '--all-features', true); diff --git a/packages/web-platform/web-core-wasm/src/constants.rs b/packages/web-platform/web-core-wasm/src/constants.rs index d19f905231..59dff99f24 100644 --- a/packages/web-platform/web-core-wasm/src/constants.rs +++ b/packages/web-platform/web-core-wasm/src/constants.rs @@ -9,7 +9,7 @@ use fnv::{FnvHashMap, FnvHashSet}; pub const CSS_ID_ATTRIBUTE: &str = "l-css-id"; pub const LYNX_ENTRY_NAME_ATTRIBUTE: &str = "l-e-name"; -#[cfg(feature = "client")] +#[cfg(any(feature = "client", feature = "server"))] pub const LYNX_UNIQUE_ID_ATTRIBUTE: &str = "l-uid"; // #[cfg(feature = "client")] // pub const LYNX_TEMPLATE_MEMBER_ID_ATTRIBUTE: &str = "l-t-e-id"; diff --git a/packages/web-platform/web-core-wasm/src/lib.rs b/packages/web-platform/web-core-wasm/src/lib.rs index 53f99913f5..681680308d 100644 --- a/packages/web-platform/web-core-wasm/src/lib.rs +++ b/packages/web-platform/web-core-wasm/src/lib.rs @@ -8,20 +8,20 @@ mod constants; pub mod css_tokenizer; #[cfg(feature = "client")] mod js_binding; -#[cfg(feature = "client")] mod main_thread; + mod style_transformer; + mod template; pub mod utils; #[cfg(feature = "client")] pub use main_thread::{ - element_apis::{ - element_data::{EventHandler, LynxElementData}, - event_apis::EventInfo, + client::{ + element_apis::event_apis::EventInfo, main_thread_context::MainThreadWasmContext, + style_manager::StyleManager, }, - main_thread_context::MainThreadWasmContext, - style_manager::StyleManager, + element_data::LynxElementData, }; pub use style_transformer::{Generator, StyleTransformer}; pub use template::template_sections::style_info::{ @@ -33,6 +33,4 @@ pub use template::template_sections::style_info::{ }; #[cfg(feature = "client")] -pub use template::{ - template_sections::style_info::style_sheet_resource::StyleSheetResource, TemplateManager, -}; +pub use template::template_sections::style_info::style_sheet_resource::StyleSheetResource; diff --git a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/component_apis.rs b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/component_apis.rs similarity index 95% rename from packages/web-platform/web-core-wasm/src/main_thread/element_apis/component_apis.rs rename to packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/component_apis.rs index b76f4ced04..74d6b3fba8 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/component_apis.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/component_apis.rs @@ -9,7 +9,6 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] impl MainThreadWasmContext { - #[wasm_bindgen] pub fn get_component_id(&self, unique_id: usize) -> Result, JsError> { Ok( self @@ -21,7 +20,6 @@ impl MainThreadWasmContext { ) } - #[wasm_bindgen] pub fn get_element_config(&self, unique_id: usize) -> Result, JsError> { Ok( self @@ -33,7 +31,6 @@ impl MainThreadWasmContext { ) } - #[wasm_bindgen] /** * key: String * value: stringifyed js value @@ -47,7 +44,6 @@ impl MainThreadWasmContext { Ok(()) } - #[wasm_bindgen] pub fn get_config(&self, unique_id: usize) -> Result { let binding = self .get_element_data_by_unique_id(unique_id) @@ -62,7 +58,6 @@ impl MainThreadWasmContext { } } - #[wasm_bindgen] pub fn update_component_id( &self, unique_id: usize, diff --git a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/dataset_apis.rs b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/dataset_apis.rs similarity index 98% rename from packages/web-platform/web-core-wasm/src/main_thread/element_apis/dataset_apis.rs rename to packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/dataset_apis.rs index e0867ae688..7ab241cac1 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/dataset_apis.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/dataset_apis.rs @@ -9,7 +9,6 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] impl MainThreadWasmContext { - #[wasm_bindgen] pub fn set_dataset( &mut self, unique_id: usize, @@ -55,7 +54,6 @@ impl MainThreadWasmContext { Ok(()) } - #[wasm_bindgen] pub fn add_dataset( &mut self, unique_id: usize, @@ -77,7 +75,6 @@ impl MainThreadWasmContext { Ok(()) } - #[wasm_bindgen] pub fn get_dataset(&self, unique_id: usize) -> Result { let element_rc = self .get_element_data_by_unique_id(unique_id) @@ -90,7 +87,6 @@ impl MainThreadWasmContext { } } - #[wasm_bindgen] pub fn get_data_by_key( &self, unique_id: usize, diff --git a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/event_apis.rs b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/event_apis.rs similarity index 99% rename from packages/web-platform/web-core-wasm/src/main_thread/element_apis/event_apis.rs rename to packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/event_apis.rs index d9f0e79036..3064fabd9b 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/event_apis.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/event_apis.rs @@ -23,7 +23,6 @@ pub struct EventInfo { #[wasm_bindgen] impl MainThreadWasmContext { - #[wasm_bindgen] pub fn add_cross_thread_event( &mut self, unique_id: usize, @@ -73,7 +72,6 @@ impl MainThreadWasmContext { } } - #[wasm_bindgen] pub fn add_run_worklet_event( &mut self, unique_id: usize, @@ -123,7 +121,6 @@ impl MainThreadWasmContext { } } - #[wasm_bindgen] pub fn get_event( &self, unique_id: usize, @@ -139,7 +136,6 @@ impl MainThreadWasmContext { ) } - #[wasm_bindgen] pub fn get_events(&self, unique_id: usize) -> Vec { let mut event_infos: Vec = vec![]; let event_types = vec!["bindevent", "capture-bind", "catchevent", "capture-catch"]; @@ -278,7 +274,6 @@ impl MainThreadWasmContext { false } - #[wasm_bindgen] pub fn common_event_handler( &self, event: JsValue, diff --git a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/mod.rs b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/mod.rs similarity index 82% rename from packages/web-platform/web-core-wasm/src/main_thread/element_apis/mod.rs rename to packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/mod.rs index b27ec5b933..acd977cd97 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/mod.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/mod.rs @@ -6,9 +6,6 @@ pub(crate) mod component_apis; pub(crate) mod dataset_apis; -pub(crate) mod element_data; - pub(crate) mod event_apis; pub(crate) mod style_apis; use super::main_thread_context::MainThreadWasmContext; -pub(super) use element_data::LynxElementData; diff --git a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/style_apis.rs b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/style_apis.rs similarity index 99% rename from packages/web-platform/web-core-wasm/src/main_thread/element_apis/style_apis.rs rename to packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/style_apis.rs index 9758ec0cd7..ce19d5f64f 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/style_apis.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/client/element_apis/style_apis.rs @@ -13,7 +13,6 @@ use wasm_bindgen::prelude::*; #[wasm_bindgen] impl MainThreadWasmContext { - #[wasm_bindgen] pub fn set_css_id( &mut self, elements_unique_id: Vec, @@ -44,7 +43,6 @@ impl MainThreadWasmContext { Ok(()) } - #[wasm_bindgen] pub fn update_css_og_style( &mut self, unique_id: usize, diff --git a/packages/web-platform/web-core-wasm/src/main_thread/main_thread_context.rs b/packages/web-platform/web-core-wasm/src/main_thread/client/main_thread_context.rs similarity index 86% rename from packages/web-platform/web-core-wasm/src/main_thread/main_thread_context.rs rename to packages/web-platform/web-core-wasm/src/main_thread/client/main_thread_context.rs index 0092d002a3..ebac923184 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/main_thread_context.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/client/main_thread_context.rs @@ -4,11 +4,11 @@ * LICENSE file in the root directory of this source tree. */ -use super::element_apis::LynxElementData; +use super::style_manager::StyleManager; use crate::constants; use crate::js_binding::RustMainthreadContextBinding; -use crate::main_thread::style_manager::StyleManager; -use crate::template::TemplateManager; +use crate::main_thread::element_data::LynxElementData; +use crate::template::template_sections::style_info::StyleSheetResource; use fnv::{FnvHashMap, FnvHashSet}; use std::cell::RefCell; use std::{rc::Rc, vec}; @@ -60,34 +60,18 @@ impl MainThreadWasmContext { } } - #[wasm_bindgen] pub fn push_style_sheet( &mut self, - template_manager: &TemplateManager, - entry_name: String, - is_entry_template: bool, + style_info: &StyleSheetResource, + entry_name: Option, ) -> Result<(), JsError> { - let style_info = template_manager.get_style_info_by_name(&entry_name); - if let Some(style_info) = style_info { - self.style_manager.push_style_sheet( - style_info, - if is_entry_template { - None - } else { - Some(entry_name) - }, - ) - } else { - Ok(()) - } + self.style_manager.push_style_sheet(style_info, entry_name) } - #[wasm_bindgen] pub fn set_page_element_unique_id(&mut self, unique_id: usize) { self.page_element_unique_id = Some(unique_id); } - #[wasm_bindgen] pub fn create_element_common( self: &mut MainThreadWasmContext, parent_component_unique_id: usize, @@ -129,17 +113,14 @@ impl MainThreadWasmContext { unique_id } - #[wasm_bindgen] pub fn get_dom_by_unique_id(&self, unique_id: usize) -> Option { self.unique_id_to_dom_map.get(&unique_id).cloned() } - #[wasm_bindgen] pub fn take_timing_flags(&mut self) -> Vec { std::mem::take(&mut self.timing_flags) } - #[wasm_bindgen] pub fn get_unique_id_by_component_id(&self, component_id: &str) -> Option { for (unique_id, element_data_option) in self.unique_id_to_element_map.iter().enumerate() { if let Some(element_data_cell) = element_data_option { @@ -154,7 +135,6 @@ impl MainThreadWasmContext { None } - #[wasm_bindgen] pub fn get_css_id_by_unique_id(&self, unique_id: usize) -> Option { self .unique_id_to_element_map @@ -163,7 +143,6 @@ impl MainThreadWasmContext { .map(|element_data_cell| element_data_cell.borrow().css_id) } - // #[wasm_bindgen] // pub fn gc(&mut self) { // self.unique_id_to_element_map.retain(|_, value| { // let dom = value.get_dom(); diff --git a/packages/web-platform/web-core-wasm/src/main_thread/client/mod.rs b/packages/web-platform/web-core-wasm/src/main_thread/client/mod.rs new file mode 100644 index 0000000000..7f0fc78d03 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/main_thread/client/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod element_apis; +pub(crate) mod main_thread_context; +pub mod style_manager; diff --git a/packages/web-platform/web-core-wasm/src/main_thread/style_manager.rs b/packages/web-platform/web-core-wasm/src/main_thread/client/style_manager.rs similarity index 96% rename from packages/web-platform/web-core-wasm/src/main_thread/style_manager.rs rename to packages/web-platform/web-core-wasm/src/main_thread/client/style_manager.rs index 1c1cde6c32..1c9036fed1 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/style_manager.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/client/style_manager.rs @@ -7,14 +7,13 @@ use crate::constants; use crate::template::template_sections::style_info::StyleSheetResource; use fnv::FnvHashMap; -use std::rc::Rc; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use web_sys::{CssStyleSheet, HtmlStyleElement, Node}; pub struct StyleManager { root_node: Node, - css_query_map_by_entry_name: FnvHashMap>, + css_query_map_by_entry_name: FnvHashMap, css_og_style_sheet: Option, unique_id_to_style_declarations_map: Option>, } @@ -103,7 +102,7 @@ impl StyleManager { pub fn push_style_sheet( &mut self, - resource: Rc, + resource: &StyleSheetResource, entry_name: Option, ) -> Result<(), JsError> { let entry_key = entry_name.clone().unwrap_or_else(|| "__Card__".to_string()); @@ -143,7 +142,9 @@ impl StyleManager { } } - self.css_query_map_by_entry_name.insert(entry_key, resource); + self + .css_query_map_by_entry_name + .insert(entry_key, resource.clone()); Ok(()) } } diff --git a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/element_data.rs b/packages/web-platform/web-core-wasm/src/main_thread/element_data.rs similarity index 73% rename from packages/web-platform/web-core-wasm/src/main_thread/element_apis/element_data.rs rename to packages/web-platform/web-core-wasm/src/main_thread/element_data.rs index 75af125709..cd44994888 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/element_apis/element_data.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/element_data.rs @@ -6,27 +6,44 @@ use fnv::FnvHashMap; -#[derive(Default, Clone)] +#[cfg(feature = "server")] +use rkyv::Serialize; +use rkyv::{Archive, Deserialize}; +#[derive(Default, Clone, Archive, Deserialize)] +#[cfg_attr(feature = "server", derive(Serialize))] pub struct EventHandler { /* bind capture-bind catch capture-catch */ - framework_cross_thread_identifier: FnvHashMap, + pub(crate) framework_cross_thread_identifier: FnvHashMap, /* bind capture-bind catch capture-catch */ - framework_run_worklet_identifier: FnvHashMap, - /* bind capture-bind catch capture-catch */ - // event_type_to_handlers: FnvHashMap>, + #[with(rkyv::with::Skip)] + pub(crate) framework_run_worklet_identifier: FnvHashMap, } +#[derive(Archive, Deserialize, Clone)] +#[cfg_attr(feature = "server", derive(Serialize))] pub struct LynxElementData { pub(crate) parent_component_unique_id: usize, pub(crate) css_id: i32, + #[with(rkyv::with::Skip)] pub(crate) component_id: Option, + #[with(rkyv::with::Skip)] pub(crate) dataset: Option, + #[with(rkyv::with::Skip)] pub(crate) component_config: Option, + #[with(rkyv::with::Skip)] pub(crate) event_handlers_map: Option>, - // /** - // * Whether the exposure-id attribute has been assigned - // */ - // pub(crate) exposure_id_assigned: bool, + + #[cfg(feature = "server")] + #[with(rkyv::with::Skip)] + pub(crate) tag_name: String, + + #[cfg(feature = "server")] + #[with(rkyv::with::Skip)] + pub(crate) attributes: FnvHashMap, + + #[cfg(feature = "server")] + #[with(rkyv::with::Skip)] + pub(crate) children: Vec, } impl LynxElementData { @@ -42,42 +59,18 @@ impl LynxElementData { dataset: None, component_config: None, event_handlers_map: None, - // exposure_id_assigned: false, + #[cfg(feature = "server")] + tag_name: String::new(), + #[cfg(feature = "server")] + attributes: FnvHashMap::default(), + #[cfg(feature = "server")] + children: Vec::new(), } } +} - // pub(crate) fn clone_node(&self, parent_component_unique_id: usize, css_id: i32) -> Self { - // LynxElementData { - // parent_component_unique_id, - // css_id, - // dataset: self - // .dataset - // .as_ref() - // .map(|dataset| js_sys::Object::assign(&js_sys::Object::default(), dataset)), - // component_config: self.component_config.as_ref().map(|component_config| { - // js_sys::Object::assign(&js_sys::Object::default(), component_config) - // }), - // component_id: self.component_id.clone(), - // event_handlers_map: self.event_handlers_map.clone(), - // exposure_id_assigned: self.exposure_id_assigned, - // } - // } - - // /** - // * There are two conditions to enable exposure/disexposure(InsectionObserver) detection: - // * 1. an element has exposure-id attribute - // * 2. an element has 'appear'/'disappear' event listener added - // */ - // pub(crate) fn should_enable_exposure_event(&self) -> bool { - // self.exposure_id_assigned || { - // if let Some(event_handlers_map) = &self.event_handlers_map { - // event_handlers_map.contains_key("appear") || event_handlers_map.contains_key("disappear") - // } else { - // false - // } - // } - // } - +#[cfg(feature = "client")] +impl LynxElementData { pub(crate) fn get_framework_cross_thread_event_handler( &self, event_name: &str, @@ -146,6 +139,72 @@ impl LynxElementData { .remove(&event_type); } } +} + +#[cfg(feature = "server")] +impl LynxElementData { + pub(crate) fn new_with_tag_name( + parent_component_unique_id: usize, + css_id: i32, + component_id: Option, + tag_name: String, + ) -> Self { + let mut data = Self::new(parent_component_unique_id, css_id, component_id); + data.tag_name = tag_name; + data + } + + pub(crate) fn set_attribute(&mut self, key: String, value: String) { + self.attributes.insert(key, value); + } + + pub(crate) fn set_style(&mut self, key: String, value: String) { + if value.is_empty() { + // In SSR we do not support remove style + return; + } + let style = self.attributes.entry("style".to_string()).or_default(); + style.push_str(&key); + style.push(':'); + style.push_str(&value); + style.push(';'); + } + + pub(crate) fn append_child(&mut self, child_id: usize) { + self.children.push(child_id); + } + + // pub(crate) fn clone_node(&self, parent_component_unique_id: usize, css_id: i32) -> Self { + // LynxElementData { + // parent_component_unique_id, + // css_id, + // dataset: self + // .dataset + // .as_ref() + // .map(|dataset| js_sys::Object::assign(&js_sys::Object::default(), dataset)), + // component_config: self.component_config.as_ref().map(|component_config| { + // js_sys::Object::assign(&js_sys::Object::default(), component_config) + // }), + // component_id: self.component_id.clone(), + // event_handlers_map: self.event_handlers_map.clone(), + // exposure_id_assigned: self.exposure_id_assigned, + // } + // } + + // /** + // * There are two conditions to enable exposure/disexposure(InsectionObserver) detection: + // * 1. an element has exposure-id attribute + // * 2. an element has 'appear'/'disappear' event listener added + // */ + // pub(crate) fn should_enable_exposure_event(&self) -> bool { + // self.exposure_id_assigned || { + // if let Some(event_handlers_map) = &self.event_handlers_map { + // event_handlers_map.contains_key("appear") || event_handlers_map.contains_key("disappear") + // } else { + // false + // } + // } + // } // pub(crate) fn add_event_listener_with_js_function( // &self, diff --git a/packages/web-platform/web-core-wasm/src/main_thread/mod.rs b/packages/web-platform/web-core-wasm/src/main_thread/mod.rs index 06b67ec9c3..d0bdd38d28 100644 --- a/packages/web-platform/web-core-wasm/src/main_thread/mod.rs +++ b/packages/web-platform/web-core-wasm/src/main_thread/mod.rs @@ -2,16 +2,11 @@ * 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. - */ +*/ -/// Main Thread module. -/// -/// This module contains the core logic for the main thread of the Lynx web platform. -/// It manages the state of the application, including elements, templates, and interactions with the DOM. -/// -/// Key components: -/// - `main_thread_context`: Defines `MainThreadWasmContext`, the central struct holding the application state. -/// - `element_apis`: Contains APIs for manipulating elements, handling events, and managing styles. -pub(crate) mod element_apis; -pub(crate) mod main_thread_context; -pub mod style_manager; +#[cfg(feature = "client")] +pub(crate) mod client; +#[cfg(any(feature = "client", feature = "server"))] +pub mod element_data; +#[cfg(feature = "server")] +pub(crate) mod server; diff --git a/packages/web-platform/web-core-wasm/src/main_thread/server/main_thread_server_context.rs b/packages/web-platform/web-core-wasm/src/main_thread/server/main_thread_server_context.rs new file mode 100644 index 0000000000..bb032eba3e --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/main_thread/server/main_thread_server_context.rs @@ -0,0 +1,429 @@ +/* + * 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. + */ + +use super::style_manager_server::StyleManagerServer; +use crate::main_thread::element_data::LynxElementData; +use crate::style_transformer::{ + query_transform_rules, transform_inline_style_key_value_vec, transform_inline_style_string, +}; +use crate::template::template_sections::style_info::css_property::CSSProperty; +use crate::template::template_sections::style_info::StyleSheetResource; +use std::borrow::Cow; +use wasm_bindgen::prelude::*; + +#[wasm_bindgen] +pub struct MainThreadServerContext { + elements: Vec>, + style_manager: StyleManagerServer, + view_attributes: String, + enable_css_selector: bool, +} + +#[wasm_bindgen] +impl MainThreadServerContext { + #[wasm_bindgen(constructor)] + pub fn new(view_attributes: String, enable_css_selector: bool) -> Self { + Self { + elements: Vec::new(), + style_manager: StyleManagerServer::new(), + view_attributes, + enable_css_selector, + } + } + + pub fn push_style_sheet( + &mut self, + resource: &StyleSheetResource, + entry_name: Option, + ) -> Result<(), JsError> { + self + .style_manager + .push_style_sheet(resource, entry_name) + .map_err(|e| JsError::new(&e)) + } + + pub fn remove_attribute(&mut self, element_id: usize, key: String) { + if let Some(Some(element)) = self.elements.get_mut(element_id) { + element.attributes.remove(&key); + } + } + + pub fn set_css_id( + &mut self, + elements_unique_id: Vec, + css_id: i32, + entry_name: Option, + ) -> Result<(), JsError> { + for unique_id in elements_unique_id.into_iter() { + if let Some(entry_name) = &entry_name { + self.set_attribute( + unique_id, + crate::constants::LYNX_ENTRY_NAME_ATTRIBUTE.to_string(), + entry_name.clone(), + ); + } + if css_id != 0 { + self.set_attribute( + unique_id, + crate::constants::CSS_ID_ATTRIBUTE.to_string(), + css_id.to_string(), + ); + } else { + self.remove_attribute(unique_id, crate::constants::CSS_ID_ATTRIBUTE.to_string()); + } + if let Some(Some(element_data)) = self.elements.get_mut(unique_id) { + element_data.css_id = css_id; + } + if !self.enable_css_selector { + self.update_css_og_style(unique_id, entry_name.clone())?; + } + } + Ok(()) + } + + pub fn update_css_og_style( + &mut self, + unique_id: usize, + entry_name: Option, + ) -> Result<(), JsError> { + let (css_id, class_names) = if let Some(Some(element)) = self.elements.get(unique_id) { + let css_id = element.css_id; + let class_names = if let Some(class_attr) = element.attributes.get("class") { + class_attr + .split_whitespace() + .map(|s| s.to_string()) + .collect() + } else { + vec![] + }; + (css_id, class_names) + } else { + (0, vec![]) + }; + self + .style_manager + .update_css_og_style(unique_id, css_id, class_names, entry_name) + .map_err(|e| JsError::new(&e)) + } + + pub fn get_page_css(&self) -> String { + self.style_manager.get_css_string() + } + + pub fn create_element( + &mut self, + tag_name: String, + parent_component_unique_id: Option, + css_id_opt: Option, + component_id: Option, + ) -> usize { + let id = self.elements.len(); + let parent_id = parent_component_unique_id.unwrap_or(0); + + let css_id = if let Some(css_id) = css_id_opt { + css_id + } else if let Some(Some(parent_component_data)) = self.elements.get(parent_id) { + parent_component_data.css_id + } else { + 0 + }; + + let mut element = LynxElementData::new_with_tag_name(parent_id, css_id, component_id, tag_name); + if css_id != 0 { + element.set_attribute( + crate::constants::CSS_ID_ATTRIBUTE.to_string(), + css_id.to_string(), + ); + } + self.elements.push(Some(element)); + id + } + + pub fn append_child(&mut self, parent_id: usize, child_id: usize) { + if let Some(Some(parent)) = self.elements.get_mut(parent_id) { + parent.append_child(child_id); + } + } + + pub fn set_attribute(&mut self, element_id: usize, key: String, value: String) { + if let Some(Some(element)) = self.elements.get_mut(element_id) { + if key == "style" { + let transformed = transform_inline_style_string(&value); + element.set_attribute(key, transformed); + } else { + element.set_attribute(key, value); + } + } + } + + pub fn add_inline_style_raw_string_key( + &mut self, + element_id: usize, + key: &str, + value: Option, + ) { + if let Some(Some(element)) = self.elements.get_mut(element_id) { + if let Some(value) = value { + let property_id: CSSProperty = key.into(); + let (transformed, _) = query_transform_rules(&property_id, &value); + if transformed.is_empty() { + element.set_style(key.to_string(), value); + } else { + for (k, v) in transformed { + element.set_style(k.to_string(), v.to_string()); + } + } + } + } + } + + pub fn set_inline_styles_number_key( + &mut self, + element_id: usize, + key: usize, + value: Option, + ) { + if let Some(Some(element)) = self.elements.get_mut(element_id) { + let property_id: CSSProperty = key.into(); + if let Some(value) = value { + let (transformed, _) = query_transform_rules(&property_id, &value); + if transformed.is_empty() { + element.set_style(property_id.to_string(), value); + } else { + for (k, v) in transformed { + element.set_style(k.to_string(), v.to_string()); + } + } + } + } + } + + pub fn set_inline_styles_in_str(&mut self, element_id: usize, styles: String) -> bool { + let transformed_style_str = transform_inline_style_string(&styles); + if transformed_style_str == styles { + return false; + } + if let Some(Some(element)) = self.elements.get_mut(element_id) { + element.set_attribute("style".to_string(), transformed_style_str); + } + true + } + + pub fn get_inline_styles_in_key_value_vec(&mut self, element_id: usize, k_v_vec: Vec) { + let transformed_style_str = transform_inline_style_key_value_vec(k_v_vec); + if let Some(Some(element)) = self.elements.get_mut(element_id) { + element.set_attribute("style".to_string(), transformed_style_str); + } + } + + pub fn add_class(&mut self, element_id: usize, class_name: String) -> Result<(), JsError> { + if let Some(Some(element)) = self.elements.get_mut(element_id) { + let classes_attr = element.attributes.entry("class".to_string()).or_default(); + let exists = classes_attr.split_whitespace().any(|c| c == class_name); + if !exists { + if !classes_attr.is_empty() { + classes_attr.push(' '); + } + classes_attr.push_str(&class_name); + } + } + Ok(()) + } + + pub fn get_attribute(&self, element_id: usize, key: String) -> Option { + if let Some(Some(element)) = self.elements.get(element_id) { + element.attributes.get(&key).cloned() + } else { + None + } + } + + pub fn get_attributes(&self, element_id: usize) -> Result { + if let Some(Some(element)) = self.elements.get(element_id) { + let obj = js_sys::Object::new(); + for (key, value) in &element.attributes { + js_sys::Reflect::set(&obj, &JsValue::from_str(key), &JsValue::from_str(value))?; + } + Ok(obj) + } else { + Ok(js_sys::Object::new()) + } + } + + pub fn get_tag(&self, element_id: usize) -> Option { + if let Some(Some(element)) = self.elements.get(element_id) { + Some(element.tag_name.clone()) + } else { + None + } + } + + pub fn generate_html(&self, element_id: usize) -> String { + let mut buffer = String::with_capacity(4096); + buffer.push_str(""); + buffer + } +} + +impl MainThreadServerContext { + fn render_element(&self, root_id: usize, buffer: &mut String) { + enum Action { + Open(usize), + Close(usize), + } + + let mut stack = Vec::with_capacity(64); + stack.push(Action::Open(root_id)); + + while let Some(action) = stack.pop() { + match action { + Action::Open(element_id) => { + if let Some(Some(element)) = self.elements.get(element_id) { + buffer.push('<'); + buffer.push_str(&element.tag_name); + + // Attributes + for (key, value) in &element.attributes { + buffer.push(' '); + buffer.push_str(key); + buffer.push_str("=\""); + + let mut last_escape = 0; + for (i, b) in value.bytes().enumerate() { + let replacement = match b { + b'"' => """, + b'&' => "&", + b'<' => "<", + b'>' => ">", + b'\'' => "'", + _ => continue, + }; + buffer.push_str(&value[last_escape..i]); + buffer.push_str(replacement); + last_escape = i + 1; + } + buffer.push_str(&value[last_escape..]); + + buffer.push('"'); + } + + buffer.push('>'); + + let template_str: Option> = match element.tag_name.as_str() { + "scroll-view" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_SCROLL_VIEW)), + "x-audio-tt" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_X_AUDIO_TT)), + "x-image" => web_elements::template::template_x_image( + element.attributes.get("src").map(|s| s.as_str()), + ) + .ok() + .map(Cow::Owned), + "filter-image" => web_elements::template::template_filter_image( + element.attributes.get("src").map(|s| s.as_str()), + ) + .ok() + .map(Cow::Owned), + "inline-image" => web_elements::template::template_inline_image( + element.attributes.get("src").map(|s| s.as_str()), + ) + .ok() + .map(Cow::Owned), + "x-input" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_X_INPUT)), + "x-list" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_X_LIST)), + "x-overlay-ng" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_X_OVERLAY_NG)), + "x-refresh-view" => Some(Cow::Borrowed( + web_elements::template::TEMPLATE_X_REFRESH_VIEW, + )), + "x-svg" => Some(Cow::Owned(web_elements::template::template_x_svg())), + "x-swiper" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_X_SWIPER)), + "x-text" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_X_TEXT)), + "x-textarea" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_X_TEXTAREA)), + "x-viewpager-ng" => Some(Cow::Borrowed( + web_elements::template::TEMPLATE_X_VIEWPAGE_NG, + )), + "x-web-view" => Some(Cow::Borrowed(web_elements::template::TEMPLATE_X_WEB_VIEW)), + _ => None, + }; + + if let Some(content_str) = template_str { + buffer.push_str(r#""); + } + + stack.push(Action::Close(element_id)); + + for child_id in element.children.iter().rev() { + stack.push(Action::Open(*child_id)); + } + } + } + Action::Close(element_id) => { + if let Some(Some(element)) = self.elements.get(element_id) { + buffer.push_str("'); + } + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_html_generation() { + let mut ctx = MainThreadServerContext::new("".to_string(), true); + + // Create
+ let div_id = ctx.create_element("div".to_string(), None, None, None); + ctx.set_attribute(div_id, "id".to_string(), "container".to_string()); + ctx.add_inline_style_raw_string_key(div_id, "color", Some("red".to_string())); + + // Create child + let span_id = ctx.create_element("span".to_string(), None, None, None); + ctx.set_attribute(span_id, "class".to_string(), "text".to_string()); + ctx.append_child(div_id, span_id); + + let html = ctx.generate_html(div_id); + + // Check structural correctness (attributes/style order might vary in HashMaps) + assert!(html.starts_with("")); + + // Verify initial CSS is empty + assert_eq!(ctx.get_page_css(), ""); + } + + #[test] + fn test_set_style_empty_value() { + let mut ctx = MainThreadServerContext::new("".to_string(), true); + let div_id = ctx.create_element("div".to_string(), None, None, None); + + // This should not panic + ctx.add_inline_style_raw_string_key(div_id, "background-color", Some("".to_string())); + + let html = ctx.generate_html(div_id); + // Should not contain the style property since we ignored the empty value + assert!(!html.contains("background-color:")); + } +} diff --git a/packages/web-platform/web-core-wasm/src/main_thread/server/mod.rs b/packages/web-platform/web-core-wasm/src/main_thread/server/mod.rs new file mode 100644 index 0000000000..73b65e7f07 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/main_thread/server/mod.rs @@ -0,0 +1,3 @@ +pub(crate) mod main_thread_server_context; + +pub(crate) mod style_manager_server; diff --git a/packages/web-platform/web-core-wasm/src/main_thread/server/style_manager_server.rs b/packages/web-platform/web-core-wasm/src/main_thread/server/style_manager_server.rs new file mode 100644 index 0000000000..260b1e8414 --- /dev/null +++ b/packages/web-platform/web-core-wasm/src/main_thread/server/style_manager_server.rs @@ -0,0 +1,81 @@ +/* + * 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. + */ + +use crate::constants; +use crate::template::template_sections::style_info::StyleSheetResource; +use fnv::FnvHashMap; + +pub struct StyleManagerServer { + css_query_map_by_entry_name: FnvHashMap, + global_style_buffer: String, + unique_id_to_style_declarations_map: FnvHashMap, +} + +impl StyleManagerServer { + pub fn new() -> Self { + Self { + css_query_map_by_entry_name: FnvHashMap::default(), + global_style_buffer: String::new(), + unique_id_to_style_declarations_map: FnvHashMap::default(), + } + } + + pub fn push_style_sheet( + &mut self, + resource: &StyleSheetResource, + entry_name: Option, + ) -> Result<(), String> { + let entry_key = entry_name.clone().unwrap_or_else(|| "__Card__".to_string()); + + if let Some(content) = &resource.style_content_str { + self.global_style_buffer.push_str(content); + } + if let Some(content) = &resource.font_face_content_str { + self.global_style_buffer.push_str(content); + } + + self + .css_query_map_by_entry_name + .insert(entry_key, resource.clone()); + Ok(()) + } + + pub fn update_css_og_style( + &mut self, + unique_id: usize, + css_id: i32, + class_names: Vec, + entry_name: Option, + ) -> Result<(), String> { + let entry_name = entry_name.as_deref().unwrap_or("__Card__"); + let declarations = if let Some(resource) = self.css_query_map_by_entry_name.get(entry_name) { + resource.query_css_og_declarations_by_css_id(css_id, class_names) + } else { + String::new() + }; + + if !declarations.is_empty() { + // Format: [unique_id="{unique_id}"] { declarations } + let rule = format!( + "[{}=\"{unique_id}\"] {{{declarations}}}", + constants::LYNX_UNIQUE_ID_ATTRIBUTE + ); + self + .unique_id_to_style_declarations_map + .insert(unique_id, rule); + } + + Ok(()) + } + + pub fn get_css_string(&self) -> String { + let mut css = self.global_style_buffer.clone(); + for rule in self.unique_id_to_style_declarations_map.values() { + css.push_str(rule); + } + css + } +} diff --git a/packages/web-platform/web-core-wasm/src/style_transformer/inline_style.rs b/packages/web-platform/web-core-wasm/src/style_transformer/inline_style.rs index c450fdf8e2..99cd498ad1 100644 --- a/packages/web-platform/web-core-wasm/src/style_transformer/inline_style.rs +++ b/packages/web-platform/web-core-wasm/src/style_transformer/inline_style.rs @@ -4,9 +4,9 @@ * LICENSE file in the root directory of this source tree. */ use super::transformer::StyleTransformer; -use crate::{ - style_transformer::transformer::Generator, utils::hyphenate_style_name::hyphenate_style_name, -}; +use crate::style_transformer::transformer::Generator; +#[cfg(any(feature = "client", feature = "server"))] +use crate::utils::hyphenate_style_name::hyphenate_style_name; struct InlineStyleGenerator { string_buffer: String, } @@ -27,6 +27,7 @@ pub(crate) fn transform_inline_style_string(source: &str) -> String { generator.string_buffer } +#[cfg(any(feature = "client", feature = "server"))] pub(crate) fn transform_inline_style_key_value_vec(source: Vec) -> String { let mut generator = InlineStyleGenerator { string_buffer: String::new(), diff --git a/packages/web-platform/web-core-wasm/src/style_transformer/mod.rs b/packages/web-platform/web-core-wasm/src/style_transformer/mod.rs index 9637c25913..33ae907add 100644 --- a/packages/web-platform/web-core-wasm/src/style_transformer/mod.rs +++ b/packages/web-platform/web-core-wasm/src/style_transformer/mod.rs @@ -12,16 +12,16 @@ /// - `transformer`: Defines `StyleTransformer` and `Generator` trait for processing styles. /// - `rules`: Defines transformation rules for CSS properties. /// - `inline_style`: Handles transformation of inline styles. -#[cfg(feature = "client")] +#[cfg(any(feature = "client", feature = "server"))] mod inline_style; mod rules; mod token_transformer; mod transformer; -#[cfg(feature = "client")] -pub(crate) use inline_style::{ - transform_inline_style_key_value_vec, transform_inline_style_string, -}; -#[cfg(feature = "client")] +#[cfg(any(feature = "client", feature = "server"))] +pub(crate) use inline_style::transform_inline_style_key_value_vec; +#[cfg(any(feature = "client", feature = "server"))] +pub(crate) use inline_style::transform_inline_style_string; +#[cfg(any(feature = "client", feature = "server"))] pub(crate) use rules::query_transform_rules; pub use transformer::Generator; pub use transformer::StyleTransformer; diff --git a/packages/web-platform/web-core-wasm/src/style_transformer/transformer.rs b/packages/web-platform/web-core-wasm/src/style_transformer/transformer.rs index 532331fa97..71198461a6 100644 --- a/packages/web-platform/web-core-wasm/src/style_transformer/transformer.rs +++ b/packages/web-platform/web-core-wasm/src/style_transformer/transformer.rs @@ -4,7 +4,7 @@ * LICENSE file in the root directory of this source tree. */ use super::token_transformer::transform_one_token; -#[cfg(any(feature = "client", test))] +#[cfg(any(feature = "client", feature = "server", test))] use crate::css_tokenizer::tokenize; use crate::css_tokenizer::{ char_code_definitions::is_white_space, token_types::*, tokenize::Parser, @@ -138,7 +138,7 @@ impl<'a, T: Generator> StyleTransformer<'a, T> { } } - #[cfg(any(feature = "client", test))] + #[cfg(any(feature = "client", feature = "server", test))] pub fn parse(&mut self, source: &str) { tokenize::tokenize(source, self); if self.prev_token_type != SEMICOLON_TOKEN { diff --git a/packages/web-platform/web-core-wasm/src/template/mod.rs b/packages/web-platform/web-core-wasm/src/template/mod.rs index ad210e7808..6a65e34912 100644 --- a/packages/web-platform/web-core-wasm/src/template/mod.rs +++ b/packages/web-platform/web-core-wasm/src/template/mod.rs @@ -4,8 +4,6 @@ * LICENSE file in the root directory of this source tree. */ -#[cfg(feature = "client")] -mod template_manager; /// Template module. /// /// This module defines the structure of Lynx templates, including element templates and style information. @@ -16,5 +14,3 @@ mod template_manager; /// - `element_template`: Defines `RawElementTemplate` which contains operations to build the element tree. /// - `style_info`: Defines `RawStyleInfo` which contains style sheets and rules. pub(crate) mod template_sections; -#[cfg(feature = "client")] -pub use template_manager::TemplateManager; diff --git a/packages/web-platform/web-core-wasm/src/template/template_manager.rs b/packages/web-platform/web-core-wasm/src/template/template_manager.rs deleted file mode 100644 index b3c90b0275..0000000000 --- a/packages/web-platform/web-core-wasm/src/template/template_manager.rs +++ /dev/null @@ -1,60 +0,0 @@ -/* - * 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. - */ - -use super::template_sections::style_info::StyleSheetResource; -use fnv::FnvHashMap; -use std::rc::Rc; -use wasm_bindgen::prelude::*; - -#[wasm_bindgen] -#[derive(Default)] -pub struct TemplateManager { - pub(crate) style_info_map: FnvHashMap>, -} - -#[wasm_bindgen] -impl TemplateManager { - #[wasm_bindgen(constructor)] - pub fn new() -> TemplateManager { - TemplateManager::default() - } - - #[wasm_bindgen] - pub fn add_style_info( - &mut self, - template_name: String, - buf: js_sys::Uint8Array, - document: &web_sys::Document, - ) -> Result<(), wasm_bindgen::JsError> { - self.style_info_map.insert( - template_name.clone(), - Rc::new(StyleSheetResource::new(buf, document)?), - ); - Ok(()) - } -} - -impl TemplateManager { - pub(crate) fn get_style_info_by_name( - &self, - template_name: &String, - ) -> Option> { - self.style_info_map.get(template_name).cloned() - } -} - -#[cfg(test)] -mod tests { - use crate::template::TemplateManager; - use wasm_bindgen_test::*; - - wasm_bindgen_test_configure!(run_in_node_experimental); - - #[wasm_bindgen_test] - fn test_template_manager_create() { - let _manager = TemplateManager::new(); - } -} diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/mod.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/mod.rs index b2d034dd3f..3b23bb642b 100644 --- a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/mod.rs +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/mod.rs @@ -15,9 +15,9 @@ type CssOgClassSelectorNameToDeclarationsMap = FnvHashMap; type CssOgCssIdToClassSelectorNameToDeclarationsMap = FnvHashMap; -#[cfg(feature = "client")] +#[cfg(any(feature = "client", feature = "server"))] pub(crate) mod style_sheet_resource; -#[cfg(feature = "client")] +#[cfg(any(feature = "client", feature = "server"))] pub use style_sheet_resource::StyleSheetResource; #[cfg(test)] diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/raw_style_info.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/raw_style_info.rs index 582cd7ac7a..22677972ce 100644 --- a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/raw_style_info.rs +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/raw_style_info.rs @@ -118,7 +118,6 @@ impl RawStyleInfo { * @param css_id - The ID of the CSS file. * @param import_css_id - The ID of the imported CSS file. */ - #[wasm_bindgen] pub fn append_import(&mut self, css_id: i32, import_css_id: i32) { // if css_id not exist, create a new StyleSheet let style_sheet = self.css_id_to_style_sheet.entry(css_id).or_default(); @@ -131,7 +130,6 @@ impl RawStyleInfo { * @param css_id - The ID of the CSS file. * @param rule - The rule to append. */ - #[wasm_bindgen] pub fn push_rule(&mut self, css_id: i32, rule: Rule) { let style_sheet = self.css_id_to_style_sheet.entry(css_id).or_default(); style_sheet.rules.push(rule); @@ -142,7 +140,6 @@ impl RawStyleInfo { * @returns A Uint8Array containing the serialized RawStyleInfo. */ #[cfg(feature = "encode")] - #[wasm_bindgen] pub fn encode(&mut self) -> Result { let decoded_style_info = StyleInfoDecoder::new(self.clone(), None, true)?; self.style_content_str_size_hint = decoded_style_info.style_content.len(); @@ -184,7 +181,6 @@ impl Rule { * Sets the prelude for the rule. * @param prelude - The prelude to set (SelectorList or KeyFramesPrelude). */ - #[wasm_bindgen] pub fn set_prelude(&mut self, prelude: RulePrelude) { self.prelude = prelude; } @@ -195,7 +191,6 @@ impl Rule { * @param property_name - The property name. * @param value - The property value. */ - #[wasm_bindgen] pub fn push_declaration(&mut self, property_name: String, value: String) { self .declaration_block @@ -207,7 +202,6 @@ impl Rule { * Pushes a nested rule to the rule. * @param rule - The nested rule to add. */ - #[wasm_bindgen] pub fn push_rule_children(&mut self, rule: Rule) { self.nested_rules.push(rule); } @@ -226,7 +220,6 @@ impl RulePrelude { * Pushes a selector to the list. * @param selector - The selector to add. */ - #[wasm_bindgen] pub fn push_selector(&mut self, selector: Selector) { self.selector_list.push(selector); } @@ -246,7 +239,6 @@ impl Selector { * @param selector_type - The type of the selector section (e.g., "ClassSelector", "IdSelector"). * @param value - The value of the selector section. */ - #[wasm_bindgen] pub fn push_one_selector_section( &mut self, selector_type: String, diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/style_info_decoder.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/style_info_decoder.rs index c3532cd2d1..c1d0fe452c 100644 --- a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/style_info_decoder.rs +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/style_info_decoder.rs @@ -876,12 +876,12 @@ mod tests_roundtrip { #[wasm_bindgen_test] fn test_style_info_roundtrip() { - let mut raw = build_sample_style_info(); + let mut _raw = build_sample_style_info(); // Enable encode feature usage manually or assume it's available since tests run with it #[cfg(feature = "encode")] { - let bytes = raw.encode().expect("Should encode"); + let bytes = _raw.encode().expect("Should encode"); // decode manually using accessing internal logic via new // We can use StyleInfoDecoder::new directly as we are in the same module diff --git a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/style_sheet_resource.rs b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/style_sheet_resource.rs index 03d212bf86..e5ce3d3b44 100644 --- a/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/style_sheet_resource.rs +++ b/packages/web-platform/web-core-wasm/src/template/template_sections/style_info/style_sheet_resource.rs @@ -5,46 +5,83 @@ */ use super::decoded_style_data::DecodedStyleData; +use std::rc::Rc; +use wasm_bindgen::prelude::*; +#[wasm_bindgen] +#[derive(Clone)] pub struct StyleSheetResource { + #[cfg(feature = "client")] pub(crate) style_content_element: Option, + #[cfg(feature = "client")] pub(crate) font_face_element: Option, + + #[cfg(feature = "server")] + pub(crate) style_content_str: Option, + #[cfg(feature = "server")] + pub(crate) font_face_content_str: Option, + pub(crate) css_og_css_id_to_class_selector_name_to_declarations_map: - Option, + Option>, } +#[wasm_bindgen] impl StyleSheetResource { + #[wasm_bindgen(constructor)] pub fn new( buffer: js_sys::Uint8Array, - document: &web_sys::Document, + _document: JsValue, ) -> Result { let decoded_style_data: DecodedStyleData = buffer.try_into()?; - let style_content_element = if let Some(style_content) = decoded_style_data.style_content { - let style_content_element = document.create_element("style").map_err(|e| { - wasm_bindgen::JsError::new(&format!("Failed to create style element: {e:?}")) - })?; - style_content_element.set_text_content(Some(&style_content)); - Some(style_content_element) - } else { - None - }; - let font_face_element = if let Some(font_face_content) = decoded_style_data.font_face_content { - let style_content_element = document.create_element("style").map_err(|e| { - wasm_bindgen::JsError::new(&format!("Failed to create style element: {e:?}")) - })?; - style_content_element.set_text_content(Some(&font_face_content)); - Some(style_content_element) - } else { - None + + #[cfg(feature = "client")] + let (style_content_element, font_face_element) = { + let document = _document.unchecked_into::(); + let style_content_element = if let Some(style_content) = &decoded_style_data.style_content { + let style_content_element = document.create_element("style").map_err(|e| { + wasm_bindgen::JsError::new(&format!("Failed to create style element: {e:?}")) + })?; + style_content_element.set_text_content(Some(style_content)); + Some(style_content_element) + } else { + None + }; + let font_face_element = if let Some(font_face_content) = &decoded_style_data.font_face_content + { + let style_content_element = document.create_element("style").map_err(|e| { + wasm_bindgen::JsError::new(&format!("Failed to create style element: {e:?}")) + })?; + style_content_element.set_text_content(Some(font_face_content)); + Some(style_content_element) + } else { + None + }; + (style_content_element, font_face_element) }; + + #[cfg(feature = "server")] + let (style_content_str, font_face_content_str) = ( + decoded_style_data.style_content.clone(), + decoded_style_data.font_face_content.clone(), + ); + Ok(Self { + #[cfg(feature = "client")] style_content_element, + #[cfg(feature = "client")] font_face_element, + #[cfg(feature = "server")] + style_content_str, + #[cfg(feature = "server")] + font_face_content_str, css_og_css_id_to_class_selector_name_to_declarations_map: decoded_style_data - .css_og_css_id_to_class_selector_name_to_declarations_map, + .css_og_css_id_to_class_selector_name_to_declarations_map + .map(Rc::new), }) } +} +impl StyleSheetResource { pub(crate) fn query_css_og_declarations_by_css_id( &self, css_id: i32, diff --git a/packages/web-platform/web-core-wasm/tests/__snapshots__/server-compat.spec.ts.snap b/packages/web-platform/web-core-wasm/tests/__snapshots__/server-compat.spec.ts.snap new file mode 100644 index 0000000000..873e0755d3 --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/__snapshots__/server-compat.spec.ts.snap @@ -0,0 +1,7 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`Server Compat Tests > basic-performance-div-10 1`] = `""`; + +exports[`Server Compat Tests > basic-performance-event-div-100 1`] = `""`; + +exports[`Server Compat Tests > basic-performance-nest-level-100 1`] = `""`; diff --git a/packages/web-platform/web-core-wasm/tests/decode.spec.ts b/packages/web-platform/web-core-wasm/tests/decode.spec.ts new file mode 100644 index 0000000000..3fc09d4363 --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/decode.spec.ts @@ -0,0 +1,199 @@ +import { describe, it, expect, vi } from 'vitest'; +import { decodeTemplate } from '../ts/server/decode.js'; +import { + MagicHeader0, + MagicHeader1, + TemplateSectionLabel, +} from '../ts/constants.js'; +import * as wasm from '../ts/server/wasm.js'; + +vi.mock('../ts/server/wasm.js', () => ({ + decode_style_info: vi.fn((buffer: Uint8Array) => buffer), +})); + +function encodeString(str: string): Uint8Array { + return new TextEncoder().encode(str); +} + +function encodeBinaryMap(map: Record): Uint8Array { + const keys = Object.keys(map); + const parts: Uint8Array[] = []; + + // Count (4 bytes) + const countBuf = new Uint8Array(4); + new DataView(countBuf.buffer).setUint32(0, keys.length, true); + parts.push(countBuf); + + for (const key of keys) { + const val = map[key]; + const keyBytes = encodeString(key); + const valBytes = encodeString(val); + + // Key Len + const keyLenBuf = new Uint8Array(4); + new DataView(keyLenBuf.buffer).setUint32(0, keyBytes.length, true); + parts.push(keyLenBuf); + parts.push(keyBytes); + + // Val Len + const valLenBuf = new Uint8Array(4); + new DataView(valLenBuf.buffer).setUint32(0, valBytes.length, true); + parts.push(valLenBuf); + parts.push(valBytes); + } + + const totalLen = parts.reduce((acc, p) => acc + p.length, 0); + const result = new Uint8Array(totalLen); + let offset = 0; + for (const p of parts) { + result.set(p, offset); + offset += p.length; + } + return result; +} + +function createTemplate(callbacks: { + version?: number; + sections?: Array<{ label: number; content: Uint8Array }>; +} = {}): Uint8Array { + const parts: Uint8Array[] = []; + + // Magic Header + const magicBuf = new Uint8Array(8); + const view = new DataView(magicBuf.buffer); + view.setUint32(0, MagicHeader0, true); + view.setUint32(4, MagicHeader1, true); + parts.push(magicBuf); + + // Version + const versionBuf = new Uint8Array(4); + new DataView(versionBuf.buffer).setUint32( + 0, + callbacks.version !== undefined ? callbacks.version : 1, + true, + ); + parts.push(versionBuf); + + // Sections + if (callbacks.sections) { + for (const sec of callbacks.sections) { + // Label + const labelBuf = new Uint8Array(4); + new DataView(labelBuf.buffer).setUint32(0, sec.label, true); + parts.push(labelBuf); + + // Length + const lenBuf = new Uint8Array(4); + new DataView(lenBuf.buffer).setUint32(0, sec.content.length, true); + parts.push(lenBuf); + + // Content + parts.push(sec.content); + } + } + + const totalLen = parts.reduce((acc, p) => acc + p.length, 0); + const result = new Uint8Array(totalLen); + let offset = 0; + for (const p of parts) { + result.set(p, offset); + offset += p.length; + } + return result; +} + +describe('decodeTemplate', () => { + it('should throw if buffer is too short', () => { + expect(() => decodeTemplate(new Uint8Array(4))).toThrow( + 'Buffer too short for Magic Header', + ); + }); + + it('should throw if magic header is invalid', () => { + const buffer = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]); + expect(() => decodeTemplate(buffer)).toThrow('Invalid Magic Header'); + }); + + it('should throw if version is unsupported', () => { + const buffer = createTemplate({ version: 2 }); + expect(() => decodeTemplate(buffer)).toThrow('Unsupported version: 2'); + }); + + it('should decode empty template correctly', () => { + const buffer = createTemplate({ sections: [] }); + const result = decodeTemplate(buffer); + expect(result).toEqual({ + config: {}, + styleInfo: undefined, + lepusCode: {}, + customSections: undefined, + }); + }); + + it('should decode configurations section', () => { + const config = { isLazy: 'true', test: '123' }; + const jsonStr = JSON.stringify(config); + const content = Buffer.from(jsonStr, 'utf16le'); // Configuration is utf16le JSON + + const buffer = createTemplate({ + sections: [ + { + label: TemplateSectionLabel.Configurations, + content: new Uint8Array(content), + }, + ], + }); + + const result = decodeTemplate(buffer); + expect(result.config).toEqual(config); + }); + + it('should decode lepus code section', () => { + const lepusMap = { 'entry.js': 'console.log("hello")' }; + const content = encodeBinaryMap(lepusMap); + + const buffer = createTemplate({ + sections: [ + { label: TemplateSectionLabel.LepusCode, content }, + ], + }); + + const result = decodeTemplate(buffer); + expect(String.fromCharCode(...result.lepusCode['entry.js'])).toEqual( + lepusMap['entry.js'], + ); + }); + + it('should decode style info', () => { + const content = new Uint8Array([1, 2, 3]); + // Mock wasmInstance.decode_style_info to return content + // In this test file setup, the mock simply returns the input buffer, so we expect [1, 2, 3] back but as a distinct array if copied, or same. + // Our mock implementation: (buffer) => buffer + + const buffer = createTemplate({ + sections: [ + { label: TemplateSectionLabel.StyleInfo, content }, + ], + }); + + const result = decodeTemplate(buffer); + // It should call wasmInstance.decode_style_info + expect(wasm.decode_style_info).toHaveBeenCalled(); + expect(result.styleInfo).toEqual(content); + }); + + it('should handle custom sections', () => { + const customObj = { custom: 'data' }; + const jsonStr = JSON.stringify(customObj); + const customData = new Uint8Array(Buffer.from(jsonStr, 'utf16le')); + + const buffer = createTemplate({ + sections: [ + { label: TemplateSectionLabel.CustomSections, content: customData }, + ], + }); + + const result = decodeTemplate(buffer); + expect(result.customSections).toEqual(customObj); + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/element-apis.spec.ts b/packages/web-platform/web-core-wasm/tests/element-apis.spec.ts index caaabc0b5c..4750af8c8c 100644 --- a/packages/web-platform/web-core-wasm/tests/element-apis.spec.ts +++ b/packages/web-platform/web-core-wasm/tests/element-apis.spec.ts @@ -4,6 +4,13 @@ import { createElementAPI } from '../ts/client/mainthread/elementAPIs/createElem import { WASMJSBinding } from '../ts/client/mainthread/elementAPIs/WASMJSBinding.js'; import { vi } from 'vitest'; import { cssIdAttribute } from '../ts/constants.js'; +import { + createElementAPI as createServerElementAPI, + SSRBinding, +} from '../ts/server/elementAPIs/createElementAPI.js'; +import { wasmInstance } from '../ts/client/wasm.js'; +import { encodeCSS } from '../ts/encode/encodeCSS.js'; + describe('Element APIs', () => { let lynxViewDom: HTMLElement; let rootDom: ShadowRoot; @@ -66,6 +73,12 @@ describe('Element APIs', () => { expect(mtsGlobalThis.__GetTag(ret)).toBe('view'); }); + test('__CreatePage tag reverse mapping', () => { + const ret = mtsGlobalThis.__CreatePage('test', 0); + // Even though it uses 'div' under the hood, __GetTag should reverse-map to 'page' + expect(mtsGlobalThis.__GetTag(ret)).toBe('page'); + }); + test('__CreateScrollView', () => { const ret = mtsGlobalThis.__CreateScrollView(0); expect(mtsGlobalThis.__GetTag(ret)).toBe('scroll-view'); @@ -1322,4 +1335,110 @@ describe('Element APIs', () => { expect(classes).toEqual(expect.arrayContaining(['foo', 'bar'])); expect(classes.length).toBe(2); }); + + describe('Server Element APIs SSR Propagation', () => { + test('create element infer css id from parent component in SSR', () => { + const binding: SSRBinding = { + ssrResult: '', + }; + const config = { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }; + const { globalThisAPIs: api, wasmContext: wasmCtx } = + createServerElementAPI( + binding, + undefined, + '', + config, + ); + + const root = api.__CreatePage('page', 0); + const parentComponent = api.__CreateComponent( + api.__GetElementUniqueID(root), + 'id', + 100, + 'test_entry', + 'name', + ); + const parentComponentUniqueId = api.__GetElementUniqueID(parentComponent); + const view = api.__CreateElement('view', parentComponentUniqueId); + + api.__AppendElement(parentComponent, view); + api.__AppendElement(root, parentComponent); + + const viewUid = api.__GetElementUniqueID(view); + const html = wasmCtx.generate_html(viewUid); + + expect(html).toContain('l-css-id="100"'); + }); + + test('create element wont infer css id if parent css id is 0 in SSR', () => { + const binding: SSRBinding = { + ssrResult: '', + }; + const config = { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }; + const { globalThisAPIs: api, wasmContext: wasmCtx } = + createServerElementAPI( + binding, + undefined, + '', + config, + ); + + const root = api.__CreatePage('page', 0); + const parentComponent = api.__CreateComponent( + api.__GetElementUniqueID(root), + 'id', + 0, + 'test_entry', + 'name', + ); + const parentComponentUniqueId = api.__GetElementUniqueID(parentComponent); + const view = api.__CreateElement('view', parentComponentUniqueId); + + api.__AppendElement(parentComponent, view); + api.__AppendElement(root, parentComponent); + + const viewUid = api.__GetElementUniqueID(view); + const html = wasmCtx.generate_html(viewUid); + }); + }); + + test('push_style_sheet', () => { + const { StyleSheetResource } = wasmInstance; + const encodedRawStyleInfo = encodeCSS({ + '0': [ + { + type: 'StyleRule', + selectorText: { value: '.test' }, + style: [{ name: 'color', value: 'red' }], + variables: {}, + }, + ], + }); + const encodedStyleInfo = wasmInstance.decode_style_info( + encodedRawStyleInfo, + undefined, + true, + ); + const resource = new StyleSheetResource(encodedStyleInfo, document); + mtsBinding.wasmContext!.push_style_sheet(resource); + + const page = mtsGlobalThis.__CreatePage('page', 0); + const view = mtsGlobalThis.__CreateView(0); + mtsGlobalThis.__AddClass(view, 'test'); + mtsGlobalThis.__AppendElement(page, view); + mtsGlobalThis.__FlushElementTree(); + + const styleElement = rootDom.querySelector('style'); + expect(styleElement).not.toBeNull(); + expect(styleElement!.textContent).toContain('.test'); + expect(styleElement!.textContent).toContain('color:red'); + }); }); diff --git a/packages/web-platform/web-core-wasm/tests/server-compat.spec.ts b/packages/web-platform/web-core-wasm/tests/server-compat.spec.ts new file mode 100644 index 0000000000..5d045bc8d6 --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/server-compat.spec.ts @@ -0,0 +1,110 @@ +/* + * 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 { describe, it, expect } from 'vitest'; +import { createElementAPI } from '../ts/server/index.js'; + +describe('Server Compat Tests', () => { + it('basic-performance-div-10', () => { + const binding: any = {}; + const { globalThisAPIs: api } = createElementAPI( + binding, + undefined, + '', + { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }, + ); + + const page = api.__CreatePage('0', 0); + + for (let i = 0; i < 10; i++) { + const div = api.__CreateElement('div', 0); + api.__SetAttribute(div, 'id', `target-${i}`); + api.__SetInlineStyles(div, 'height:100px;width:100px;background:pink;'); + api.__AppendElement(page, div); + } + + api.__FlushElementTree(page, {}); + const html = binding.ssrResult; + expect(html).toMatchSnapshot(); + }); + + it('basic-performance-nest-level-100', () => { + const binding: any = {}; + const { globalThisAPIs: api } = createElementAPI( + binding, + undefined, + '', + { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }, + ); + + const page = api.__CreatePage('0', 0); + + let parent = page; + // index.jsx: App count={100}. + //
+ // So nesting goes: page -> target-100 -> target-99 ... -> target-1 + // Loop from 100 down to 1. + for (let i = 100; i >= 1; i--) { + const div = api.__CreateElement('div', 0); + api.__SetAttribute(div, 'id', `target-${i}`); + api.__AppendElement(parent, div); + parent = div; + } + + api.__FlushElementTree(page, {}); + const html = binding.ssrResult; + expect(html).toMatchSnapshot(); + }); + + it('basic-performance-event-div-100', () => { + const binding: any = {}; + const { globalThisAPIs: api } = createElementAPI( + binding, + undefined, + '', + { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }, + ); + + const page = api.__CreatePage('0', 0); + + for (let i = 0; i < 100; i++) { + const div = api.__CreateElement('div', 0); + api.__SetAttribute(div, 'id', `target-${i}`); + api.__SetInlineStyles(div, 'height:100px;width:100px;background:pink;'); + // bindtap={handleTap} + // In web-core snapshot: events: [[2, "bindEvent", "tap", "-2:1:"]] + // We simulate adding event listener + // function name for bindtap is usually inferred or provided. + // createElementAPI.__AddEventListener(element, eventName, eventType, listenerId) + // eventType: 'bind' | 'catch' | 'capture-bind' | 'capture-catch' ? + // Actually ElementData has keys like 'bind', 'catch'. + // Snapshot says "bindEvent" as a separate string? "events":[[id, type, name, funcName]] + // type="bindEvent". + // In `element_data.rs`: `framework_cross_thread_identifier` keys are "bind", "capture-bind" etc. + // Wait, `createElementAPI` maps `__AddEventListener` to `wasmCtx.add_event_listener`. + // Let's check `createElementAPI.ts` implementation details. + // __AddEvent(element, eventType, eventName, listenerId) + api.__AddEvent(div, 'bindEvent', 'tap', `handleTap-${i}`); + api.__AppendElement(page, div); + } + + api.__FlushElementTree(page, {}); + const html = binding.ssrResult; + expect(html).toMatchSnapshot(); + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/server-ssr-bulk.spec.ts b/packages/web-platform/web-core-wasm/tests/server-ssr-bulk.spec.ts new file mode 100644 index 0000000000..b0c4150490 --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/server-ssr-bulk.spec.ts @@ -0,0 +1,63 @@ +import { describe, it, expect } from 'vitest'; +import { createElementAPI } from '../ts/server/index.js'; +import { MainThreadServerContext, SSRBinding } from '../ts/server/wasm.js'; + +describe('Server SSR Bulk Styles', () => { + it('should handle object-based SetInlineStyles', () => { + const binding: SSRBinding = {}; + const config = { enableCSSSelector: true }; + const { globalThisAPIs: api, wasmContext: wasmCtx } = createElementAPI( + binding, + undefined, + '', + config, + ); + + const el = api.__CreateElement('view', 0); + api.__SetAttribute(el, 'id', 'test-bulk'); + + // Test object-based SetInlineStyles + api.__SetInlineStyles(el, { + 'color': 'red', + 'font-size': '16px', + 'margin-top': '10px', + }); + + const uid = api.__GetElementUniqueID(el); + const html = wasmCtx.generate_html(uid); + + console.log('Bulk Style HTML:', html); + + expect(html).toContain('color:red;'); + expect(html).toContain('font-size:16px;'); + expect(html).toContain('margin-top:10px;'); + }); + + it('should handle numeric values in SetInlineStyles', () => { + const binding: SSRBinding = {}; + const config = { enableCSSSelector: true }; + const { globalThisAPIs: api, wasmContext: wasmCtx } = createElementAPI( + binding, + undefined, + '', + config, + ); + + const el = api.__CreateElement('view', 0); + + // Test object-based with numbers (should be converted to string by TS binding) + api.__SetInlineStyles(el, { + 'flex': 1 as any, + 'opacity': 0.5 as any, + }); + + const uid = api.__GetElementUniqueID(el); + const html = wasmCtx.generate_html(uid); + + console.log('Bulk Style Numeric HTML:', html); + + // flex: 1 might be transformed to --flex:1 or similar, but let's check basic presence + expect(html).toContain(':1'); + expect(html).toContain(':0.5'); + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/server-ssr.spec.ts b/packages/web-platform/web-core-wasm/tests/server-ssr.spec.ts new file mode 100644 index 0000000000..3931014052 --- /dev/null +++ b/packages/web-platform/web-core-wasm/tests/server-ssr.spec.ts @@ -0,0 +1,192 @@ +/* + * 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 { describe, it, expect } from 'vitest'; +import { createElementAPI, type SSRBinding } from '../ts/server/index.js'; +import { MainThreadServerContext } from '../ts/server/wasm.js'; + +describe('Server SSR', () => { + it('should generate html correctly', () => { + const binding: SSRBinding = { + ssrResult: '', + }; + const config = { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }; + const { globalThisAPIs: api } = createElementAPI( + binding, + undefined, + '', + config, + ); + + // Create Page + const page = api.__CreatePage('0', 0); + // Add content to page + const view = api.__CreateElement('view', 0); + api.__SetAttribute(view, 'id', 'main'); + api.__SetInlineStyles(view, 'color: red;'); + api.__AppendElement(page, view); + + // Create text + const text = api.__CreateRawText('Hello World'); + api.__AppendElement(view, text); + + // Flush to generate HTML + api.__FlushElementTree(); + + // Retrieve result + const html = binding.ssrResult; + + // Debug output + console.log('Generated HTML:', html); + + expect(html).toContain('
'); + expect(html).toContain('
'); + }); + + it('should handle attributes and styles', () => { + const binding: any = {}; + const config = { enableCSSSelector: true }; + const { globalThisAPIs: api, wasmContext: wasmCtx } = createElementAPI( + binding, + undefined, + '', + config, + ); + + const el = api.__CreateElement('image', 0); + api.__SetAttribute(el, 'src', 'http://example.com/img.png'); + api.__AddInlineStyle(el, 'width', '100px'); + api.__AddInlineStyle(el, 'height', '100px'); + + const uid = api.__GetElementUniqueID(el); + const html = wasmCtx.generate_html(uid); + + console.log('Image HTML:', html); + + expect(html).toContain(' { + const binding: SSRBinding = { + ssrResult: '', + }; + const config = { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }; + const { globalThisAPIs: api, wasmContext: wasmCtx } = createElementAPI( + binding, + undefined, + '', + config, + ); + + const el = api.__CreateElement('view', 0); + // Test key-value transformation + api.__AddInlineStyle(el, 'flex', '1'); + + const uid = api.__GetElementUniqueID(el); + let html = wasmCtx.generate_html(uid); + // Check key-value transform (flex -> --flex) + expect(html).toContain('--flex:1'); + + // Test string transformation + const el2 = api.__CreateElement('view', 0); + api.__SetAttribute(el2, 'style', 'linear-layout-gravity: right;'); + + const uid2 = api.__GetElementUniqueID(el2); + html = wasmCtx.generate_html(uid2); + + // Check string transform (linear-layout-gravity -> --align-self-column:end) + expect(html).toContain('--align-self-column:end'); + }); + + it('should infer css id from parent component in SSR', () => { + const binding: SSRBinding = { + ssrResult: '', + }; + const config = { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }; + const { globalThisAPIs: api, wasmContext: wasmCtx } = createElementAPI( + binding, + undefined, + '', + config, + ); + + const root = api.__CreatePage('page', 0); + const parentComponent = api.__CreateComponent( + api.__GetElementUniqueID(root), + 'id', + 100, + 'test_entry', + 'name', + ); + const parentComponentUniqueId = api.__GetElementUniqueID(parentComponent); + const view = api.__CreateElement('view', parentComponentUniqueId); + + api.__AppendElement(parentComponent, view); + api.__AppendElement(root, parentComponent); + + const viewUid = api.__GetElementUniqueID(view); + const html = wasmCtx.generate_html(viewUid); + + expect(html).toContain('l-css-id="100"'); + }); + + it('should not infer css id if parent css id is 0 in SSR', () => { + const binding: SSRBinding = { + ssrResult: '', + }; + const config = { + enableCSSSelector: true, + defaultOverflowVisible: false, + defaultDisplayLinear: true, + }; + const { globalThisAPIs: api, wasmContext: wasmCtx } = createElementAPI( + binding, + undefined, + '', + config, + ); + + const root = api.__CreatePage('page', 0); + const parentComponent = api.__CreateComponent( + api.__GetElementUniqueID(root), + 'id', + 0, + 'test_entry', + 'name', + ); + const parentComponentUniqueId = api.__GetElementUniqueID(parentComponent); + const view = api.__CreateElement('view', parentComponentUniqueId); + + api.__AppendElement(parentComponent, view); + api.__AppendElement(root, parentComponent); + + const viewUid = api.__GetElementUniqueID(view); + const html = wasmCtx.generate_html(viewUid); + + expect(html).not.toContain('l-css-id'); + }); +}); diff --git a/packages/web-platform/web-core-wasm/tests/template-manager.spec.ts b/packages/web-platform/web-core-wasm/tests/template-manager.spec.ts index 9b0f36e9ec..9ccc2e146f 100644 --- a/packages/web-platform/web-core-wasm/tests/template-manager.spec.ts +++ b/packages/web-platform/web-core-wasm/tests/template-manager.spec.ts @@ -1,7 +1,7 @@ import './jsdom.js'; import { describe, test, expect, vi, beforeEach } from 'vitest'; import { encode, type TasmJSONInfo } from '../ts/encode/index.js'; -import { MagicHeader } from '../ts/constants.js'; +import { MagicHeader0, MagicHeader1 } from '../ts/constants.js'; import type { LynxViewInstance } from '../ts/client/mainthread/LynxViewInstance.js'; // Import the worker script to execute it and register the handler @@ -61,8 +61,10 @@ describe('Template Manager', () => { // Verify version in encoded buffer const view = new DataView(encoded.buffer); - const magic = view.getBigUint64(0, true); - expect(magic).toBe(BigInt(MagicHeader)); + const magic0 = view.getUint32(0, true); + const magic1 = view.getUint32(4, true); + expect(magic0).toBe(MagicHeader0); + expect(magic1).toBe(MagicHeader1); const version = view.getUint32(8, true); expect(version).toBe(1); diff --git a/packages/web-platform/web-core-wasm/ts/client/decodeWorker/decode.worker.ts b/packages/web-platform/web-core-wasm/ts/client/decodeWorker/decode.worker.ts index cca5c71d5e..30dd1b7993 100644 --- a/packages/web-platform/web-core-wasm/ts/client/decodeWorker/decode.worker.ts +++ b/packages/web-platform/web-core-wasm/ts/client/decodeWorker/decode.worker.ts @@ -1,4 +1,8 @@ -import { TemplateSectionLabel, MagicHeader } from '../../constants.js'; +import { + TemplateSectionLabel, + MagicHeader0, + MagicHeader1, +} from '../../constants.js'; import type { InitMessage, LoadTemplateMessage, MainMessage } from './types.js'; import { wasmInstance } from '../wasm.js'; @@ -10,6 +14,7 @@ const wasmModuleLoadedPromise: Promise = new Promise((resolve) => { }); import { loadStyleFromJSON } from './cssLoader.js'; +import { decodeBinaryMap } from '../../common/decodeUtils.js'; class StreamReader { #reader: ReadableStreamDefaultReader; @@ -92,52 +97,6 @@ function decodeJSONMap(buffer: Uint8Array): Record { return JSON.parse(jsonString); } -function decodeBinaryMap(buffer: Uint8Array): Record { - const view = new DataView( - buffer.buffer, - buffer.byteOffset, - buffer.byteLength, - ); - let offset = 0; - if (buffer.byteLength < 4) { - throw new Error('Buffer too short for count'); - } - const count = view.getUint32(offset, true); - offset += 4; - - const result: Record = {}; - const decoder = new TextDecoder(); - - for (let i = 0; i < count; i++) { - if (buffer.byteLength < offset + 4) { - throw new Error('Buffer too short for key length'); - } - const keyLen = view.getUint32(offset, true); - offset += 4; - - if (buffer.byteLength < offset + keyLen) { - throw new Error('Buffer too short for key'); - } - const key = decoder.decode(buffer.subarray(offset, offset + keyLen)); - offset += keyLen; - - if (buffer.byteLength < offset + 4) { - throw new Error('Buffer too short for value length'); - } - const valLen = view.getUint32(offset, true); - offset += 4; - - if (buffer.byteLength < offset + valLen) { - throw new Error('Buffer too short for value'); - } - const val = buffer.subarray(offset, offset + valLen); - offset += valLen; - - result[key] = val; - } - return result; -} - self.onmessage = async ( event: MessageEvent | MessageEvent, ) => { @@ -196,8 +155,9 @@ async function handleStream( headerBytes.byteOffset, headerBytes.byteLength, ); - const magic = view.getBigUint64(0, true); // Little Endian - if (magic !== BigInt(MagicHeader)) { + const magic0 = view.getUint32(0, true); + const magic1 = view.getUint32(4, true); + if (magic0 !== MagicHeader0 || magic1 !== MagicHeader1) { throw new Error('Invalid Magic Header'); } @@ -283,12 +243,16 @@ async function handleStream( const isLazy = config['isLazy'] === 'true'; const blobMap: Record = {}; for (const [key, code] of Object.entries(codeMap)) { - const prefix = - `(function(){ "use strict"; const navigator=void 0,postMessage=void 0,window=void 0; ${ - isLazy ? 'module.exports=' : '' - } `; - const suffix = ` \n })()\n//# sourceURL=${url}\n`; - const blob = new Blob([prefix, code as unknown as BlobPart, suffix], { + const blob = new Blob([ + '//# allFunctionsCalledOnLoad\n(function(){ "use strict"; const navigator=void 0,postMessage=void 0,window=void 0; ', + isLazy ? 'module.exports=' : '', + code as unknown as BlobPart, + ' \n })()\n//# sourceURL=', + url, + '/', + key, + '\n', + ], { type: 'text/javascript; charset=utf-8', }); blobMap[key] = URL.createObjectURL(blob); @@ -318,8 +282,13 @@ async function handleStream( const codeMap = decodeBinaryMap(content); const blobMap: Record = {}; for (const [key, code] of Object.entries(codeMap)) { - const suffix = `//# sourceURL=${url}/${key}`; - const blob = new Blob([code as unknown as BlobPart, suffix], { + const blob = new Blob([ + code as unknown as BlobPart, + '//# sourceURL=', + url, + '/', + key, + ], { type: 'text/javascript; charset=utf-8', }); blobMap[key] = URL.createObjectURL(blob); @@ -388,12 +357,12 @@ async function handleJSON( for (const [key, code] of Object.entries(json.lepusCode)) { if (typeof code !== 'string') continue; const prefix = - `(function(){ "use strict"; const navigator=void 0,postMessage=void 0,window=void 0; ${ + `//# allFunctionsCalledOnLoad\n(function(){ "use strict"; const navigator=void 0,postMessage=void 0,window=void 0; ${ isLazy ? 'module.exports=' : '' } `; const suffix = ` \n })()\n//# sourceURL=${url}/${key}\n`; const blob = new Blob([prefix, code, suffix], { - type: 'text/javascript;', + type: 'text/javascript; charset=utf-8', }); blobMap[key] = URL.createObjectURL(blob); } diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/Background.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/Background.ts index a30da8c9b1..438f268d99 100644 --- a/packages/web-platform/web-core-wasm/ts/client/mainthread/Background.ts +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/Background.ts @@ -99,8 +99,7 @@ export class BackgroundThread implements AsyncDisposable { ) { this.#lynxGroupId = lynxGroupId; this.#lynxViewInstance = lynxViewInstance; - const btsRpc = new Rpc(undefined, 'main-to-bg'); - this.#rpc = btsRpc; + this.#rpc = new Rpc(undefined, 'main-to-bg'); this.jsContext = new LynxCrossThreadContext({ rpc: this.#rpc, receiveEventEndpoint: dispatchJSContextOnMainThreadEndpoint, diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxViewInstance.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxViewInstance.ts index 1f31082ef9..f31bab0e84 100644 --- a/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxViewInstance.ts +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/LynxViewInstance.ts @@ -25,7 +25,9 @@ import { createMainThreadGlobalAPIs } from './createMainThreadGlobalAPIs.js'; import { templateManager } from './TemplateManager.js'; import { loadAllWebElements } from '../webElementsDynamicLoader.js'; import type { LynxViewElement } from './LynxView.js'; -import { templateManagerWasm } from '../wasm.js'; +loadAllWebElements().catch((e) => { + console.error('[lynx-web] Failed to load web elements', e); +}); const pixelRatio = window.devicePixelRatio; const screenWidth = window.screen.availWidth * pixelRatio; @@ -57,10 +59,7 @@ export class LynxViewInstance implements AsyncDisposable { readonly i18nManager: I18nManager; readonly exposureServices: ExposureServices; readonly webElementsLoadingPromises: Promise[] = []; - readonly styleReadyPromise: Promise; - readonly styleReadyResolve: () => void; - #renderPageFunction: ((data: Cloneable) => void) | null = null; #queryComponentCache: Map> = new Map(); #pageConfig?: PageConfig; #nativeModulesMap: NativeModulesMap; @@ -82,13 +81,6 @@ export class LynxViewInstance implements AsyncDisposable { ) { this.#nativeModulesMap = nativeModulesMap; this.#napiModulesMap = napiModulesMap; - let resolve!: () => void; - const promise = new Promise((res) => { - resolve = res; - }); - this.styleReadyPromise = promise; - this.styleReadyResolve = resolve; - this.parentDom.style.display = 'none'; this.mainThreadGlobalThis = mtsRealm.globalWindow as & typeof globalThis & MainThreadGlobalThis; @@ -130,34 +122,23 @@ export class LynxViewInstance implements AsyncDisposable { this, ), ); - Object.defineProperty(this.mainThreadGlobalThis, 'renderPage', { - get: () => { - return this.#renderPageFunction; - }, - set: (v) => { - this.#renderPageFunction = v; - this.onMTSScriptsExecuted(); - }, - configurable: true, - enumerable: true, - }); } onStyleInfoReady( currentUrl: string, ) { if (this.mtsWasmBinding.wasmContext) { - this.mtsWasmBinding.wasmContext.push_style_sheet( - templateManagerWasm!, - currentUrl, - this.templateUrl === currentUrl, - ); + const resource = templateManager.getStyleSheet(currentUrl); + if (resource) { + this.mtsWasmBinding.wasmContext.push_style_sheet( + resource, + this.templateUrl === currentUrl ? undefined : currentUrl, + ); + } } - this.parentDom.style.display = 'flex'; - this.styleReadyResolve(); } - onMTSScriptsLoaded(currentUrl: string, isLazy: boolean) { + async onMTSScriptsLoaded(currentUrl: string, isLazy: boolean) { this.backgroundThread.markTiming('lepus_execute_start'); const urlMap = templateManager.getTemplate(currentUrl) ?.lepusCode as Record; @@ -166,21 +147,15 @@ export class LynxViewInstance implements AsyncDisposable { urlMap, ); if (!isLazy) { - this.mtsRealm.loadScript( + await this.mtsRealm.loadScript( urlMap['root']!, ); + this.onMTSScriptsExecuted(); } } - async onMTSScriptsExecuted() { + onMTSScriptsExecuted() { this.backgroundThread.markTiming('lepus_execute_end'); - - this.webElementsLoadingPromises.push(loadAllWebElements()); - - await Promise.all([ - ...this.webElementsLoadingPromises, - this.styleReadyPromise, - ]); this.webElementsLoadingPromises.length = 0; this.backgroundThread.markTiming('data_processor_start'); const processedData = this.mainThreadGlobalThis.processData @@ -198,7 +173,7 @@ export class LynxViewInstance implements AsyncDisposable { this.#nativeModulesMap, this.#napiModulesMap, ); - this.#renderPageFunction?.(processedData); + this.mainThreadGlobalThis.renderPage?.(processedData); this.mainThreadGlobalThis.__FlushElementTree(); } diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/TemplateManager.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/TemplateManager.ts index 568cb10aef..03ceb139d1 100644 --- a/packages/web-platform/web-core-wasm/ts/client/mainthread/TemplateManager.ts +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/TemplateManager.ts @@ -187,10 +187,10 @@ export class TemplateManager { ) { const [ instance, - templateManagerWasm, + StyleSheetResource, ] = await Promise.all([ instancePromise, - wasm.then((wasm) => (wasm.templateManagerWasm)), + wasm.then((wasm) => (wasm.wasmInstance.StyleSheetResource)), ]); const { label, data, url, config } = msg; switch (label) { @@ -201,11 +201,14 @@ export class TemplateManager { break; } case TemplateSectionLabel.StyleInfo: { - templateManagerWasm!.add_style_info( - url, + const resource = new StyleSheetResource( new Uint8Array(data as ArrayBuffer), document, ); + const template = this.#templates.get(url); + if (template) { + template.styleSheet = resource; + } instance.onStyleInfoReady(url); break; } @@ -250,6 +253,9 @@ export class TemplateManager { URL.revokeObjectURL(blobUrl); } } + if (template.styleSheet) { + template.styleSheet.free(); + } } this.#templates.delete(url); } @@ -257,6 +263,7 @@ export class TemplateManager { } #removeTemplate(url: string) { + this.createTemplate(url); // This actually clears it in current logic this.#templates.delete(url); } @@ -294,6 +301,10 @@ export class TemplateManager { public getTemplate(url: string): DecodedTemplate | undefined { return this.#templates.get(url); } + + public getStyleSheet(url: string): any { + return this.#templates.get(url)?.styleSheet; + } } export const templateManager = new TemplateManager(); diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/elementAPIs/createElementAPI.ts index 4a4f5de3bd..a3d34fd575 100644 --- a/packages/web-platform/web-core-wasm/ts/client/mainthread/elementAPIs/createElementAPI.ts +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/elementAPIs/createElementAPI.ts @@ -46,6 +46,7 @@ import type { import type { WASMJSBinding } from './WASMJSBinding.js'; // @ts-expect-error import IN_SHADOW_CSS_MODERN from '../../../../css/in_shadow.css?inline'; +import { requestIdleCallbackImpl } from '../utils/requestIdleCallback.js'; const IN_SHADOW_CSS = URL.createObjectURL( new Blob([IN_SHADOW_CSS_MODERN], { type: 'text/css' }), @@ -496,7 +497,7 @@ export function createElementAPI( let timingFlagsAll = timingFlags.concat( wasmContext.take_timing_flags(), ); - requestAnimationFrame(() => { + requestIdleCallbackImpl(() => { mtsBinding.postTimingFlags( timingFlagsAll, pipelineId, diff --git a/packages/web-platform/web-core-wasm/ts/client/mainthread/utils/requestIdleCallback.ts b/packages/web-platform/web-core-wasm/ts/client/mainthread/utils/requestIdleCallback.ts new file mode 100644 index 0000000000..9545a78a35 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/client/mainthread/utils/requestIdleCallback.ts @@ -0,0 +1,5 @@ +// Safari doesn't support requestIdleCallback +export const requestIdleCallbackImpl = + typeof requestIdleCallback === 'undefined' + ? (callback: () => void) => setTimeout(callback, 16) + : requestIdleCallback; diff --git a/packages/web-platform/web-core-wasm/ts/client/wasm.ts b/packages/web-platform/web-core-wasm/ts/client/wasm.ts index 427c586482..8818bed93f 100644 --- a/packages/web-platform/web-core-wasm/ts/client/wasm.ts +++ b/packages/web-platform/web-core-wasm/ts/client/wasm.ts @@ -40,9 +40,6 @@ export const [wasmInstance, wasmModule] = await wasmLoaded; if (!isWorker) { wasmInstance.initSync({ module: wasmModule! }); } -export const templateManagerWasm = isWorker - ? undefined - : new wasmInstance.TemplateManager(); export type MainThreadWasmContext = typeof import('../../binary/client/client.js').MainThreadWasmContext; diff --git a/packages/web-platform/web-core-wasm/ts/common/decodeUtils.ts b/packages/web-platform/web-core-wasm/ts/common/decodeUtils.ts new file mode 100644 index 0000000000..454e2317d7 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/common/decodeUtils.ts @@ -0,0 +1,53 @@ +/* + * 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. + */ + +export function decodeBinaryMap( + buffer: Uint8Array, +): Record { + const view = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ); + let offset = 0; + if (buffer.byteLength < 4) { + throw new Error('Buffer too short for count'); + } + const count = view.getUint32(offset, true); + offset += 4; + + const result: Record = {}; + const decoder = new TextDecoder(); + + for (let i = 0; i < count; i++) { + if (buffer.byteLength < offset + 4) { + throw new Error('Buffer too short for key length'); + } + const keyLen = view.getUint32(offset, true); + offset += 4; + + if (buffer.byteLength < offset + keyLen) { + throw new Error('Buffer too short for key'); + } + const key = decoder.decode(buffer.subarray(offset, offset + keyLen)); + offset += keyLen; + + if (buffer.byteLength < offset + 4) { + throw new Error('Buffer too short for value length'); + } + const valLen = view.getUint32(offset, true); + offset += 4; + + if (buffer.byteLength < offset + valLen) { + throw new Error('Buffer too short for value'); + } + const val = buffer.subarray(offset, offset + valLen); + offset += valLen; + + result[key] = val; + } + return result; +} diff --git a/packages/web-platform/web-core-wasm/ts/constants.ts b/packages/web-platform/web-core-wasm/ts/constants.ts index b21c97bbe8..3010a36055 100644 --- a/packages/web-platform/web-core-wasm/ts/constants.ts +++ b/packages/web-platform/web-core-wasm/ts/constants.ts @@ -2,10 +2,10 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. -export const cssIdAttribute = /*#__PURE__*/ 'l-css-id' as const; - export const lynxUniqueIdAttribute = /*#__PURE__*/ 'l-uid' as const; +export const cssIdAttribute = /*#__PURE__*/ 'l-css-id' as const; + export const lynxEntryNameAttribute = /*#__PURE__*/ 'l-e-name' as const; export const lynxDisposedAttribute = /*#__PURE__*/ 'l-disposed' as const; @@ -48,7 +48,8 @@ export const LynxEventNameToW3cCommon: Record = Object.entries(W3cEventNameToLynx).map(([k, v]) => [v, k]), ); -export const MagicHeader = /*#__PURE__*/ 0x464F525741524453; // random magic number for verifying the stream is a Lynx encoded template +export const MagicHeader0 = /*#__PURE__*/ 0x41524453; // 'SDRA' +export const MagicHeader1 = /*#__PURE__*/ 0x464F5257; // 'WROF' export const TemplateSectionLabel = /*#__PURE__*/ { Manifest: 1, diff --git a/packages/web-platform/web-core-wasm/ts/encode/webEncoder.ts b/packages/web-platform/web-core-wasm/ts/encode/webEncoder.ts index c3ff296344..788509423e 100644 --- a/packages/web-platform/web-core-wasm/ts/encode/webEncoder.ts +++ b/packages/web-platform/web-core-wasm/ts/encode/webEncoder.ts @@ -6,7 +6,11 @@ import type * as CSS from '@lynx-js/css-serializer'; import type { ElementTemplateData } from '../types/index.js'; import { encodeCSS } from './encodeCSS.js'; -import { MagicHeader, TemplateSectionLabel } from '../constants.js'; +import { + MagicHeader0, + MagicHeader1, + TemplateSectionLabel, +} from '../constants.js'; function encodeAsJSON(map: Record): Uint8Array { const jsonString = JSON.stringify(map); @@ -108,8 +112,10 @@ export function encode(tasmJSON: TasmJSONInfo): Uint8Array { const buffer = new Uint8Array(bufferLength); let offset = 0; const dataView = new DataView(buffer.buffer); - dataView.setBigUint64(offset, BigInt(MagicHeader), true); - offset += 8; + dataView.setUint32(offset, MagicHeader0, true); + offset += 4; + dataView.setUint32(offset, MagicHeader1, true); + offset += 4; // Version dataView.setUint32(offset, 1, true); diff --git a/packages/web-platform/web-core-wasm/ts/server/createServerLynx.ts b/packages/web-platform/web-core-wasm/ts/server/createServerLynx.ts new file mode 100644 index 0000000000..07ea6355d4 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/server/createServerLynx.ts @@ -0,0 +1,46 @@ +/* + * Copyright 2025 The Lynx Authors. All rights reserved. + * Licensed under the Apache License Version 2.0 that can be found in the + * LICENSE file in the root directory of this source tree. + */ + +import type { MainThreadLynx } from '../types/MainThreadLynx.js'; +import type { Cloneable } from '../types/index.js'; + +export function createServerLynx( + globalProps: Cloneable, + customSections: Record, +): MainThreadLynx { + return { + getJSContext() { + // Return a basic mock for SSR + return {} as any; + }, + requestAnimationFrame(cb: () => void) { + // Invoke immediately or ignore in SSR + // Since it's often used for animations, we might just ignore or run once. + // Running using setImmediate or setTimeout(0) is closest to behavior if we want async execution. + // But for simple SSR generation effectively being synchronous, calling immediately might be dangerous if recursive. + // Let's rely on standard timer mocks or just return a dummy id. + const id = setTimeout(cb, 0); + return id as unknown as number; + }, + cancelAnimationFrame(handler: number) { + clearTimeout(handler); + }, + __globalProps: globalProps ?? {}, + getCustomSectionSync(key: string) { + return customSections?.[key]; + }, + markPipelineTiming(_pipelineId: string, _timingKey: string) { + // skip + }, + SystemInfo: { + platform: 'web-ssr', + }, + setTimeout: setTimeout, + clearTimeout: clearTimeout, + setInterval: setInterval, + clearInterval: clearInterval, + }; +} diff --git a/packages/web-platform/web-core-wasm/ts/server/decode.ts b/packages/web-platform/web-core-wasm/ts/server/decode.ts new file mode 100644 index 0000000000..c2bece55a2 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/server/decode.ts @@ -0,0 +1,116 @@ +import { + TemplateSectionLabel, + MagicHeader0, + MagicHeader1, +} from '../constants.js'; + +import { decode_style_info } from './wasm.js'; +import { decodeBinaryMap } from '../common/decodeUtils.js'; + +export interface DecodedTemplate { + config: Record; + styleInfo?: Uint8Array; + lepusCode: Record; + customSections?: Record; +} + +export function decodeTemplate(buffer: Uint8Array): DecodedTemplate { + if (buffer.length < 8) { + throw new Error('Buffer too short for Magic Header'); + } + + const view = new DataView( + buffer.buffer, + buffer.byteOffset, + buffer.byteLength, + ); + const magic0 = view.getUint32(0, true); + const magic1 = view.getUint32(4, true); + + if (magic0 !== MagicHeader0 || magic1 !== MagicHeader1) { + throw new Error('Invalid Magic Header'); + } + + let offset = 8; + + // Version + if (buffer.length < offset + 4) { + throw new Error('Unexpected EOF reading version'); + } + const version = view.getUint32(offset, true); + offset += 4; + + if (version > 1) { + throw new Error(`Unsupported version: ${version}`); + } + + let config: Record = {}; + let styleInfo: Uint8Array | undefined; + let lepusCode: Record = {}; + let customSections: Record | undefined; + + while (offset < buffer.length) { + if (buffer.length < offset + 4) { + break; // EOF or partial + } + const label = view.getUint32(offset, true); + offset += 4; + + if (buffer.length < offset + 4) { + throw new Error('Unexpected EOF reading section length'); + } + const length = view.getUint32(offset, true); + offset += 4; + + if (buffer.length < offset + length) { + throw new Error( + `Unexpected EOF reading section content. Expected ${length} bytes.`, + ); + } + + const content = buffer.subarray(offset, offset + length); + offset += length; + + switch (label) { + case TemplateSectionLabel.Configurations: { + const decoder = new TextDecoder('utf-16le'); + const jsonString = decoder.decode(content); + config = JSON.parse(jsonString); + break; + } + case TemplateSectionLabel.StyleInfo: { + const buffer = decode_style_info( + content, + config['isLazy'] === 'true' ? '' : undefined, // URL is not available in synchronous decode usually, or passed as arg? The user req says "uint8array as params decode directly". Assuming URL is empty or unneeded for sync server decode unless specified. + config['enableCSSSelector'] === 'true', + ); + styleInfo = buffer; + break; + } + case TemplateSectionLabel.LepusCode: { + lepusCode = decodeBinaryMap(content); + break; + } + case TemplateSectionLabel.CustomSections: { + const decoder = new TextDecoder('utf-16le'); + customSections = JSON.parse(decoder.decode(content)); + break; + } + case TemplateSectionLabel.Manifest: + case TemplateSectionLabel.ElementTemplates: { + // Ignore these sections for now + break; + } + default: + // Ignore unknown sections or throw? Worker throws. + throw new Error(`Unknown section label: ${label}`); + } + } + + return { + config, + styleInfo, + lepusCode, + customSections, + }; +} diff --git a/packages/web-platform/web-core-wasm/ts/server/deploy.ts b/packages/web-platform/web-core-wasm/ts/server/deploy.ts new file mode 100644 index 0000000000..988ff6593e --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/server/deploy.ts @@ -0,0 +1,87 @@ +import * as vm from 'vm'; +import { decodeTemplate } from './decode.js'; +import { + createElementAPI, + type SSRBinding, +} from './elementAPIs/createElementAPI.js'; +import type { Cloneable, InitI18nResources } from '../types/index.js'; +import { createServerLynx } from './createServerLynx.js'; + +export function executeTemplate( + templateBuffer: Buffer, + initData: Cloneable, + globalProps: Cloneable, + _initI18nResources: InitI18nResources, + viewAttributes?: string, +): string | undefined { + const result = decodeTemplate(templateBuffer); + const config = result.config; + + const binding: SSRBinding = { ssrResult: '' }; + const { globalThisAPIs: elementAPIs } = createElementAPI( + binding, + result.styleInfo, + viewAttributes ?? '', + { + enableCSSSelector: config['enableCSSSelector'] === 'true', + defaultOverflowVisible: config['defaultOverflowVisible'] === 'true', + defaultDisplayLinear: config['defaultDisplayLinear'] !== 'false', // Default to true if not present or 'true' + }, + ); + + const sandbox: Record = { + module: { exports: {} }, + exports: {}, + console: console, + // Mock globals to match client environment if needed + setTimeout: setTimeout, + clearTimeout: clearTimeout, + lynx: createServerLynx( + globalProps, + result.customSections as unknown as Record, + ), + __OnLifecycleEvent: () => {}, + ...elementAPIs, + }; + + const context = vm.createContext(sandbox); + + // Style Info block removed as it is passed to createElementAPI + + // Lepus Code + const rootCodeBuf = result.lepusCode['root']; + if (rootCodeBuf) { + const rootCode = new TextDecoder('utf-8').decode(rootCodeBuf); + const isLazy = config['isLazy'] === 'true'; + + const wrappedCode = ` + (function() { + "use strict"; + const navigator = undefined; + const postMessage = undefined; + const window = undefined; + ${isLazy ? 'module.exports =' : ''} + ${rootCode} + })() + `; + + // Execute root code + // This execution should trigger the assignment of globalThis.renderPage, + // which in turn triggers our setter, queues the microtask. + vm.runInContext(wrappedCode, context, { + filename: `root`, + }); + const renderPageFunction = sandbox['renderPage']; + if (typeof renderPageFunction === 'function') { + const processData = sandbox['processData']; + const processedData = processData + ? processData(initData) + : initData; + renderPageFunction(processedData); + elementAPIs.__FlushElementTree(); + return binding.ssrResult; + } + } + + return undefined; +} diff --git a/packages/web-platform/web-core-wasm/ts/server/elementAPIs/createElementAPI.ts b/packages/web-platform/web-core-wasm/ts/server/elementAPIs/createElementAPI.ts new file mode 100644 index 0000000000..cfd57eb167 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/server/elementAPIs/createElementAPI.ts @@ -0,0 +1,429 @@ +/* + * 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 { MainThreadServerContext, StyleSheetResource } from '../wasm.js'; + +import { + LYNX_TAG_TO_HTML_TAG_MAP, + uniqueIdSymbol, + lynxDefaultDisplayLinearAttribute, + lynxEntryNameAttribute, +} from '../../constants.js'; +import type { + AddClassPAPI, + AddInlineStylePAPI, + AppendElementPAPI, + CreateComponentPAPI, + CreateElementPAPI, + CreateImagePAPI, + CreateListPAPI, + CreatePagePAPI, + CreateRawTextPAPI, + CreateScrollViewPAPI, + CreateTextPAPI, + CreateViewPAPI, + CreateWrapperElementPAPI, + DecoratedHTMLElement, + ElementPAPIs, + GetAttributesPAPI, + GetClassesPAPI, + GetIDPAPI, + GetTagPAPI, + SetAttributePAPI, + SetAttributePAPIUpdateListInfo, + SetCSSIdPAPI, + SetClassesPAPI, + SetIDPAPI, + SetInlineStylesPAPI, + UpdateListInfoAttributeValue, +} from '../../types/index.js'; +import { + __AddConfig, + __AddDataset, + __AddEvent, + __ElementIsEqual, + __FirstElement, + __GetChildren, + __GetComponentID, + __GetConfig, + __GetDataByKey, + __GetDataset, + __GetElementConfig, + __GetElementUniqueID, + __GetEvent, + __GetEvents, + __GetPageElement, + __GetParent, + __GetTemplateParts, + __InsertElementBefore, + __LastElement, + __MarkPartElement, + __MarkTemplateElement, + __NextElement, + __RemoveElement, + __ReplaceElement, + __ReplaceElements, + __SetConfig, + __SetDataset, + __SetEvents, + __SwapElement, + __UpdateComponentID, + __UpdateComponentInfo, + __UpdateListCallbacks, + getUniqueId, + type ServerElement, +} from './pureElementAPIs.js'; + +export type SSRBinding = { + ssrResult: string; +}; + +export function createElementAPI( + mtsBinding: SSRBinding, + styleInfo: Uint8Array | undefined, + viewAttributes: string, + config: { + enableCSSSelector: boolean; + defaultOverflowVisible: boolean; + defaultDisplayLinear: boolean; + }, +): { globalThisAPIs: ElementPAPIs; wasmContext: MainThreadServerContext } { + const wasmContext = new MainThreadServerContext( + viewAttributes, + config.enableCSSSelector, + ); + if (styleInfo) { + const resource = new StyleSheetResource(styleInfo, undefined); + wasmContext.push_style_sheet(resource); + } + + let pageElementId: number | undefined; + + function getAttribute( + element: ServerElement, + key: string, + ): string | undefined { + return wasmContext.get_attribute(element[uniqueIdSymbol], key) || undefined; + } + + const __SetCSSId: SetCSSIdPAPI = ( + elements: HTMLElement[], + cssId: number | null, + entryName?: string, + ) => { + const uniqueIds = elements.map( + (element) => (element as ServerElement)[uniqueIdSymbol], + ); + wasmContext.set_css_id(new Uint32Array(uniqueIds), cssId ?? 0, entryName); + }; + + const __SetClasses: SetClassesPAPI = ( + element: HTMLElement, + classname: string | null, + ) => { + const el = element as ServerElement; + if (classname) { + wasmContext.set_attribute(el[uniqueIdSymbol], 'class', classname); + } else { + wasmContext.remove_attribute(el[uniqueIdSymbol], 'class'); + } + }; + + const __AddClass: AddClassPAPI = ( + element: HTMLElement, + className: string, + ) => { + const el = element as ServerElement; + wasmContext.add_class(el[uniqueIdSymbol], className); + }; + + return { + globalThisAPIs: { + // Pure/Throwing Methods + __GetID: ((element: HTMLElement) => { + return getAttribute(element as ServerElement, 'id') ?? null; + }) as GetIDPAPI, + __GetTag: ((element: HTMLElement) => { + const el = element as ServerElement; + const tag = wasmContext.get_tag(el[uniqueIdSymbol]) ?? ''; + // Reverse-map HTML tag to Lynx tag (consistent with CSR `__GetTag` behavior) + for ( + const [lynxTag, htmlTag] of Object.entries(LYNX_TAG_TO_HTML_TAG_MAP) + ) { + if (tag === htmlTag) { + return lynxTag; + } + } + return tag; + }) as GetTagPAPI, + __GetAttributes: ((element: HTMLElement) => { + const el = element as ServerElement; + return wasmContext.get_attributes(el[uniqueIdSymbol]); + }) as GetAttributesPAPI, + __GetAttributeByName: (element: unknown, name: string) => { + return getAttribute(element as ServerElement, name) ?? null; + }, + __GetClasses: ((element: HTMLElement) => { + const cls = getAttribute(element as ServerElement, 'class'); + if (!cls) return []; + return cls.split(/\s+/).filter((c) => c.length > 0); + }) as GetClassesPAPI, + __GetParent, + __GetChildren, + __AddEvent, + __GetEvent, + __GetEvents, + __SetEvents, + __UpdateListCallbacks, + __GetConfig, + __SetConfig, + __GetElementConfig, + __GetComponentID, + __GetDataset, + __SetDataset, + __AddDataset, + __GetDataByKey, + __ElementIsEqual, + __GetElementUniqueID, + __FirstElement, + __LastElement, + __NextElement, + __RemoveElement, + __ReplaceElement, + __SwapElement, + + __SetCSSId, + __SetClasses: config.enableCSSSelector + ? __SetClasses + : ((element, classname) => { + __SetClasses(element, classname); + const el = element as ServerElement; + wasmContext.update_css_og_style( + el[uniqueIdSymbol], + getAttribute(el, lynxEntryNameAttribute), + ); + }), + __AddClass, + + __AddConfig, + __UpdateComponentInfo, + __UpdateComponentID, + __MarkTemplateElement, + __MarkPartElement, + __GetTemplateParts, + __GetPageElement, + __InsertElementBefore, + __ReplaceElements, + + // Context-Dependent Methods + __CreateView: ((parentComponentUniqueId: number) => { + const id = wasmContext.create_element( + 'x-view', + parentComponentUniqueId, + ); + return { [uniqueIdSymbol]: id } as unknown as DecoratedHTMLElement; + }) as CreateViewPAPI, + __CreateText: ((parentComponentUniqueId: number) => { + const id = wasmContext.create_element( + 'x-text', + parentComponentUniqueId, + ); + return { [uniqueIdSymbol]: id } as unknown as DecoratedHTMLElement; + }) as CreateTextPAPI, + __CreateImage: ((parentComponentUniqueId: number) => { + const id = wasmContext.create_element( + 'x-image', + parentComponentUniqueId, + ); + return { [uniqueIdSymbol]: id } as unknown as DecoratedHTMLElement; + }) as CreateImagePAPI, + __CreateRawText: ((text: string) => { + const id = wasmContext.create_element('raw-text'); + wasmContext.set_attribute(id, 'text', text); + return { [uniqueIdSymbol]: id } as unknown as DecoratedHTMLElement; + }) as CreateRawTextPAPI, + __CreateScrollView: ((parentComponentUniqueId: number) => { + const id = wasmContext.create_element( + 'scroll-view', + parentComponentUniqueId, + ); + return { [uniqueIdSymbol]: id } as unknown as DecoratedHTMLElement; + }) as CreateScrollViewPAPI, + __CreateElement: ((tagName: string, parentComponentUniqueId: number) => { + const htmlTag = LYNX_TAG_TO_HTML_TAG_MAP[tagName] ?? tagName; + const id = wasmContext.create_element(htmlTag, parentComponentUniqueId); + const el = { [uniqueIdSymbol]: id }; + if (!config.enableCSSSelector) { + wasmContext.set_attribute(id, 'l-uid', id.toString()); + } + return el as unknown as DecoratedHTMLElement; + }) as CreateElementPAPI, + __CreateComponent: (( + parentComponentUniqueId: number, + _componentID: string, + _cssID: number, + entryName: string, + name: string, + ) => { + const id = wasmContext.create_element( + 'x-view', + parentComponentUniqueId, + _cssID, + _componentID, + ); // Component host + const el = { [uniqueIdSymbol]: id } as ServerElement; + if (!config.enableCSSSelector) { + wasmContext.set_attribute(id, 'l-uid', id.toString()); + } + if (entryName) { + wasmContext.set_attribute(id, 'lynx-entry-name', entryName); + } + if (name) { + wasmContext.set_attribute(id, 'name', name); + } + return el as unknown as DecoratedHTMLElement; + }) as CreateComponentPAPI, + __CreateWrapperElement: ((parentComponentUniqueId: number) => { + const id = wasmContext.create_element( + 'lynx-wrapper', + parentComponentUniqueId, + ); + return { [uniqueIdSymbol]: id } as unknown as DecoratedHTMLElement; + }) as CreateWrapperElementPAPI, + __CreateList: ((parentComponentUniqueId: number) => { + const id = wasmContext.create_element( + 'x-list', + parentComponentUniqueId, + ); + return { [uniqueIdSymbol]: id } as unknown as DecoratedHTMLElement; + }) as CreateListPAPI, + __CreatePage: ((_componentID: string, _cssID: number) => { + const id = wasmContext.create_element( + 'div', + 0, + _cssID, + _componentID, + ); + pageElementId = id; + const el = { [uniqueIdSymbol]: id } as ServerElement; + if (!config.enableCSSSelector) { + wasmContext.set_attribute(id, 'l-uid', id.toString()); + } + wasmContext.set_attribute(id, 'part', 'page'); + + if (config.defaultDisplayLinear === false) { + wasmContext.set_attribute( + id, + lynxDefaultDisplayLinearAttribute, + 'false', + ); + } + if (config.defaultOverflowVisible === true) { + wasmContext.set_attribute( + id, + 'lynx-default-overflow-visible', + 'true', + ); + } + + return el as unknown as DecoratedHTMLElement; + }) as CreatePagePAPI, + + __AppendElement: ((parent: HTMLElement, child: HTMLElement) => { + const parentId = getUniqueId(parent); + const childId = getUniqueId(child); + wasmContext.append_child(parentId, childId); + }) as AppendElementPAPI, + + __SetAttribute: (( + element: HTMLElement, + name: string, + value: + | string + | boolean + | null + | undefined + | UpdateListInfoAttributeValue, + ) => { + const el = element as ServerElement; + let valStr = ''; + if (value == null) { + valStr = ''; + } else { + valStr = value.toString(); + } + wasmContext.set_attribute(el[uniqueIdSymbol], name, valStr); + }) as SetAttributePAPI & SetAttributePAPIUpdateListInfo, + + __SetInlineStyles: (( + element: HTMLElement, + value: string | Record | undefined, + ) => { + const uniqueId = (element as ServerElement)[uniqueIdSymbol]; + if (!value) { + wasmContext.remove_attribute(uniqueId, 'style'); + } else { + if (typeof value === 'string') { + if ( + !wasmContext.set_inline_styles_in_str( + uniqueId, + value, + ) + ) { + wasmContext.set_attribute(uniqueId, 'style', value); + } + } else if (!value) { + wasmContext.remove_attribute(uniqueId, 'style'); + } else { + wasmContext.get_inline_styles_in_key_value_vec( + uniqueId, + Object.entries(value).flat().map((item) => item.toString()), + ); + } + } + }) as SetInlineStylesPAPI, + + __AddInlineStyle: (( + element: HTMLElement, + key: string | number, + value: string | number | null | undefined, + ) => { + const uniqueId = (element as ServerElement)[uniqueIdSymbol]; + if (typeof value != 'string') { + value = (value as number).toString(); + } + if (typeof key === 'number') { + return wasmContext.set_inline_styles_number_key( + uniqueId, + key, + value as string | null, + ); + } else { + return wasmContext.add_inline_style_raw_string_key( + uniqueId, + key.toString(), + value as string | null, + ); + } + }) as AddInlineStylePAPI, + + __FlushElementTree: (() => { + if (pageElementId !== undefined) { + mtsBinding.ssrResult = wasmContext.generate_html(pageElementId); + } + }), + + __SetID: ((element: HTMLElement, id: string | null) => { + wasmContext.set_attribute( + (element as ServerElement)[uniqueIdSymbol], + 'id', + id ?? '', + ); + }) as SetIDPAPI, + }, + wasmContext, + }; +} diff --git a/packages/web-platform/web-core-wasm/ts/server/elementAPIs/pureElementAPIs.ts b/packages/web-platform/web-core-wasm/ts/server/elementAPIs/pureElementAPIs.ts new file mode 100644 index 0000000000..89cf835982 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/server/elementAPIs/pureElementAPIs.ts @@ -0,0 +1,213 @@ +/* + * 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 { uniqueIdSymbol } from '../../constants.js'; +import type { + AddConfigPAPI, + AddDatasetPAPI, + AddEventPAPI, + Cloneable, + ElementIsEqualPAPI, + FirstElementPAPI, + GetChildrenPAPI, + GetComponentIdPAPI, + GetDataByKeyPAPI, + GetDatasetPAPI, + GetElementConfigPAPI, + GetElementUniqueIDPAPI, + GetEventPAPI, + GetEventsPAPI, + GetPageElementPAPI, + GetParentPAPI, + GetTemplatePartsPAPI, + InsertElementBeforePAPI, + LastElementPAPI, + MarkPartElementPAPI, + MarkTemplateElementPAPI, + NextElementPAPI, + RemoveElementPAPI, + ReplaceElementPAPI, + ReplaceElementsPAPI, + SetConfigPAPI, + SetDatasetPAPI, + SetEventsPAPI, + SwapElementPAPI, + UpdateComponentIDPAPI, + UpdateComponentInfoPAPI, + UpdateListCallbacksPAPI, +} from '../../types/index.js'; + +export interface ServerElement extends HTMLElement { + [uniqueIdSymbol]: number; +} + +export function getUniqueId(element: unknown): number { + return (element as ServerElement)[uniqueIdSymbol]; +} + +export const __ElementIsEqual: ElementIsEqualPAPI = ( + left: HTMLElement | null, + right: HTMLElement | null, +) => { + if (left === right) return true; + if (!left || !right) return false; + return getUniqueId(left) === getUniqueId(right); +}; + +export const __GetElementUniqueID: GetElementUniqueIDPAPI = ( + element: HTMLElement, +) => { + return getUniqueId(element); +}; + +// Throwing/Stub Implementations +export const __GetParent: GetParentPAPI = (_element: HTMLElement) => { + throw new Error('__GetParent is not implemented in SSR'); +}; + +export const __GetChildren: GetChildrenPAPI = (_element: HTMLElement) => { + throw new Error('__GetChildren is not implemented in SSR'); +}; + +export const __AddEvent: AddEventPAPI = () => { + // Silent return for SSR compatibility +}; + +export const __GetEvent: GetEventPAPI = () => { + throw new Error('__GetEvent is not implemented in SSR'); +}; + +export const __GetEvents: GetEventsPAPI = () => { + throw new Error('__GetEvents is not implemented in SSR'); +}; + +export const __SetEvents: SetEventsPAPI = () => { + throw new Error('__SetEvents is not implemented in SSR'); +}; + +export const __UpdateListCallbacks: UpdateListCallbacksPAPI = () => { + // No-op in SSR +}; + +// __GetConfig uses GetElementConfigPAPI +export const __GetConfig: GetElementConfigPAPI = () => { + throw new Error('__GetConfig is not implemented in SSR'); +}; + +export const __SetConfig: SetConfigPAPI = () => { + throw new Error('__SetConfig is not implemented in SSR'); +}; + +export const __GetElementConfig: GetElementConfigPAPI = () => { + throw new Error('__GetElementConfig is not implemented in SSR'); +}; + +export const __GetComponentID: GetComponentIdPAPI = () => { + throw new Error('__GetComponentID is not implemented in SSR'); +}; + +export const __GetDataset: GetDatasetPAPI = (_element: HTMLElement) => { + throw new Error('__GetDataset is not implemented in SSR'); +}; + +export const __SetDataset: SetDatasetPAPI = ( + _element: HTMLElement, + _dataset: Record, +) => { + throw new Error('__SetDataset is not implemented in SSR'); +}; + +export const __AddDataset: AddDatasetPAPI = ( + _element: HTMLElement, + _key: string, + _value: Cloneable, +) => { + // No-op in SSR +}; + +export const __GetDataByKey: GetDataByKeyPAPI = ( + _element: HTMLElement, + _key: string, +) => { + throw new Error('__GetDataByKey is not implemented in SSR'); +}; + +export const __FirstElement: FirstElementPAPI = (_element: HTMLElement) => { + throw new Error('__FirstElement is not implemented in SSR'); +}; + +export const __LastElement: LastElementPAPI = (_element: HTMLElement) => { + throw new Error('__LastElement is not implemented in SSR'); +}; + +export const __NextElement: NextElementPAPI = (_element: HTMLElement) => { + throw new Error('__NextElement is not implemented in SSR'); +}; + +export const __RemoveElement: RemoveElementPAPI = ( + _parent: HTMLElement, + _child: HTMLElement, +) => { + throw new Error('__RemoveElement is not implemented in SSR'); +}; + +export const __ReplaceElement: ReplaceElementPAPI = ( + _newEl: HTMLElement, + _oldEl: HTMLElement, +) => { + throw new Error('__ReplaceElement is not implemented in SSR'); +}; + +export const __SwapElement: SwapElementPAPI = ( + _a: HTMLElement, + _b: HTMLElement, +) => { + throw new Error('__SwapElement is not implemented in SSR'); +}; + +export const __AddConfig: AddConfigPAPI = () => { + throw new Error('__AddConfig is not implemented in SSR'); +}; + +export const __UpdateComponentInfo: UpdateComponentInfoPAPI = () => { + throw new Error('__UpdateComponentInfo is not implemented in SSR'); +}; + +export const __UpdateComponentID: UpdateComponentIDPAPI = () => { + throw new Error('__UpdateComponentID is not implemented in SSR'); +}; + +export const __MarkTemplateElement: MarkTemplateElementPAPI = () => { + throw new Error('__MarkTemplateElement is not implemented in SSR'); +}; + +export const __MarkPartElement: MarkPartElementPAPI = () => { + throw new Error('__MarkPartElement is not implemented in SSR'); +}; + +export const __GetTemplateParts: GetTemplatePartsPAPI = () => { + throw new Error('__GetTemplateParts is not implemented in SSR'); +}; + +export const __GetPageElement: GetPageElementPAPI = () => { + throw new Error('__GetPageElement is not implemented in SSR'); +}; + +export const __InsertElementBefore: InsertElementBeforePAPI = ( + _parent: HTMLElement, + _child: HTMLElement, + _ref: HTMLElement | null | undefined, +) => { + throw new Error('__InsertElementBefore is not implemented in SSR'); +}; + +export const __ReplaceElements: ReplaceElementsPAPI = ( + _parent: HTMLElement, + _newChildren: HTMLElement[] | HTMLElement, + _oldChildren: HTMLElement[] | HTMLElement | null | undefined, +) => { + throw new Error('__ReplaceElements is not implemented in SSR'); +}; diff --git a/packages/web-platform/web-core-wasm/ts/server/index.ts b/packages/web-platform/web-core-wasm/ts/server/index.ts new file mode 100644 index 0000000000..a2185dc8b9 --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/server/index.ts @@ -0,0 +1,10 @@ +/* + * 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. + */ + +export * from './wasm.js'; +export * from './elementAPIs/createElementAPI.js'; +export * from './deploy.js'; +export * from './decode.js'; diff --git a/packages/web-platform/web-core-wasm/ts/server/wasm.ts b/packages/web-platform/web-core-wasm/ts/server/wasm.ts new file mode 100644 index 0000000000..38b7a1535c --- /dev/null +++ b/packages/web-platform/web-core-wasm/ts/server/wasm.ts @@ -0,0 +1,7 @@ +/* + * 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. + */ + +export * from '../../binary/server/server.js'; diff --git a/packages/web-platform/web-core-wasm/ts/types/DecodedTemplate.ts b/packages/web-platform/web-core-wasm/ts/types/DecodedTemplate.ts index 281c45ccf1..5bacd5f93f 100644 --- a/packages/web-platform/web-core-wasm/ts/types/DecodedTemplate.ts +++ b/packages/web-platform/web-core-wasm/ts/types/DecodedTemplate.ts @@ -5,10 +5,12 @@ */ import type { PageConfig } from './PageConfig.js'; +import type { StyleSheetResource } from '../../binary/client/client.js'; export interface DecodedTemplate { config?: PageConfig; lepusCode?: Record; customSections?: Record; backgroundCode?: Record; + styleSheet?: StyleSheetResource; } diff --git a/packages/web-platform/web-elements/AGENTS.md b/packages/web-platform/web-elements/AGENTS.md index 3440d80ecf..9f3c000090 100644 --- a/packages/web-platform/web-elements/AGENTS.md +++ b/packages/web-platform/web-elements/AGENTS.md @@ -86,10 +86,12 @@ To verify specific test cases or fixtures manually: - **Main Thread**: These elements run on the main thread. Minimize heavy computation in `attributeChangedCallback`. - **Batching**: Group DOM updates. -## Shadow DOM Structure (`htmlTemplates.ts`) +## Shadow DOM Structure (`htmlTemplates.ts` & `src/template.rs`) The internal structure of web elements is defined in `src/elements/htmlTemplates.ts`. This ensures consistency and encapsulates implementation details. Agents should refer to this file to understand the shadow tree hierarchy (e.g., `ScrollView`'s observer containers, `XInput`'s inner `` element). +**CRITICAL SYNCHRONIZATION**: The templates in `htmlTemplates.ts` are strictly mirrored in the Rust sub-package at `src/template.rs` (a pure Rust lib). Any modifications, additions, or removals of templates in `htmlTemplates.ts` **MUST** be exactly replicated in `src/template.rs`. There is an automated test (`tests/template_sync.rs`) that verifies the generated strings from both TypeScript and Rust match exactly. + ## Implementation Guidelines for New Elements When implementing a new web element, follow these strict guidelines: diff --git a/packages/web-platform/web-elements/Cargo.toml b/packages/web-platform/web-elements/Cargo.toml new file mode 100644 index 0000000000..d983f5888c --- /dev/null +++ b/packages/web-platform/web-elements/Cargo.toml @@ -0,0 +1,8 @@ +[package] +name = "web_elements" +version = "0.1.0" +edition = "2021" + +[dev-dependencies] +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } diff --git a/packages/web-platform/web-elements/src/compat/LinearContainer/LinearContainer.ts b/packages/web-platform/web-elements/src/compat/LinearContainer/LinearContainer.ts index 0b48e06bbe..00c7af6764 100644 --- a/packages/web-platform/web-elements/src/compat/LinearContainer/LinearContainer.ts +++ b/packages/web-platform/web-elements/src/compat/LinearContainer/LinearContainer.ts @@ -5,7 +5,6 @@ import { type AttributeReactiveClass, bindToAttribute, } from '../../element-reactive/index.js'; -import '../../../src/compat/LinearContainer/linear-compat.css'; /** For @container * chrome 111, safari 18, firefox no @@ -43,15 +42,15 @@ import '../../../src/compat/LinearContainer/linear-compat.css'; * it fixed in 116.0.5806.0, detail: https://issues.chromium.org/issues/40270007 * * so we limit this feature to chrome 117, safari 18, firefox no: - * rex unit: chrome 111, safari 17.2, firefox no - * https://developer.mozilla.org/en-US/docs/Web/CSS/length + * -webkit-box-reflect: chrome 4, safari 4, firefox no + * https://developer.mozilla.org/en-US/docs/Web/CSS/Reference/Properties/-webkit-box-reflect * transition-behavior:allow-discrete: chrome 117, safari 18, firefox 125 * https://developer.mozilla.org/en-US/docs/Web/CSS/transition-behavior * https://caniuse.com/mdn-css_properties_display_is_transitionable * * update this once firefox supports this. */ -const supportContainerStyleQuery = CSS.supports('width:1rex') +const supportContainerStyleQuery = CSS.supports('-webkit-box-reflect: above') && CSS.supports('transition-behavior:allow-discrete') && CSS.supports('content-visibility: auto'); diff --git a/packages/web-platform/web-elements/src/elements/XSwiper/XSwiperIndicator.ts b/packages/web-platform/web-elements/src/elements/XSwiper/XSwiperIndicator.ts index 970bc366d2..7059d2fec6 100644 --- a/packages/web-platform/web-elements/src/elements/XSwiper/XSwiperIndicator.ts +++ b/packages/web-platform/web-elements/src/elements/XSwiper/XSwiperIndicator.ts @@ -8,7 +8,6 @@ import { genDomGetter, registerAttributeHandler, bindToStyle, - boostedQueueMicrotask, } from '../../element-reactive/index.js'; import type { XSwiper } from './XSwiper.js'; @@ -136,17 +135,18 @@ export class XSwiperIndicator } }).bind(this) as EventListener, ); - boostedQueueMicrotask(() => { - ( - this.#getIndicatorContainer().children[ - this.#dom.currentIndex - ] as HTMLElement - )?.style.setProperty( - 'background-color', - 'var(--indicator-active-color)', - 'important', - ); - }); + const firstPaintIndex = parseFloat( + this.#dom.getAttribute('current') ?? '0', + ); + ( + this.#getIndicatorContainer().children[ + firstPaintIndex + ] as HTMLElement + )?.style.setProperty( + 'background-color', + 'var(--indicator-active-color)', + 'important', + ); } } dispose(): void { diff --git a/packages/web-platform/web-elements/src/elements/common/constants.ts b/packages/web-platform/web-elements/src/elements/common/constants.ts index cb166a7dda..bd472e5a8e 100644 --- a/packages/web-platform/web-elements/src/elements/common/constants.ts +++ b/packages/web-platform/web-elements/src/elements/common/constants.ts @@ -4,12 +4,5 @@ // safari cannot use scrollend event export const useScrollEnd = 'onscrollend' in document; -const UA = window.navigator.userAgent; -export const isChromium = UA.includes('Chrome'); -export const isWebkit = /\b(iPad|iPhone|iPod|OS X)\b/.test(UA) - && !/Edge/.test(UA) - && /WebKit/.test(UA) - // @ts-expect-error - && !window.MSStream; export const scrollContainerDom = Symbol.for('lynx-scroll-container-dom'); diff --git a/packages/web-platform/web-elements/src/elements/htmlTemplates.ts b/packages/web-platform/web-elements/src/elements/htmlTemplates.ts index 3fb743c17d..6f7a0f0163 100644 --- a/packages/web-platform/web-elements/src/elements/htmlTemplates.ts +++ b/packages/web-platform/web-elements/src/elements/htmlTemplates.ts @@ -2,6 +2,13 @@ // Licensed under the Apache License Version 2.0 that can be found in the // LICENSE file in the root directory of this source tree. +// --- IMPORTANT SYNCHRONIZATION NOTICE --- +// The templates defined in this file are mirrored in the pure Rust library `web_elements`. +// If you modify, add, or remove any template in this file, you MUST ALSO update +// the corresponding Rust implementation in `src/template.rs` to ensure they +// remain exactly synchronized. Tests enforce this parity. +// ---------------------------------------- + export const templateScrollView = ` +
+
+
+
+ +
+
+
+
"#; + +pub const TEMPLATE_X_AUDIO_TT: &str = r#""#; + +pub fn template_x_image(src: Option<&str>) -> Result { + if let Some(src_str) = src { + let has_xss = src_str + .to_lowercase() + .split('<') + .skip(1) + .any(|part| part.trim_start().starts_with("script")); + + if has_xss { + return Err( + "detected "# + )) + } else { + Ok(r#" "#.to_string()) + } +} + +pub fn template_filter_image(src: Option<&str>) -> Result { + template_x_image(src) +} + +pub const TEMPLATE_X_INPUT: &str = r#" +
+ +
"#; + +pub const TEMPLATE_X_LIST: &str = r#" +
+
+
+
+ +
+
+
+
"#; + +pub const TEMPLATE_X_OVERLAY_NG: &str = r#" + +
+ +
+
+
"#; + +pub const TEMPLATE_X_REFRESH_VIEW: &str = r#" +
+
+ +
+ +
+ +
+
"#; + +pub const TEMPLATE_X_SWIPER: &str = r#" + +
+
+
+ + + +
"#; + +pub const TEMPLATE_X_TEXT: &str = r#"
"#; + +pub fn template_inline_image(src: Option<&str>) -> Result { + template_x_image(src) +} + +pub const TEMPLATE_X_TEXTAREA: &str = r#" +
+ +
"#; + +pub const TEMPLATE_X_VIEWPAGE_NG: &str = r#" +
+
+ +
"#; + +pub const TEMPLATE_X_WEB_VIEW: &str = r#" +"#; + +pub fn template_x_svg() -> String { + r#" "#.to_string() +} diff --git a/packages/web-platform/web-elements/tests/template.spec.ts b/packages/web-platform/web-elements/tests/template.spec.ts new file mode 100644 index 0000000000..d18212f874 --- /dev/null +++ b/packages/web-platform/web-elements/tests/template.spec.ts @@ -0,0 +1,91 @@ +import { expect, test } from '@playwright/test'; +import fs from 'node:fs'; +import path from 'node:path'; +import * as templates from '../src/elements/htmlTemplates.js'; + +import { fileURLToPath } from 'node:url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +test.describe('template sync', () => { + test('sync between rust and ts', () => { + const rsPath = path.resolve(__dirname, '../src/template.rs'); + const rsContent = fs.readFileSync(rsPath, 'utf8'); + + const extractConst = (name: string) => { + // Find `pub const NAME: &str = r#"CONTENT"#;` + const regex = new RegExp(`pub const ${name}: &str = r#"([\\s\\S]*?)"#;`); + const match = rsContent.match(regex); + if (!match) throw new Error(`Could not find ${name} in template.rs`); + return match[1]; + }; + + expect(templates.templateScrollView).toBe( + extractConst('TEMPLATE_SCROLL_VIEW'), + ); + expect(templates.templateXAudioTT).toBe( + extractConst('TEMPLATE_X_AUDIO_TT'), + ); + expect(templates.templateXInput).toBe(extractConst('TEMPLATE_X_INPUT')); + expect(templates.templateXList).toBe(extractConst('TEMPLATE_X_LIST')); + expect(templates.templateXOverlayNg).toBe( + extractConst('TEMPLATE_X_OVERLAY_NG'), + ); + expect(templates.templateXRefreshView).toBe( + extractConst('TEMPLATE_X_REFRESH_VIEW'), + ); + expect(templates.templateXSwiper).toBe(extractConst('TEMPLATE_X_SWIPER')); + expect(templates.templateXText).toBe(extractConst('TEMPLATE_X_TEXT')); + expect(templates.templateXTextarea).toBe( + extractConst('TEMPLATE_X_TEXTAREA'), + ); + expect(templates.templateXViewpageNg).toBe( + extractConst('TEMPLATE_X_VIEWPAGE_NG'), + ); + expect(templates.templateXWebView).toBe( + extractConst('TEMPLATE_X_WEB_VIEW'), + ); + + // Check template_x_image + const imageNoneRsMatch = rsContent.match( + /r#" "#/, + ); + if (!imageNoneRsMatch) { + throw new Error( + `Could not find templateImageNone base string in template.rs`, + ); + } + const imageNoneRs = imageNoneRsMatch[0].slice(3, -2); + expect(templates.templateXImage({})).toBe(imageNoneRs); + + const imageSomeRsMatch = rsContent.match( + /r#" "#/, + ); + if (!imageSomeRsMatch) { + throw new Error( + `Could not find templateImageSome base string in template.rs`, + ); + } + const imageSomeRs = imageSomeRsMatch[0].slice(3, -2).replace( + '{src_str}', + 'https://example.com/a.png', + ); + expect(templates.templateXImage({ src: 'https://example.com/a.png' })).toBe( + imageSomeRs, + ); + + // Check XSS error logic locally in TS as well + expect(() => templates.templateXImage({ src: '